diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 739e76bd..9a438e6a 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -51,6 +51,16 @@ 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; +// 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. +const MERGE_COLUMNS = ["number", "meeting", "attendees", "time"] as const; +const MERGE_COLUMN_INDEX: Record<(typeof MERGE_COLUMNS)[number], number> = { + number: 0, + meeting: 1, + attendees: 2, + time: 3, +}; const REQUEST_TYPES = [ "create", "edit", @@ -206,6 +216,31 @@ const meetingCreateSchema = z { message: "startTime must be <= endTime", path: ["endTime"] }, ); +// Cell-merge overlay payload accepted by PATCH. The whole `merge` key is +// optional; when present it must either be `null` (clear) or a fully +// specified object describing the column range and the free-text label. +// `mergeText` accepts sanitized rich-text HTML up to ~10k bytes (same +// budget as titleAr/titleEn). +const meetingMergeSchema = z + .union([ + z.null(), + z.object({ + mergeStartColumn: z.enum(MERGE_COLUMNS), + mergeEndColumn: z.enum(MERGE_COLUMNS), + mergeText: z.string().trim().max(10000), + }), + ]) + .refine( + (v) => + v === null || + MERGE_COLUMN_INDEX[v.mergeStartColumn] <= + MERGE_COLUMN_INDEX[v.mergeEndColumn], + { + message: "mergeStartColumn must come before mergeEndColumn", + path: ["mergeEndColumn"], + }, + ); + const meetingPatchSchema = z .object({ titleAr: meetingBaseFields.titleAr.optional(), @@ -221,6 +256,7 @@ const meetingPatchSchema = z isHighlighted: meetingBaseFields.isHighlighted, notes: meetingBaseFields.notes, attendees: z.array(attendeeSchema).optional(), + merge: meetingMergeSchema.optional(), }) .refine( (v) => !v.startTime || !v.endTime || v.startTime <= v.endTime, @@ -694,6 +730,17 @@ router.patch( if (data.isHighlighted !== undefined) updateValues.isHighlighted = data.isHighlighted; if (data.notes !== undefined) updateValues.notes = data.notes; + if (data.merge !== undefined) { + if (data.merge === null) { + updateValues.mergeStartColumn = null; + updateValues.mergeEndColumn = null; + updateValues.mergeText = null; + } else { + updateValues.mergeStartColumn = data.merge.mergeStartColumn; + updateValues.mergeEndColumn = data.merge.mergeEndColumn; + updateValues.mergeText = sanitizeRichText(data.merge.mergeText); + } + } if (Object.keys(updateValues).length > 1) { await tx diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 723b7996..cc11c240 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 48d407b8..9d0eaff0 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -822,6 +822,15 @@ "rowColor": { "label": "لون الصف" }, + "merge": { + "label": "دمج الخلايا", + "editLabel": "تعديل الخلية المدموجة", + "placeholder": "اضغط لإضافة نص", + "meetingAttendees": "دمج الاجتماع + الحضور", + "meetingTime": "دمج الاجتماع + الحضور + الوقت", + "all": "دمج الصف بالكامل", + "unmerge": "إلغاء الدمج" + }, "nav": { "schedule": "جدول الاجتماعات اليومي", "manage": "إدارة الاجتماعات", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 38066c30..5b877ec3 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -745,6 +745,15 @@ "rowColor": { "label": "Row color" }, + "merge": { + "label": "Merge cells", + "editLabel": "Edit merged cell", + "placeholder": "Click to add text", + "meetingAttendees": "Merge meeting + attendees", + "meetingTime": "Merge meeting + attendees + time", + "all": "Merge entire row", + "unmerge": "Unmerge" + }, "schedule": { "addRow": "+ Add meeting", "newMeetingDefaultAr": "اجتماع جديد", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 3937ee76..a31da44a 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -39,6 +39,7 @@ import { RotateCcw, Palette, Sliders, + Combine, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -112,6 +113,14 @@ type Meeting = { isHighlighted: number; notes: string | null; attendees: Attendee[]; + // Optional cell-merge overlay. When all three are non-null the + // schedule renders one merged cell spanning + // [mergeStartColumn..mergeEndColumn] showing mergeText (sanitized + // HTML) instead of the original column values. Originals are + // preserved on the row so clearing the merge restores them. + mergeStartColumn?: ColumnId | null; + mergeEndColumn?: ColumnId | null; + mergeText?: string | null; }; type DayResponse = { date: string; meetings: Meeting[] }; @@ -965,6 +974,36 @@ function ScheduleSection({ [refreshDay, toast, t], ); + // PATCH the cell-merge overlay on a meeting. `merge === null` clears + // the overlay and restores the original cells; otherwise it sets a + // new range + free text that the row will display in place of the + // spanned cells. + const saveMerge = useCallback( + async ( + meeting: Meeting, + merge: + | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string } + | null, + ) => { + try { + await apiJson(`/api/executive-meetings/${meeting.id}`, { + method: "PATCH", + body: { merge }, + }); + refreshDay(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + toast({ + title: t("common.error"), + description: msg, + variant: "destructive", + }); + throw err; + } + }, + [refreshDay, toast, t], + ); + const [creatingRow, setCreatingRow] = useState(false); // When the user clicks "Add row" we capture the new meeting id here so // that, once the refreshed list contains it, we can scroll the row into @@ -1286,6 +1325,7 @@ function ScheduleSection({ saveAttendeeName(m, idx, html) } onSaveTimes={(start, end) => saveTimes(m, start, end)} + onSaveMerge={(merge) => saveMerge(m, merge)} isCurrent={ highlightPrefs.enabled && currentMeetingId === m.id } @@ -1674,6 +1714,7 @@ function MeetingRow({ onSaveTitle, onSaveAttendeeName, onSaveTimes, + onSaveMerge, isCurrent, highlightColor, reordering, @@ -1697,6 +1738,11 @@ function MeetingRow({ startTime: string | null, endTime: string | null, ) => Promise; + onSaveMerge: ( + merge: + | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string } + | null, + ) => Promise; isCurrent: boolean; highlightColor: string; reordering: boolean; @@ -1752,18 +1798,88 @@ function MeetingRow({ overflow: "hidden", }); - const renderCell = (col: ColumnSetting): ReactNode => { + // Compute the cell-merge overlay for this row. We resolve the stored + // start/end column ids against the *canonical* schedule column order + // (not against `visibleColumns`) so that a merge whose start or end + // column is currently hidden still renders across whichever columns + // in the range remain visible. This guarantees a row that has a + // merge in the DB never silently falls back to "unmerged" just + // because the user toggled off one of the boundary columns. + const canonStart = canonicalMergeIndex(meeting.mergeStartColumn); + const canonEnd = canonicalMergeIndex(meeting.mergeEndColumn); + const hasStoredMerge = + canonStart >= 0 && canonEnd >= canonStart && typeof meeting.mergeText === "string"; + const visibleMergeIndices: number[] = []; + if (hasStoredMerge) { + visibleColumns.forEach((c, idx) => { + const ci = canonicalMergeIndex(c.id); + if (ci >= canonStart && ci <= canonEnd) visibleMergeIndices.push(idx); + }); + } + // Headers are themselves drag-sortable, so users can reorder the + // schedule columns. If a previously-set merge ends up spanning + // *non-contiguous* visible indices after a reorder, rendering one + // colSpan cell at the first index would silently swallow the wrong + // columns. In that case we degrade to "unmerged" rendering — the DB + // merge state stays intact, and unmerging or restoring canonical + // order brings the merged cell back. + const visibleMergeContiguous = + visibleMergeIndices.length === 0 || + visibleMergeIndices[visibleMergeIndices.length - 1] - + visibleMergeIndices[0] + + 1 === + visibleMergeIndices.length; + const isMerged = + hasStoredMerge && visibleMergeIndices.length > 0 && visibleMergeContiguous; + const mergeFirstIdx = isMerged ? visibleMergeIndices[0] : -1; + const mergeSkipSet = isMerged ? new Set(visibleMergeIndices.slice(1)) : null; + // First visible cell that's NOT part of a merge. The merge trigger + // for an unmerged row is anchored here so editors can still open the + // merge menu even when the # column is hidden. We look for the + // first index in visibleColumns whose canonical id exists (which is + // every schedule column), so this collapses to `0` in practice but + // would skip non-mergeable columns if they're added later. + const firstUnmergedIdx = isMerged ? -1 : visibleColumns.length > 0 ? 0 : -1; + + // Light tint for the current meeting on the editable cells (meeting, + // attendees, time). The number cell keeps its solid white background + // and original text colour so it stays legible — only the rest of the + // row gets a soft wash of highlightColor. We keep the original text + // colour everywhere so the row remains readable. + const tintBg = + isCurrent && !(highlight || isCancelled) + ? hexToRgba(highlightColor, 0.13) + : null; + const tintedCellStyle = (col: ColumnSetting): CSSProperties => + tintBg ? { ...cellStyle(col), backgroundColor: tintBg } : cellStyle(col); + + const renderCell = (col: ColumnSetting, idx: number): ReactNode => { + // Anchor the merge-menu trigger to whichever cell is the first + // visible one on an unmerged row. Using "first visible cell" + // (instead of always the # cell) keeps the trigger reachable when + // editors hide the number column. The trigger is rendered as an + // absolutely-positioned overlay inside the cell, so every cell type + // needs `relative group` on its for the overlay to find it. + const showMergeTrigger = canMutate && !isMerged && idx === firstUnmergedIdx; + const mergeOverlay = showMergeTrigger ? ( + + ) : null; switch (col.id) { case "number": return (
{canMutate && ( @@ -1792,14 +1908,15 @@ function MeetingRow({ {meeting.dailyNumber}
+ {mergeOverlay} ); case "meeting": return ( )} + {mergeOverlay} ); case "attendees": return ( + {mergeOverlay} ); case "time": return ( + {mergeOverlay} ); } }; + // When a merge is active we render a single with an + // EditableCell for `mergeText`, and skip the cells the merge covers + // after the first. The merged cell hosts its own MergeMenu so users + // can always unmerge or re-merge — even when the # column is hidden. + // Originals stay in the DB so unmerge restores them. + const renderCells = (): ReactNode[] => { + if (!isMerged) { + return visibleColumns.map((c, idx) => renderCell(c, idx)); + } + const cells: ReactNode[] = []; + const span = visibleMergeIndices.length; + visibleColumns.forEach((col, idx) => { + if (mergeSkipSet?.has(idx)) return; + if (idx === mergeFirstIdx) { + // Sum widths of the spanned visible columns so layout in + // table-fixed mode reserves the right horizontal space. + const totalWidth = visibleMergeIndices.reduce( + (sum, i) => sum + (visibleColumns[i].width || 0), + 0, + ); + cells.push( + + { + await onSaveMerge({ + mergeStartColumn: meeting.mergeStartColumn as ColumnId, + mergeEndColumn: meeting.mergeEndColumn as ColumnId, + mergeText: html, + }); + }} + ariaLabel={t("executiveMeetings.merge.editLabel")} + dir={isRtl ? "rtl" : "ltr"} + className="font-medium leading-snug break-words" + disabled={!canMutate} + placeholder={t("executiveMeetings.merge.placeholder")} + testId={`em-merge-edit-${meeting.id}`} + /> + {canMutate && ( + + )} + , + ); + } else { + cells.push(renderCell(col, idx)); + } + }); + return cells; + }; + // Combine row background with highlight tint. When the row is the current // meeting we apply a soft tint (color at low opacity) plus a coloured ring // via box-shadow (rings on don't render across cells; box-shadow does). @@ -1876,11 +2063,145 @@ function MeetingRow({ data-testid={`em-row-${meeting.id}`} data-current-meeting={isCurrent ? "true" : undefined} > - {visibleColumns.map((c) => renderCell(c))} + {renderCells()} ); } +// Canonical schedule column order for cell-merge ranges. MUST stay in +// sync with the API's MERGE_COLUMNS array in +// artifacts/api-server/src/routes/executive-meetings.ts. We use this +// (instead of `visibleColumns`) when computing whether a stored merge +// overlaps the currently visible columns, so that hiding one boundary +// of the range still lets the merged cell render across whichever +// columns inside the range remain visible. +const CANONICAL_MERGE_ORDER: ColumnId[] = [ + "number", + "meeting", + "attendees", + "time", +]; +function canonicalMergeIndex(id: ColumnId | string | null | undefined): number { + if (id == null) return -1; + return CANONICAL_MERGE_ORDER.indexOf(id as ColumnId); +} + +// Convert a #RRGGBB or #RGB hex color into an rgba() string with the +// given alpha. Used to derive the soft tint applied to the current +// meeting row from the user's highlight color preference. Falls back +// to the input string unchanged if it isn't a recognised hex. +function hexToRgba(hex: string, alpha: number): string { + const m3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(hex); + const m6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); + let r = 0; + let g = 0; + let b = 0; + if (m6) { + r = parseInt(m6[1], 16); + g = parseInt(m6[2], 16); + b = parseInt(m6[3], 16); + } else if (m3) { + r = parseInt(m3[1] + m3[1], 16); + g = parseInt(m3[2] + m3[2], 16); + b = parseInt(m3[3] + m3[3], 16); + } else { + return hex; + } + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +// Small popover-driven control attached to a row (in the # cell when +// not merged, or to the merged cell itself) that lets editors pick a +// merge range or clear an existing one. Each option corresponds to a +// fixed [start..end] column range. +function MergeMenu({ + meetingId, + // True iff the row is rendered as a merged cell *right now*. Used + // for testid wiring only. + isMerged = false, + // True iff the row has a stored merge in the DB (regardless of + // whether it's currently renderable as merged). Drives the + // "Unmerge" option so users can always clear stored merges, even + // when the visible columns aren't contiguous. + hasStoredMerge, + // Existing merge text from the DB, preserved when the user picks a + // new merge range so we never silently overwrite their content. + existingMergeText, + t, + onChange, +}: { + meetingId: number; + isMerged?: boolean; + hasStoredMerge: boolean; + existingMergeText: string | null; + t: (k: string) => string; + onChange: ( + merge: + | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string } + | null, + ) => Promise; +}) { + // Re-merging keeps the existing text so users don't accidentally lose + // what they had typed. New merges (no stored text) start empty. + const apply = async (start: ColumnId, end: ColumnId) => { + await onChange({ + mergeStartColumn: start, + mergeEndColumn: end, + mergeText: existingMergeText ?? "", + }); + }; + return ( + + + + + + + + + {hasStoredMerge && ( + + )} + + + ); +} + function TimeRangeCell({ meeting, t, diff --git a/lib/db/src/schema/executive-meetings.ts b/lib/db/src/schema/executive-meetings.ts index 8807de79..1d8a1def 100644 --- a/lib/db/src/schema/executive-meetings.ts +++ b/lib/db/src/schema/executive-meetings.ts @@ -33,6 +33,17 @@ export const executiveMeetingsTable = pgTable( isHighlighted: integer("is_highlighted").notNull().default(0), notes: text("notes"), attachments: jsonb("attachments").default([]), + // Optional cell-merge overlay for the schedule grid. When set, the + // schedule renders one merged cell spanning columns + // [mergeStartColumn..mergeEndColumn] and shows mergeText (sanitized + // HTML) instead of the original column values. The original + // titleAr/En + attendees + start/endTime stay untouched in the DB, + // so clearing the merge restores the original cells exactly. + // Allowed values for the column ids match the frontend ColumnId + // enum: "number" | "meeting" | "attendees" | "time". + mergeStartColumn: varchar("merge_start_column", { length: 32 }), + mergeEndColumn: varchar("merge_end_column", { length: 32 }), + mergeText: text("merge_text"), createdBy: integer("created_by").references(() => usersTable.id, { onDelete: "set null", }),