3ff1338c7e
Public booking form now captures department, meeting type (internal/
external), meeting purpose, and a repeatable "key attendees" table
(name + position + side). External meetings require the entity name
(reuses requesterOrg). "عدد الحضور" relabeled to "العدد المتوقع للحضور".
Added a static red disclaimer note about possible cancellation.
Each booking now gets an auto-generated searchable reference
(RB-{year}-{pad4}) shown on the success screen; staff can search the
admin bookings list by reference OR phone.
Changes:
- lib/db schema: department, meetingType, purpose, keyAttendees (jsonb),
bookingReference (unique) + requesterPhone index; new enums/types.
Applied via drizzle push; backfilled existing 4 rows.
- api-server: extended public booking schema + refine (org required when
external), makeBookingReference, public/internal POST generate+return
bookingReference.
- tx-os public form rewritten; admin card shows new fields + client-side
search with empty state.
- ar/en i18n keys under protocol.bookings.
Notes:
- Pre-existing push.ts typecheck errors are unrelated to this task.
- Architect review: PASS, no blocking issues.
603 lines
20 KiB
TypeScript
603 lines
20 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import {
|
||
CalendarClock,
|
||
CheckCircle2,
|
||
Loader2,
|
||
Plus,
|
||
X,
|
||
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";
|
||
|
||
const API = `${import.meta.env.BASE_URL}api`;
|
||
|
||
type PublicRoom = { id: number; nameAr: string; nameEn: string };
|
||
|
||
type MeetingType = "internal" | "external";
|
||
type AttendeeSide = "internal" | "external";
|
||
type AttendeeRow = { name: string; position: string };
|
||
|
||
type FormState = {
|
||
requesterName: string;
|
||
title: string;
|
||
requesterPhone: string;
|
||
department: string;
|
||
meetingType: MeetingType;
|
||
requesterOrg: string;
|
||
attendeeCount: string;
|
||
purpose: string;
|
||
roomId: string;
|
||
startsAt: string;
|
||
endsAt: string;
|
||
notes: string;
|
||
website: string; // honeypot
|
||
};
|
||
|
||
const EMPTY: FormState = {
|
||
requesterName: "",
|
||
title: "",
|
||
requesterPhone: "",
|
||
department: "",
|
||
meetingType: "internal",
|
||
requesterOrg: "",
|
||
attendeeCount: "",
|
||
purpose: "",
|
||
roomId: "",
|
||
startsAt: "",
|
||
endsAt: "",
|
||
notes: "",
|
||
website: "",
|
||
};
|
||
|
||
const EMPTY_ROW: AttendeeRow = { name: "", position: "" };
|
||
|
||
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);
|
||
// Key attendees ("أبرز الحضور") are two separate lists: من الهيئة (internal)
|
||
// and خارج الهيئة (external). Each row is a name + position.
|
||
const [internalAttendees, setInternalAttendees] = useState<AttendeeRow[]>([
|
||
{ ...EMPTY_ROW },
|
||
]);
|
||
const [externalAttendees, setExternalAttendees] = useState<AttendeeRow[]>([
|
||
{ ...EMPTY_ROW },
|
||
]);
|
||
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 }));
|
||
|
||
const updateRow = (
|
||
side: AttendeeSide,
|
||
idx: number,
|
||
field: keyof AttendeeRow,
|
||
value: string,
|
||
) => {
|
||
const setter =
|
||
side === "internal" ? setInternalAttendees : setExternalAttendees;
|
||
setter((rows) =>
|
||
rows.map((r, i) => (i === idx ? { ...r, [field]: value } : r)),
|
||
);
|
||
};
|
||
|
||
const addRow = (side: AttendeeSide) => {
|
||
const setter =
|
||
side === "internal" ? setInternalAttendees : setExternalAttendees;
|
||
setter((rows) => [...rows, { ...EMPTY_ROW }]);
|
||
};
|
||
|
||
const removeRow = (side: AttendeeSide, idx: number) => {
|
||
const setter =
|
||
side === "internal" ? setInternalAttendees : setExternalAttendees;
|
||
setter((rows) =>
|
||
rows.length <= 1 ? [{ ...EMPTY_ROW }] : rows.filter((_, i) => i !== idx),
|
||
);
|
||
};
|
||
|
||
const isExternal = form.meetingType === "external";
|
||
|
||
const canSubmit = useMemo(() => {
|
||
return (
|
||
form.requesterName.trim().length > 0 &&
|
||
form.title.trim().length > 0 &&
|
||
form.requesterPhone.trim().length >= 3 &&
|
||
form.roomId !== "" &&
|
||
form.startsAt !== "" &&
|
||
form.endsAt !== "" &&
|
||
new Date(form.startsAt) < new Date(form.endsAt) &&
|
||
(!isExternal || form.requesterOrg.trim().length > 0)
|
||
);
|
||
}, [form, isExternal]);
|
||
|
||
const onSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError(null);
|
||
if (!canSubmit || submitting) return;
|
||
setSubmitting(true);
|
||
try {
|
||
const keyAttendees = [
|
||
...internalAttendees
|
||
.filter((r) => r.name.trim() !== "")
|
||
.map((r) => ({
|
||
name: r.name.trim(),
|
||
position: r.position.trim(),
|
||
side: "internal" as const,
|
||
})),
|
||
...externalAttendees
|
||
.filter((r) => r.name.trim() !== "")
|
||
.map((r) => ({
|
||
name: r.name.trim(),
|
||
position: r.position.trim(),
|
||
side: "external" as const,
|
||
})),
|
||
];
|
||
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: localToIso(form.startsAt),
|
||
endsAt: 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);
|
||
setInternalAttendees([{ ...EMPTY_ROW }]);
|
||
setExternalAttendees([{ ...EMPTY_ROW }]);
|
||
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) => set("roomId", v)}>
|
||
<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>
|
||
|
||
<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>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<AttendeeColumn
|
||
title="من الهيئة"
|
||
rows={internalAttendees}
|
||
onChange={(idx, field, value) =>
|
||
updateRow("internal", idx, field, value)
|
||
}
|
||
onAdd={() => addRow("internal")}
|
||
onRemove={(idx) => removeRow("internal", idx)}
|
||
/>
|
||
<AttendeeColumn
|
||
title="خارج الهيئة"
|
||
rows={externalAttendees}
|
||
onChange={(idx, field, value) =>
|
||
updateRow("external", idx, field, value)
|
||
}
|
||
onAdd={() => addRow("external")}
|
||
onRemove={(idx) => removeRow("external", idx)}
|
||
/>
|
||
</div>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
function AttendeeColumn({
|
||
title,
|
||
rows,
|
||
onChange,
|
||
onAdd,
|
||
onRemove,
|
||
}: {
|
||
title: string;
|
||
rows: AttendeeRow[];
|
||
onChange: (idx: number, field: keyof AttendeeRow, value: string) => void;
|
||
onAdd: () => void;
|
||
onRemove: (idx: number) => void;
|
||
}) {
|
||
return (
|
||
<div className="rounded-lg border border-slate-200 p-3 space-y-3">
|
||
<p className="text-sm font-semibold text-slate-700">{title}</p>
|
||
<div className="space-y-3">
|
||
{rows.map((row, idx) => (
|
||
<div key={idx} className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
value={row.name}
|
||
onChange={(e) => onChange(idx, "name", e.target.value)}
|
||
placeholder="الاسم"
|
||
/>
|
||
{rows.length > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove(idx)}
|
||
className="shrink-0 text-slate-400 hover:text-rose-500 p-1"
|
||
aria-label="حذف الصف"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<Input
|
||
value={row.position}
|
||
onChange={(e) => onChange(idx, "position", e.target.value)}
|
||
placeholder="المنصب"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onAdd}
|
||
className="flex items-center gap-1 text-sm font-medium text-sky-600 hover:text-sky-700"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
إضافة صف
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|