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