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.
This commit is contained in:
Riyadh
2026-07-07 12:26:19 +00:00
parent e167b3ef45
commit 3ff1338c7e
6 changed files with 524 additions and 73 deletions
+67 -11
View File
@@ -165,6 +165,14 @@ const roomPatchSchema = roomCreateSchema.partial().refine(
{ message: "no fields to update" },
);
// A single "key attendee" (أبرز الحضور) row.
const keyAttendeeSchema = z.object({
name: z.string().trim().min(1).max(300),
position: z.string().trim().max(300).default(""),
side: z.enum(["internal", "external"]),
});
const keyAttendeesSchema = z.array(keyAttendeeSchema).max(100);
const bookingCreateSchema = z
.object({
roomId: z.number().int().positive(),
@@ -201,8 +209,14 @@ const publicBookingRequestSchema = z
title: z.string().trim().min(1).max(500),
requesterName: z.string().trim().min(1).max(300),
requesterPhone: z.string().trim().min(3).max(40),
// The external entity name (اسم الجهة). Required when meetingType is
// "external"; ignored/null for internal meetings.
requesterOrg: z.string().trim().max(300).nullable().optional(),
department: z.string().trim().max(300).nullable().optional(),
meetingType: z.enum(["internal", "external"]).default("internal"),
purpose: z.string().trim().max(5000).nullable().optional(),
attendeeCount: z.number().int().positive().max(100000).nullable().optional(),
keyAttendees: keyAttendeesSchema.optional().default([]),
startsAt: isoDateTime,
endsAt: isoDateTime,
notes: z.string().trim().max(5000).nullable().optional(),
@@ -214,7 +228,18 @@ const publicBookingRequestSchema = z
.refine((v) => new Date(v.startsAt) < new Date(v.endsAt), {
message: "startsAt must be before endsAt",
path: ["endsAt"],
});
})
// When the meeting is with an external entity, its name (اسم الجهة) is
// required.
.refine(
(v) =>
v.meetingType !== "external" ||
(typeof v.requesterOrg === "string" && v.requesterOrg.trim().length > 0),
{
message: "requesterOrg is required for external meetings",
path: ["requesterOrg"],
},
);
const rejectSchema = z.object({
reason: z.string().trim().max(1000).nullable().optional(),
@@ -323,6 +348,13 @@ async function lockRoomRow(
return rows.length > 0;
}
// Build a human-friendly booking reference (رقم الحجز) from a row id and its
// creation date, e.g. "RB-2026-0042". Deterministic + unique because the id
// is unique.
function makeBookingReference(id: number, when: Date): string {
return `RB-${when.getFullYear()}-${String(id).padStart(4, "0")}`;
}
async function hasBookingConflict(
executor: DbExecutor,
opts: {
@@ -462,6 +494,7 @@ router.post(
) {
throw new Error("__conflict__");
}
const isExternal = body.meetingType === "external";
const [row] = await tx
.insert(protocolRoomBookingsTable)
.values({
@@ -469,8 +502,16 @@ router.post(
title: body.title,
requesterName: body.requesterName,
requesterPhone: body.requesterPhone,
requesterOrg: body.requesterOrg ?? null,
requesterOrg: isExternal ? (body.requesterOrg ?? null) : null,
department: body.department ?? null,
meetingType: body.meetingType,
purpose: body.purpose ?? null,
attendeeCount: body.attendeeCount ?? null,
keyAttendees: (body.keyAttendees ?? []).map((a) => ({
name: a.name,
position: a.position ?? "",
side: a.side,
})),
startsAt,
endsAt,
status: "pending",
@@ -479,18 +520,27 @@ router.post(
createdBy: null,
})
.returning();
const bookingReference = makeBookingReference(row.id, row.createdAt);
const [withRef] = await tx
.update(protocolRoomBookingsTable)
.set({ bookingReference })
.where(eq(protocolRoomBookingsTable.id, row.id))
.returning();
await logAudit(tx, {
action: "booking.public_request",
entityType: "booking",
entityId: row.id,
newValue: row,
entityId: withRef.id,
newValue: withRef,
performedBy: null,
});
return row;
return withRef;
});
// Deliberately return only an acknowledgement — never the stored row
// (which could leak internal ids / other data) to an anonymous caller.
res.status(201).json({ ok: true, id: created.id });
// Deliberately return only an acknowledgement plus the booking
// reference — never the full stored row (which could leak internal ids
// / other data) to an anonymous caller.
res
.status(201)
.json({ ok: true, id: created.id, bookingReference: created.bookingReference });
} catch (e) {
if (e instanceof Error && e.message === "__conflict__") {
res
@@ -710,14 +760,20 @@ router.post(
approvedAt: canApprove ? new Date() : null,
})
.returning();
const bookingReference = makeBookingReference(row.id, row.createdAt);
const [withRef] = await tx
.update(protocolRoomBookingsTable)
.set({ bookingReference })
.where(eq(protocolRoomBookingsTable.id, row.id))
.returning();
await logAudit(tx, {
action: "booking.create",
entityType: "booking",
entityId: row.id,
newValue: row,
entityId: withRef.id,
newValue: withRef,
performedBy: req.session.userId!,
});
return row;
return withRef;
});
res.status(201).json(created);
} catch (e) {
+17 -1
View File
@@ -1716,8 +1716,24 @@
"requester": "مقدّم الطلب",
"org": "الجهة",
"phone": "الهاتف",
"attendeeCount": "عدد الحضور",
"attendeeCount": "العدد المتوقع للحضور",
"publicRequest": "طلب عام",
"reference": "رقم الحجز",
"department": "الإدارة",
"entity": "اسم الجهة",
"purpose": "الغرض من الاجتماع",
"meetingType": "نوع الاجتماع",
"meetingTypes": {
"internal": "اجتماع داخلي",
"external": "اجتماع مع جهة خارجية"
},
"keyAttendees": "أبرز الحضور",
"attendeeSides": {
"internal": "من الهيئة",
"external": "خارج الهيئة"
},
"searchPlaceholder": "ابحث برقم الحجز أو الهاتف…",
"searchEmpty": "لا توجد حجوزات مطابقة.",
"roomLabel": "القاعة",
"titleLabel": "عنوان الحجز",
"startsAt": "من",
+17 -1
View File
@@ -1589,8 +1589,24 @@
"requester": "Requester",
"org": "Organization",
"phone": "Phone",
"attendeeCount": "Attendees",
"attendeeCount": "Expected attendees",
"publicRequest": "Public request",
"reference": "Booking ref",
"department": "Department",
"entity": "Entity",
"purpose": "Meeting purpose",
"meetingType": "Meeting type",
"meetingTypes": {
"internal": "Internal meeting",
"external": "Meeting with external entity"
},
"keyAttendees": "Key attendees",
"attendeeSides": {
"internal": "From the authority",
"external": "External"
},
"searchPlaceholder": "Search by booking ref or phone…",
"searchEmpty": "No matching bookings.",
"roomLabel": "Room",
"titleLabel": "Booking title",
"startsAt": "From",
+291 -53
View File
@@ -1,6 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CalendarClock, CheckCircle2, Loader2 } from "lucide-react";
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";
@@ -18,12 +25,19 @@ 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;
@@ -35,8 +49,11 @@ const EMPTY: FormState = {
requesterName: "",
title: "",
requesterPhone: "",
department: "",
meetingType: "internal",
requesterOrg: "",
attendeeCount: "",
purpose: "",
roomId: "",
startsAt: "",
endsAt: "",
@@ -44,6 +61,8 @@ const EMPTY: FormState = {
website: "",
};
const EMPTY_ROW: AttendeeRow = { name: "", position: "" };
function localToIso(v: string): string {
return new Date(v).toISOString();
}
@@ -74,13 +93,51 @@ export default function ProtocolRequestPage() {
});
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 &&
@@ -89,9 +146,10 @@ export default function ProtocolRequestPage() {
form.roomId !== "" &&
form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) < new Date(form.endsAt)
new Date(form.startsAt) < new Date(form.endsAt) &&
(!isExternal || form.requesterOrg.trim().length > 0)
);
}, [form]);
}, [form, isExternal]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -99,6 +157,22 @@ export default function ProtocolRequestPage() {
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" },
@@ -107,11 +181,15 @@ export default function ProtocolRequestPage() {
title: form.title.trim(),
requesterName: form.requesterName.trim(),
requesterPhone: form.requesterPhone.trim(),
requesterOrg: form.requesterOrg.trim() || null,
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,
@@ -119,6 +197,14 @@ export default function ProtocolRequestPage() {
}),
});
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;
}
@@ -140,6 +226,14 @@ export default function ProtocolRequestPage() {
}
};
const resetForm = () => {
setForm(EMPTY);
setInternalAttendees([{ ...EMPTY_ROW }]);
setExternalAttendees([{ ...EMPTY_ROW }]);
setReference(null);
setDone(false);
};
if (done) {
return (
<div
@@ -155,14 +249,21 @@ export default function ProtocolRequestPage() {
سيقوم فريق المراسم بمراجعة طلب الحجز والتواصل معك على رقم الهاتف
المُدخل لتأكيد الموعد.
</p>
<Button
className="mt-6"
variant="outline"
onClick={() => {
setForm(EMPTY);
setDone(false);
}}
>
{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>
@@ -205,7 +306,7 @@ export default function ProtocolRequestPage() {
<div className="space-y-2">
<Label htmlFor="requesterName">
الاسم الكامل <span className="text-rose-500">*</span>
الاسم <span className="text-rose-500">*</span>
</Label>
<Input
id="requesterName"
@@ -216,19 +317,6 @@ export default function ProtocolRequestPage() {
/>
</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 htmlFor="requesterPhone">
رقم الهاتف للتواصل <span className="text-rose-500">*</span>
@@ -246,37 +334,11 @@ export default function ProtocolRequestPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="requesterOrg">الجهة / المؤسسة</Label>
<Input
id="requesterOrg"
value={form.requesterOrg}
onChange={(e) => set("requesterOrg", e.target.value)}
placeholder="اسم الجهة أو المؤسسة (اختياري)"
/>
</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="room">
القاعة <span className="text-rose-500">*</span>
</Label>
<Select
value={form.roomId}
onValueChange={(v) => set("roomId", v)}
>
<Select value={form.roomId} onValueChange={(v) => set("roomId", v)}>
<SelectTrigger id="room">
<SelectValue
placeholder={
@@ -299,6 +361,95 @@ export default function ProtocolRequestPage() {
)}
</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">
@@ -332,6 +483,30 @@ export default function ProtocolRequestPage() {
</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
@@ -343,6 +518,13 @@ export default function ProtocolRequestPage() {
/>
</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}
@@ -362,3 +544,59 @@ export default function ProtocolRequestPage() {
</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>
);
}
+89 -5
View File
@@ -22,6 +22,7 @@ import {
X,
CalendarDays,
List as ListIcon,
Search as SearchIcon,
Link2,
Copy,
Upload,
@@ -90,6 +91,12 @@ type Room = {
};
type BookingStatus = "pending" | "approved" | "rejected" | "cancelled";
type MeetingType = "internal" | "external";
type KeyAttendee = {
name: string;
position: string;
side: "internal" | "external";
};
type Booking = {
id: number;
roomId: number;
@@ -98,6 +105,11 @@ type Booking = {
requesterPhone: string | null;
requesterOrg: string | null;
attendeeCount: number | null;
department: string | null;
meetingType: MeetingType;
purpose: string | null;
keyAttendees: KeyAttendee[] | null;
bookingReference: string | null;
source: "internal" | "public";
startsAt: string;
endsAt: string;
@@ -564,6 +576,7 @@ export default function ProtocolPage() {
id: number;
} | null>(null);
const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list");
const [bookingSearch, setBookingSearch] = useState("");
const [selectedDay, setSelectedDay] = useState<Date>(() => new Date());
const [calendarMonth, setCalendarMonth] = useState<Date>(() => new Date());
const now = useNow(60000);
@@ -737,14 +750,29 @@ export default function ProtocolPage() {
{roomName(b.roomId)} · {fmtDateTime(b.startsAt)} {" "}
{fmtDateTime(b.endsAt)}
</div>
{b.bookingReference && (
<div className="text-xs font-medium text-sky-600">
{t("protocol.bookings.reference")}:{" "}
<span dir="ltr">{b.bookingReference}</span>
</div>
)}
{b.requesterName && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.requester")}: {b.requesterName}
</div>
)}
{b.requesterOrg && (
{b.department && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.org")}: {b.requesterOrg}
{t("protocol.bookings.department")}: {b.department}
</div>
)}
<div className="text-xs text-slate-400">
{t("protocol.bookings.meetingType")}:{" "}
{t(`protocol.bookings.meetingTypes.${b.meetingType}`)}
</div>
{b.meetingType === "external" && b.requesterOrg && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.entity")}: {b.requesterOrg}
</div>
)}
{b.requesterPhone && (
@@ -758,6 +786,27 @@ export default function ProtocolPage() {
{t("protocol.bookings.attendeeCount")}: {b.attendeeCount}
</div>
)}
{b.purpose && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.purpose")}: {b.purpose}
</div>
)}
{b.keyAttendees && b.keyAttendees.length > 0 && (
<div className="text-xs text-slate-400 mt-1">
<span className="font-medium">
{t("protocol.bookings.keyAttendees")}:
</span>
<ul className="mt-0.5 space-y-0.5">
{b.keyAttendees.map((a, i) => (
<li key={i}>
{a.name}
{a.position ? `${a.position}` : ""} (
{t(`protocol.bookings.attendeeSides.${a.side}`)})
</li>
))}
</ul>
</div>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{b.source === "public" && (
@@ -1008,13 +1057,48 @@ export default function ProtocolPage() {
</Button>
</div>
</div>
{bookingsView === "list" && (
<div className="relative">
<SearchIcon
size={16}
className="pointer-events-none absolute top-1/2 -translate-y-1/2 start-3 text-slate-400"
/>
<Input
value={bookingSearch}
onChange={(e) => setBookingSearch(e.target.value)}
placeholder={t("protocol.bookings.searchPlaceholder")}
className="ps-9"
/>
</div>
)}
{bookings.data?.length === 0 && (
<EmptyState text={t("protocol.bookings.empty")} />
)}
{bookingsView === "list" ? (
<div className="grid gap-2">
{(bookings.data ?? []).map((b) => renderBookingCard(b))}
</div>
(() => {
const q = bookingSearch.trim().toLowerCase();
const filtered = q
? (bookings.data ?? []).filter(
(b) =>
(b.bookingReference ?? "")
.toLowerCase()
.includes(q) ||
(b.requesterPhone ?? "").toLowerCase().includes(q),
)
: (bookings.data ?? []);
if (q && filtered.length === 0) {
return (
<EmptyState
text={t("protocol.bookings.searchEmpty")}
/>
);
}
return (
<div className="grid gap-2">
{filtered.map((b) => renderBookingCard(b))}
</div>
);
})()
) : (
<div className="flex flex-col gap-4 lg:flex-row">
<div className="shrink-0 self-center lg:self-start">
+43 -2
View File
@@ -8,6 +8,7 @@ import {
jsonb,
boolean,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
@@ -35,6 +36,25 @@ export const PROTOCOL_BOOKING_STATUSES = [
export type ProtocolBookingStatus =
(typeof PROTOCOL_BOOKING_STATUSES)[number];
// A room booking is either an internal meeting or a meeting with an external
// entity. When external, the requester's organization (requesterOrg / اسم
// الجهة) is required.
export const PROTOCOL_MEETING_TYPES = ["internal", "external"] as const;
export type ProtocolMeetingType = (typeof PROTOCOL_MEETING_TYPES)[number];
// Which "side" a key attendee belongs to: from the authority (من الهيئة) or
// from outside it (خارج الهيئة).
export const PROTOCOL_ATTENDEE_SIDES = ["internal", "external"] as const;
export type ProtocolAttendeeSide = (typeof PROTOCOL_ATTENDEE_SIDES)[number];
// A notable attendee ("أبرز الحضور") captured on the booking request. Stored
// as a JSON array on the booking row.
export type ProtocolKeyAttendee = {
name: string;
position: string;
side: ProtocolAttendeeSide;
};
export const PROTOCOL_ISSUE_STATUSES = [
"pending",
"approved",
@@ -94,9 +114,26 @@ export const protocolRoomBookingsTable = pgTable(
// The requester's organization / entity (الجهة). Optional even for
// public requests; always null for internal bookings unless set.
requesterOrg: varchar("requester_org", { length: 300 }),
// Expected number of attendees (عدد الحضور). Optional; supplied by the
// guest on a public request or by staff on an internal booking.
// Expected number of attendees (العدد المتوقع للحضور). Optional; supplied
// by the guest on a public request or by staff on an internal booking.
attendeeCount: integer("attendee_count"),
// The requester's department / administration (الإدارة). Optional.
department: varchar("department", { length: 300 }),
// Whether the meeting is internal or with an external entity. When
// "external", requesterOrg (اسم الجهة) is required. Defaults to internal.
meetingType: varchar("meeting_type", { length: 20 })
.notNull()
.default("internal"),
// Free-text purpose of the meeting (الغرض من الاجتماع). Optional.
purpose: text("purpose"),
// Notable attendees (أبرز الحضور): array of { name, position, side }.
keyAttendees: jsonb("key_attendees")
.$type<ProtocolKeyAttendee[]>()
.notNull()
.default([]),
// Human-friendly, unique booking reference (رقم الحجز) shown to the
// requester on success and searchable by staff. Generated on insert.
bookingReference: varchar("booking_reference", { length: 40 }),
// Provenance marker. "internal" for bookings created by authenticated
// protocol staff through the app; "public" for guest requests coming in
// via the no-login booking-request link. Defaults to "internal" so all
@@ -128,6 +165,10 @@ export const protocolRoomBookingsTable = pgTable(
roomIdx: index("protocol_bookings_room_idx").on(t.roomId),
startIdx: index("protocol_bookings_start_idx").on(t.startsAt),
statusIdx: index("protocol_bookings_status_idx").on(t.status),
referenceIdx: uniqueIndex("protocol_bookings_reference_idx").on(
t.bookingReference,
),
phoneIdx: index("protocol_bookings_phone_idx").on(t.requesterPhone),
}),
);