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": {
|
||||
"name": "الاسم",
|
||||
"position": "المنصب",
|
||||
"side": "الجهة",
|
||||
"addRow": "إضافة صف",
|
||||
"removeRow": "حذف الصف"
|
||||
},
|
||||
|
||||
@@ -1702,6 +1702,7 @@
|
||||
"form": {
|
||||
"name": "Name",
|
||||
"position": "Position",
|
||||
"side": "Side",
|
||||
"addRow": "Add row",
|
||||
"removeRow": "Remove row"
|
||||
},
|
||||
|
||||
@@ -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<AttendeeRow[]>([
|
||||
{ ...EMPTY_ROW },
|
||||
]);
|
||||
const [externalAttendees, setExternalAttendees] = useState<AttendeeRow[]>([
|
||||
{ ...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<EditableAttendeeRow[]>([
|
||||
emptyAttendeeRow(),
|
||||
]);
|
||||
const [submitting, setSubmitting] = 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]) =>
|
||||
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
|
||||
const keyAttendees = attendees
|
||||
.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,
|
||||
})),
|
||||
];
|
||||
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() {
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>أبرز الحضور</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<AttendeeColumn
|
||||
title="من الهيئة"
|
||||
rows={internalAttendees}
|
||||
onChange={(idx, field, value) =>
|
||||
updateRow("internal", idx, field, value)
|
||||
}
|
||||
onAdd={() => addRow("internal")}
|
||||
onRemove={(idx) => removeRow("internal", idx)}
|
||||
<AttendeeTableEditor
|
||||
rows={attendees}
|
||||
setRows={setAttendees}
|
||||
labels={{
|
||||
name: "الاسم",
|
||||
position: "المنصب",
|
||||
side: "الجهة",
|
||||
sideInternal: "من الهيئة",
|
||||
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 className="space-y-2">
|
||||
@@ -757,59 +718,3 @@ export default function ProtocolRequestPage() {
|
||||
</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,
|
||||
} 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<EditableAttendeeRow[]>(() => {
|
||||
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
|
||||
const keyAttendees = attendees
|
||||
.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,
|
||||
})),
|
||||
];
|
||||
side: r.side,
|
||||
}));
|
||||
const body = {
|
||||
roomId: Number(roomId),
|
||||
title,
|
||||
@@ -4005,18 +4008,19 @@ function BookingDialog({
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("protocol.bookings.keyAttendees")}</Label>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<BookingAttendeeColumn
|
||||
title={t("protocol.bookings.attendeeSides.internal")}
|
||||
rows={internalAttendees}
|
||||
setRows={setInternalAttendees}
|
||||
<AttendeeTableEditor
|
||||
rows={attendees}
|
||||
setRows={setAttendees}
|
||||
labels={{
|
||||
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>
|
||||
<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({
|
||||
edit,
|
||||
onClose,
|
||||
|
||||
Reference in New Issue
Block a user