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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user