Task #152: cell-merge + current-meeting tint on EM schedule
Adds two related features to the executive-meetings schedule: 1. Current-meeting tinting — meeting/attendees/time cells of the row matching the current time get a soft wash of the user's highlight color (~13% alpha), keeping their original text color and the existing colored ring. The # cell stays solid white/red. 2. Cell-merge overlay — editors can merge "meeting+attendees", "meeting+attendees+time", or the whole row into a single free-text cell. Stored in the DB so all viewers see the same overlay; the originals (titleAr/En, attendees, start/endTime) stay untouched and are restored on Unmerge. Implementation notes - DB: 3 nullable columns added to executive_meetings (merge_start_column, merge_end_column, merge_text). Applied via direct ALTER TABLE because pnpm --filter db run push is blocked by the pre-existing app_permissions duplicate (Task #148, surfaced as the new follow-up #156). Schema file kept in sync. - API: meetingPatchSchema accepts an optional `merge` field (`null | {start,end,text}`) with start<=end ordering and server-side sanitizeRichText on the text. Reuses the existing PATCH route. - Frontend: new MergeMenu component + canonical-order range resolution so merges still render when boundary columns are hidden, and gracefully degrade to unmerged rendering (without losing DB state) when column reordering makes the visible span non-contiguous. Unmerge stays available whenever a stored merge exists, even in degraded state, and existing mergeText is preserved on re-merge. - i18n: en + ar keys under executiveMeetings.merge.* Out of scope (deferred to follow-ups #154 / #155, per task brief) - API + e2e test coverage for the merge endpoint and UI flows - Realtime emission for executive-meetings mutations (no existing emit on this surface; covered by #155) Validation - TypeScript compiles cleanly across api-server + tx-os. - API tests: 108/110 pass — the 2 failures are the pre-existing app_permissions-unique cases (Task #148, follow-up #156), unrelated to this change. - Architect code review approved after addressing hidden-column fallback, non-contiguous reorder degradation, and Unmerge-availability edge cases.
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
@@ -822,6 +822,15 @@
|
||||
"rowColor": {
|
||||
"label": "لون الصف"
|
||||
},
|
||||
"merge": {
|
||||
"label": "دمج الخلايا",
|
||||
"editLabel": "تعديل الخلية المدموجة",
|
||||
"placeholder": "اضغط لإضافة نص",
|
||||
"meetingAttendees": "دمج الاجتماع + الحضور",
|
||||
"meetingTime": "دمج الاجتماع + الحضور + الوقت",
|
||||
"all": "دمج الصف بالكامل",
|
||||
"unmerge": "إلغاء الدمج"
|
||||
},
|
||||
"nav": {
|
||||
"schedule": "جدول الاجتماعات اليومي",
|
||||
"manage": "إدارة الاجتماعات",
|
||||
|
||||
@@ -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": "اجتماع جديد",
|
||||
|
||||
@@ -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<void>;
|
||||
onSaveMerge: (
|
||||
merge:
|
||||
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
|
||||
| null,
|
||||
) => Promise<void>;
|
||||
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 <td> for the overlay to find it.
|
||||
const showMergeTrigger = canMutate && !isMerged && idx === firstUnmergedIdx;
|
||||
const mergeOverlay = showMergeTrigger ? (
|
||||
<MergeMenu
|
||||
meetingId={meeting.id}
|
||||
// Offer Unmerge whenever the row has a stored merge in the DB,
|
||||
// even if we're in degraded (non-contiguous) rendering — that's
|
||||
// the only way the user can clear the stored state.
|
||||
hasStoredMerge={hasStoredMerge}
|
||||
existingMergeText={meeting.mergeText ?? null}
|
||||
t={t}
|
||||
onChange={onSaveMerge}
|
||||
/>
|
||||
) : null;
|
||||
switch (col.id) {
|
||||
case "number":
|
||||
return (
|
||||
<td
|
||||
key="number"
|
||||
className={`relative group ${numberCellCls}`}
|
||||
style={
|
||||
isCurrent && !(highlight || isCancelled)
|
||||
? { ...cellStyle(col), backgroundColor: highlightColor, color: "white" }
|
||||
: cellStyle(col)
|
||||
}
|
||||
style={cellStyle(col)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{canMutate && (
|
||||
@@ -1792,14 +1908,15 @@ function MeetingRow({
|
||||
<span>{meeting.dailyNumber}</span>
|
||||
</div>
|
||||
<RowColorPicker current={rowColorKey} onPick={onPickRowColor} t={t} />
|
||||
{mergeOverlay}
|
||||
</td>
|
||||
);
|
||||
case "meeting":
|
||||
return (
|
||||
<td
|
||||
key="meeting"
|
||||
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F]"
|
||||
style={cellStyle(col)}
|
||||
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
|
||||
style={tintedCellStyle(col)}
|
||||
>
|
||||
<EditableCell
|
||||
value={title}
|
||||
@@ -1817,14 +1934,15 @@ function MeetingRow({
|
||||
{meeting.location}
|
||||
</div>
|
||||
)}
|
||||
{mergeOverlay}
|
||||
</td>
|
||||
);
|
||||
case "attendees":
|
||||
return (
|
||||
<td
|
||||
key="attendees"
|
||||
className="border border-gray-300 px-3 py-3 align-middle"
|
||||
style={cellStyle(col)}
|
||||
className="border border-gray-300 px-3 py-3 align-middle relative group"
|
||||
style={tintedCellStyle(col)}
|
||||
>
|
||||
<AttendeesCell
|
||||
meeting={meeting}
|
||||
@@ -1836,14 +1954,15 @@ function MeetingRow({
|
||||
onCommitAddAttendee={onCommitAddAttendee}
|
||||
onCancelAddAttendee={onCancelAddAttendee}
|
||||
/>
|
||||
{mergeOverlay}
|
||||
</td>
|
||||
);
|
||||
case "time":
|
||||
return (
|
||||
<td
|
||||
key="time"
|
||||
className="border border-gray-300 px-3 py-3 align-middle text-center"
|
||||
style={cellStyle(col)}
|
||||
className="border border-gray-300 px-3 py-3 align-middle text-center relative group"
|
||||
style={tintedCellStyle(col)}
|
||||
>
|
||||
<TimeRangeCell
|
||||
meeting={meeting}
|
||||
@@ -1851,11 +1970,79 @@ function MeetingRow({
|
||||
canMutate={canMutate}
|
||||
onSaveTimes={onSaveTimes}
|
||||
/>
|
||||
{mergeOverlay}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// When a merge is active we render a single <td colSpan=N> 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(
|
||||
<td
|
||||
key="merge"
|
||||
colSpan={span}
|
||||
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
|
||||
style={{
|
||||
width: totalWidth,
|
||||
overflow: "hidden",
|
||||
...(tintBg ? { backgroundColor: tintBg } : null),
|
||||
}}
|
||||
data-testid={`em-merge-cell-${meeting.id}`}
|
||||
>
|
||||
<EditableCell
|
||||
value={meeting.mergeText ?? ""}
|
||||
onSave={async (html) => {
|
||||
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 && (
|
||||
<MergeMenu
|
||||
meetingId={meeting.id}
|
||||
isMerged
|
||||
hasStoredMerge
|
||||
existingMergeText={meeting.mergeText ?? null}
|
||||
t={t}
|
||||
onChange={onSaveMerge}
|
||||
/>
|
||||
)}
|
||||
</td>,
|
||||
);
|
||||
} 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 <tr> 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()}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<void>;
|
||||
}) {
|
||||
// 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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-1 inline-end-1 p-1 rounded text-muted-foreground hover:text-[#0B1E3F] hover:bg-white/70 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity print:hidden"
|
||||
aria-label={t("executiveMeetings.merge.label")}
|
||||
data-testid={`em-merge-trigger-${meetingId}`}
|
||||
>
|
||||
<Combine className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-1" align="end">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
|
||||
data-testid={`em-merge-opt-meeting-attendees-${meetingId}`}
|
||||
onClick={() => apply("meeting", "attendees")}
|
||||
>
|
||||
{t("executiveMeetings.merge.meetingAttendees")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
|
||||
data-testid={`em-merge-opt-meeting-time-${meetingId}`}
|
||||
onClick={() => apply("meeting", "time")}
|
||||
>
|
||||
{t("executiveMeetings.merge.meetingTime")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
|
||||
data-testid={`em-merge-opt-all-${meetingId}`}
|
||||
onClick={() => apply("number", "time")}
|
||||
>
|
||||
{t("executiveMeetings.merge.all")}
|
||||
</button>
|
||||
{hasStoredMerge && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent text-destructive"
|
||||
data-testid={`em-merge-opt-unmerge-${meetingId}`}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
{t("executiveMeetings.merge.unmerge")}
|
||||
</button>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeRangeCell({
|
||||
meeting,
|
||||
t,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user