Files
TX/artifacts/tx-os/src/pages/protocol-request.tsx
T
Replit Agent 7daaf6f2f0 Task #718: Numbered key-attendees table auto-synced with expected attendee count
- New shared component artifacts/tx-os/src/components/attendee-table-editor.tsx:
  AttendeeTableEditor (# | name | position | side select | remove), plus
  syncAttendeeRows/emptyAttendeeRow helpers and MAX_ATTENDEE_ROWS=50 cap.
- Internal booking dialog (protocol.tsx): replaced the two side-by-side
  BookingAttendeeColumn lists with the single numbered table; each row now
  carries its own side. Edit mode prefills existing keyAttendees then pads
  to attendeeCount. useEffect syncs row count with the attendeeCount field:
  growing adds empty rows, shrinking removes only empty trailing rows (never
  filled ones), min 1 row.
- Public form (protocol-request.tsx): same conversion (AttendeeColumn removed,
  Arabic labels passed directly since the page is forced-RTL Arabic).
- Submission payload unchanged: only rows with non-empty name sent, same
  keyAttendees {name, position, side} shape — no API/schema changes.
- i18n: added protocol.bookings.form.side ("الجهة"/"Side") in ar.json/en.json.
- Removed dead code (BookingAttendeeColumn, AttendeeColumn, AttendeeRow type,
  unused Plus/X imports in protocol-request).
- Verified: tsc --noEmit clean; page serves 200; architect review PASS.
- Follow-up proposed: #719 UI test for count sync/prefill/submit.
2026-07-09 06:03:40 +00:00

721 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CalendarClock,
CheckCircle2,
Loader2,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DateTimePicker } from "@/components/date-time-picker";
import {
AttendeeTableEditor,
emptyAttendeeRow,
syncAttendeeRows,
type EditableAttendeeRow,
} from "@/components/attendee-table-editor";
const API = `${import.meta.env.BASE_URL}api`;
type PublicRoom = { id: number; nameAr: string; nameEn: string };
// Availability for one room + Riyadh calendar day: the admin-configured slot
// grid with per-slot booked / too-soon flags. An empty `slots` array means
// the admin hasn't configured slots — the form falls back to free date-time
// pickers (legacy mode).
type AvailabilitySlot = {
id: number;
startTime: string; // "HH:mm" Riyadh wall clock
durationMinutes: number;
booked: boolean;
tooSoon: boolean;
available: boolean;
};
type Availability = { minLeadMinutes: number; slots: AvailabilitySlot[] };
function fmtSlotLabel(startTime: string, durationMinutes: number): string {
const [h, m] = startTime.split(":").map(Number);
const endTotal = h * 60 + m + durationMinutes;
const eh = Math.floor(endTotal / 60) % 24;
const em = endTotal % 60;
const pad = (n: number) => String(n).padStart(2, "0");
const to12 = (hh: number, mm: number) => {
const period = hh < 12 ? "ص" : "م";
let h12 = hh % 12;
if (h12 === 0) h12 = 12;
return `${h12}:${pad(mm)} ${period}`;
};
return `${to12(h, m)} ${to12(eh, em)}`;
}
// Slot start/end as absolute instants. Riyadh has no DST so the fixed
// +03:00 offset is always correct.
function slotIso(date: string, startTime: string, durationMinutes?: number) {
const start = new Date(`${date}T${startTime}:00+03:00`);
if (durationMinutes === undefined) return start.toISOString();
return new Date(start.getTime() + durationMinutes * 60_000).toISOString();
}
function todayRiyadh(): string {
// en-CA gives YYYY-MM-DD directly.
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Riyadh",
}).format(new Date());
}
type MeetingType = "internal" | "external";
type FormState = {
requesterName: string;
title: string;
requesterPhone: string;
department: string;
meetingType: MeetingType;
requesterOrg: string;
attendeeCount: string;
purpose: string;
roomId: string;
date: string; // YYYY-MM-DD (Riyadh calendar day) — slot mode
slotId: string; // selected slot id — slot mode
startsAt: string; // legacy free mode (no slots configured)
endsAt: string;
notes: string;
website: string; // honeypot
};
const EMPTY: FormState = {
requesterName: "",
title: "",
requesterPhone: "",
department: "",
meetingType: "internal",
requesterOrg: "",
attendeeCount: "",
purpose: "",
roomId: "",
date: "",
slotId: "",
startsAt: "",
endsAt: "",
notes: "",
website: "",
};
function localToIso(v: string): string {
return new Date(v).toISOString();
}
export default function ProtocolRequestPage() {
// This page is public (no auth). Force RTL Arabic regardless of any
// stored language preference so a guest always sees the intended form.
useEffect(() => {
const prevDir = document.documentElement.dir;
const prevLang = document.documentElement.lang;
document.documentElement.dir = "rtl";
document.documentElement.lang = "ar";
return () => {
// Restore the app's direction/language so navigating away from this
// public page doesn't leave the rest of the app forced into RTL Arabic.
document.documentElement.dir = prevDir;
document.documentElement.lang = prevLang;
};
}, []);
const rooms = useQuery<PublicRoom[]>({
queryKey: ["protocol-public", "rooms"],
queryFn: async () => {
const r = await fetch(`${API}/protocol/public/rooms`);
if (!r.ok) throw new Error("rooms");
return r.json();
},
});
const [form, setForm] = useState<FormState>(EMPTY);
// Availability (slot grid) for the chosen room + day. When the admin has
// configured slots, guests pick a day then one of the returned slots; the
// legacy free start/end pickers only render when no slots exist at all.
const availability = useQuery<Availability>({
queryKey: ["protocol-public", "availability", form.roomId, form.date],
enabled: form.roomId !== "" && form.date !== "",
queryFn: async () => {
const r = await fetch(
`${API}/protocol/public/availability?roomId=${encodeURIComponent(
form.roomId,
)}&date=${encodeURIComponent(form.date)}`,
);
if (!r.ok) throw new Error("availability");
return r.json();
},
});
// Probe with today's date before a day is picked, so we know whether slot
// mode is on (the slot list itself is per-day, but its existence is global).
const slotProbe = useQuery<Availability>({
queryKey: ["protocol-public", "availability-probe", form.roomId],
enabled: form.roomId !== "",
queryFn: async () => {
const r = await fetch(
`${API}/protocol/public/availability?roomId=${encodeURIComponent(
form.roomId,
)}&date=${todayRiyadh()}`,
);
if (!r.ok) throw new Error("availability");
return r.json();
},
});
const slotMode =
form.roomId !== "" &&
slotProbe.data !== undefined &&
slotProbe.data.slots.length > 0;
const daySlots = availability.data?.slots ?? [];
const selectedSlot = daySlots.find((s) => String(s.id) === form.slotId);
// Key attendees ("أبرز الحضور"): one numbered table where each row carries
// its own side (من الهيئة / خارج الهيئة). Row count follows the expected
// attendee count field.
const [attendees, setAttendees] = useState<EditableAttendeeRow[]>([
emptyAttendeeRow(),
]);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [reference, setReference] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((f) => ({ ...f, [k]: v }));
// Auto-generate table rows to match "العدد المتوقع للحضور".
useEffect(() => {
const n =
form.attendeeCount.trim() === "" ? null : Number(form.attendeeCount);
setAttendees((rs) => syncAttendeeRows(rs, n));
}, [form.attendeeCount]);
const isExternal = form.meetingType === "external";
const canSubmit = useMemo(() => {
const timeOk = slotMode
? form.date !== "" && selectedSlot !== undefined && selectedSlot.available
: form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) < new Date(form.endsAt);
return (
form.requesterName.trim().length > 0 &&
form.title.trim().length > 0 &&
form.requesterPhone.trim().length >= 3 &&
form.roomId !== "" &&
timeOk &&
(!isExternal || form.requesterOrg.trim().length > 0)
);
}, [form, isExternal, slotMode, selectedSlot]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!canSubmit || submitting) return;
setSubmitting(true);
try {
const keyAttendees = attendees
.filter((r) => r.name.trim() !== "")
.map((r) => ({
name: r.name.trim(),
position: r.position.trim(),
side: r.side,
}));
const res = await fetch(`${API}/protocol/public/booking-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: Number(form.roomId),
title: form.title.trim(),
requesterName: form.requesterName.trim(),
requesterPhone: form.requesterPhone.trim(),
department: form.department.trim() || null,
meetingType: form.meetingType,
requesterOrg: isExternal ? form.requesterOrg.trim() : null,
purpose: form.purpose.trim() || null,
attendeeCount:
form.attendeeCount.trim() === ""
? null
: Number(form.attendeeCount),
keyAttendees,
startsAt:
slotMode && selectedSlot
? slotIso(form.date, selectedSlot.startTime)
: localToIso(form.startsAt),
endsAt:
slotMode && selectedSlot
? slotIso(
form.date,
selectedSlot.startTime,
selectedSlot.durationMinutes,
)
: localToIso(form.endsAt),
notes: form.notes.trim() || null,
website: form.website,
}),
});
if (res.ok) {
try {
const data = await res.json();
if (data?.bookingReference && typeof data.bookingReference === "string") {
setReference(data.bookingReference);
}
} catch {
// no reference in body — still a success
}
setDone(true);
return;
}
let msg = "تعذّر إرسال الطلب، يرجى المحاولة مرة أخرى.";
try {
const data = await res.json();
if (data?.error && typeof data.error === "string") msg = data.error;
} catch {
// keep default message
}
if (res.status === 429) {
msg = "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.";
}
setError(msg);
} catch {
setError("تعذّر الاتصال بالخادم، تحقق من الاتصال وحاول مجدداً.");
} finally {
setSubmitting(false);
}
};
const resetForm = () => {
setForm(EMPTY);
setAttendees([emptyAttendeeRow()]);
setReference(null);
setDone(false);
};
if (done) {
return (
<div
dir="rtl"
className="min-h-screen bg-slate-50 flex items-center justify-center p-4"
>
<div className="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-sm p-8 text-center">
<CheckCircle2 className="w-14 h-14 text-emerald-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-slate-800 mb-2">
تم استلام طلبك
</h1>
<p className="text-slate-500 leading-relaxed">
سيقوم فريق المراسم بمراجعة طلب الحجز والتواصل معك على رقم الهاتف
المُدخل لتأكيد الموعد.
</p>
{reference && (
<div className="mt-5 rounded-xl bg-sky-50 border border-sky-200 px-4 py-4">
<p className="text-sm text-slate-500 mb-1">رقم الحجز</p>
<p
className="text-2xl font-bold tracking-wider text-sky-700"
dir="ltr"
>
{reference}
</p>
<p className="text-xs text-slate-500 mt-2">
احتفظ بهذا الرقم لمتابعة حالة طلبك.
</p>
</div>
)}
<Button className="mt-6" variant="outline" onClick={resetForm}>
إرسال طلب آخر
</Button>
</div>
</div>
);
}
return (
<div dir="rtl" className="min-h-screen bg-slate-50 py-8 px-4">
<div className="w-full max-w-xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<div className="w-11 h-11 rounded-xl bg-sky-500/10 text-sky-600 flex items-center justify-center">
<CalendarClock className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-800">طلب حجز قاعة</h1>
<p className="text-sm text-slate-500">
يرجى تعبئة البيانات وسيتواصل معك فريق المراسم للتأكيد.
</p>
</div>
</div>
<form
onSubmit={onSubmit}
className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 space-y-5"
>
{/* Honeypot: hidden from real users, catches bots. */}
<div className="hidden" aria-hidden="true">
<label>
Website
<input
type="text"
tabIndex={-1}
autoComplete="off"
value={form.website}
onChange={(e) => set("website", e.target.value)}
/>
</label>
</div>
<div className="space-y-2">
<Label htmlFor="requesterName">
الاسم <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterName"
value={form.requesterName}
onChange={(e) => set("requesterName", e.target.value)}
placeholder="الاسم الثلاثي"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="requesterPhone">
رقم الهاتف للتواصل <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterPhone"
type="tel"
inputMode="tel"
dir="ltr"
className="text-right"
value={form.requesterPhone}
onChange={(e) => set("requesterPhone", e.target.value)}
placeholder="05xxxxxxxx"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="room">
القاعة <span className="text-rose-500">*</span>
</Label>
<Select
value={form.roomId}
onValueChange={(v) =>
// Reset the time selection when the room changes so no stale
// date/slot/free-time values survive a room switch (the slot
// grid is per-room and slot mode itself may flip).
setForm((f) => ({
...f,
roomId: v,
date: "",
slotId: "",
startsAt: "",
endsAt: "",
}))
}
>
<SelectTrigger id="room">
<SelectValue
placeholder={
rooms.isLoading ? "جارٍ التحميل…" : "اختر القاعة"
}
/>
</SelectTrigger>
<SelectContent>
{(rooms.data ?? []).map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{r.nameAr || r.nameEn}
</SelectItem>
))}
</SelectContent>
</Select>
{rooms.isError && (
<p className="text-xs text-rose-500">
تعذّر تحميل قائمة القاعات، يرجى تحديث الصفحة.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="department">الإدارة</Label>
<Input
id="department"
value={form.department}
onChange={(e) => set("department", e.target.value)}
placeholder="الإدارة أو القسم (اختياري)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="title">
عنوان الاجتماع <span className="text-rose-500">*</span>
</Label>
<Input
id="title"
value={form.title}
onChange={(e) => set("title", e.target.value)}
placeholder="موضوع أو عنوان الاجتماع"
required
/>
</div>
<div className="space-y-2">
<Label>
نوع الاجتماع <span className="text-rose-500">*</span>
</Label>
<div className="grid grid-cols-2 gap-2">
{(
[
{ value: "internal", label: "اجتماع داخلي" },
{ value: "external", label: "اجتماع مع جهة خارجية" },
] as const
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => set("meetingType", opt.value)}
className={`rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors ${
form.meetingType === opt.value
? "border-sky-500 bg-sky-50 text-sky-700"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{isExternal && (
<div className="space-y-2">
<Label htmlFor="requesterOrg">
اسم الجهة <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterOrg"
value={form.requesterOrg}
onChange={(e) => set("requesterOrg", e.target.value)}
placeholder="اسم الجهة الخارجية"
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="attendeeCount">العدد المتوقع للحضور</Label>
<Input
id="attendeeCount"
type="number"
inputMode="numeric"
min={1}
value={form.attendeeCount}
onChange={(e) => set("attendeeCount", e.target.value)}
placeholder="العدد المتوقع للحضور (اختياري)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="purpose">الغرض من الاجتماع</Label>
<Textarea
id="purpose"
value={form.purpose}
onChange={(e) => set("purpose", e.target.value)}
placeholder="اشرح الغرض من الاجتماع (اختياري)"
rows={2}
/>
</div>
{form.roomId === "" ? (
// No room chosen yet → we don't know whether slot mode is on, so
// show a hint instead of the (possibly wrong) free pickers.
<div className="space-y-2">
<Label>
موعد الحجز <span className="text-rose-500">*</span>
</Label>
<p className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-500">
اختر القاعة أولًا لعرض المواعيد المتاحة.
</p>
</div>
) : slotProbe.isLoading ? (
<div className="space-y-2">
<Label>
موعد الحجز <span className="text-rose-500">*</span>
</Label>
<p className="text-sm text-slate-500">
جارٍ تحميل المواعيد المتاحة
</p>
</div>
) : slotProbe.isError ? (
<div className="space-y-2">
<Label>
موعد الحجز <span className="text-rose-500">*</span>
</Label>
<p className="text-xs text-rose-500">
تعذّر تحميل المواعيد، يرجى المحاولة مرة أخرى.
</p>
</div>
) : slotMode ? (
<>
<div className="space-y-2">
<Label htmlFor="bookingDate">
تاريخ الحجز <span className="text-rose-500">*</span>
</Label>
<Input
id="bookingDate"
type="date"
min={todayRiyadh()}
value={form.date}
onChange={(e) => {
set("date", e.target.value);
set("slotId", "");
}}
/>
</div>
{form.date !== "" && (
<div className="space-y-2">
<Label>
الوقت المتاح <span className="text-rose-500">*</span>
</Label>
{availability.isLoading && (
<p className="text-sm text-slate-500">
جارٍ تحميل الأوقات المتاحة
</p>
)}
{availability.isError && (
<p className="text-xs text-rose-500">
تعذّر تحميل الأوقات، يرجى المحاولة مرة أخرى.
</p>
)}
{availability.data && (
<>
<div className="grid grid-cols-2 gap-2">
{daySlots.map((s) => (
<button
key={s.id}
type="button"
disabled={!s.available}
onClick={() => set("slotId", String(s.id))}
className={`rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors ${
form.slotId === String(s.id)
? "border-sky-500 bg-sky-50 text-sky-700"
: s.available
? "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
: "border-slate-100 bg-slate-50 text-slate-300 cursor-not-allowed line-through"
}`}
dir="rtl"
>
{fmtSlotLabel(s.startTime, s.durationMinutes)}
</button>
))}
</div>
{daySlots.length > 0 &&
daySlots.every((s) => !s.available) && (
<p className="text-xs text-amber-600">
لا توجد أوقات متاحة في هذا اليوم، يرجى اختيار يوم
آخر.
</p>
)}
</>
)}
</div>
)}
</>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startsAt">
من <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="startsAt"
value={form.startsAt}
onChange={(v) => set("startsAt", v)}
placeholder="اختر تاريخ ووقت البداية"
/>
</div>
<div className="space-y-2">
<Label htmlFor="endsAt">
إلى <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="endsAt"
value={form.endsAt}
onChange={(v) => set("endsAt", v)}
placeholder="اختر تاريخ ووقت النهاية"
minDate={form.startsAt ? new Date(form.startsAt) : undefined}
/>
</div>
</div>
{form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) >= new Date(form.endsAt) && (
<p className="text-xs text-rose-500 -mt-2">
يجب أن يكون وقت البداية قبل وقت النهاية.
</p>
)}
</>
)}
<div className="space-y-3">
<Label>أبرز الحضور</Label>
<AttendeeTableEditor
rows={attendees}
setRows={setAttendees}
labels={{
name: "الاسم",
position: "المنصب",
side: "الجهة",
sideInternal: "من الهيئة",
sideExternal: "خارج الهيئة",
addRow: "إضافة صف",
removeRow: "حذف الصف",
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">ملاحظات</Label>
<Textarea
id="notes"
value={form.notes}
onChange={(e) => set("notes", e.target.value)}
placeholder="أي تفاصيل إضافية (اختياري)"
rows={3}
/>
</div>
<div className="flex items-start gap-2 rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-3">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
للإحاطة: في حال الحاجة للقاعة سيتم إلغاء الاجتماع أو تعديل موقعه.
</span>
</div>
{error && (
<div className="rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2">
{error}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={!canSubmit || submitting}
>
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
إرسال الطلب
</Button>
</form>
</div>
</div>
);
}