Task #701: توحيد نموذج الحجز الداخلي مع نموذج الرابط العام

- Extended internal BookingDialog (artifacts/tx-os/src/pages/protocol.tsx) to match
  the public booking form: requester name, phone, room, department, meeting title,
  meeting type toggle (داخلي / مع جهة خارجية), conditional entity name (external only),
  expected attendee count, purpose, from/to, key attendees in two columns
  (من الهيئة / خارج الهيئة) with add/remove name+position rows, and notes.
- Edit mode prefills all new fields from the booking; click-to-book initialStart
  behavior unchanged.
- api-server (artifacts/api-server/src/routes/protocol.ts): bookingCreateSchema and
  bookingPatchSchema now accept requesterPhone, requesterOrg, department, meetingType,
  purpose, keyAttendees; POST/PATCH /protocol/bookings persist them. requesterOrg is
  nulled whenever effective meetingType is internal (create and patch).
- Added i18n keys protocol.bookings.form.{name,position,addRow,removeRow} (ar/en);
  all other labels reuse existing protocol.bookings keys.
- Verified: tsc clean for changed files (push.ts / executive-meetings.ts errors are
  pre-existing and unrelated); API workflow restarted and serving; architect review PASS.
- Note: entity name (اسم الجهة) is not enforced as required for external meetings on
  the internal endpoint (unlike the strict public endpoint) — internal staff keep
  flexibility; can be tightened later if requested.
