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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1857,6 +1857,7 @@
|
|||||||
"form": {
|
"form": {
|
||||||
"name": "الاسم",
|
"name": "الاسم",
|
||||||
"position": "المنصب",
|
"position": "المنصب",
|
||||||
|
"side": "الجهة",
|
||||||
"addRow": "إضافة صف",
|
"addRow": "إضافة صف",
|
||||||
"removeRow": "حذف الصف"
|
"removeRow": "حذف الصف"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: r.side,
|
||||||
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 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: "إضافة صف",
|
||||||
<AttendeeColumn
|
removeRow: "حذف الصف",
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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: r.side,
|
||||||
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 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"),
|
||||||
<BookingAttendeeColumn
|
side: t("protocol.bookings.form.side"),
|
||||||
title={t("protocol.bookings.attendeeSides.external")}
|
sideInternal: t("protocol.bookings.attendeeSides.internal"),
|
||||||
rows={externalAttendees}
|
sideExternal: t("protocol.bookings.attendeeSides.external"),
|
||||||
setRows={setExternalAttendees}
|
addRow: t("protocol.bookings.form.addRow"),
|
||||||
/>
|
removeRow: t("protocol.bookings.form.removeRow"),
|
||||||
</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,
|
||||||
|
|||||||
Reference in New Issue
Block a user