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({ 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(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({ 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({ 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([ emptyAttendeeRow(), ]); const [submitting, setSubmitting] = useState(false); const [done, setDone] = useState(false); const [reference, setReference] = useState(null); const [error, setError] = useState(null); const set = (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 (

تم استلام طلبك

سيقوم فريق المراسم بمراجعة طلب الحجز والتواصل معك على رقم الهاتف المُدخل لتأكيد الموعد.

{reference && (

رقم الحجز

{reference}

احتفظ بهذا الرقم لمتابعة حالة طلبك.

)}
); } return (

طلب حجز قاعة

يرجى تعبئة البيانات وسيتواصل معك فريق المراسم للتأكيد.

{/* Honeypot: hidden from real users, catches bots. */}
set("requesterName", e.target.value)} placeholder="الاسم الثلاثي" required />
set("requesterPhone", e.target.value)} placeholder="05xxxxxxxx" required />
{rooms.isError && (

تعذّر تحميل قائمة القاعات، يرجى تحديث الصفحة.

)}
set("department", e.target.value)} placeholder="الإدارة أو القسم (اختياري)" />
set("title", e.target.value)} placeholder="موضوع أو عنوان الاجتماع" required />
{( [ { value: "internal", label: "اجتماع داخلي" }, { value: "external", label: "اجتماع مع جهة خارجية" }, ] as const ).map((opt) => ( ))}
{isExternal && (
set("requesterOrg", e.target.value)} placeholder="اسم الجهة الخارجية" required />
)}
set("attendeeCount", e.target.value)} placeholder="العدد المتوقع للحضور (اختياري)" />