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,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user