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>
);
}
+1
View File
@@ -1857,6 +1857,7 @@
"form": { "form": {
"name": "الاسم", "name": "الاسم",
"position": "المنصب", "position": "المنصب",
"side": "الجهة",
"addRow": "إضافة صف", "addRow": "إضافة صف",
"removeRow": "حذف الصف" "removeRow": "حذف الصف"
}, },
+1
View File
@@ -1702,6 +1702,7 @@
"form": { "form": {
"name": "Name", "name": "Name",
"position": "Position", "position": "Position",
"side": "Side",
"addRow": "Add row", "addRow": "Add row",
"removeRow": "Remove row" "removeRow": "Remove row"
}, },
+33 -128
View File
@@ -4,8 +4,6 @@ import {
CalendarClock, CalendarClock,
CheckCircle2, CheckCircle2,
Loader2, Loader2,
Plus,
X,
AlertTriangle, AlertTriangle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -20,6 +18,12 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { DateTimePicker } from "@/components/date-time-picker"; 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`; const API = `${import.meta.env.BASE_URL}api`;
@@ -70,8 +74,6 @@ function todayRiyadh(): string {
} }
type MeetingType = "internal" | "external"; type MeetingType = "internal" | "external";
type AttendeeSide = "internal" | "external";
type AttendeeRow = { name: string; position: string };
type FormState = { type FormState = {
requesterName: string; requesterName: string;
@@ -109,8 +111,6 @@ const EMPTY: FormState = {
website: "", website: "",
}; };
const EMPTY_ROW: AttendeeRow = { name: "", position: "" };
function localToIso(v: string): string { function localToIso(v: string): string {
return new Date(v).toISOString(); return new Date(v).toISOString();
} }
@@ -179,13 +179,11 @@ export default function ProtocolRequestPage() {
slotProbe.data.slots.length > 0; slotProbe.data.slots.length > 0;
const daySlots = availability.data?.slots ?? []; const daySlots = availability.data?.slots ?? [];
const selectedSlot = daySlots.find((s) => String(s.id) === form.slotId); const selectedSlot = daySlots.find((s) => String(s.id) === form.slotId);
// Key attendees ("أبرز الحضور") are two separate lists: من الهيئة (internal) // Key attendees ("أبرز الحضور"): one numbered table where each row carries
// and خارج الهيئة (external). Each row is a name + position. // its own side (من الهيئة / خارج الهيئة). Row count follows the expected
const [internalAttendees, setInternalAttendees] = useState<AttendeeRow[]>([ // attendee count field.
{ ...EMPTY_ROW }, const [attendees, setAttendees] = useState<EditableAttendeeRow[]>([
]); emptyAttendeeRow(),
const [externalAttendees, setExternalAttendees] = useState<AttendeeRow[]>([
{ ...EMPTY_ROW },
]); ]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [done, setDone] = 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]) => const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
setForm((f) => ({ ...f, [k]: v })); setForm((f) => ({ ...f, [k]: v }));
const updateRow = ( // Auto-generate table rows to match "العدد المتوقع للحضور".
side: AttendeeSide, useEffect(() => {
idx: number, const n =
field: keyof AttendeeRow, form.attendeeCount.trim() === "" ? null : Number(form.attendeeCount);
value: string, setAttendees((rs) => syncAttendeeRows(rs, n));
) => { }, [form.attendeeCount]);
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),
);
};
const isExternal = form.meetingType === "external"; const isExternal = form.meetingType === "external";
@@ -246,22 +224,13 @@ export default function ProtocolRequestPage() {
if (!canSubmit || submitting) return; if (!canSubmit || submitting) return;
setSubmitting(true); setSubmitting(true);
try { try {
const keyAttendees = [ const keyAttendees = attendees
...internalAttendees
.filter((r) => r.name.trim() !== "") .filter((r) => r.name.trim() !== "")
.map((r) => ({ .map((r) => ({
name: r.name.trim(), name: r.name.trim(),
position: r.position.trim(), position: r.position.trim(),
side: "internal" as const, side: r.side,
})), }));
...externalAttendees
.filter((r) => r.name.trim() !== "")
.map((r) => ({
name: r.name.trim(),
position: r.position.trim(),
side: "external" as const,
})),
];
const res = await fetch(`${API}/protocol/public/booking-requests`, { const res = await fetch(`${API}/protocol/public/booking-requests`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -327,8 +296,7 @@ export default function ProtocolRequestPage() {
const resetForm = () => { const resetForm = () => {
setForm(EMPTY); setForm(EMPTY);
setInternalAttendees([{ ...EMPTY_ROW }]); setAttendees([emptyAttendeeRow()]);
setExternalAttendees([{ ...EMPTY_ROW }]);
setReference(null); setReference(null);
setDone(false); setDone(false);
}; };
@@ -698,26 +666,19 @@ export default function ProtocolRequestPage() {
<div className="space-y-3"> <div className="space-y-3">
<Label>أبرز الحضور</Label> <Label>أبرز الحضور</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <AttendeeTableEditor
<AttendeeColumn rows={attendees}
title="من الهيئة" setRows={setAttendees}
rows={internalAttendees} labels={{
onChange={(idx, field, value) => name: "الاسم",
updateRow("internal", idx, field, value) position: "المنصب",
} side: "الجهة",
onAdd={() => addRow("internal")} sideInternal: "من الهيئة",
onRemove={(idx) => removeRow("internal", idx)} sideExternal: "خارج الهيئة",
addRow: "إضافة صف",
removeRow: "حذف الصف",
}}
/> />
<AttendeeColumn
title="خارج الهيئة"
rows={externalAttendees}
onChange={(idx, field, value) =>
updateRow("external", idx, field, value)
}
onAdd={() => addRow("external")}
onRemove={(idx) => removeRow("external", idx)}
/>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -757,59 +718,3 @@ export default function ProtocolRequestPage() {
</div> </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>
);
}
+39 -104
View File
@@ -86,6 +86,12 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { AppDock } from "@/components/app-dock"; import { AppDock } from "@/components/app-dock";
import {
AttendeeTableEditor,
emptyAttendeeRow,
syncAttendeeRows,
type EditableAttendeeRow,
} from "@/components/attendee-table-editor";
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
import { useNow } from "@/components/clock"; import { useNow } from "@/components/clock";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -3768,39 +3774,36 @@ function BookingDialog({
edit?.attendeeCount != null ? String(edit.attendeeCount) : "", edit?.attendeeCount != null ? String(edit.attendeeCount) : "",
); );
const [purpose, setPurpose] = useState(edit?.purpose ?? ""); const [purpose, setPurpose] = useState(edit?.purpose ?? "");
const emptyRow = { name: "", position: "" }; // One numbered table of key attendees; each row carries its own side
const initialRows = (side: "internal" | "external") => { // (من الهيئة / خارج الهيئة). Existing rows first, then padded with empty
const rows = (edit?.keyAttendees ?? []) // rows up to the expected attendee count.
.filter((a) => a.side === side) const [attendees, setAttendees] = useState<EditableAttendeeRow[]>(() => {
.map((a) => ({ name: a.name, position: a.position })); const existing = (edit?.keyAttendees ?? []).map((a) => ({
return rows.length > 0 ? rows : [{ ...emptyRow }]; name: a.name,
}; position: a.position,
const [internalAttendees, setInternalAttendees] = useState(() => side: a.side,
initialRows("internal"), }));
); const base = existing.length > 0 ? existing : [emptyAttendeeRow()];
const [externalAttendees, setExternalAttendees] = useState(() => return syncAttendeeRows(
initialRows("external"), base,
edit?.attendeeCount != null ? edit.attendeeCount : null,
); );
});
useEffect(() => {
const n = attendeeCount.trim() === "" ? null : Number(attendeeCount);
setAttendees((rs) => syncAttendeeRows(rs, n));
}, [attendeeCount]);
const isExternal = meetingType === "external"; const isExternal = meetingType === "external";
const mut = useMutation({ const mut = useMutation({
mutationFn: () => { mutationFn: () => {
const keyAttendees = [ const keyAttendees = attendees
...internalAttendees
.filter((r) => r.name.trim() !== "") .filter((r) => r.name.trim() !== "")
.map((r) => ({ .map((r) => ({
name: r.name.trim(), name: r.name.trim(),
position: r.position.trim(), position: r.position.trim(),
side: "internal" as const, side: r.side,
})), }));
...externalAttendees
.filter((r) => r.name.trim() !== "")
.map((r) => ({
name: r.name.trim(),
position: r.position.trim(),
side: "external" as const,
})),
];
const body = { const body = {
roomId: Number(roomId), roomId: Number(roomId),
title, title,
@@ -4005,18 +4008,19 @@ function BookingDialog({
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("protocol.bookings.keyAttendees")}</Label> <Label>{t("protocol.bookings.keyAttendees")}</Label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <AttendeeTableEditor
<BookingAttendeeColumn rows={attendees}
title={t("protocol.bookings.attendeeSides.internal")} setRows={setAttendees}
rows={internalAttendees} labels={{
setRows={setInternalAttendees} name: t("protocol.bookings.form.name"),
position: t("protocol.bookings.form.position"),
side: t("protocol.bookings.form.side"),
sideInternal: t("protocol.bookings.attendeeSides.internal"),
sideExternal: t("protocol.bookings.attendeeSides.external"),
addRow: t("protocol.bookings.form.addRow"),
removeRow: t("protocol.bookings.form.removeRow"),
}}
/> />
<BookingAttendeeColumn
title={t("protocol.bookings.attendeeSides.external")}
rows={externalAttendees}
setRows={setExternalAttendees}
/>
</div>
</div> </div>
<div> <div>
<Label>{t("protocol.notes")}</Label> <Label>{t("protocol.notes")}</Label>
@@ -4026,75 +4030,6 @@ 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({ function RoomDialog({
edit, edit,
onClose, onClose,