Task #718: Numbered key-attendees table auto-synced with expected attendee count

- New shared component artifacts/tx-os/src/components/attendee-table-editor.tsx:
  AttendeeTableEditor (# | name | position | side select | remove), plus
  syncAttendeeRows/emptyAttendeeRow helpers and MAX_ATTENDEE_ROWS=50 cap.
- Internal booking dialog (protocol.tsx): replaced the two side-by-side
  BookingAttendeeColumn lists with the single numbered table; each row now
  carries its own side. Edit mode prefills existing keyAttendees then pads
  to attendeeCount. useEffect syncs row count with the attendeeCount field:
  growing adds empty rows, shrinking removes only empty trailing rows (never
  filled ones), min 1 row.
- Public form (protocol-request.tsx): same conversion (AttendeeColumn removed,
  Arabic labels passed directly since the page is forced-RTL Arabic).
- Submission payload unchanged: only rows with non-empty name sent, same
  keyAttendees {name, position, side} shape — no API/schema changes.
- i18n: added protocol.bookings.form.side ("الجهة"/"Side") in ar.json/en.json.
- Removed dead code (BookingAttendeeColumn, AttendeeColumn, AttendeeRow type,
  unused Plus/X imports in protocol-request).
- Verified: tsc --noEmit clean; page serves 200; architect review PASS.
- Follow-up proposed: #719 UI test for count sync/prefill/submit.
This commit is contained in:
Replit Agent
2026-07-09 06:03:40 +00:00
parent 3cf4aae18e
commit 7daaf6f2f0
5 changed files with 253 additions and 243 deletions
+38 -133
View File
@@ -4,8 +4,6 @@ import {
CalendarClock,
CheckCircle2,
Loader2,
Plus,
X,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -20,6 +18,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { DateTimePicker } from "@/components/date-time-picker";
import {
AttendeeTableEditor,
emptyAttendeeRow,
syncAttendeeRows,
type EditableAttendeeRow,
} from "@/components/attendee-table-editor";
const API = `${import.meta.env.BASE_URL}api`;
@@ -70,8 +74,6 @@ function todayRiyadh(): string {
}
type MeetingType = "internal" | "external";
type AttendeeSide = "internal" | "external";
type AttendeeRow = { name: string; position: string };
type FormState = {
requesterName: string;
@@ -109,8 +111,6 @@ const EMPTY: FormState = {
website: "",
};
const EMPTY_ROW: AttendeeRow = { name: "", position: "" };
function localToIso(v: string): string {
return new Date(v).toISOString();
}
@@ -179,13 +179,11 @@ export default function ProtocolRequestPage() {
slotProbe.data.slots.length > 0;
const daySlots = availability.data?.slots ?? [];
const selectedSlot = daySlots.find((s) => String(s.id) === form.slotId);
// 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 },
// Key attendees ("أبرز الحضور"): one numbered table where each row carries
// its own side (من الهيئة / خارج الهيئة). Row count follows the expected
// attendee count field.
const [attendees, setAttendees] = useState<EditableAttendeeRow[]>([
emptyAttendeeRow(),
]);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
@@ -195,32 +193,12 @@ export default function ProtocolRequestPage() {
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),
);
};
// Auto-generate table rows to match "العدد المتوقع للحضور".
useEffect(() => {
const n =
form.attendeeCount.trim() === "" ? null : Number(form.attendeeCount);
setAttendees((rs) => syncAttendeeRows(rs, n));
}, [form.attendeeCount]);
const isExternal = form.meetingType === "external";
@@ -246,22 +224,13 @@ 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 keyAttendees = attendees
.filter((r) => r.name.trim() !== "")
.map((r) => ({
name: r.name.trim(),
position: r.position.trim(),
side: r.side,
}));
const res = await fetch(`${API}/protocol/public/booking-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -327,8 +296,7 @@ export default function ProtocolRequestPage() {
const resetForm = () => {
setForm(EMPTY);
setInternalAttendees([{ ...EMPTY_ROW }]);
setExternalAttendees([{ ...EMPTY_ROW }]);
setAttendees([emptyAttendeeRow()]);
setReference(null);
setDone(false);
};
@@ -698,26 +666,19 @@ export default function ProtocolRequestPage() {
<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>
<AttendeeTableEditor
rows={attendees}
setRows={setAttendees}
labels={{
name: "الاسم",
position: "المنصب",
side: "الجهة",
sideInternal: "من الهيئة",
sideExternal: "خارج الهيئة",
addRow: "إضافة صف",
removeRow: "حذف الصف",
}}
/>
</div>
<div className="space-y-2">
@@ -757,59 +718,3 @@ 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>
);
}