Files
TX/artifacts/tx-os/src/pages/protocol-request.tsx
T
Riyadh 3ff1338c7e Task #670: revamp public room-booking form (protocol)
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.
2026-07-07 12:26:19 +00:00

603 lines
20 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,
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>
);
}