diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index 23fd924f..1ea9ec07 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -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) { diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index c72fdb97..b87c213a 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": "من", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index c1d245ae..f2f39d56 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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", diff --git a/artifacts/tx-os/src/pages/protocol-request.tsx b/artifacts/tx-os/src/pages/protocol-request.tsx index 994d0b82..49d5dce9 100644 --- a/artifacts/tx-os/src/pages/protocol-request.tsx +++ b/artifacts/tx-os/src/pages/protocol-request.tsx @@ -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(EMPTY); + // Key attendees ("أبرز الحضور") are two separate lists: من الهيئة (internal) + // and خارج الهيئة (external). Each row is a name + position. + const [internalAttendees, setInternalAttendees] = useState([ + { ...EMPTY_ROW }, + ]); + const [externalAttendees, setExternalAttendees] = useState([ + { ...EMPTY_ROW }, + ]); const [submitting, setSubmitting] = useState(false); const [done, setDone] = useState(false); + const [reference, setReference] = useState(null); const [error, setError] = useState(null); const set = (k: K, v: FormState[K]) => setForm((f) => ({ ...f, [k]: v })); + 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 (
-
@@ -205,7 +306,7 @@ export default function ProtocolRequestPage() {
-
- - set("title", e.target.value)} - placeholder="موضوع أو عنوان الاجتماع" - required - /> -
-
-
- - set("requesterOrg", e.target.value)} - placeholder="اسم الجهة أو المؤسسة (اختياري)" - /> -
- -
- - set("attendeeCount", e.target.value)} - placeholder="العدد المتوقع للحضور (اختياري)" - /> -
-
- set("roomId", v)}> +
+ + set("department", e.target.value)} + placeholder="الإدارة أو القسم (اختياري)" + /> +
+ +
+ + set("title", e.target.value)} + placeholder="موضوع أو عنوان الاجتماع" + required + /> +
+ +
+ +
+ {( + [ + { value: "internal", label: "اجتماع داخلي" }, + { value: "external", label: "اجتماع مع جهة خارجية" }, + ] as const + ).map((opt) => ( + + ))} +
+
+ + {isExternal && ( +
+ + set("requesterOrg", e.target.value)} + placeholder="اسم الجهة الخارجية" + required + /> +
+ )} + +
+ + set("attendeeCount", e.target.value)} + placeholder="العدد المتوقع للحضور (اختياري)" + /> +
+ +
+ +