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:
@@ -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) {
|
||||
|
||||
@@ -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": "من",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user