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
@@ -0,0 +1,168 @@
import { Plus, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type EditableAttendeeRow = {
name: string;
position: string;
side: "internal" | "external";
};
export const MAX_ATTENDEE_ROWS = 50;
export function emptyAttendeeRow(): EditableAttendeeRow {
return { name: "", position: "", side: "internal" };
}
// Keep the number of editable rows in sync with the expected attendee count:
// growing adds empty rows at the bottom; shrinking only removes empty rows
// from the bottom (never a row the user already typed into). Always keeps at
// least one row so the table is never empty.
export function syncAttendeeRows(
rows: EditableAttendeeRow[],
count: number | null,
): EditableAttendeeRow[] {
if (count == null || !Number.isFinite(count) || count < 1) return rows;
const target = Math.min(Math.floor(count), MAX_ATTENDEE_ROWS);
if (rows.length < target) {
return [
...rows,
...Array.from({ length: target - rows.length }, emptyAttendeeRow),
];
}
if (rows.length > target) {
const next = [...rows];
while (
next.length > target &&
next.length > 1 &&
next[next.length - 1].name.trim() === "" &&
next[next.length - 1].position.trim() === ""
) {
next.pop();
}
return next;
}
return rows;
}
export type AttendeeTableLabels = {
name: string;
position: string;
side: string;
sideInternal: string;
sideExternal: string;
addRow: string;
removeRow: string;
};
// Numbered "أبرز الحضور" table: # | name | position | side | remove.
export function AttendeeTableEditor({
rows,
setRows,
labels,
}: {
rows: EditableAttendeeRow[];
setRows: React.Dispatch<React.SetStateAction<EditableAttendeeRow[]>>;
labels: AttendeeTableLabels;
}) {
const update = (
idx: number,
patch: Partial<EditableAttendeeRow>,
) =>
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
return (
<div className="space-y-2">
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-start text-xs font-semibold text-slate-600">
<th className="w-10 px-2 py-2 text-center">#</th>
<th className="px-2 py-2 text-start">{labels.name}</th>
<th className="px-2 py-2 text-start">{labels.position}</th>
<th className="w-36 px-2 py-2 text-start">{labels.side}</th>
<th className="w-10 px-1 py-2" />
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={idx} className="border-b border-slate-100 last:border-b-0">
<td className="px-2 py-1.5 text-center font-medium text-slate-500">
{idx + 1}
</td>
<td className="px-2 py-1.5">
<Input
className="h-8"
value={row.name}
onChange={(e) => update(idx, { name: e.target.value })}
placeholder={labels.name}
/>
</td>
<td className="px-2 py-1.5">
<Input
className="h-8"
value={row.position}
onChange={(e) => update(idx, { position: e.target.value })}
placeholder={labels.position}
/>
</td>
<td className="px-2 py-1.5">
<Select
value={row.side}
onValueChange={(v) =>
update(idx, { side: v as "internal" | "external" })
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal">
{labels.sideInternal}
</SelectItem>
<SelectItem value="external">
{labels.sideExternal}
</SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-1 py-1.5 text-center">
{rows.length > 1 && (
<button
type="button"
onClick={() =>
setRows((rs) => rs.filter((_, i) => i !== idx))
}
className="p-1 text-slate-400 hover:text-rose-500"
aria-label={labels.removeRow}
>
<X className="h-4 w-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
onClick={() =>
setRows((rs) =>
rs.length >= MAX_ATTENDEE_ROWS ? rs : [...rs, emptyAttendeeRow()],
)
}
className="flex items-center gap-1 text-sm font-medium text-sky-600 hover:text-sky-700"
>
<Plus className="h-4 w-4" />
{labels.addRow}
</button>
</div>
);
}