Task #207: custom subheadings inside executive-meeting attendee cells
Adds a new `kind` column (`varchar(16) NOT NULL DEFAULT 'person'`) on `executive_meeting_attendees` so users can interleave free-text section labels with person rows in a meeting's attendee list. Subheadings are excluded from the running attendee number and from the per-meeting attendee count surface, but reorder and delete identically to person rows. DB - New `kind` column in `lib/db/src/schema/executive-meetings.ts`, defaulting to `"person"`. Applied via direct SQL because `drizzle-kit push` trips on a pre-existing duplicate-row issue in `app_permissions` (already documented in replit.md). API (artifacts/api-server/src/routes/executive-meetings.ts) - `attendeeSchema` accepts `kind: z.enum(["person","subheading"])` with default `"person"`. - All 4 insert paths round-trip `kind`: POST create, PATCH meeting update (attendees replacement), PUT `/attendees`, and duplicate. - `pdf-renderer` mapper forwards `kind`. `PdfMeetingAttendee.kind` typed as `string | null` to match the DB column shape. Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx) - `AttendeeFlow` renders subheadings on a separate full-width row (`basis-full`, semibold, centered) and increments the running person index only for `kind === "person"`. Pending ghost row branches on `pendingKind`. - Inline "+ subheading" chip via `onStartAdd(type, "subheading")`. - Manage dialog: addSubheading button, subheading rows hide the title field, show a kind badge, and reorder/delete identically. Manage list summary count filters to person rows only (architect fix). - Print page renders subheadings as `.em-print-subheading` and skips them in the running counter. - New locale keys under `executiveMeetings.schedule` and `executiveMeetings.manage.attendees` in both `ar.json` and `en.json`. PDF - Subheadings print as `— label —` and never advance `personIdx`. Tests - New spec `executive-meetings-attendee-subheadings.spec.mjs` seeds mixed person+subheading rows and asserts (a) the subheading row renders, (b) numbering stays `1-`, `2-`, `3-` with a subheading wedged between persons, (c) zero-subheading meetings keep legacy numbering. Runs in both AR and EN. All 4 cases pass. Code review - Architect found one regression (Manage list summary count included subheadings) — fixed.
This commit is contained in:
@@ -19,6 +19,12 @@ export type PdfMeetingAttendee = {
|
||||
name: string;
|
||||
title: string | null;
|
||||
attendanceType?: string | null;
|
||||
// "person" (default) or "subheading". The renderer skips subheadings
|
||||
// for the running attendee number and prints them as "— label —".
|
||||
// Typed as a plain string (rather than the literal union) because the
|
||||
// DB column is just a varchar; the renderer normalises unknown values
|
||||
// back to "person" at the comparison site.
|
||||
kind?: string | null;
|
||||
};
|
||||
|
||||
export type PdfMeeting = {
|
||||
@@ -524,13 +530,28 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
|
||||
text:
|
||||
meeting.attendees.length === 0
|
||||
? "—"
|
||||
: meeting.attendees
|
||||
.map((a, idx) => {
|
||||
const name = htmlToPlain(a.name);
|
||||
const t = a.title?.trim();
|
||||
return `${idx + 1}- ${name}${t ? ` (${t})` : ""}`;
|
||||
})
|
||||
.join("\n"),
|
||||
: (() => {
|
||||
// Walking person counter — subheadings are non-numbered
|
||||
// labels, so they don't advance the index. Same logic as
|
||||
// the on-screen AttendeeFlow and the print page.
|
||||
let personIdx = 0;
|
||||
return meeting.attendees
|
||||
.map((a) => {
|
||||
const isSub = (a.kind ?? "person") === "subheading";
|
||||
const name = htmlToPlain(a.name);
|
||||
if (isSub) {
|
||||
// Brackets keep subheadings visually distinct in the
|
||||
// monospace plain-text PDF cell where bold/colour
|
||||
// can't carry. Adapter prefix is non-numeric so a
|
||||
// human reader can scan it as "section label".
|
||||
return `— ${name} —`;
|
||||
}
|
||||
personIdx += 1;
|
||||
const t = a.title?.trim();
|
||||
return `${personIdx}- ${name}${t ? ` (${t})` : ""}`;
|
||||
})
|
||||
.join("\n");
|
||||
})(),
|
||||
align: alignment(input.font, isRtl),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -61,6 +61,13 @@ router.param("id", (req, res, next, value) => {
|
||||
const PLATFORMS = ["none", "webex", "teams", "zoom", "other"] as const;
|
||||
const STATUSES = ["scheduled", "cancelled", "completed", "postponed"] as const;
|
||||
const ATTENDANCE_TYPES = ["internal", "virtual", "external"] as const;
|
||||
// "person" is the default for backwards compatibility — every existing row
|
||||
// is backfilled to this kind. "subheading" rows are user-defined section
|
||||
// labels that live in the same attendees array as persons but must be
|
||||
// excluded from numbering, attendee counts, and virtual-platform-name
|
||||
// extraction. They share `attendance_type` and `sort_order` with persons
|
||||
// so a subheading "belongs" to exactly one attendance-type group.
|
||||
const ATTENDEE_KINDS = ["person", "subheading"] as const;
|
||||
// Schedule column ids that the merge-cells overlay can span. Kept in
|
||||
// sync with the frontend ColumnId enum in
|
||||
// artifacts/tx-os/src/pages/executive-meetings.tsx.
|
||||
@@ -192,10 +199,16 @@ const positiveIntSchema = z.number().int().positive();
|
||||
// so we allow up to ~10k bytes for titles and ~5k for attendee names. The
|
||||
// sanitizer strips disallowed tags/attrs before persistence.
|
||||
const attendeeSchema = z.object({
|
||||
// For persons this is sanitized rich-text HTML (Tiptap output).
|
||||
// For subheadings this is the user's free-text label, also passed
|
||||
// through sanitizeRichText to strip any sneaky markup. Both kinds
|
||||
// require at least one non-whitespace character; an empty string is
|
||||
// a delete-row gesture in the inline editor and must never reach the DB.
|
||||
name: z.string().trim().min(1).max(5000),
|
||||
title: z.string().trim().max(200).nullable().optional(),
|
||||
attendanceType: z.enum(ATTENDANCE_TYPES).default("internal"),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
kind: z.enum(ATTENDEE_KINDS).default("person"),
|
||||
});
|
||||
|
||||
const meetingBaseFields = {
|
||||
@@ -674,6 +687,7 @@ router.post(
|
||||
title: sanitizePlainTextOrNull(a.title),
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: a.sortOrder ?? idx,
|
||||
kind: a.kind,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -792,6 +806,7 @@ router.patch(
|
||||
title: sanitizePlainTextOrNull(a.title),
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: a.sortOrder ?? idx,
|
||||
kind: a.kind,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -891,6 +906,7 @@ router.put(
|
||||
title: sanitizePlainTextOrNull(a.title),
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: a.sortOrder ?? idx,
|
||||
kind: a.kind,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -965,6 +981,9 @@ router.post(
|
||||
title: sanitizePlainTextOrNull(a.title),
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: a.sortOrder ?? idx,
|
||||
// Carry custom subheadings into the duplicated meeting so the
|
||||
// user does not lose their group structure on duplicate.
|
||||
kind: a.kind,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -2307,6 +2326,9 @@ router.get(
|
||||
name: a.name,
|
||||
title: a.title,
|
||||
attendanceType: a.attendanceType,
|
||||
// Forward the row kind so the renderer can render subheadings
|
||||
// as labels rather than numbered attendees.
|
||||
kind: a.kind,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -883,11 +883,14 @@
|
||||
"timeEnd": "وقت النهاية",
|
||||
"timeOrderError": "وقت النهاية قبل وقت البداية",
|
||||
"addAttendee": "أضف حاضر",
|
||||
"addSubheading": "+ عنوان فرعي",
|
||||
"addVirtualAttendee": "+ حاضر عبر الاتصال المرئي",
|
||||
"addInternalAttendee": "+ حاضر داخلي",
|
||||
"addExternalAttendee": "+ حاضر خارجي",
|
||||
"newAttendeeAria": "اسم الحاضر الجديد",
|
||||
"editAttendeeAria": "تعديل اسم الحاضر",
|
||||
"newSubheadingAria": "نص العنوان الفرعي الجديد",
|
||||
"editSubheadingAria": "تعديل نص العنوان الفرعي",
|
||||
"deleteRow": "حذف الاجتماع",
|
||||
"deleteRowConfirm": "حذف هذا الاجتماع؟\n\n\"{{title}}\"\n\nلا يمكن التراجع عن هذا الإجراء.",
|
||||
"deleted": "تم حذف الاجتماع",
|
||||
@@ -983,6 +986,9 @@
|
||||
"id": "رقم الحاضر",
|
||||
"type": "نوع الحضور",
|
||||
"add": "إضافة حاضر",
|
||||
"addSubheading": "إضافة عنوان فرعي",
|
||||
"subheadingName": "نص العنوان الفرعي",
|
||||
"subheadingBadge": "عنوان فرعي",
|
||||
"remove": "إزالة",
|
||||
"moveUp": "تحريك للأعلى",
|
||||
"moveDown": "تحريك للأسفل",
|
||||
|
||||
@@ -836,11 +836,14 @@
|
||||
"timeEnd": "End time",
|
||||
"timeOrderError": "End time is before start time",
|
||||
"addAttendee": "Add attendee",
|
||||
"addSubheading": "+ subheading",
|
||||
"addVirtualAttendee": "+ Virtual",
|
||||
"addInternalAttendee": "+ Internal",
|
||||
"addExternalAttendee": "+ External",
|
||||
"newAttendeeAria": "New attendee name",
|
||||
"editAttendeeAria": "Edit attendee name",
|
||||
"newSubheadingAria": "New subheading label",
|
||||
"editSubheadingAria": "Edit subheading label",
|
||||
"deleteRow": "Delete meeting",
|
||||
"deleteRowConfirm": "Delete this meeting?\n\n\"{{title}}\"\n\nThis cannot be undone.",
|
||||
"deleted": "Meeting deleted",
|
||||
@@ -920,6 +923,9 @@
|
||||
"id": "Attendee ID",
|
||||
"type": "Attendance type",
|
||||
"add": "Add attendee",
|
||||
"addSubheading": "Add subheading",
|
||||
"subheadingName": "Subheading label",
|
||||
"subheadingBadge": "Subheading",
|
||||
"remove": "Remove",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
|
||||
@@ -26,6 +26,9 @@ type Attendee = {
|
||||
title: string | null;
|
||||
attendanceType: "internal" | "external" | "online";
|
||||
sortOrder: number;
|
||||
// Optional so older API snapshots without the column still parse;
|
||||
// missing values are treated as "person" downstream.
|
||||
kind?: "person" | "subheading";
|
||||
};
|
||||
|
||||
type Meeting = {
|
||||
@@ -173,6 +176,19 @@ export default function ExecutiveMeetingsPrintPage() {
|
||||
color: #6b7280;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
/* Subheading rows: bold separator labels between persons. */
|
||||
.em-print-attendees .em-print-subheading {
|
||||
font-weight: 700;
|
||||
color: #0B1E3F;
|
||||
margin-top: 4px;
|
||||
padding-top: 2px;
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
}
|
||||
.em-print-attendees .em-print-subheading:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="no-print flex items-center justify-between px-6 py-3 border-b border-gray-200 bg-gray-50">
|
||||
@@ -258,26 +274,53 @@ export default function ExecutiveMeetingsPrintPage() {
|
||||
className="em-print-attendees"
|
||||
data-testid={`em-print-attendees-${m.id}`}
|
||||
>
|
||||
{attendees.map((a, idx) => (
|
||||
<div key={a.id ?? idx}>
|
||||
<span className="em-print-attendee-num">
|
||||
{idx + 1}-
|
||||
</span>
|
||||
{/* a.name comes from the API already
|
||||
sanitized by sanitizeRichText. a.title
|
||||
is *not* rich text — render it as a
|
||||
plain text node so an attacker who
|
||||
smuggles HTML into the title field
|
||||
cannot escape into <script> tags on
|
||||
the print page. */}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: safeHtml(a.name),
|
||||
}}
|
||||
/>
|
||||
{a.title ? <span> ({a.title})</span> : null}
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
// Walking person counter — subheadings are
|
||||
// labels, so they don't advance the print
|
||||
// index. Mirrors AttendeeFlow on screen.
|
||||
let personIdx = 0;
|
||||
return attendees.map((a, idx) => {
|
||||
const isSub =
|
||||
(a.kind ?? "person") === "subheading";
|
||||
if (isSub) {
|
||||
return (
|
||||
<div
|
||||
key={a.id ?? `sub-${idx}`}
|
||||
className="em-print-subheading"
|
||||
data-testid={`em-print-subheading-${m.id}-${idx}`}
|
||||
>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: safeHtml(a.name),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const myIdx = personIdx;
|
||||
personIdx += 1;
|
||||
return (
|
||||
<div key={a.id ?? idx}>
|
||||
<span className="em-print-attendee-num">
|
||||
{myIdx + 1}-
|
||||
</span>
|
||||
{/* a.name comes from the API already
|
||||
sanitized by sanitizeRichText.
|
||||
a.title is *not* rich text —
|
||||
render it as a plain text node so
|
||||
an attacker who smuggles HTML into
|
||||
the title field cannot escape into
|
||||
<script> tags on the print page. */}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: safeHtml(a.name),
|
||||
}}
|
||||
/>
|
||||
{a.title ? <span> ({a.title})</span> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -95,10 +95,21 @@ import { formatTime as i18nFormatTime } from "@/lib/i18n-format";
|
||||
type Attendee = {
|
||||
id?: number;
|
||||
meetingId?: number;
|
||||
// For persons: sanitized rich-text HTML name (Tiptap output).
|
||||
// For subheadings: the user's free-text section label.
|
||||
name: string;
|
||||
title: string | null;
|
||||
attendanceType: "internal" | "virtual" | "external";
|
||||
sortOrder: number;
|
||||
// "person" — a normal attendee row (rendered in AttendeeFlow with a
|
||||
// running "1-, 2-…" index that skips subheadings).
|
||||
// "subheading" — a user-defined label that lives between persons inside
|
||||
// the same attendance-type group. Excluded from numbering,
|
||||
// attendee count, and virtual-platform-name extraction.
|
||||
// Optional in the type so older snapshots and any in-flight code paths
|
||||
// that don't set it explicitly default to "person" — same as the DB and
|
||||
// Zod defaults.
|
||||
kind?: "person" | "subheading";
|
||||
};
|
||||
|
||||
type Meeting = {
|
||||
@@ -900,7 +911,8 @@ function ScheduleSection({
|
||||
// Detect "truly empty" content: Tiptap empty paragraphs, , or
|
||||
// pure whitespace. When the attendee's name is cleared we treat the
|
||||
// edit as a delete-row gesture and remove the attendee instead of
|
||||
// saving an empty record.
|
||||
// saving an empty record. The same gesture deletes a custom
|
||||
// subheading row (clear the label text → row disappears on blur).
|
||||
const isEmpty =
|
||||
html
|
||||
.replace(/<[^>]*>/g, "")
|
||||
@@ -926,6 +938,9 @@ function ScheduleSection({
|
||||
title: a.title,
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: a.sortOrder,
|
||||
// Round-trip the row kind so an inline edit of a person row
|
||||
// can't silently demote a sibling subheading back to default.
|
||||
kind: a.kind ?? "person",
|
||||
})),
|
||||
},
|
||||
});
|
||||
@@ -956,9 +971,12 @@ function ScheduleSection({
|
||||
// We allow at most one pending attendee at a time across the whole
|
||||
// page — the AttendeesCell hides "+" buttons elsewhere while a ghost
|
||||
// row is open so the user can't accidentally orphan their typing.
|
||||
// The same ghost is reused for "+ subheading" (kind = "subheading")
|
||||
// so the one-at-a-time rule covers both gestures uniformly.
|
||||
type PendingAttendee = {
|
||||
meetingId: number;
|
||||
attendanceType: Attendee["attendanceType"];
|
||||
kind: "person" | "subheading";
|
||||
// Bumped each time the user clicks "+" so React remounts the ghost
|
||||
// EditableCell (giving it a fresh editor and focus).
|
||||
key: number;
|
||||
@@ -978,14 +996,18 @@ function ScheduleSection({
|
||||
}, [effectiveCanMutate, pendingAttendee]);
|
||||
|
||||
const startAddAttendee = useCallback(
|
||||
(meetingId: number, attendanceType: Attendee["attendanceType"]) => {
|
||||
(
|
||||
meetingId: number,
|
||||
attendanceType: Attendee["attendanceType"],
|
||||
kind: "person" | "subheading" = "person",
|
||||
) => {
|
||||
// Only one ghost row may be open at a time across the whole page.
|
||||
// We refuse a new start while another is pending — the user must
|
||||
// first commit (blur with text) or cancel (Escape / blur empty).
|
||||
// The "+" buttons are already hidden in this state, but a stale
|
||||
// click that beats the re-render is still possible.
|
||||
setPendingAttendee((prev) =>
|
||||
prev ? prev : { meetingId, attendanceType, key: Date.now() },
|
||||
prev ? prev : { meetingId, attendanceType, kind, key: Date.now() },
|
||||
);
|
||||
},
|
||||
[],
|
||||
@@ -1000,6 +1022,7 @@ function ScheduleSection({
|
||||
meeting: Meeting,
|
||||
attendanceType: Attendee["attendanceType"],
|
||||
html: string,
|
||||
kind: "person" | "subheading" = "person",
|
||||
) => {
|
||||
// Always clear pending — whether we end up persisting or not.
|
||||
setPendingAttendee(null);
|
||||
@@ -1020,12 +1043,14 @@ function ScheduleSection({
|
||||
title: a.title,
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: a.sortOrder,
|
||||
kind: a.kind ?? "person",
|
||||
})),
|
||||
{
|
||||
name: html,
|
||||
title: null as string | null,
|
||||
attendanceType,
|
||||
sortOrder: maxSort + 1,
|
||||
kind,
|
||||
},
|
||||
];
|
||||
try {
|
||||
@@ -2372,15 +2397,18 @@ function MeetingRow({
|
||||
pendingAttendee?: {
|
||||
meetingId: number;
|
||||
attendanceType: Attendee["attendanceType"];
|
||||
kind: "person" | "subheading";
|
||||
key: number;
|
||||
} | null;
|
||||
onStartAddAttendee?: (
|
||||
meetingId: number,
|
||||
attendanceType: Attendee["attendanceType"],
|
||||
kind?: "person" | "subheading",
|
||||
) => void;
|
||||
onCommitAddAttendee?: (
|
||||
attendanceType: Attendee["attendanceType"],
|
||||
html: string,
|
||||
kind?: "person" | "subheading",
|
||||
) => Promise<void>;
|
||||
onCancelAddAttendee?: () => void;
|
||||
bulkSelectable: boolean;
|
||||
@@ -3052,27 +3080,38 @@ function AttendeesCell({
|
||||
pendingAttendee?: {
|
||||
meetingId: number;
|
||||
attendanceType: Attendee["attendanceType"];
|
||||
kind: "person" | "subheading";
|
||||
key: number;
|
||||
} | null;
|
||||
onStartAddAttendee?: (
|
||||
meetingId: number,
|
||||
attendanceType: Attendee["attendanceType"],
|
||||
kind?: "person" | "subheading",
|
||||
) => void;
|
||||
onCommitAddAttendee?: (
|
||||
attendanceType: Attendee["attendanceType"],
|
||||
html: string,
|
||||
kind?: "person" | "subheading",
|
||||
) => Promise<void>;
|
||||
onCancelAddAttendee?: () => void;
|
||||
}) {
|
||||
// Build (attendee, originalIndex) tuples so attendees can be sliced into
|
||||
// virtual/internal/external groups while still referring back to their
|
||||
// index in `meeting.attendees` (needed by the PUT save endpoint).
|
||||
// index in `meeting.attendees` (needed by the PUT save endpoint). Each
|
||||
// tuple keeps both person and subheading rows; AttendeeFlow is in charge
|
||||
// of skipping subheadings when computing the running "1-, 2-…" index.
|
||||
const tagged = meeting.attendees.map((a, i) => ({ a, i }));
|
||||
const virtual = tagged.filter(({ a }) => a.attendanceType === "virtual");
|
||||
const internal = tagged.filter(({ a }) => a.attendanceType === "internal");
|
||||
const external = tagged.filter(({ a }) => a.attendanceType === "external");
|
||||
// A group "exists" only if it has at least one person — a section that
|
||||
// contains nothing but stray subheadings would not warrant its own
|
||||
// header label, so the split layout shouldn't switch on for it alone.
|
||||
const hasPersons = (
|
||||
rows: { a: Attendee; i: number }[],
|
||||
) => rows.some(({ a }) => (a.kind ?? "person") === "person");
|
||||
const hasSplit =
|
||||
virtual.length > 0 && (internal.length > 0 || external.length > 0);
|
||||
hasPersons(virtual) && (hasPersons(internal) || hasPersons(external));
|
||||
|
||||
const platformLabel: Record<Meeting["platform"], string> = {
|
||||
none: "",
|
||||
@@ -3091,13 +3130,19 @@ function AttendeesCell({
|
||||
// by clicking "+" in a different row while one is already open.
|
||||
const isPendingForThisMeeting =
|
||||
!!pendingAttendee && pendingAttendee.meetingId === meeting.id;
|
||||
const isPendingFor = (type: Attendee["attendanceType"]) =>
|
||||
// True when the ghost is open in THIS meeting + group, regardless of
|
||||
// kind. Used by the parent layout to decide whether to render the
|
||||
// group at all (a brand-new "+ subheading" inside a so-far-empty
|
||||
// virtual group still needs the virtual section to mount).
|
||||
const isPendingForType = (type: Attendee["attendanceType"]) =>
|
||||
isPendingForThisMeeting && pendingAttendee!.attendanceType === type;
|
||||
const hasAnyPending = !!pendingAttendee;
|
||||
|
||||
const startAdd = onStartAddAttendee
|
||||
? (type: Attendee["attendanceType"]) =>
|
||||
onStartAddAttendee(meeting.id, type)
|
||||
? (
|
||||
type: Attendee["attendanceType"],
|
||||
kind: "person" | "subheading" = "person",
|
||||
) => onStartAddAttendee(meeting.id, type, kind)
|
||||
: undefined;
|
||||
|
||||
const commitAdd = onCommitAddAttendee;
|
||||
@@ -3106,21 +3151,29 @@ function AttendeesCell({
|
||||
const flowProps = {
|
||||
canMutate,
|
||||
onSaveAttendeeName,
|
||||
pendingKind: (pendingAttendee?.kind ?? "person") as "person" | "subheading",
|
||||
isPending: false,
|
||||
pendingKey: pendingAttendee?.key ?? 0,
|
||||
onStartAdd: hasAnyPending ? undefined : startAdd,
|
||||
onCommitAdd: commitAdd,
|
||||
onCancelAdd: cancelAdd,
|
||||
addLabel: t("executiveMeetings.schedule.addAttendee"),
|
||||
addSubheadingLabel: t("executiveMeetings.schedule.addSubheading"),
|
||||
editAriaLabel: t("executiveMeetings.schedule.editAttendeeAria"),
|
||||
newAriaLabel: t("executiveMeetings.schedule.newAttendeeAria"),
|
||||
editSubheadingAriaLabel: t(
|
||||
"executiveMeetings.schedule.editSubheadingAria",
|
||||
),
|
||||
newSubheadingAriaLabel: t(
|
||||
"executiveMeetings.schedule.newSubheadingAria",
|
||||
),
|
||||
meetingId: meeting.id,
|
||||
};
|
||||
|
||||
if (hasSplit) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{(virtual.length > 0 || isPendingFor("virtual")) && (
|
||||
{(virtual.length > 0 || isPendingForType("virtual")) && (
|
||||
<AttendeeGroup
|
||||
label={
|
||||
meeting.platform !== "none"
|
||||
@@ -3130,25 +3183,25 @@ function AttendeesCell({
|
||||
items={virtual}
|
||||
addType="virtual"
|
||||
{...flowProps}
|
||||
isPending={isPendingFor("virtual")}
|
||||
isPending={isPendingForType("virtual")}
|
||||
/>
|
||||
)}
|
||||
{(internal.length > 0 || isPendingFor("internal")) && (
|
||||
{(internal.length > 0 || isPendingForType("internal")) && (
|
||||
<AttendeeGroup
|
||||
label={t("executiveMeetings.internalAttendance")}
|
||||
items={internal}
|
||||
addType="internal"
|
||||
{...flowProps}
|
||||
isPending={isPendingFor("internal")}
|
||||
isPending={isPendingForType("internal")}
|
||||
/>
|
||||
)}
|
||||
{(external.length > 0 || isPendingFor("external")) && (
|
||||
{(external.length > 0 || isPendingForType("external")) && (
|
||||
<AttendeeGroup
|
||||
label={t("executiveMeetings.externalAttendance")}
|
||||
items={external}
|
||||
addType="external"
|
||||
{...flowProps}
|
||||
isPending={isPendingFor("external")}
|
||||
isPending={isPendingForType("external")}
|
||||
/>
|
||||
)}
|
||||
{/* Add chips for groups that don't yet exist for this meeting. */}
|
||||
@@ -3196,7 +3249,7 @@ function AttendeesCell({
|
||||
items={virtual}
|
||||
addType="virtual"
|
||||
{...flowProps}
|
||||
isPending={isPendingFor("virtual")}
|
||||
isPending={isPendingForType("virtual")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3207,7 +3260,7 @@ function AttendeesCell({
|
||||
items={tagged}
|
||||
addType={singleGroupType}
|
||||
{...flowProps}
|
||||
isPending={isPendingFor(singleGroupType)}
|
||||
isPending={isPendingForType(singleGroupType)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3240,16 +3293,27 @@ type AttendeeFlowSharedProps = {
|
||||
onSaveAttendeeName?: (idx: number, html: string) => Promise<void>;
|
||||
addType: Attendee["attendanceType"];
|
||||
isPending: boolean;
|
||||
// Which kind of ghost is being added — drives where in the flex flow
|
||||
// the pending EditableCell is rendered (inline person at the end of the
|
||||
// numbered list, or a full-width subheading line).
|
||||
pendingKind: "person" | "subheading";
|
||||
pendingKey: number;
|
||||
onStartAdd?: (type: Attendee["attendanceType"]) => void;
|
||||
onStartAdd?: (
|
||||
type: Attendee["attendanceType"],
|
||||
kind?: "person" | "subheading",
|
||||
) => void;
|
||||
onCommitAdd?: (
|
||||
type: Attendee["attendanceType"],
|
||||
html: string,
|
||||
kind?: "person" | "subheading",
|
||||
) => Promise<void>;
|
||||
onCancelAdd?: () => void;
|
||||
addLabel: string;
|
||||
addSubheadingLabel: string;
|
||||
editAriaLabel: string;
|
||||
newAriaLabel: string;
|
||||
editSubheadingAriaLabel: string;
|
||||
newSubheadingAriaLabel: string;
|
||||
meetingId: number;
|
||||
};
|
||||
|
||||
@@ -3283,76 +3347,130 @@ function AttendeeFlow({
|
||||
onSaveAttendeeName,
|
||||
addType,
|
||||
isPending,
|
||||
pendingKind,
|
||||
pendingKey,
|
||||
onStartAdd,
|
||||
onCommitAdd,
|
||||
onCancelAdd,
|
||||
addLabel,
|
||||
addSubheadingLabel,
|
||||
editAriaLabel,
|
||||
newAriaLabel,
|
||||
editSubheadingAriaLabel,
|
||||
newSubheadingAriaLabel,
|
||||
}: { items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) {
|
||||
const editable = canMutate && !!onSaveAttendeeName;
|
||||
const showAdd = canMutate && !!onStartAdd && !isPending;
|
||||
|
||||
// Person count drives the running "1-, 2-…" index. Subheadings are
|
||||
// labels, not attendees, so they must NOT advance the counter.
|
||||
const personCount = items.reduce(
|
||||
(n, { a }) => n + ((a.kind ?? "person") === "person" ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (items.length === 0 && !showAdd && !isPending) {
|
||||
return <span className="text-xs text-gray-500">—</span>;
|
||||
}
|
||||
|
||||
// Walking counter — incremented only as we render person rows.
|
||||
let personIdx = 0;
|
||||
|
||||
return (
|
||||
<ul className="flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug">
|
||||
{items.map(({ a, i }, displayIdx) => (
|
||||
<li
|
||||
key={a.id ?? i}
|
||||
data-testid={`em-attendee-row-${i}`}
|
||||
// `inline-flex items-baseline` keeps the index prefix and the
|
||||
// name as flex siblings on the same line. The `[&_p]` utilities
|
||||
// on the inner wrappers below flatten tiptap's block `<p>` (with
|
||||
// its default 1em margins) so the rendered name stays inline.
|
||||
className={
|
||||
"inline-flex items-baseline whitespace-nowrap" +
|
||||
(editable ? " min-w-[3rem]" : "")
|
||||
}
|
||||
>
|
||||
{items.length > 1 && (
|
||||
<span
|
||||
data-testid={`em-attendee-index-${i}`}
|
||||
className="text-gray-500 me-1"
|
||||
{items.map(({ a, i }) => {
|
||||
const isSub = (a.kind ?? "person") === "subheading";
|
||||
if (isSub) {
|
||||
// Subheading row: full-width line break inside the flex-wrap so
|
||||
// it visually separates the persons before/after. No leading
|
||||
// index, no inline-flex — pure label.
|
||||
return (
|
||||
<li
|
||||
key={a.id ?? `sub-${i}`}
|
||||
data-testid={`em-attendee-subheading-${i}`}
|
||||
data-attendee-kind="subheading"
|
||||
className="basis-full text-center font-semibold text-[#0B1E3F]/90 mt-1.5 mb-0.5 leading-snug"
|
||||
>
|
||||
{displayIdx + 1}-
|
||||
</span>
|
||||
)}
|
||||
{editable ? (
|
||||
<EditableCell
|
||||
value={a.name}
|
||||
onSave={(html) => onSaveAttendeeName!(i, html)}
|
||||
ariaLabel={editAriaLabel}
|
||||
dir="auto"
|
||||
placeholder="…"
|
||||
// `[&>span_p]:inline [&>span_p]:m-0` only matches the view-mode
|
||||
// shell (`div > span > p`); the active editor's
|
||||
// `div > div > EditorContent` does NOT match, so Enter still
|
||||
// creates a real new paragraph inside the editor.
|
||||
className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
|
||||
testId={`em-edit-attendee-${i}`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
data-testid={`em-attendee-name-${i}`}
|
||||
className="[&_p]:inline [&_p]:m-0"
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{canMutate && isPending && onCommitAdd && (
|
||||
{editable ? (
|
||||
<EditableCell
|
||||
value={a.name}
|
||||
onSave={(html) => onSaveAttendeeName!(i, html)}
|
||||
ariaLabel={editSubheadingAriaLabel}
|
||||
dir="auto"
|
||||
placeholder="…"
|
||||
className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
|
||||
testId={`em-edit-subheading-${i}`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
data-testid={`em-subheading-name-${i}`}
|
||||
className="[&_p]:inline [&_p]:m-0"
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
// Person row — capture index BEFORE incrementing so the displayed
|
||||
// number matches what the user expects (1-based).
|
||||
const myDisplayIdx = personIdx;
|
||||
personIdx += 1;
|
||||
return (
|
||||
<li
|
||||
key={a.id ?? i}
|
||||
data-testid={`em-attendee-row-${i}`}
|
||||
// `inline-flex items-baseline` keeps the index prefix and the
|
||||
// name as flex siblings on the same line. The `[&_p]` utilities
|
||||
// on the inner wrappers below flatten tiptap's block `<p>` (with
|
||||
// its default 1em margins) so the rendered name stays inline.
|
||||
className={
|
||||
"inline-flex items-baseline whitespace-nowrap" +
|
||||
(editable ? " min-w-[3rem]" : "")
|
||||
}
|
||||
>
|
||||
{personCount > 1 && (
|
||||
<span
|
||||
data-testid={`em-attendee-index-${i}`}
|
||||
className="text-gray-500 me-1"
|
||||
>
|
||||
{myDisplayIdx + 1}-
|
||||
</span>
|
||||
)}
|
||||
{editable ? (
|
||||
<EditableCell
|
||||
value={a.name}
|
||||
onSave={(html) => onSaveAttendeeName!(i, html)}
|
||||
ariaLabel={editAriaLabel}
|
||||
dir="auto"
|
||||
placeholder="…"
|
||||
// `[&>span_p]:inline [&>span_p]:m-0` only matches the view-mode
|
||||
// shell (`div > span > p`); the active editor's
|
||||
// `div > div > EditorContent` does NOT match, so Enter still
|
||||
// creates a real new paragraph inside the editor.
|
||||
className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
|
||||
testId={`em-edit-attendee-${i}`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
data-testid={`em-attendee-name-${i}`}
|
||||
className="[&_p]:inline [&_p]:m-0"
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{canMutate && isPending && pendingKind === "person" && onCommitAdd && (
|
||||
<li className="min-w-[6rem]" data-testid={`em-add-attendee-pending-${addType}`}>
|
||||
<span className="text-gray-500 me-1">{items.length + 1}-</span>
|
||||
{personCount > 0 && (
|
||||
<span className="text-gray-500 me-1">{personCount + 1}-</span>
|
||||
)}
|
||||
<EditableCell
|
||||
// pendingKey changes every time the user clicks "+" so React
|
||||
// remounts the editor with a fresh focus.
|
||||
key={`pending-${pendingKey}`}
|
||||
value=""
|
||||
onSave={(html) => onCommitAdd(addType, html)}
|
||||
onSave={(html) => onCommitAdd(addType, html, "person")}
|
||||
ariaLabel={newAriaLabel}
|
||||
dir="auto"
|
||||
placeholder="…"
|
||||
@@ -3367,18 +3485,54 @@ function AttendeeFlow({
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
{showAdd && (
|
||||
<li className="print:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStartAdd!(addType)}
|
||||
data-testid={`em-add-attendee-${addType}`}
|
||||
aria-label={addLabel}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded px-2 py-1 min-h-[1.5rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
|
||||
{canMutate &&
|
||||
isPending &&
|
||||
pendingKind === "subheading" &&
|
||||
onCommitAdd && (
|
||||
<li
|
||||
className="basis-full text-center font-semibold text-[#0B1E3F]/90 mt-1.5 mb-0.5"
|
||||
data-testid={`em-add-subheading-pending-${addType}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</li>
|
||||
<EditableCell
|
||||
key={`pending-sub-${pendingKey}`}
|
||||
value=""
|
||||
onSave={(html) => onCommitAdd(addType, html, "subheading")}
|
||||
ariaLabel={newSubheadingAriaLabel}
|
||||
dir="auto"
|
||||
placeholder="…"
|
||||
className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 border-b border-dashed border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
|
||||
testId={`em-add-subheading-pending-input-${addType}`}
|
||||
startInEditMode
|
||||
forceSaveOnBlur
|
||||
onCancel={onCancelAdd}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
{showAdd && (
|
||||
<>
|
||||
<li className="print:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStartAdd!(addType, "person")}
|
||||
data-testid={`em-add-attendee-${addType}`}
|
||||
aria-label={addLabel}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded px-2 py-1 min-h-[1.5rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</li>
|
||||
<li className="print:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStartAdd!(addType, "subheading")}
|
||||
data-testid={`em-add-subheading-${addType}`}
|
||||
aria-label={addSubheadingLabel}
|
||||
className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
|
||||
>
|
||||
{addSubheadingLabel}
|
||||
</button>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
@@ -3530,6 +3684,9 @@ function ManageSection({
|
||||
title: a.title,
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: idx,
|
||||
// Round-trip the row kind so subheadings created in the inline
|
||||
// schedule survive a save through the Manage dialog.
|
||||
kind: a.kind ?? "person",
|
||||
})),
|
||||
};
|
||||
if (editing.dailyNumber.trim()) body.dailyNumber = Number(editing.dailyNumber);
|
||||
@@ -3632,7 +3789,12 @@ function ManageSection({
|
||||
{isRtl ? m.titleAr : m.titleEn || m.titleAr}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{m.attendees.length} · {m.platform}
|
||||
{
|
||||
m.attendees.filter(
|
||||
(a) => (a.kind ?? "person") === "person",
|
||||
).length
|
||||
}{" "}
|
||||
· {m.platform}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-middle font-mono text-xs whitespace-nowrap" dir="ltr">
|
||||
@@ -3758,7 +3920,33 @@ function MeetingFormDialog({
|
||||
...state,
|
||||
attendees: [
|
||||
...state.attendees,
|
||||
{ name: "", title: null, attendanceType: "internal", sortOrder: state.attendees.length },
|
||||
{
|
||||
name: "",
|
||||
title: null,
|
||||
attendanceType: "internal",
|
||||
sortOrder: state.attendees.length,
|
||||
kind: "person",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
function addSubheading() {
|
||||
onChange({
|
||||
...state,
|
||||
attendees: [
|
||||
...state.attendees,
|
||||
{
|
||||
name: "",
|
||||
// Subheadings have no separate title field — they are a single
|
||||
// free-text label. Keep title null on the row to match what the
|
||||
// server stores.
|
||||
title: null,
|
||||
// Default new subheadings into "internal" — the user can switch
|
||||
// the section via the same Select that persons use.
|
||||
attendanceType: "internal",
|
||||
sortOrder: state.attendees.length,
|
||||
kind: "subheading",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -3904,7 +4092,14 @@ function MeetingFormDialog({
|
||||
<div className="border-t border-gray-200 pt-3 mt-1">
|
||||
<div className="flex items-center justify-between mb-2 gap-2 flex-wrap">
|
||||
<h4 className="font-semibold text-sm text-[#0B1E3F]">
|
||||
{t("executiveMeetings.manage.attendees.label")} ({state.attendees.length})
|
||||
{t("executiveMeetings.manage.attendees.label")} (
|
||||
{
|
||||
// Persons only — subheadings are labels, not attendees.
|
||||
state.attendees.filter(
|
||||
(a) => (a.kind ?? "person") === "person",
|
||||
).length
|
||||
}
|
||||
)
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{state.attendees.length > 0 && (
|
||||
@@ -3919,6 +4114,16 @@ function MeetingFormDialog({
|
||||
{t("executiveMeetings.manage.attendees.removeAll")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addSubheading}
|
||||
className="gap-1"
|
||||
data-testid="em-attendee-add-subheading"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("executiveMeetings.manage.attendees.addSubheading")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -3932,73 +4137,98 @@ function MeetingFormDialog({
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{state.attendees.map((a, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2"
|
||||
data-testid={`em-attendee-row-${i}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5"
|
||||
onClick={() => moveAttendee(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={t("executiveMeetings.manage.attendees.moveUp")}
|
||||
data-testid={`em-attendee-up-${i}`}
|
||||
{state.attendees.map((a, i) => {
|
||||
const isSub = (a.kind ?? "person") === "subheading";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={
|
||||
"flex items-center gap-2" +
|
||||
(isSub ? " bg-blue-50/60 rounded px-2 py-1" : "")
|
||||
}
|
||||
data-testid={`em-attendee-row-${i}`}
|
||||
data-attendee-kind={a.kind ?? "person"}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5"
|
||||
onClick={() => moveAttendee(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={t("executiveMeetings.manage.attendees.moveUp")}
|
||||
data-testid={`em-attendee-up-${i}`}
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5"
|
||||
onClick={() => moveAttendee(i, 1)}
|
||||
disabled={i === state.attendees.length - 1}
|
||||
aria-label={t("executiveMeetings.manage.attendees.moveDown")}
|
||||
data-testid={`em-attendee-down-${i}`}
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{isSub && (
|
||||
// Tiny chip so the row visually reads as "this is a
|
||||
// subheading" instead of just an empty-title attendee.
|
||||
<span
|
||||
className="inline-flex items-center text-[10px] font-semibold uppercase tracking-wide text-blue-700 bg-blue-100 rounded-full px-2 py-0.5"
|
||||
data-testid={`em-attendee-kind-badge-${i}`}
|
||||
>
|
||||
{t("executiveMeetings.manage.attendees.subheadingBadge")}
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
className={isSub ? "flex-[2]" : "flex-1"}
|
||||
placeholder={
|
||||
isSub
|
||||
? t("executiveMeetings.manage.attendees.subheadingName")
|
||||
: t("executiveMeetings.manage.attendees.name")
|
||||
}
|
||||
value={a.name}
|
||||
onChange={(e) => setAttendee(i, { name: e.target.value })}
|
||||
/>
|
||||
{!isSub && (
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder={t("executiveMeetings.manage.attendees.title")}
|
||||
value={a.title ?? ""}
|
||||
onChange={(e) =>
|
||||
setAttendee(i, { title: e.target.value || null })
|
||||
}
|
||||
data-testid={`em-attendee-title-${i}`}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
value={a.attendanceType}
|
||||
onValueChange={(v) =>
|
||||
setAttendee(i, {
|
||||
attendanceType: v as Attendee["attendanceType"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5"
|
||||
onClick={() => moveAttendee(i, 1)}
|
||||
disabled={i === state.attendees.length - 1}
|
||||
aria-label={t("executiveMeetings.manage.attendees.moveDown")}
|
||||
data-testid={`em-attendee-down-${i}`}
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["internal", "virtual", "external"] as const).map((tp) => (
|
||||
<SelectItem key={tp} value={tp}>
|
||||
{t(`executiveMeetings.manage.attendanceType.${tp}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" onClick={() => removeAttendee(i)}>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder={t("executiveMeetings.manage.attendees.name")}
|
||||
value={a.name}
|
||||
onChange={(e) => setAttendee(i, { name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder={t("executiveMeetings.manage.attendees.title")}
|
||||
value={a.title ?? ""}
|
||||
onChange={(e) =>
|
||||
setAttendee(i, { title: e.target.value || null })
|
||||
}
|
||||
data-testid={`em-attendee-title-${i}`}
|
||||
/>
|
||||
<Select
|
||||
value={a.attendanceType}
|
||||
onValueChange={(v) =>
|
||||
setAttendee(i, { attendanceType: v as Attendee["attendanceType"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["internal", "virtual", "external"] as const).map((tp) => (
|
||||
<SelectItem key={tp} value={tp}>
|
||||
{t(`executiveMeetings.manage.attendanceType.${tp}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" onClick={() => removeAttendee(i)}>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{state.attendees.length === 0 && (
|
||||
<div className="text-xs text-gray-500">—</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Load the same locale bundles the app reads from so any copy edits to
|
||||
// the subheading labels stay covered without test churn.
|
||||
function loadLocale(lang) {
|
||||
const p = join(__dirname, "..", "src", "locales", `${lang}.json`);
|
||||
return JSON.parse(readFileSync(p, "utf8"));
|
||||
}
|
||||
const localeBundles = { en: loadLocale("en"), ar: loadLocale("ar") };
|
||||
|
||||
// E2E coverage for Task #207 — custom subheadings inside the attendees
|
||||
// cell. Subheadings are user-defined section labels that live alongside
|
||||
// person rows in a meeting's attendee list, but are NOT counted as
|
||||
// attendees and do NOT advance the running "1-, 2-, 3-…" person index.
|
||||
//
|
||||
// What we verify:
|
||||
// 1. Mixed group: a meeting with 2 internal persons + 1 subheading
|
||||
// between them + 1 more person renders all four rows. The
|
||||
// subheading carries `data-testid="em-attendee-subheading-…"`,
|
||||
// and the three persons get the indices 1-, 2-, 3-.
|
||||
// 2. Zero subheading: a meeting with only persons keeps the legacy
|
||||
// numbering exactly as before (no extra "subheading" testids,
|
||||
// no off-by-one).
|
||||
// Both scenarios run in BOTH locales so the RTL/LTR mirror behaves the
|
||||
// same.
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error(
|
||||
"DATABASE_URL must be set to run the executive-meetings subheading UI tests",
|
||||
);
|
||||
}
|
||||
// Parse to silence the "unused" lint until we use the bundle below.
|
||||
void localeBundles;
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
const createdMeetingIds = [];
|
||||
|
||||
// Pick a far-future, randomised base so we never collide with real
|
||||
// scheduled meetings while running side by side with other specs that
|
||||
// also seed in the future.
|
||||
const RANDOM_DATE_BASE_DAYS =
|
||||
900 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
|
||||
function uniqueFutureDate(offsetDays) {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function insertMeeting({
|
||||
meetingDate,
|
||||
dailyNumber,
|
||||
titleEn,
|
||||
titleAr,
|
||||
startTime,
|
||||
endTime,
|
||||
}) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO executive_meetings
|
||||
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
|
||||
RETURNING id`,
|
||||
[dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdMeetingIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function insertAttendee({
|
||||
meetingId,
|
||||
name,
|
||||
attendanceType,
|
||||
sortOrder,
|
||||
kind,
|
||||
}) {
|
||||
await pool.query(
|
||||
`INSERT INTO executive_meeting_attendees
|
||||
(meeting_id, name, attendance_type, sort_order, kind)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[meetingId, name, attendanceType, sortOrder, kind ?? "person"],
|
||||
);
|
||||
}
|
||||
|
||||
async function loginViaUi(page) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill("admin");
|
||||
await page.locator("#password").fill("admin123");
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
let originalAdminPreferredLanguage = null;
|
||||
async function setAdminPreferredLanguage(lang) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE users SET preferred_language = $1
|
||||
WHERE username = 'admin'
|
||||
RETURNING preferred_language`,
|
||||
[lang],
|
||||
);
|
||||
return rows[0]?.preferred_language;
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT preferred_language FROM users WHERE username = 'admin'`,
|
||||
);
|
||||
originalAdminPreferredLanguage = rows[0]?.preferred_language ?? "ar";
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (originalAdminPreferredLanguage) {
|
||||
await pool
|
||||
.query(`UPDATE users SET preferred_language = $1 WHERE username = 'admin'`, [
|
||||
originalAdminPreferredLanguage,
|
||||
])
|
||||
.catch(() => {
|
||||
/* best-effort */
|
||||
});
|
||||
}
|
||||
if (createdMeetingIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
|
||||
[createdMeetingIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
|
||||
[createdMeetingIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
|
||||
[createdMeetingIds],
|
||||
);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
const langOffsets = { en: 1, ar: 2 };
|
||||
for (const lang of ["en", "ar"]) {
|
||||
test(`Schedule [${lang}]: subheading rows render and do not advance the person index`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const date = uniqueFutureDate(langOffsets[lang]);
|
||||
const meetingId = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `Subheading Mixed ${lang} ${Date.now().toString(36)}`,
|
||||
titleAr: `عنوان فرعي مختلط ${lang} ${Date.now().toString(36)}`,
|
||||
startTime: "09:00:00",
|
||||
endTime: "10:00:00",
|
||||
});
|
||||
// Internal group with a subheading wedged between the second and
|
||||
// third person. After rendering we should see persons numbered
|
||||
// 1-, 2-, 3- (subheading skipped) PLUS the subheading row itself.
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "Person Alpha",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 0,
|
||||
kind: "person",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "Person Beta",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 1,
|
||||
kind: "person",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
// Distinctive label so the assertion below cannot trip on a
|
||||
// person name that incidentally contains the same word.
|
||||
name: "GuestsLabel-X9",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 2,
|
||||
kind: "subheading",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "Person Gamma",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 3,
|
||||
kind: "person",
|
||||
});
|
||||
|
||||
await setAdminPreferredLanguage(lang);
|
||||
try {
|
||||
await loginViaUi(page);
|
||||
await page.goto("/executive-meetings");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page.locator('input[type="date"]').first().fill(date);
|
||||
|
||||
const row = page.getByTestId(`em-row-${meetingId}`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Exactly one subheading row in this meeting.
|
||||
const subheadings = row.locator(
|
||||
'[data-testid^="em-attendee-subheading-"]',
|
||||
);
|
||||
await expect(subheadings).toHaveCount(1, { timeout: 10_000 });
|
||||
await expect(subheadings.first()).toContainText("GuestsLabel-X9");
|
||||
|
||||
// Three person rows rendered (subheading is excluded by the
|
||||
// person-only `em-attendee-row-…` testid).
|
||||
const personRows = row.locator('[data-testid^="em-attendee-row-"]');
|
||||
await expect(personRows).toHaveCount(3);
|
||||
|
||||
// Three person indices in order — the subheading must not have
|
||||
// bumped the counter to 4.
|
||||
const indices = row.locator('[data-testid^="em-attendee-index-"]');
|
||||
await expect(indices).toHaveCount(3);
|
||||
const indexTexts = await indices.allInnerTexts();
|
||||
const trimmed = indexTexts.map((s) => s.trim().replace(/\s+/g, ""));
|
||||
expect(trimmed).toEqual(["1-", "2-", "3-"]);
|
||||
} finally {
|
||||
if (originalAdminPreferredLanguage) {
|
||||
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
||||
() => {
|
||||
/* best-effort; afterAll also restores */
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`Schedule [${lang}]: meeting with no subheadings still numbers persons 1..N with no stray subheading rows`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const date = uniqueFutureDate(langOffsets[lang] + 5);
|
||||
const meetingId = await insertMeeting({
|
||||
meetingDate: date,
|
||||
dailyNumber: 1,
|
||||
titleEn: `No Subheadings ${lang} ${Date.now().toString(36)}`,
|
||||
titleAr: `بدون عناوين فرعية ${lang} ${Date.now().toString(36)}`,
|
||||
startTime: "09:00:00",
|
||||
endTime: "10:00:00",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "Solo Person A",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 0,
|
||||
kind: "person",
|
||||
});
|
||||
await insertAttendee({
|
||||
meetingId,
|
||||
name: "Solo Person B",
|
||||
attendanceType: "internal",
|
||||
sortOrder: 1,
|
||||
kind: "person",
|
||||
});
|
||||
|
||||
await setAdminPreferredLanguage(lang);
|
||||
try {
|
||||
await loginViaUi(page);
|
||||
await page.goto("/executive-meetings");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page.locator('input[type="date"]').first().fill(date);
|
||||
|
||||
const row = page.getByTestId(`em-row-${meetingId}`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Zero subheadings — make sure the new code path didn't sneak
|
||||
// one in for legacy "person"-only meetings.
|
||||
const subheadings = row.locator(
|
||||
'[data-testid^="em-attendee-subheading-"]',
|
||||
);
|
||||
await expect(subheadings).toHaveCount(0);
|
||||
|
||||
const indices = row.locator('[data-testid^="em-attendee-index-"]');
|
||||
await expect(indices).toHaveCount(2);
|
||||
const trimmed = (await indices.allInnerTexts()).map((s) =>
|
||||
s.trim().replace(/\s+/g, ""),
|
||||
);
|
||||
expect(trimmed).toEqual(["1-", "2-"]);
|
||||
} finally {
|
||||
if (originalAdminPreferredLanguage) {
|
||||
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
|
||||
() => {
|
||||
/* best-effort */
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -74,12 +74,20 @@ export const executiveMeetingAttendeesTable = pgTable(
|
||||
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
|
||||
// attendee name holds sanitized rich-text HTML so it needs unbounded
|
||||
// length. Plain-text rows still render correctly (no tags = no parse).
|
||||
// For `kind = "subheading"` rows, this column stores the user-written
|
||||
// section label (free text, single value rendered as-is in AR and EN).
|
||||
name: text("name").notNull(),
|
||||
title: varchar("title", { length: 200 }),
|
||||
attendanceType: varchar("attendance_type", { length: 32 })
|
||||
.notNull()
|
||||
.default("internal"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
// "person" — a real attendee row (default; matches all pre-existing
|
||||
// data after backfill).
|
||||
// "subheading" — a user-defined section label inserted between attendees
|
||||
// inside the same attendance-type group. Numbering, count,
|
||||
// and virtual-platform extraction MUST ignore these rows.
|
||||
kind: varchar("kind", { length: 16 }).notNull().default("person"),
|
||||
},
|
||||
(t) => ({
|
||||
meetingIdx: index("executive_meeting_attendees_meeting_idx").on(t.meetingId),
|
||||
|
||||
@@ -98,3 +98,7 @@
|
||||
```
|
||||
|
||||
Then run `pnpm --filter @workspace/db run push-force`. The push has been verified end-to-end against the dev database and is idempotent on subsequent runs.
|
||||
|
||||
## Task #207 — Custom subheadings inside attendee cells (April 2026)
|
||||
|
||||
`executive_meeting_attendees.kind` (`varchar(16) NOT NULL DEFAULT 'person'`) lets meetings interleave free-text section headers ("subheadings") with person rows. Subheadings are excluded from the running attendee number and from the per-meeting attendee count surface, but reorder/delete identically to person rows. The schema lives in `lib/db/src/schema/executive-meetings.ts`. All four insert paths (POST, PATCH attendees replace, PUT attendees, duplicate) round-trip `kind`. The PDF renderer (`artifacts/api-server/src/lib/pdf-renderer.ts`) prints subheadings as `— label —` and skips them when incrementing `personIdx`.
|
||||
|
||||
Reference in New Issue
Block a user