This commit is contained in:
Replit Agent
2026-07-08 13:40:20 +00:00
parent 088e17bf85
commit 89f29e2169
4 changed files with 268 additions and 3 deletions
@@ -191,7 +191,15 @@ const bookingCreateSchema = z
roomId: z.number().int().positive(),
title: z.string().trim().min(1).max(500),
requesterName: z.string().trim().max(300).nullable().optional(),
requesterPhone: z.string().trim().max(40).nullable().optional(),
// The external entity name (اسم الجهة); only meaningful when
// meetingType is "external".
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(),
@@ -206,7 +214,13 @@ const bookingPatchSchema = z
roomId: z.number().int().positive().optional(),
title: z.string().trim().min(1).max(500).optional(),
requesterName: z.string().trim().max(300).nullable().optional(),
requesterPhone: z.string().trim().max(40).nullable().optional(),
requesterOrg: z.string().trim().max(300).nullable().optional(),
department: z.string().trim().max(300).nullable().optional(),
meetingType: z.enum(["internal", "external"]).optional(),
purpose: z.string().trim().max(5000).nullable().optional(),
attendeeCount: z.number().int().positive().max(100000).nullable().optional(),
keyAttendees: keyAttendeesSchema.optional(),
startsAt: isoDateTime.optional(),
endsAt: isoDateTime.optional(),
notes: z.string().trim().max(5000).nullable().optional(),
@@ -789,7 +803,18 @@ router.post(
roomId: body.roomId,
title: body.title,
requesterName: body.requesterName ?? null,
requesterPhone: body.requesterPhone ?? null,
requesterOrg:
body.meetingType === "external" ? (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,
})),
bookingReference: makeBookingReference(bookingId, new Date()),
startsAt,
endsAt,
@@ -877,9 +902,38 @@ router.patch(
...(body.requesterName !== undefined
? { requesterName: body.requesterName }
: {}),
...(body.requesterPhone !== undefined
? { requesterPhone: body.requesterPhone }
: {}),
...(body.meetingType !== undefined
? { meetingType: body.meetingType }
: {}),
...(body.requesterOrg !== undefined || body.meetingType !== undefined
? {
requesterOrg:
(body.meetingType ?? existing.meetingType) === "external"
? (body.requesterOrg !== undefined
? body.requesterOrg
: existing.requesterOrg)
: null,
}
: {}),
...(body.department !== undefined
? { department: body.department }
: {}),
...(body.purpose !== undefined ? { purpose: body.purpose } : {}),
...(body.attendeeCount !== undefined
? { attendeeCount: body.attendeeCount }
: {}),
...(body.keyAttendees !== undefined
? {
keyAttendees: body.keyAttendees.map((a) => ({
name: a.name,
position: a.position ?? "",
side: a.side,
})),
}
: {}),
startsAt,
endsAt,
...(body.notes !== undefined ? { notes: body.notes } : {}),
+6
View File
@@ -1818,6 +1818,12 @@
"durationMinutes_few": "{{count}} دقائق",
"durationMinutes_many": "{{count}} دقيقة",
"durationMinutes_other": "{{count}} دقيقة"
},
"form": {
"name": "الاسم",
"position": "المنصب",
"addRow": "إضافة صف",
"removeRow": "حذف الصف"
}
},
"external": {
+6
View File
@@ -1671,6 +1671,12 @@
"durationHours_other": "{{count}} hours",
"durationMinutes_one": "1 minute",
"durationMinutes_other": "{{count}} minutes"
},
"form": {
"name": "Name",
"position": "Position",
"addRow": "Add row",
"removeRow": "Remove row"
}
},
"external": {
+202 -3
View File
@@ -2604,13 +2604,63 @@ function BookingDialog({
return dayAt(10);
});
const [notes, setNotes] = useState(edit?.notes ?? "");
const [requesterPhone, setRequesterPhone] = useState(
edit?.requesterPhone ?? "",
);
const [department, setDepartment] = useState(edit?.department ?? "");
const [meetingType, setMeetingType] = useState<MeetingType>(
edit?.meetingType ?? "internal",
);
const [requesterOrg, setRequesterOrg] = useState(edit?.requesterOrg ?? "");
const [attendeeCount, setAttendeeCount] = useState(
edit?.attendeeCount != null ? String(edit.attendeeCount) : "",
);
const [purpose, setPurpose] = useState(edit?.purpose ?? "");
const emptyRow = { name: "", position: "" };
const initialRows = (side: "internal" | "external") => {
const rows = (edit?.keyAttendees ?? [])
.filter((a) => a.side === side)
.map((a) => ({ name: a.name, position: a.position }));
return rows.length > 0 ? rows : [{ ...emptyRow }];
};
const [internalAttendees, setInternalAttendees] = useState(() =>
initialRows("internal"),
);
const [externalAttendees, setExternalAttendees] = useState(() =>
initialRows("external"),
);
const isExternal = meetingType === "external";
const mut = useMutation({
mutationFn: () => {
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 body = {
roomId: Number(roomId),
title,
requesterName: requesterName || null,
requesterPhone: requesterPhone.trim() || null,
department: department.trim() || null,
meetingType,
requesterOrg: isExternal ? requesterOrg.trim() || null : null,
attendeeCount:
attendeeCount.trim() === "" ? null : Number(attendeeCount),
purpose: purpose.trim() || null,
keyAttendees,
startsAt: fromLocalInput(startsAt),
endsAt: fromLocalInput(endsAt),
notes: notes || null,
@@ -2637,6 +2687,24 @@ function BookingDialog({
submitLabel={t("common.save")}
saving={mut.isPending}
>
<div>
<Label>{t("protocol.bookings.requester")}</Label>
<Input
value={requesterName}
onChange={(e) => setRequesterName(e.target.value)}
/>
</div>
<div>
<Label>{t("protocol.bookings.phone")}</Label>
<Input
type="tel"
inputMode="tel"
dir="ltr"
value={requesterPhone}
onChange={(e) => setRequesterPhone(e.target.value)}
placeholder="05xxxxxxxx"
/>
</div>
<div>
<Label>{t("protocol.bookings.roomLabel")}</Label>
<Select value={roomId} onValueChange={setRoomId}>
@@ -2652,15 +2720,62 @@ function BookingDialog({
</SelectContent>
</Select>
</div>
<div>
<Label>{t("protocol.bookings.department")}</Label>
<Input
value={department}
onChange={(e) => setDepartment(e.target.value)}
/>
</div>
<div>
<Label>{t("protocol.bookings.titleLabel")}</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div>
<Label>{t("protocol.bookings.requester")}</Label>
<Label>{t("protocol.bookings.meetingType")}</Label>
<div className="mt-1 grid grid-cols-2 gap-2">
{(["internal", "external"] as const).map((opt) => (
<button
key={opt}
type="button"
onClick={() => setMeetingType(opt)}
className={cn(
"rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
meetingType === opt
? "border-sky-500 bg-sky-50 text-sky-700"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300",
)}
>
{t(`protocol.bookings.meetingTypes.${opt}`)}
</button>
))}
</div>
</div>
{isExternal && (
<div>
<Label>{t("protocol.bookings.entity")}</Label>
<Input
value={requesterOrg}
onChange={(e) => setRequesterOrg(e.target.value)}
/>
</div>
)}
<div>
<Label>{t("protocol.bookings.attendeeCount")}</Label>
<Input
value={requesterName}
onChange={(e) => setRequesterName(e.target.value)}
type="number"
inputMode="numeric"
min={1}
value={attendeeCount}
onChange={(e) => setAttendeeCount(e.target.value)}
/>
</div>
<div>
<Label>{t("protocol.bookings.purpose")}</Label>
<Textarea
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -2683,6 +2798,21 @@ function BookingDialog({
/>
</div>
</div>
<div className="space-y-2">
<Label>{t("protocol.bookings.keyAttendees")}</Label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<BookingAttendeeColumn
title={t("protocol.bookings.attendeeSides.internal")}
rows={internalAttendees}
setRows={setInternalAttendees}
/>
<BookingAttendeeColumn
title={t("protocol.bookings.attendeeSides.external")}
rows={externalAttendees}
setRows={setExternalAttendees}
/>
</div>
</div>
<div>
<Label>{t("protocol.notes")}</Label>
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
@@ -2691,6 +2821,75 @@ function BookingDialog({
);
}
// One "أبرز الحضور" column (من الهيئة / خارج الهيئة) in the internal
// booking dialog: rows of name + position with add/remove.
function BookingAttendeeColumn({
title,
rows,
setRows,
}: {
title: string;
rows: Array<{ name: string; position: string }>;
setRows: React.Dispatch<
React.SetStateAction<Array<{ name: string; position: string }>>
>;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
<p className="text-sm font-semibold text-slate-700">{title}</p>
{rows.map((row, idx) => (
<div key={idx} className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Input
value={row.name}
onChange={(e) =>
setRows((rs) =>
rs.map((r, i) =>
i === idx ? { ...r, name: e.target.value } : r,
),
)
}
placeholder={t("protocol.bookings.form.name")}
/>
{rows.length > 1 && (
<button
type="button"
onClick={() =>
setRows((rs) => rs.filter((_, i) => i !== idx))
}
className="shrink-0 p-1 text-slate-400 hover:text-rose-500"
aria-label={t("protocol.bookings.form.removeRow")}
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Input
value={row.position}
onChange={(e) =>
setRows((rs) =>
rs.map((r, i) =>
i === idx ? { ...r, position: e.target.value } : r,
),
)
}
placeholder={t("protocol.bookings.form.position")}
/>
</div>
))}
<button
type="button"
onClick={() => setRows((rs) => [...rs, { name: "", position: "" }])}
className="flex items-center gap-1 text-sm font-medium text-sky-600 hover:text-sky-700"
>
<Plus className="h-4 w-4" />
{t("protocol.bookings.form.addRow")}
</button>
</div>
);
}
function RoomDialog({
edit,
onClose,