diff --git a/artifacts/tx-os/src/components/attendee-table-editor.tsx b/artifacts/tx-os/src/components/attendee-table-editor.tsx new file mode 100644 index 00000000..f7e21be5 --- /dev/null +++ b/artifacts/tx-os/src/components/attendee-table-editor.tsx @@ -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>; + labels: AttendeeTableLabels; +}) { + const update = ( + idx: number, + patch: Partial, + ) => + setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r))); + + return ( +
+
+ + + + + + + + + + + {rows.map((row, idx) => ( + + + + + + + + ))} + +
#{labels.name}{labels.position}{labels.side} +
+ {idx + 1} + + update(idx, { name: e.target.value })} + placeholder={labels.name} + /> + + update(idx, { position: e.target.value })} + placeholder={labels.position} + /> + + + + {rows.length > 1 && ( + + )} +
+
+ +
+ ); +} diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 7651bfab..d858dcb3 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1857,6 +1857,7 @@ "form": { "name": "الاسم", "position": "المنصب", + "side": "الجهة", "addRow": "إضافة صف", "removeRow": "حذف الصف" }, diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index acf4c037..91abc856 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1702,6 +1702,7 @@ "form": { "name": "Name", "position": "Position", + "side": "Side", "addRow": "Add row", "removeRow": "Remove row" }, diff --git a/artifacts/tx-os/src/pages/protocol-request.tsx b/artifacts/tx-os/src/pages/protocol-request.tsx index 73573d23..742a5927 100644 --- a/artifacts/tx-os/src/pages/protocol-request.tsx +++ b/artifacts/tx-os/src/pages/protocol-request.tsx @@ -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([ - { ...EMPTY_ROW }, - ]); - const [externalAttendees, setExternalAttendees] = useState([ - { ...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([ + emptyAttendeeRow(), ]); const [submitting, setSubmitting] = useState(false); const [done, setDone] = useState(false); @@ -195,32 +193,12 @@ export default function ProtocolRequestPage() { const set = (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() {
-
- - updateRow("internal", idx, field, value) - } - onAdd={() => addRow("internal")} - onRemove={(idx) => removeRow("internal", idx)} - /> - - updateRow("external", idx, field, value) - } - onAdd={() => addRow("external")} - onRemove={(idx) => removeRow("external", idx)} - /> -
+
@@ -757,59 +718,3 @@ export default function ProtocolRequestPage() {
); } - -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 ( -
-

{title}

-
- {rows.map((row, idx) => ( -
-
- onChange(idx, "name", e.target.value)} - placeholder="الاسم" - /> - {rows.length > 1 && ( - - )} -
- onChange(idx, "position", e.target.value)} - placeholder="المنصب" - /> -
- ))} -
- -
- ); -} diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx index c9a98dbd..c2c00fd0 100644 --- a/artifacts/tx-os/src/pages/protocol.tsx +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -86,6 +86,12 @@ import { TableRow, } from "@/components/ui/table"; import { AppDock } from "@/components/app-dock"; +import { + AttendeeTableEditor, + emptyAttendeeRow, + syncAttendeeRows, + type EditableAttendeeRow, +} from "@/components/attendee-table-editor"; import { Calendar } from "@/components/ui/calendar"; import { useNow } from "@/components/clock"; import { useToast } from "@/hooks/use-toast"; @@ -3768,39 +3774,36 @@ function BookingDialog({ 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"), - ); + // One numbered table of key attendees; each row carries its own side + // (من الهيئة / خارج الهيئة). Existing rows first, then padded with empty + // rows up to the expected attendee count. + const [attendees, setAttendees] = useState(() => { + const existing = (edit?.keyAttendees ?? []).map((a) => ({ + name: a.name, + position: a.position, + side: a.side, + })); + const base = existing.length > 0 ? existing : [emptyAttendeeRow()]; + return syncAttendeeRows( + 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 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 keyAttendees = attendees + .filter((r) => r.name.trim() !== "") + .map((r) => ({ + name: r.name.trim(), + position: r.position.trim(), + side: r.side, + })); const body = { roomId: Number(roomId), title, @@ -4005,18 +4008,19 @@ function BookingDialog({ )}
-
- - -
+
@@ -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> - >; -}) { - const { t } = useTranslation(); - return ( -
-

{title}

- {rows.map((row, idx) => ( -
-
- - setRows((rs) => - rs.map((r, i) => - i === idx ? { ...r, name: e.target.value } : r, - ), - ) - } - placeholder={t("protocol.bookings.form.name")} - /> - {rows.length > 1 && ( - - )} -
- - setRows((rs) => - rs.map((r, i) => - i === idx ? { ...r, position: e.target.value } : r, - ), - ) - } - placeholder={t("protocol.bookings.form.position")} - /> -
- ))} - -
- ); -} - function RoomDialog({ edit, onClose,