diff --git a/artifacts/api-server/src/lib/pdf-renderer.ts b/artifacts/api-server/src/lib/pdf-renderer.ts index aab778ba..f913c416 100644 --- a/artifacts/api-server/src/lib/pdf-renderer.ts +++ b/artifacts/api-server/src/lib/pdf-renderer.ts @@ -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 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), }, { diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 2a869c76..ee582dae 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -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, })), })), }); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index d66321bb..fd3ed110 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": "تحريك للأسفل", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index ec33dcba..2eae83b8 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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", diff --git a/artifacts/tx-os/src/pages/executive-meetings-print.tsx b/artifacts/tx-os/src/pages/executive-meetings-print.tsx index ea2fb9db..44525a45 100644 --- a/artifacts/tx-os/src/pages/executive-meetings-print.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings-print.tsx @@ -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; + } `}
@@ -258,26 +274,53 @@ export default function ExecutiveMeetingsPrintPage() { className="em-print-attendees" data-testid={`em-print-attendees-${m.id}`} > - {attendees.map((a, idx) => ( -
- - {idx + 1}- - - {/* 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