Files
TX/artifacts/tx-os/src/pages/executive-meetings.tsx
T
riyadhafraa db6b835f75 feat(executive-meetings): replace native window.confirm with in-app AlertDialog (#342)
Native window.confirm exposes the Repl hostname and ignores the app's
RTL/branding. The user (Arabic-speaking) flagged it as visually jarring
inside Executive Meetings.

Changes (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Add a small ConfirmProvider + useConfirm() backed by the existing
  shadcn AlertDialog (`@/components/ui/alert-dialog`). The hook returns
  Promise<boolean> so call sites stay one-liners:
    if (!(await confirm({ message, destructive: true }))) return;
- Single-flight guard: if a second confirm() arrives while the first is
  still pending, the previous Promise resolves to false (no orphaned
  resolvers).
- Mount the provider once around the page (split the default export
  into a thin wrapper + ExecutiveMeetingsPageInner so the inner tree
  can call useConfirm()).
- Centered title + description, destructive-styled confirm button,
  mobile-safe sizing (max-w-md, w-[calc(100%-2rem)], max-h-[90vh],
  overflow-y-auto). Explicit `dir` on AlertDialogContent so the
  portal-rendered dialog always inherits the page's RTL/LTR.
- Footer renders Action first then Cancel: in LTR Confirm sits on the
  left and Cancel on the right; RTL flips automatically (Confirm right,
  Cancel left). Mobile stacks Cancel on top, destructive at the bottom.
- Replace all 5 window.confirm sites:
  1. Schedule single-row delete (deleteMeeting)
  2. Schedule bulk delete
  3. Manage bulk delete
  4. Manage single delete (confirmDelete)
  5. MeetingFormDialog "remove all attendees" (function turned async)
- Add `confirm` to the relevant useCallback deps (deleteMeeting,
  bulk delete) and inject useConfirm() inside ScheduleSection,
  ManageSection, MeetingFormDialog (all rendered inside the provider).
- New i18n keys: executiveMeetings.common.confirm and .confirmTitle in
  both ar.json and en.json (used as default title / non-destructive
  confirm label).

`pnpm -C artifacts/tx-os exec tsc --noEmit` clean. Pre-existing
api-server / test workflow failures are unrelated to this UI change
(also failing before the task started).
2026-05-03 12:15:58 +00:00

7733 lines
283 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type Dispatch,
type ReactNode,
type SetStateAction,
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowRight,
CalendarClock,
FileDown,
ListChecks,
Bell,
ScrollText,
FileText,
Settings as SettingsIcon,
Plus,
Pencil,
Trash2,
Check,
X,
ChevronUp,
ChevronDown,
Copy,
Eye,
EyeOff,
RotateCcw,
Palette,
Sliders,
Combine,
MoreVertical,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
TimePicker12h,
type TimePicker12hHandle,
} from "@/components/time-picker-12h";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { useToast } from "@/hooks/use-toast";
import { ToastAction } from "@/components/ui/toast";
import {
DndContext,
PointerSensor,
KeyboardSensor,
TouchSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
verticalListSortingStrategy,
useSortable,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { EditableCell } from "@/components/editable-cell";
import { safeHtml } from "@/lib/safe-html";
import {
ALERT_COLOR_PRESETS,
DEFAULT_ALERT_PREFS,
useAlertPrefs,
type AlertPrefs,
} from "@/lib/upcoming-alert-prefs";
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";
// Client-only stable id used by the manage-dialog drag-and-drop. Never
// sent to the server (the save projection at the PATCH/POST call site
// explicitly enumerates wire fields and omits this). Persisted attendees
// can fall back to `id` for sortable identity but new rows have no `id`
// until the first save, so we always assign `_sid` when the dialog opens
// or a new row is added.
_sid?: string;
};
type Meeting = {
id: number;
dailyNumber: number;
titleAr: string;
titleEn: string;
meetingDate: string;
startTime: string | null;
endTime: string | null;
location: string | null;
meetingUrl: string | null;
platform: "none" | "webex" | "teams" | "zoom" | "other";
status: string;
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;
// Shared row-highlight colour (#288). Stored on the meeting row so it
// is the same for every viewer. NULL/missing = "default" (no tint).
// Allowed values come from ROW_COLOR_OPTIONS below.
rowColor?: string | null;
};
type DayResponse = { date: string; meetings: Meeting[] };
type AuditEntry = {
id: number;
action: string;
entityType: string;
entityId: number | null;
oldValue: unknown;
newValue: unknown;
performedBy: number | null;
performedAt: string;
actor: {
username: string;
displayNameAr: string | null;
displayNameEn: string | null;
} | null;
};
type NotificationRow = {
id: number;
meetingId: number | null;
userId: number | null;
notificationType: string;
scheduledAt: string | null;
sentAt: string | null;
status: string;
createdAt: string;
user: {
username: string;
displayNameAr: string | null;
displayNameEn: string | null;
} | null;
};
type FontPrefs = {
fontFamily: string;
fontSize: number;
fontWeight: "regular" | "bold";
alignment: "start" | "center";
};
type FontSettingsResponse = {
global: ({ scope: "global"; userId: null } & FontPrefs) | null;
user: ({ scope: "user"; userId: number } & FontPrefs) | null;
};
type PdfArchive = {
id: number;
archiveDate: string;
filePath: string;
version: number;
// Bytes recorded by the server-side generator. Null on legacy rows
// that were created before we wrote the PDF to object storage.
byteSize: number | null;
generatedBy: number | null;
generatedAt: string;
generator: {
username: string;
displayNameAr: string | null;
displayNameEn: string | null;
} | null;
};
function formatBytesForDisplay(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1024 && unitIdx < units.length - 1) {
value /= 1024;
unitIdx += 1;
}
return `${value < 10 && unitIdx > 0 ? value.toFixed(1) : Math.round(value)} ${units[unitIdx]}`;
}
const SECTIONS = [
{ key: "schedule", icon: CalendarClock },
{ key: "manage", icon: ListChecks },
{ key: "notifications", icon: Bell },
{ key: "audit", icon: ScrollText },
{ key: "pdf", icon: FileText },
{ key: "settings", icon: SettingsIcon },
] as const;
type SectionKey = (typeof SECTIONS)[number]["key"];
type MeCapabilities = {
userId: number;
roles: string[];
canRead: boolean;
canMutate: boolean;
canEditGlobalFontSettings: boolean;
canViewAudit: boolean;
};
type MeRoles = MeCapabilities;
function isSectionVisible(
key: SectionKey,
me: MeCapabilities | null | undefined,
): boolean {
if (!me) return key === "schedule"; // Pre-load: only show the read-only schedule.
switch (key) {
case "schedule":
case "pdf":
case "settings":
return me.canRead;
case "manage":
return me.canMutate;
case "notifications":
return me.canRead;
case "audit":
return me.canViewAudit;
default:
return false;
}
}
const DEFAULT_FONT: FontPrefs = {
fontFamily: "system",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
};
type ColumnId = "number" | "meeting" | "attendees" | "time";
type ColumnSetting = {
id: ColumnId;
visible: boolean;
width: number;
};
const DEFAULT_COLUMNS: ColumnSetting[] = [
{ id: "number", visible: true, width: 56 },
{ id: "meeting", visible: true, width: 320 },
{ id: "attendees", visible: true, width: 600 },
// Wide enough to fit "HH:MM HH:MM" on one line, plus the two
// <input type="time"> controls when the cell enters edit mode.
{ id: "time", visible: true, width: 200 },
];
const MIN_COL_WIDTH = 56;
const MAX_COL_WIDTH = 1200;
const ROW_COLOR_OPTIONS: { key: string; bg: string; label: string }[] = [
{ key: "default", bg: "", label: "default" },
{ key: "red", bg: "#fee2e2", label: "red" },
{ key: "amber", bg: "#fef3c7", label: "amber" },
{ key: "green", bg: "#dcfce7", label: "green" },
{ key: "blue", bg: "#dbeafe", label: "blue" },
{ key: "violet", bg: "#ede9fe", label: "violet" },
{ key: "gray", bg: "#f3f4f6", label: "gray" },
];
const COLS_STORAGE_KEY = "em-schedule-cols-v1";
const ROW_COLORS_STORAGE_KEY = "em-schedule-row-colors-v1";
const HIGHLIGHT_STORAGE_KEY = "em-current-meeting-highlight-v1";
const EDIT_MODE_STORAGE_KEY = "em-schedule-edit-mode-v1";
type HighlightPrefs = { enabled: boolean; color: string };
const DEFAULT_HIGHLIGHT_PREFS: HighlightPrefs = {
enabled: true,
color: "#16a34a",
};
const HIGHLIGHT_COLOR_SWATCHES = [
"#16a34a",
"#2563eb",
"#dc2626",
"#ea580c",
"#7c3aed",
"#0B1E3F",
];
function parseTimeToMinutes(t: string | null): number | null {
if (!t) return null;
const m = /^(\d{2}):(\d{2})/.exec(t);
if (!m) return null;
const h = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
return h * 60 + mm;
}
/**
* Returns the id of the meeting that is currently in progress on `dateIso`,
* or null. A meeting is "current" when the wall clock falls within
* [startTime, endTime) and the displayed date matches today.
*/
function computeCurrentMeetingId(
meetings: Meeting[],
dateIso: string,
nowMs: number,
): number | null {
const now = new Date(nowMs);
const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
if (todayIso !== dateIso) return null;
const minutesNow = now.getHours() * 60 + now.getMinutes();
for (const m of meetings) {
const s = parseTimeToMinutes(m.startTime);
const e = parseTimeToMinutes(m.endTime);
if (s === null || e === null) continue;
if (s <= minutesNow && minutesNow < e) return m.id;
}
return null;
}
function formatTime(t: string | null, lang: "ar" | "en"): string {
if (!t) return "";
// DB stores HH:mm:ss (24h). Build a Date anchored to a fixed day so
// Intl can format the time-of-day in the user's language. We render
// in 12-hour format BUT without the AM/PM (English) or ص/م (Arabic)
// day-period suffix — admins know meetings sit in business hours and
// requested the cleaner compact form ("5:39 5:50"). To do this we
// use `formatToParts` and drop the `dayPeriod` segment, then trim
// any leading/trailing whitespace literals that were glueing it on
// (Intl typically inserts a regular space, but some locales use
// U+00A0 / U+202F — JS `String.prototype.trim` handles all of those).
// `hour: "numeric"` (vs "2-digit") drops the leading zero so
// single-digit hours render as "5:39" instead of "05:39". Latin
// digits are forced via the `-u-nu-latn` locale extension so Arabic
// doesn't bleed Arabic-Indic numerals into the table. Falls back
// to the raw HH:mm slice if parsing somehow fails (defensive —
// shouldn't happen for our DB shape).
const hhmm = t.slice(0, 5);
const parsed = new Date(`1970-01-01T${hhmm}:00`);
if (Number.isNaN(parsed.getTime())) return hhmm;
const locale = lang === "ar" ? "ar-u-nu-latn" : "en-US-u-nu-latn";
const parts = new Intl.DateTimeFormat(locale, {
hour: "numeric",
minute: "2-digit",
hour12: true,
numberingSystem: "latn",
}).formatToParts(parsed);
return parts
.filter((p) => p.type !== "dayPeriod")
.map((p) => p.value)
.join("")
.trim();
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function fontWeightToCss(w: FontPrefs["fontWeight"]): number {
return { regular: 400, bold: 700 }[w];
}
function fontFamilyToCss(name: string): string {
if (name === "system") return "";
if (/[\s-]/.test(name)) return `"${name}", system-ui, sans-serif`;
return `${name}, system-ui, sans-serif`;
}
function buildFontStyle(prefs: FontPrefs): CSSProperties {
return {
fontFamily: fontFamilyToCss(prefs.fontFamily) || undefined,
fontSize: prefs.fontSize,
fontWeight: fontWeightToCss(prefs.fontWeight),
textAlign: prefs.alignment,
};
}
// #326: Map known server-side validation messages to friendly,
// translated copy. The API strings come straight from the Zod
// `superRefine` rules in routes/executive-meetings.ts; matching by
// substring keeps the helper resilient to surrounding prefixes
// like the field path (e.g. "endTime: startTime must be <= endTime").
// Anything we don't recognize falls through to the raw message so
// unexpected server problems aren't silently hidden.
function translateSaveError(rawMsg: string, t: (k: string) => string): string {
if (rawMsg.includes("startTime must be <= endTime")) {
return t("executiveMeetings.manage.errors.endTimeBeforeStart");
}
return rawMsg;
}
// #329: Normalize a free-text string so the schedule's search box can match
// across HTML markup (titles + attendee names are sanitized Tiptap rich text)
// and across the most common Arabic spelling variants. Strips:
// • HTML tags + common entities (&nbsp; / U+00A0)
// • Arabic diacritics (\u064B\u065F) and tatweel (\u0640)
// • Folds alef variants (آ أ إ ٱ → ا), ya (ى → ي), ta marbouta (ة → ه),
// hamza on waw/ya (ؤ ئ → و / ي)
// Then lowercases for case-insensitive English matching. Empty/whitespace
// input becomes "" so the caller can short-circuit.
function normalizeForSearch(input: string | null | undefined): string {
if (!input) return "";
let s = String(input);
// Strip tags + common HTML entities first so we don't index markup.
s = s.replace(/<[^>]*>/g, " ").replace(/&nbsp;/gi, " ").replace(/\u00a0/g, " ");
// Drop Arabic diacritics + tatweel.
s = s.replace(/[\u064B-\u065F\u0640]/g, "");
// Fold common Arabic letter variants.
s = s
.replace(/[\u0622\u0623\u0625\u0671]/g, "\u0627") // alef variants → ا
.replace(/\u0649/g, "\u064A") // ى → ي
.replace(/\u0629/g, "\u0647") // ة → ه
.replace(/\u0624/g, "\u0648") // ؤ → و
.replace(/\u0626/g, "\u064A"); // ئ → ي
return s.toLowerCase().replace(/\s+/g, " ").trim();
}
function meetingMatchesSearch(m: Meeting, needle: string): boolean {
if (!needle) return true;
const haystacks: string[] = [
normalizeForSearch(m.titleAr),
normalizeForSearch(m.titleEn),
normalizeForSearch(m.location),
normalizeForSearch(m.notes),
normalizeForSearch(m.mergeText ?? ""),
];
for (const a of m.attendees) {
haystacks.push(normalizeForSearch(a.name));
haystacks.push(normalizeForSearch(a.title));
}
for (const h of haystacks) {
if (h && h.includes(needle)) return true;
}
return false;
}
function tWithFallback(
t: (key: string) => string,
key: string,
fallback: string,
): string {
const value = t(key);
return value === key ? fallback : value;
}
async function apiJson<T>(
url: string,
init?: Omit<RequestInit, "body"> & { body?: unknown },
): Promise<T> {
const { body: rawBody, headers: rawHeaders, ...rest } = init ?? {};
const opts: RequestInit = {
credentials: "include",
...rest,
headers: { "Content-Type": "application/json", ...(rawHeaders ?? {}) },
};
if (rawBody !== undefined) {
opts.body =
typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody);
}
const res = await fetch(url, opts);
if (res.status === 204) return undefined as T;
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
if (!res.ok) {
throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
}
return data;
}
function readJsonFromStorage<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
function writeJsonToStorage(key: string, value: unknown): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* ignore */
}
}
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
setMatches(mql.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
function normalizeColumns(value: unknown): ColumnSetting[] {
if (!Array.isArray(value)) return DEFAULT_COLUMNS;
const seen = new Set<ColumnId>();
const out: ColumnSetting[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") continue;
const e = entry as Partial<ColumnSetting>;
if (typeof e.id !== "string") continue;
if (!DEFAULT_COLUMNS.some((d) => d.id === e.id)) continue;
if (seen.has(e.id as ColumnId)) continue;
seen.add(e.id as ColumnId);
const fallback = DEFAULT_COLUMNS.find((d) => d.id === e.id)!;
const width =
typeof e.width === "number" && Number.isFinite(e.width)
? Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, e.width))
: fallback.width;
out.push({
id: e.id as ColumnId,
visible: typeof e.visible === "boolean" ? e.visible : true,
width,
});
}
// Append any defaults that were missing (e.g. new columns added later).
for (const def of DEFAULT_COLUMNS) {
if (!seen.has(def.id)) out.push({ ...def });
}
return out;
}
// In-app replacement for `window.confirm`. The native browser dialog
// shows the Repl's hostname in its header and ignores the app's RTL/
// branding, which felt jarring inside the bilingual Executive Meetings
// page (#342). We expose an imperative `confirm({...})` that returns a
// Promise<boolean> so call sites read identically to the old
// `if (!window.confirm(msg)) return;` pattern — only the `await`
// changes. The dialog itself is a single shadcn `AlertDialog` mounted
// once at the page root via `ConfirmProvider`, so any nested component
// (Schedule, Manage, MeetingFormDialog) can call `useConfirm()` without
// prop drilling.
type ConfirmOptions = {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
};
const ConfirmContext = createContext<
((opts: ConfirmOptions) => Promise<boolean>) | null
>(null);
function useConfirm(): (opts: ConfirmOptions) => Promise<boolean> {
const ctx = useContext(ConfirmContext);
if (!ctx) {
throw new Error("useConfirm must be used inside <ConfirmProvider>");
}
return ctx;
}
function ConfirmProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
const isRtl = i18n.language === "ar";
const [pending, setPending] = useState<{
options: ConfirmOptions;
resolve: (v: boolean) => void;
} | null>(null);
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
// Single-flight: if a previous confirm is still pending (e.g. a
// second handler fired before the user clicked anything on the
// first dialog), settle the older Promise as `false` so its
// caller doesn't hang forever, then replace it with the new one.
setPending((prev) => {
if (prev) prev.resolve(false);
return { options, resolve };
});
});
}, []);
// Resolves the open promise exactly once and tears down the dialog.
// Guards against double-resolve when the user clicks Cancel while
// Radix is also firing onOpenChange(false) for the same close.
const settle = useCallback(
(value: boolean) => {
setPending((cur) => {
if (cur) cur.resolve(value);
return null;
});
},
[],
);
const opts = pending?.options;
const title =
opts?.title ??
tWithFallback(t, "executiveMeetings.common.confirmTitle", "تأكيد");
const confirmLabel =
opts?.confirmLabel ??
(opts?.destructive
? tWithFallback(t, "executiveMeetings.common.delete", "حذف")
: tWithFallback(t, "executiveMeetings.common.confirm", "تأكيد"));
const cancelLabel =
opts?.cancelLabel ??
tWithFallback(t, "executiveMeetings.common.cancel", "إلغاء");
return (
<ConfirmContext.Provider value={confirm}>
{children}
<AlertDialog
open={pending !== null}
onOpenChange={(open) => {
if (!open) settle(false);
}}
>
<AlertDialogContent
data-testid="em-confirm-dialog"
dir={isRtl ? "rtl" : "ltr"}
className="max-w-md w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto"
>
<AlertDialogHeader>
<AlertDialogTitle className="text-center">{title}</AlertDialogTitle>
<AlertDialogDescription className="text-center text-base text-foreground whitespace-pre-line break-words">
{opts?.message ?? ""}
</AlertDialogDescription>
</AlertDialogHeader>
{/*
Render the confirm/destructive action FIRST in the JSX so
that the default `flex-row` (LTR) places it on the left and
Cancel on the right (English spec), while in RTL the visual
order flips automatically and Confirm sits on the right with
Cancel on the left (Arabic spec). On mobile the
`flex-col-reverse` from AlertDialogFooter naturally stacks
the destructive action at the bottom and Cancel on top.
*/}
<AlertDialogFooter className="sm:justify-center gap-2">
<AlertDialogAction
data-testid="em-confirm-ok"
onClick={() => settle(true)}
className={
opts?.destructive
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: undefined
}
>
{confirmLabel}
</AlertDialogAction>
<AlertDialogCancel
data-testid="em-confirm-cancel"
onClick={() => settle(false)}
>
{cancelLabel}
</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ConfirmContext.Provider>
);
}
export default function ExecutiveMeetingsPage() {
return (
<ConfirmProvider>
<ExecutiveMeetingsPageInner />
</ConfirmProvider>
);
}
function ExecutiveMeetingsPageInner() {
const { t, i18n } = useTranslation();
const [, setLocation] = useLocation();
const isRtl = i18n.language === "ar";
const lang: "ar" | "en" = isRtl ? "ar" : "en";
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
const [section, setSection] = useState<SectionKey>("schedule");
const [date, setDate] = useState<string>(todayIso());
// Lifted from ScheduleSection so the unified Settings tab can edit
// the same column visibility / highlight state the schedule consumes.
const [columns, setColumns] = useState<ColumnSetting[]>(() =>
normalizeColumns(readJsonFromStorage(COLS_STORAGE_KEY, DEFAULT_COLUMNS)),
);
const [highlightPrefs, setHighlightPrefs] = useState<HighlightPrefs>(() =>
readJsonFromStorage<HighlightPrefs>(
HIGHLIGHT_STORAGE_KEY,
DEFAULT_HIGHLIGHT_PREFS,
),
);
useEffect(() => {
writeJsonToStorage(COLS_STORAGE_KEY, columns);
}, [columns]);
useEffect(() => {
writeJsonToStorage(HIGHLIGHT_STORAGE_KEY, highlightPrefs);
}, [highlightPrefs]);
const { data, isLoading } = useQuery<DayResponse>({
queryKey: ["/api/executive-meetings", date],
queryFn: async () => {
const res = await fetch(`/api/executive-meetings?date=${date}`, {
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
});
const { data: me } = useQuery<MeRoles>({
queryKey: ["/api/executive-meetings/me"],
queryFn: () => apiJson<MeRoles>("/api/executive-meetings/me"),
});
const { data: fontResp } = useQuery<FontSettingsResponse>({
queryKey: ["/api/executive-meetings/font-settings"],
queryFn: () => apiJson<FontSettingsResponse>("/api/executive-meetings/font-settings"),
});
const effectiveFont: FontPrefs = useMemo(() => {
const u = fontResp?.user;
const g = fontResp?.global;
const src = u ?? g;
if (!src) return DEFAULT_FONT;
return {
fontFamily: src.fontFamily,
fontSize: src.fontSize,
fontWeight: src.fontWeight,
alignment: src.alignment,
};
}, [fontResp]);
const meetings = data?.meetings ?? [];
// #267: Measure the header so the in-section sticky rows (page title +
// table thead) can sit flush below it on every breakpoint. The header
// wraps on narrow widths, so a static Tailwind `top-N` would either
// gap or overlap. We expose the live height as `--em-header-h` on the
// page root and let descendant sticky elements read it via `top:
// var(--em-header-h)`.
const rootRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const header = headerRef.current;
const root = rootRef.current;
if (!header || !root) return;
const update = () => {
root.style.setProperty("--em-header-h", `${header.offsetHeight}px`);
};
update();
const ro = new ResizeObserver(update);
ro.observe(header);
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
};
}, []);
return (
<div
ref={rootRef}
data-em-root
className="min-h-screen bg-[#f4f6fb] text-foreground"
dir={isRtl ? "rtl" : "ltr"}
style={{ ["--em-header-h" as string]: "0px", ["--em-heading-h" as string]: "0px" }}
>
<header
ref={headerRef}
data-testid="em-page-header"
className="sticky top-0 z-40 bg-white border-b border-gray-200 shadow-sm print:static print:shadow-none print:hidden"
>
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-3 sm:gap-4 flex-wrap">
<Button
variant="ghost"
size="icon"
onClick={() => setLocation("/")}
aria-label={t("common.back")}
className="text-foreground"
>
<BackIcon className="w-5 h-5" />
</Button>
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<div className="w-10 h-10 sm:w-11 sm:h-11 rounded-md bg-[#0B1E3F] flex items-center justify-center text-white shrink-0">
<CalendarClock className="w-6 h-6" />
</div>
<div className="min-w-0 leading-tight">
<div className="text-sm sm:text-base font-semibold text-[#0B1E3F] truncate">
{t("executiveMeetings.title")}
</div>
</div>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => setSection("pdf")}
data-testid="em-export-shortcut"
>
<FileDown className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
</Button>
</div>
</div>
<nav className="border-t border-gray-100 bg-white overflow-x-auto">
<div className="max-w-screen-2xl mx-auto px-2 sm:px-4 flex gap-1 sm:gap-2 min-w-max">
{SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
({ key, icon: Icon }) => {
const active = section === key;
return (
<button
key={key}
type="button"
onClick={() => setSection(key)}
className={
"flex items-center gap-1.5 px-3 sm:px-4 py-2.5 text-xs sm:text-sm whitespace-nowrap border-b-2 transition-colors " +
(active
? "border-[#0B1E3F] text-[#0B1E3F] font-semibold"
: "border-transparent text-muted-foreground hover:text-foreground")
}
data-testid={`em-nav-${key}`}
>
<Icon className="w-4 h-4" />
{t(`executiveMeetings.nav.${key}`)}
</button>
);
},
)}
</div>
</nav>
</header>
<main className="max-w-screen-2xl mx-auto px-3 sm:px-6 py-4 sm:py-6">
{section === "schedule" && (
<ScheduleSection
meetings={meetings}
date={date}
onDateChange={setDate}
isLoading={isLoading}
isRtl={isRtl}
t={t}
font={effectiveFont}
canMutate={!!me?.canMutate}
userId={me?.userId ?? null}
columns={columns}
onColumnsChange={setColumns}
highlightPrefs={highlightPrefs}
/>
)}
{section === "manage" && me && (
<ManageSection date={date} onDateChange={setDate} canMutate={me.canMutate} isRtl={isRtl} t={t} />
)}
{section === "notifications" && me && (
<NotificationsSection
canView={me.canRead}
date={date}
isRtl={isRtl}
t={t}
/>
)}
{section === "audit" && me && (
<AuditSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
)}
{section === "pdf" && (
<PdfSection date={date} onDateChange={setDate} lang={lang} t={t} />
)}
{section === "settings" && me && (
<SettingsSection
font={effectiveFont}
canEditGlobalFont={me.canEditGlobalFontSettings}
columns={columns}
onColumnsChange={setColumns}
highlightPrefs={highlightPrefs}
onHighlightPrefsChange={setHighlightPrefs}
t={t}
/>
)}
</main>
</div>
);
}
// ============ SCHEDULE ============
function ScheduleSection({
meetings,
date,
onDateChange,
isLoading,
isRtl,
t,
font,
canMutate,
userId,
columns,
onColumnsChange,
highlightPrefs,
}: {
meetings: Meeting[];
date: string;
onDateChange: (d: string) => void;
isLoading: boolean;
isRtl: boolean;
t: (k: string) => string;
font: FontPrefs;
canMutate: boolean;
userId: number | null;
columns: ColumnSetting[];
onColumnsChange: Dispatch<SetStateAction<ColumnSetting[]>>;
highlightPrefs: HighlightPrefs;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const confirm = useConfirm();
const tableStyle = buildFontStyle(font);
const setColumns = onColumnsChange;
// #288: Row highlight colours are now stored on the meeting row in the
// DB and shared across every viewer. Derive a quick lookup from the
// current `meetings` prop so the rest of this component (which still
// calls `rowColors[m.id] ?? "default"`) keeps working unchanged.
// Mutations go through `setRowColor` below, which PATCHes the meeting
// and then refetches via the day-changed query invalidation — no
// separate client-side cache to keep in sync.
const rowColors = useMemo<Record<number, string>>(() => {
const out: Record<number, string> = {};
for (const m of meetings) {
if (m.rowColor) out[m.id] = m.rowColor;
}
return out;
}, [meetings]);
// Global "Edit / View" toggle for the schedule. The default is view mode
// (no edit affordances). When a user with edit permission flips it on,
// every per-cell editor and per-row action (merge, color swatch, drag
// handle, add/delete row, inline edit, column drag, column resize) is
// re-enabled. We persist the user's last choice in localStorage so the
// toggle survives reloads + date changes, but we *also* always start in
// view mode for users without permission so they never see a stale "on"
// state from a previous session where they had access. Hydrating the
// localStorage value happens inside an effect to keep SSR-safe and to
// avoid reading from a non-mutator's namespace.
//
// The storage key is namespaced by the current user's id so a shared
// browser profile can't leak one editor's last toggle state into the
// next account that signs in. When userId is null (no /me yet) we
// fall back to view mode without touching storage.
const editModeStorageKey = useMemo(
() => (userId != null ? `${EDIT_MODE_STORAGE_KEY}:${userId}` : null),
[userId],
);
const [editMode, setEditModeState] = useState<boolean>(false);
useEffect(() => {
if (!canMutate || !editModeStorageKey) {
setEditModeState(false);
return;
}
try {
const raw = window.localStorage.getItem(editModeStorageKey);
setEditModeState(raw === "true");
} catch {
// localStorage may be unavailable (private mode); fall through to
// the default false.
}
}, [canMutate, editModeStorageKey]);
const setEditMode = useCallback(
(next: boolean) => {
if (!canMutate || !editModeStorageKey) return;
setEditModeState(next);
try {
window.localStorage.setItem(editModeStorageKey, next ? "true" : "false");
} catch {
// Persistence is best-effort; the in-memory state still flips.
}
},
[canMutate, editModeStorageKey],
);
// Anything the user can mutate must AND with editMode. We pass this
// derived flag down to every row + cell so they don't each have to
// know about the toggle. The actual `canMutate` permission stays in
// scope for the toggle button visibility check.
const effectiveCanMutate = canMutate && editMode;
const [nowTick, setNowTick] = useState(() => Date.now());
useEffect(() => {
let intervalId: ReturnType<typeof setInterval> | null = null;
const msToNextMinute = 60_000 - (Date.now() % 60_000);
const timeoutId = setTimeout(() => {
setNowTick(Date.now());
intervalId = setInterval(() => setNowTick(Date.now()), 60_000);
}, msToNextMinute);
return () => {
clearTimeout(timeoutId);
if (intervalId) clearInterval(intervalId);
};
}, []);
const currentMeetingId = useMemo(
() => computeCurrentMeetingId(meetings, date, nowTick),
[meetings, date, nowTick],
);
// Optimistic local override so we can render the new row order before the
// server round-trip finishes. Cleared on each new meetings prop.
const [optimisticOrder, setOptimisticOrder] = useState<number[] | null>(null);
const [reordering, setReordering] = useState(false);
const reorderingRef = useRef(false);
useEffect(() => {
setOptimisticOrder(null);
}, [meetings, date]);
const orderedMeetings = useMemo(() => {
// #273: cancelled meetings disappear from the daily schedule view
// (they remain queryable via archive / API for audit trails).
const visible = meetings.filter((m) => m.status !== "cancelled");
if (!optimisticOrder) return visible;
const byId = new Map(visible.map((m) => [m.id, m]));
const out: Meeting[] = [];
for (const id of optimisticOrder) {
const m = byId.get(id);
if (m) out.push(m);
}
// Append any visible meetings not in the optimistic order (defensive).
for (const m of visible) {
if (!optimisticOrder.includes(m.id)) out.push(m);
}
return out;
}, [meetings, optimisticOrder]);
// #329: in-day search. Free-text needle is normalized once (Arabic
// diacritics + letter variants folded, lowercased, HTML stripped) and
// matched against title, attendee names/titles, location, notes, and
// any merge text on the row. Empty needle → pass-through.
const [searchQuery, setSearchQuery] = useState("");
// Reset whenever the active day changes — a search is "scoped to the
// currently visible date" per the spec, so jumping days clears it.
useEffect(() => {
setSearchQuery("");
}, [date]);
const normalizedSearch = useMemo(
() => normalizeForSearch(searchQuery),
[searchQuery],
);
const filteredMeetings = useMemo(() => {
if (!normalizedSearch) return orderedMeetings;
return orderedMeetings.filter((m) =>
meetingMatchesSearch(m, normalizedSearch),
);
}, [orderedMeetings, normalizedSearch]);
// #329: while printing, ignore the active search so PDF/print output
// is always the full day's schedule. We track an `isPrinting` flag
// via the standard beforeprint/afterprint events (with a `print` media
// query listener as a fallback for browsers that fire only the latter)
// and feed `displayedMeetings` to the table render path. The search
// input itself is also `print:hidden`, so the PDF doesn't show a
// half-typed query either.
const [isPrinting, setIsPrinting] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const onBefore = () => setIsPrinting(true);
const onAfter = () => setIsPrinting(false);
window.addEventListener("beforeprint", onBefore);
window.addEventListener("afterprint", onAfter);
const mq = window.matchMedia("print");
const onMq = (e: MediaQueryListEvent) => setIsPrinting(e.matches);
mq.addEventListener?.("change", onMq);
return () => {
window.removeEventListener("beforeprint", onBefore);
window.removeEventListener("afterprint", onAfter);
mq.removeEventListener?.("change", onMq);
};
}, []);
const displayedMeetings = isPrinting ? orderedMeetings : filteredMeetings;
const refreshDay = useCallback(() => {
void qc.invalidateQueries({
queryKey: ["/api/executive-meetings", date],
});
}, [qc, date]);
// #317: optimistic-cache helper shared by every inline-edit save
// handler on the schedule. Snapshots the current DayResponse, applies
// `patch` to the matching meeting, and returns a rollback function the
// caller invokes from its `catch` branch on PATCH failure. This is
// what makes title / attendee / merge / row-color edits repaint the
// cell within a single React commit instead of waiting for the GET
// refetch — the same pattern saveTimes already uses for the schedule
// time editor (task #316).
const applyMeetingPatch = useCallback(
(meetingId: number, patch: (m: Meeting) => Meeting) => {
const queryKey = ["/api/executive-meetings", date] as const;
const previous = qc.getQueryData<DayResponse>(queryKey);
qc.setQueryData<DayResponse>(queryKey, (prev) =>
prev
? {
...prev,
meetings: prev.meetings.map((m) =>
m.id === meetingId ? patch(m) : m,
),
}
: prev,
);
return () => {
if (previous !== undefined) {
qc.setQueryData<DayResponse>(queryKey, previous);
}
};
},
[qc, date],
);
const saveTitle = useCallback(
async (meeting: Meeting, html: string) => {
// #202: route by visible UI language (i18n.language → isRtl), NOT
// by user.preferredLanguage. An ar-pref user who switches the UI
// to en and edits the cell must update titleEn.
const body = isRtl ? { titleAr: html } : { titleEn: html };
// #317: optimistic local repaint so the cell shows the new title
// in the same React commit that closes the editor. Rolls back on
// PATCH failure so a network/server error snaps the cell back to
// the previously-saved value while the toast surfaces the error.
const rollback = applyMeetingPatch(meeting.id, (m) =>
isRtl ? { ...m, titleAr: html } : { ...m, titleEn: html },
);
try {
await apiJson(`/api/executive-meetings/${meeting.id}`, {
method: "PATCH",
body,
});
refreshDay();
} catch (err) {
rollback();
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
throw err;
}
},
[applyMeetingPatch, isRtl, refreshDay, toast, t],
);
const saveAttendeeName = useCallback(
async (meeting: Meeting, attendeeIdx: number, html: string) => {
// Detect "truly empty" content: Tiptap empty paragraphs, &nbsp;, 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. The same gesture deletes a custom
// subheading row (clear the label text → row disappears on blur).
const isEmpty =
html
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/\u00a0/g, " ")
.trim() === "";
const next = isEmpty
? meeting.attendees
.filter((_, i) => i !== attendeeIdx)
// Re-pack sortOrder so the server stores a contiguous run; the
// PUT endpoint takes the array as the new source of truth and
// reassigns ids on insert.
.map((a, i) => ({ ...a, sortOrder: i }))
: meeting.attendees.map((a, i) =>
i === attendeeIdx ? { ...a, name: html } : a,
);
// #317: optimistic repaint of the attendees cell. Mirrors the
// wire-shape we PUT below so the cell collapses to the new state
// (renamed row, or removed row when the user cleared the text)
// immediately, without waiting on the server round-trip.
const rollback = applyMeetingPatch(meeting.id, (m) => ({
...m,
attendees: next.map((a) => ({
...a,
kind: a.kind ?? "person",
})) as Attendee[],
}));
try {
await apiJson(`/api/executive-meetings/${meeting.id}/attendees`, {
method: "PUT",
body: {
attendees: next.map((a) => ({
name: a.name,
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",
})),
},
});
refreshDay();
} catch (err) {
rollback();
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
throw err;
}
},
[applyMeetingPatch, refreshDay, toast, t],
);
// -------------------------------------------------------------------
// "+ Add attendee" — optimistic ghost row
//
// Clicking the inline "+" button in the attendees cell does NOT hit
// the server immediately (the API requires a non-empty name). Instead
// we render a synthetic in-edit EditableCell next to the existing
// attendees. When the user saves a non-empty name we PUT the full
// array with the new attendee appended; if they leave the cell empty
// (blur or Escape) we just discard the pending state.
//
// 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;
// When set, insert the new row at this list position (0-based,
// inserts BEFORE that index — i.e. `splice(insertAtIndex, 0, …)`).
// When undefined, append to the end of the cell (legacy behavior
// for the trailing "+" / "+ عنوان فرعي" buttons).
insertAtIndex?: number;
};
const [pendingAttendee, setPendingAttendee] =
useState<PendingAttendee | null>(null);
// If the user toggles edit mode off (or otherwise loses edit
// permission) while the ghost "+ Add attendee" row is open, drop the
// pending state so the synthetic row unmounts immediately. Without
// this the ghost row would keep its open editor in an otherwise
// read-only schedule.
useEffect(() => {
if (!effectiveCanMutate && pendingAttendee !== null) {
setPendingAttendee(null);
}
}, [effectiveCanMutate, pendingAttendee]);
const startAddAttendee = useCallback(
(
meetingId: number,
attendanceType: Attendee["attendanceType"],
kind: "person" | "subheading" = "person",
insertAtIndex?: number,
) => {
// 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,
kind,
key: Date.now(),
insertAtIndex,
},
);
},
[],
);
const cancelAddAttendee = useCallback(() => {
setPendingAttendee(null);
}, []);
const commitAddAttendee = useCallback(
async (
meeting: Meeting,
attendanceType: Attendee["attendanceType"],
html: string,
kind: "person" | "subheading" = "person",
insertAtIndex?: number,
) => {
// Always clear pending — whether we end up persisting or not.
setPendingAttendee(null);
const isEmpty =
html
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/\u00a0/g, " ")
.trim() === "";
if (isEmpty) return;
// Build the next array. When `insertAtIndex` is set we splice the
// new row INTO the existing list at that position and renumber every
// sortOrder from 0 so the ordering matches array order. Otherwise
// we keep the legacy "append with sort = max+1" behavior to leave
// pre-existing sort gaps intact for the trailing "+" button.
const existing = meeting.attendees.map((a) => ({
name: a.name,
title: a.title,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder,
kind: a.kind ?? "person",
}));
const newRow = {
name: html,
title: null as string | null,
attendanceType,
sortOrder: 0, // overwritten below
kind,
};
let next: typeof existing;
if (
insertAtIndex !== undefined &&
insertAtIndex >= 0 &&
insertAtIndex <= existing.length
) {
const draft = existing.slice();
draft.splice(insertAtIndex, 0, newRow);
next = draft.map((a, idx) => ({ ...a, sortOrder: idx }));
} else {
const maxSort = existing.reduce(
(m, a) => Math.max(m, a.sortOrder ?? 0),
-1,
);
next = [...existing, { ...newRow, sortOrder: maxSort + 1 }];
}
try {
await apiJson(`/api/executive-meetings/${meeting.id}/attendees`, {
method: "PUT",
body: { attendees: next },
});
refreshDay();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
}
},
[refreshDay, toast, t],
);
const saveTimes = useCallback(
async (
meeting: Meeting,
startTime: string | null,
endTime: string | null,
) => {
// Optimistically patch the cached meetings list so the read-only
// schedule row repaints with the new times in the same React
// commit that closes the inline editor — no flicker from
// empty/old → new while the GET refetch is in flight. We snapshot
// the previous payload first so the catch branch below can roll
// back if the PATCH fails (network error, server validation,
// anything that doesn't end with a fresh GET landing).
//
// Shape note: server returns `startTime` / `endTime` as
// "HH:MM:SS" strings. The wire body we POST is "HH:MM" (no
// seconds), so we re-append `:00` when seeding the cache so
// downstream readers (cascade-shift logic, conflict warnings,
// TimeRangeCell's own startSaved.slice(0,5)) see exactly the
// same shape they'll get from the next refetch.
const queryKey = ["/api/executive-meetings", date] as const;
const previous = qc.getQueryData<DayResponse>(queryKey);
qc.setQueryData<DayResponse>(queryKey, (prev) =>
prev
? {
...prev,
meetings: prev.meetings.map((m) =>
m.id === meeting.id
? {
...m,
startTime: startTime ? `${startTime}:00` : null,
endTime: endTime ? `${endTime}:00` : null,
}
: m,
),
}
: prev,
);
try {
await apiJson(`/api/executive-meetings/${meeting.id}`, {
method: "PATCH",
body: { startTime, endTime },
});
refreshDay();
} catch (err) {
// Roll back the optimistic cache so the row snaps back to the
// previously-saved times on any failure (network, 4xx, 5xx).
// The TimeRangeCell catch branch separately re-opens the
// editor with the user's draft so they can retry.
if (previous !== undefined) {
qc.setQueryData<DayResponse>(queryKey, previous);
}
const msg = err instanceof Error ? err.message : String(err);
// The server enforces start <= end and returns a 400 like
// "endTime: startTime must be <= endTime". Surface the same
// friendly localized message the client-side guard uses, and
// throw a tagged error so TimeRangeCell can re-open edit mode
// with the user's draft values intact instead of resetting.
if (msg.includes("startTime must be <= endTime")) {
toast({
title: t("executiveMeetings.schedule.timeOrderError"),
variant: "destructive",
});
const e = new Error("time-order");
e.name = "TimeOrderError";
throw e;
}
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
throw err;
}
},
[qc, date, 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,
) => {
// #317: optimistic repaint of the merge overlay so the cell shows
// the new merged span (or restores the original cells when
// `merge === null`) in the same React commit that closes the
// editor. Rolls back on PATCH failure.
const rollback = applyMeetingPatch(meeting.id, (m) =>
merge
? {
...m,
mergeStartColumn: merge.mergeStartColumn,
mergeEndColumn: merge.mergeEndColumn,
mergeText: merge.mergeText,
}
: {
...m,
mergeStartColumn: null,
mergeEndColumn: null,
mergeText: null,
},
);
try {
await apiJson(`/api/executive-meetings/${meeting.id}`, {
method: "PATCH",
body: { merge },
});
refreshDay();
} catch (err) {
rollback();
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
throw err;
}
},
[applyMeetingPatch, refreshDay, toast, t],
);
// Inline "delete entire meeting" action exposed on each schedule row.
// Uses the same DELETE endpoint as the Manage section, but lives on
// the schedule itself so editors can remove a row without context-
// switching. Confirmation surfaces the meeting title so users know
// exactly what will be removed (titles can contain rich-text HTML so
// we strip tags before showing the prompt).
const [deletingMeetingId, setDeletingMeetingId] = useState<number | null>(
null,
);
const deleteMeeting = useCallback(
async (meeting: Meeting) => {
if (deletingMeetingId != null) return;
// Pick the localized display title with the same fallback the row
// uses, then strip HTML so the native confirm dialog shows plain
// text (titles are Tiptap rich text in the DB).
const rawTitle = isRtl
? meeting.titleAr
: meeting.titleEn || meeting.titleAr;
const plainTitle = rawTitle
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/\u00a0/g, " ")
.trim();
const message = t("executiveMeetings.schedule.deleteRowConfirm").replace(
"{{title}}",
plainTitle,
);
if (!(await confirm({ message, destructive: true }))) return;
setDeletingMeetingId(meeting.id);
try {
await apiJson(`/api/executive-meetings/${meeting.id}`, {
method: "DELETE",
});
toast({ title: t("executiveMeetings.schedule.deleted") });
refreshDay();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
} finally {
setDeletingMeetingId(null);
}
},
[confirm, deletingMeetingId, isRtl, refreshDay, t, toast],
);
// ─── Multi-select + bulk delete (edit mode only) ────────────────────
// Lifted here (alongside the existing single-row deleteMeeting) so a
// single source of truth for "which rows are selected" is available
// to both the floating toolbar and every MeetingRow's checkbox. We
// keep ids in a Set for O(1) membership lookups during render.
const [selectedMeetingIds, setSelectedMeetingIds] = useState<Set<number>>(
() => new Set(),
);
// Drop the selection whenever the schedule context changes — switching
// dates or turning edit mode off should not leave a stale selection
// hanging around (it would resurface the moment the user re-enters
// edit mode and look like a bug).
useEffect(() => {
setSelectedMeetingIds(new Set());
}, [date, effectiveCanMutate]);
// Also clear the selection on every successful day refresh
// (refetch of the meetings query). React Query hands a brand-new
// `meetings` array reference on every refetch — including realtime
// invalidations and post-mutation refreshes triggered by
// refreshDay() — so detecting a reference change here means the
// selection cannot survive past a refresh even when the refreshed
// payload happens to contain the same ids. We skip the very first
// value (mount load) via a ref so an initially-empty selection
// isn't reset for nothing.
const lastMeetingsRef = useRef<Meeting[] | null>(null);
useEffect(() => {
if (lastMeetingsRef.current === null) {
lastMeetingsRef.current = meetings;
return;
}
if (lastMeetingsRef.current !== meetings) {
lastMeetingsRef.current = meetings;
setSelectedMeetingIds(new Set());
}
}, [meetings]);
// Defensive intersection: if the meetings list shrinks (e.g. after a
// bulk delete or someone else removed a row in real time), drop any
// ids that no longer exist so we don't try to act on ghosts.
useEffect(() => {
setSelectedMeetingIds((prev) => {
if (prev.size === 0) return prev;
const present = new Set(meetings.map((m) => m.id));
const next = new Set<number>();
let changed = false;
for (const id of prev) {
if (present.has(id)) next.add(id);
else changed = true;
}
return changed ? next : prev;
});
}, [meetings]);
const toggleMeetingSelected = useCallback(
(id: number, on: boolean) => {
setSelectedMeetingIds((prev) => {
if (on === prev.has(id)) return prev;
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
},
[],
);
const setAllMeetingsSelected = useCallback(
(on: boolean) => {
// #329: "select all" operates on the currently visible (filtered)
// rows so users can't accidentally bulk-act on rows they hid via
// the search box. Turning it off always clears the entire selection
// (including any rows that may be hidden by an active search).
setSelectedMeetingIds((prev) => {
if (!on) return new Set();
const next = new Set(prev);
for (const m of filteredMeetings) next.add(m.id);
return next;
});
},
[filteredMeetings],
);
// Tri-state hint for the "select all" checkbox in the toolbar.
// "all" → fully checked, "some" → indeterminate, "none" → unchecked.
// Computed against the visible (filtered) row set so the header
// checkbox reflects what the user can actually see.
const allSelectedState: "all" | "some" | "none" = useMemo(() => {
const total = filteredMeetings.length;
if (total === 0) return "none";
let count = 0;
for (const m of filteredMeetings) if (selectedMeetingIds.has(m.id)) count++;
if (count === 0) return "none";
if (count === total) return "all";
return "some";
}, [filteredMeetings, selectedMeetingIds]);
// #329: count of selected rows that are *currently visible*. Used by
// the bulk toolbar so the displayed count and the action it triggers
// (delete) refer to the same set, even when an active search hides
// some previously-selected rows.
const visibleSelectedCount = useMemo(() => {
let n = 0;
for (const m of displayedMeetings) if (selectedMeetingIds.has(m.id)) n++;
return n;
}, [displayedMeetings, selectedMeetingIds]);
const [bulkDeleting, setBulkDeleting] = useState(false);
// #330: bulk-duplicate-to-date dialog state. `targetDate` defaults to
// the currently viewed `date` so the dialog opens on a sensible
// value; busy flag disables the confirm button while the per-row
// POSTs are in flight. The id list at action time is the visible
// selected set (intersection with displayedMeetings) for the same
// safety reason as bulk-delete.
const [bulkDuplicateOpen, setBulkDuplicateOpen] = useState(false);
const [bulkDuplicateDate, setBulkDuplicateDate] = useState<string>(date);
const [bulkDuplicating, setBulkDuplicating] = useState(false);
const openBulkDuplicate = useCallback(() => {
setBulkDuplicateDate(date);
setBulkDuplicateOpen(true);
}, [date]);
const performBulkDuplicate = useCallback(async () => {
if (bulkDuplicating) return;
if (!bulkDuplicateDate) return;
const visibleIds = new Set(displayedMeetings.map((m) => m.id));
const ids = Array.from(selectedMeetingIds).filter((id) =>
visibleIds.has(id),
);
if (ids.length === 0) {
setBulkDuplicateOpen(false);
return;
}
setBulkDuplicating(true);
try {
// Mirror the bulk-delete pattern: allSettled so one failure
// doesn't abort the rest, then aggregate into a single toast.
const results = await Promise.allSettled(
ids.map((id) =>
apiJson(`/api/executive-meetings/${id}/duplicate`, {
method: "POST",
body: { targetDate: bulkDuplicateDate },
}),
),
);
const ok = results.filter((r) => r.status === "fulfilled").length;
const failed = results.length - ok;
if (failed === 0) {
toast({
title: t(
"executiveMeetings.schedule.bulkDuplicateResultAll",
).replace("{{n}}", String(ok)),
});
} else if (ok === 0) {
toast({
title: t("executiveMeetings.schedule.bulkDuplicateNone"),
variant: "destructive",
});
} else {
toast({
title: t("executiveMeetings.schedule.bulkDuplicatePartial")
.replace("{{ok}}", String(ok))
.replace("{{total}}", String(results.length))
.replace("{{failed}}", String(failed)),
variant: "destructive",
});
}
setSelectedMeetingIds(new Set());
setBulkDuplicateOpen(false);
// If the new rows landed on the day we're viewing, refresh in
// place; otherwise jump to the target date so the user can see
// the duplicates immediately (matches per-row duplicate UX).
if (bulkDuplicateDate === date) {
refreshDay();
} else {
onDateChange(bulkDuplicateDate);
}
} finally {
setBulkDuplicating(false);
}
}, [
bulkDuplicating,
bulkDuplicateDate,
displayedMeetings,
selectedMeetingIds,
date,
onDateChange,
refreshDay,
toast,
t,
]);
// #199: undo via recreate. dailyNumber is omitted so a stolen slot
// doesn't 409 the restore — the row gets the next available number.
const restoreMeetingFromSnapshot = useCallback(
async (snap: Meeting) => {
const created = (await apiJson("/api/executive-meetings", {
method: "POST",
body: {
titleAr: snap.titleAr,
titleEn: snap.titleEn,
meetingDate: snap.meetingDate,
startTime: snap.startTime ?? undefined,
endTime: snap.endTime ?? undefined,
location: snap.location ?? undefined,
meetingUrl: snap.meetingUrl ?? undefined,
platform: snap.platform,
status: snap.status,
isHighlighted: snap.isHighlighted ? 1 : 0,
notes: snap.notes ?? undefined,
attendees: (snap.attendees ?? []).map((a, i) => ({
name: a.name,
title: a.title ?? null,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? i,
kind: a.kind ?? "person",
})),
},
})) as Meeting;
if (snap.mergeStartColumn && snap.mergeEndColumn && snap.mergeText) {
await apiJson(`/api/executive-meetings/${created.id}`, {
method: "PATCH",
body: {
merge: {
mergeStartColumn: snap.mergeStartColumn,
mergeEndColumn: snap.mergeEndColumn,
mergeText: snap.mergeText,
},
},
});
}
},
[],
);
const deleteSelectedMeetings = useCallback(async () => {
if (bulkDeleting) return;
// #329: only act on rows that are currently visible. The selection
// set may legitimately contain ids that an active search filter is
// hiding (we don't auto-prune so that clearing the search restores
// the user's selection). Intersecting at action time prevents an
// accidental destructive op against rows the user can't even see.
const visibleIds = new Set(filteredMeetings.map((m) => m.id));
const ids = Array.from(selectedMeetingIds).filter((id) =>
visibleIds.has(id),
);
if (ids.length === 0) return;
const message = t(
"executiveMeetings.schedule.bulkDeleteConfirm",
).replace("{{n}}", String(ids.length));
if (!(await confirm({ message, destructive: true }))) return;
// Snapshot before deletion so undo can reconstruct the rows.
const idsSet = new Set(ids);
const snapshots = meetings.filter((m) => idsSet.has(m.id));
setBulkDeleting(true);
try {
// Deliberately allSettled, not all: a single failed DELETE shouldn't
// skip the others. We aggregate the outcome into one toast so the
// user sees a single, actionable result instead of N toasts.
const results = await Promise.allSettled(
ids.map((id) =>
apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" }),
),
);
const ok = results.filter((r) => r.status === "fulfilled").length;
const failed = results.length - ok;
const okIds = ids.filter((_, idx) => results[idx].status === "fulfilled");
const okSnapshots = snapshots.filter((s) => okIds.includes(s.id));
// #199: guard against double-click — restore is non-idempotent.
let undoFired = false;
const undo = async () => {
if (undoFired) return;
undoFired = true;
handle.dismiss();
const undoResults = await Promise.allSettled(
okSnapshots.map((s) => restoreMeetingFromSnapshot(s)),
);
const undoOk = undoResults.filter(
(r) => r.status === "fulfilled",
).length;
const undoFailed = undoResults.length - undoOk;
if (undoFailed === 0) {
toast({
title: t(
"executiveMeetings.schedule.bulkDeleteUndone",
).replace("{{n}}", String(undoOk)),
});
} else if (undoOk === 0) {
toast({
title: t("executiveMeetings.schedule.bulkDeleteUndoFailed"),
variant: "destructive",
});
} else {
toast({
title: t("executiveMeetings.schedule.bulkDeleteUndoPartial")
.replace("{{ok}}", String(undoOk))
.replace("{{total}}", String(undoResults.length)),
variant: "destructive",
});
}
refreshDay();
};
const undoLabel = t("executiveMeetings.schedule.bulkDeleteUndo");
const undoAction =
okSnapshots.length > 0 ? (
<ToastAction
altText={undoLabel}
onClick={() => {
void undo();
}}
data-testid="em-bulk-delete-undo"
>
{undoLabel}
</ToastAction>
) : undefined;
let handle: ReturnType<typeof toast>;
if (failed === 0) {
handle = toast({
title: t("executiveMeetings.schedule.bulkDeleteResultAll").replace(
"{{n}}",
String(ok),
),
action: undoAction,
});
} else if (ok === 0) {
handle = toast({
title: t("executiveMeetings.schedule.bulkDeleteResultNone"),
variant: "destructive",
});
} else {
handle = toast({
title: t("executiveMeetings.schedule.bulkDeletePartial")
.replace("{{ok}}", String(ok))
.replace("{{total}}", String(results.length))
.replace("{{failed}}", String(failed)),
variant: "destructive",
action: undoAction,
});
}
setSelectedMeetingIds(new Set());
refreshDay();
} finally {
setBulkDeleting(false);
}
}, [
bulkDeleting,
confirm,
selectedMeetingIds,
filteredMeetings,
meetings,
t,
toast,
refreshDay,
restoreMeetingFromSnapshot,
]);
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
// view and auto-open the title cell in inline-edit mode.
const [autoEditTitleId, setAutoEditTitleId] = useState<number | null>(null);
const createRow = useCallback(async () => {
if (creatingRow) return;
setCreatingRow(true);
try {
const created = (await apiJson(`/api/executive-meetings`, {
method: "POST",
body: {
titleAr: t("executiveMeetings.schedule.newMeetingDefaultAr"),
titleEn: t("executiveMeetings.schedule.newMeetingDefaultEn"),
meetingDate: date,
},
})) as Meeting | undefined;
if (created?.id) setAutoEditTitleId(created.id);
refreshDay();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
} finally {
setCreatingRow(false);
}
}, [creatingRow, date, refreshDay, toast, t]);
// Once the auto-edit target meeting is in the list, scroll it into view.
// The MeetingRow itself receives `startInEditMode` so its EditableCell
// opens immediately and clears `autoEditTitleId` via `onStartedEditing`.
// We also schedule a fallback clear so the state never goes stale if the
// title cell never mounts (e.g. column hidden, row filtered out).
useEffect(() => {
if (autoEditTitleId == null) return;
if (!meetings.some((m) => m.id === autoEditTitleId)) return;
const rafId = window.requestAnimationFrame(() => {
const row = document.querySelector(
`[data-testid="em-row-${autoEditTitleId}"]`,
);
if (row && "scrollIntoView" in row) {
(row as HTMLElement).scrollIntoView({
block: "center",
behavior: "smooth",
});
}
});
const fallback = window.setTimeout(() => {
setAutoEditTitleId(null);
}, 1500);
return () => {
window.cancelAnimationFrame(rafId);
window.clearTimeout(fallback);
};
}, [autoEditTitleId, meetings]);
const clearAutoEditTitleId = useCallback(() => {
setAutoEditTitleId(null);
}, []);
const reorderRows = useCallback(
async (fromId: number, toId: number) => {
if (fromId === toId) return;
if (reorderingRef.current) return;
// Use the VISIBLE list — the same one dnd-kit's SortableContext
// operates on. Building orderedIds from raw `meetings` (which
// includes hidden cancelled rows) corrupted both the index math
// and the slot-swap on the server, so dragging a meeting from
// top to bottom landed it in a non-chronological position.
const ids = orderedMeetings.map((m) => m.id);
const fromIdx = ids.indexOf(fromId);
const toIdx = ids.indexOf(toId);
if (fromIdx < 0 || toIdx < 0) return;
const newOrder = ids.slice();
newOrder.splice(fromIdx, 1);
newOrder.splice(toIdx, 0, fromId);
setOptimisticOrder(newOrder);
reorderingRef.current = true;
setReordering(true);
try {
await apiJson(`/api/executive-meetings/reorder`, {
method: "POST",
body: { meetingDate: date, orderedIds: newOrder },
});
refreshDay();
} catch (err) {
setOptimisticOrder(null);
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
} finally {
reorderingRef.current = false;
setReordering(false);
}
},
[orderedMeetings, date, refreshDay, toast, t],
);
// #265: columns persistence is owned by ExecutiveMeetingsPage now that
// both Schedule and Settings tabs can mutate the array. Keeping the
// write effect here would silently drop edits made from the Settings
// tab while ScheduleSection is unmounted.
// #288: row colours moved to the meeting record on the server, so the
// old `writeJsonToStorage(ROW_COLORS_STORAGE_KEY, rowColors)` effect is
// gone. The client-side state is now derived from the `meetings` prop
// (see the `rowColors` memo above).
// #288: Best-effort migration of legacy per-device colours into the
// shared server-side field. Runs every time the loaded day's meetings
// change, so colours saved on a date the user hasn't visited yet
// survive in localStorage and get migrated when they navigate there.
// Rules:
// - drops invalid keys / unknown colours immediately (those were
// never going to migrate anywhere),
// - skips ids whose server row already has a colour (don't overwrite
// someone else's choice),
// - PATCHes ids present in the currently-loaded day,
// - keeps ids whose meeting we haven't seen yet AND ids whose PATCH
// failed so the next effect run / next visit can retry,
// - removes the localStorage key entirely once nothing is left.
// Concurrency: a Set of in-flight ids prevents the same id from being
// PATCHed twice if the effect re-fires while a request is pending.
const migrationInFlightRef = useRef<Set<number>>(new Set());
useEffect(() => {
if (!canMutate) return;
if (typeof window === "undefined") return;
let raw: string | null = null;
try {
raw = window.localStorage.getItem(ROW_COLORS_STORAGE_KEY);
} catch {
return;
}
if (!raw) return;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
try {
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
} catch {
/* ignore */
}
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
try {
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
} catch {
/* ignore */
}
return;
}
const allowedKeys = new Set(
ROW_COLOR_OPTIONS.filter((o) => o.key !== "default").map((o) => o.key),
);
const meetingById = new Map(meetings.map((m) => [m.id, m]));
const remaining: Record<string, string> = {};
const toMigrate: Array<{ id: number; color: string }> = [];
for (const [idStr, color] of Object.entries(
parsed as Record<string, unknown>,
)) {
const id = Number(idStr);
// Drop entries that could never apply: invalid id or unknown
// colour key. Keeping them would just keep the localStorage key
// alive forever.
if (!Number.isInteger(id) || id <= 0) continue;
if (typeof color !== "string" || !allowedKeys.has(color)) continue;
const m = meetingById.get(id);
if (!m) {
// Not in this day's payload — could be a future day the user
// hasn't visited yet. Preserve so a later effect run picks it up.
remaining[idStr] = color;
continue;
}
if (m.rowColor) continue; // server already has a colour, drop ours
if (migrationInFlightRef.current.has(id)) {
remaining[idStr] = color; // already PATCHing — wait for next tick
continue;
}
toMigrate.push({ id, color });
}
if (toMigrate.length === 0) {
// Either everything left over is for unseen days, or there's
// nothing left at all.
try {
if (Object.keys(remaining).length === 0) {
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
} else {
// Rewrite to compact away the dropped (invalid / already-set)
// entries so we don't keep iterating them forever.
window.localStorage.setItem(
ROW_COLORS_STORAGE_KEY,
JSON.stringify(remaining),
);
}
} catch {
/* ignore */
}
return;
}
for (const c of toMigrate) migrationInFlightRef.current.add(c.id);
void (async () => {
const results = await Promise.allSettled(
toMigrate.map((c) =>
apiJson(`/api/executive-meetings/${c.id}`, {
method: "PATCH",
body: { rowColor: c.color },
}),
),
);
// Failed PATCHes get folded back into `remaining` so we can
// retry next time. Successful ones are dropped.
const finalRemaining: Record<string, string> = { ...remaining };
results.forEach((res, i) => {
const c = toMigrate[i];
migrationInFlightRef.current.delete(c.id);
if (res.status === "rejected") {
finalRemaining[String(c.id)] = c.color;
}
});
try {
if (Object.keys(finalRemaining).length === 0) {
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
} else {
window.localStorage.setItem(
ROW_COLORS_STORAGE_KEY,
JSON.stringify(finalRemaining),
);
}
} catch {
/* ignore */
}
void qc.invalidateQueries({
queryKey: ["/api/executive-meetings", date],
});
})();
}, [canMutate, meetings, qc, date]);
const visibleColumns = columns.filter((c) => c.visible);
const setColumnWidth = useCallback((id: ColumnId, width: number) => {
setColumns((prev) =>
prev.map((c) =>
c.id === id
? {
...c,
width: Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, width)),
}
: c,
),
);
}, []);
// #288: persist a row colour by PATCHing the meeting. The server
// validates the key against the ROW_COLOR_KEYS whitelist, writes an
// audit log entry, and emits a realtime day-changed event so other
// open Tx OS tabs/devices refetch and pick up the new colour. The
// local list refreshes through the same react-query invalidation
// (`executive-meetings:day` keyed by `date`) used by every other
// mutation on this page. We do an optimistic toast-based rollback if
// the request fails so the user always knows the colour didn't stick.
const setRowColor = useCallback(
async (meetingId: number, colorKey: string) => {
const wireValue = colorKey === "default" ? null : colorKey;
// #317: optimistic repaint so the row tint changes the moment the
// user picks a swatch, without waiting on the PATCH round-trip.
// Rolls back on failure so the colour snaps to the previous value.
const rollback = applyMeetingPatch(meetingId, (m) => ({
...m,
rowColor: wireValue,
}));
try {
await apiJson(`/api/executive-meetings/${meetingId}`, {
method: "PATCH",
body: { rowColor: wireValue },
});
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
} catch (err) {
rollback();
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
}
},
[applyMeetingPatch, qc, date, toast, t],
);
// Responsive breakpoints:
// < md (<768px) → stacked card layout (phones)
// md..<xl → fluid table, browser shrinks columns to fit (tablets)
// ≥ xl (≥1280px) → fixed-width table with resize handles (desktop)
// print → fixed-width table at all viewports (preserves the
// historical print layout from any screen size).
// Whether the configured pixel widths are *enforced* is controlled purely
// by CSS: `table-auto xl:table-fixed print:table-fixed`. We always emit the
// colgroup and per-cell widths so they're available whenever the table is
// in fixed-layout mode (xl screens AND any print). In `table-auto` mode,
// the browser treats those widths as hints and shrinks columns to fit.
// Resize handles only appear on large screens AND fine pointers (mouse).
// Touch devices — including iPad Pro at >=1280 CSS px — get no handles
// since drag-resizing with a finger fights touch scrolling.
const isXl = useMediaQuery("(min-width: 1280px)");
const isFinePointer = useMediaQuery("(pointer: fine)");
// Resize handles only render when the user is in edit mode AND on a
// large screen with a fine pointer. View mode hides them entirely so
// the table feels frozen until the editor opts in.
const showResizeHandles = isXl && isFinePointer && effectiveCanMutate;
// dnd-kit: sortable column headers + sortable rows.
//
// - PointerSensor for desktop mouse: a 6px activation distance keeps a
// normal click on cell content from being mistaken for a drag (so the
// inline editors on title/attendee/time cells still open on click).
// - TouchSensor for iPad and other touch devices: PointerSensor alone
// does not work on touch because the browser claims the gesture as a
// vertical scroll before the finger has moved 6px, so the drag never
// activates. A long-press activation (delay+tolerance) lets the user
// press-and-hold the row's grip handle to start dragging while still
// leaving normal scrolling intact everywhere else on the row.
// - KeyboardSensor for accessibility / keyboard reordering.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 8 },
}),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const onColumnDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
setColumns((prev) => {
const ids = prev.map((c) => c.id);
const fromIdx = ids.indexOf(active.id as ColumnId);
const toIdx = ids.indexOf(over.id as ColumnId);
if (fromIdx < 0 || toIdx < 0) return prev;
const next = prev.slice();
const [moved] = next.splice(fromIdx, 1);
if (!moved) return prev;
next.splice(toIdx, 0, moved);
return next;
});
},
[],
);
const onRowDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
void reorderRows(Number(active.id), Number(over.id));
},
[reorderRows],
);
// #267: Same dynamic-measurement trick as the page header — the title
// row wraps on narrow widths (heading + edit toggle + date picker), so
// we publish its live height so the table thead can sit flush below.
const headingRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const heading = headingRef.current;
if (!heading) return;
const root = heading.closest<HTMLElement>("[data-em-root]");
if (!root) return;
const update = () => {
root.style.setProperty("--em-heading-h", `${heading.offsetHeight}px`);
};
update();
const ro = new ResizeObserver(update);
ro.observe(heading);
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
// Reset so other sections (which don't render this heading) don't
// inherit a stale offset for any sticky descendants.
root.style.setProperty("--em-heading-h", "0px");
};
}, []);
// #267: Floating sticky thead. The actual table sits inside an
// overflow-x-auto wrapper (so wide tables can scroll horizontally on
// narrow screens), which means a CSS-only `position: sticky` on the
// thead is trapped inside that scrolling container and won't stick
// to the viewport. Workaround: render a separate sticky overlay
// above the wrapper that mirrors the actual table's column widths +
// horizontal scroll. The actual <thead> is hidden in screen mode
// (kept for print so paginated output still has column labels).
const tableWrapperRef = useRef<HTMLDivElement | null>(null);
const [floatingScrollLeft, setFloatingScrollLeft] = useState(0);
// Live, measured column widths in current visual order. Filled by
// the ResizeObserver below from the real thead's <th> cells. Falls
// back to the stored `columns[i].width` when measurement isn't
// available yet (first paint, or the table isn't yet in the DOM).
const [floatingColWidths, setFloatingColWidths] = useState<number[]>([]);
const [floatingTableWidth, setFloatingTableWidth] = useState<number>(0);
useEffect(() => {
const wrapper = tableWrapperRef.current;
if (!wrapper) return;
const onScroll = () => setFloatingScrollLeft(wrapper.scrollLeft);
wrapper.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => wrapper.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const wrapper = tableWrapperRef.current;
if (!wrapper) return;
const measure = () => {
const table = wrapper.querySelector("table");
if (!table) return;
// The actual <thead> is display:none in screen mode (only the
// floating thead is visible), so we measure column widths from
// the first regular tbody row whose td-count matches the
// visible-column count. Subheading / placeholder / colspan
// rows are skipped.
const rows = Array.from(table.querySelectorAll<HTMLTableRowElement>("tbody > tr"));
const sample = rows.find((r) => r.children.length === visibleColumns.length);
if (sample) {
const widths = Array.from(sample.children).map(
(td) => (td as HTMLElement).getBoundingClientRect().width,
);
setFloatingColWidths(widths);
}
setFloatingTableWidth(table.getBoundingClientRect().width);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(wrapper);
const table = wrapper.querySelector("table");
if (table) ro.observe(table);
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orderedMeetings.length, visibleColumns.length]);
return (
<div className="space-y-4 print:space-y-2">
<div
ref={headingRef}
data-testid="em-schedule-heading-bar"
className="sticky z-30 bg-[#f4f6fb] -mx-3 sm:-mx-6 px-3 sm:px-6 py-2 shadow-[0_4px_6px_-4px_rgba(11,30,63,0.15)] flex items-center justify-between gap-3 flex-wrap print:hidden print:!static print:!shadow-none print:!mx-0 print:!px-0"
style={{ top: "var(--em-header-h, 0px)" }}
>
<h2
className="text-xl sm:text-2xl font-bold text-[#0B1E3F]"
data-testid="em-schedule-heading"
>
{t("executiveMeetings.scheduleHeading")}
</h2>
<div className="flex items-center gap-2 flex-wrap">
{canMutate && (
<button
type="button"
onClick={() => setEditMode(!editMode)}
aria-pressed={editMode}
aria-label={
editMode
? t("executiveMeetings.schedule.saveToggleAria")
: t("executiveMeetings.schedule.editToggleAria")
}
title={
editMode
? t("executiveMeetings.schedule.saveToggleOn")
: t("executiveMeetings.schedule.saveToggleOff")
}
data-testid="em-edit-mode-toggle"
className={
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[#0B1E3F]/40 " +
(editMode
? "bg-[#0B1E3F] text-white border-[#0B1E3F] hover:bg-[#0B1E3F]/90"
: "bg-white text-[#0B1E3F] border-gray-300 hover:bg-gray-50")
}
>
{editMode ? (
<Check className="w-3.5 h-3.5" />
) : (
<Pencil className="w-3.5 h-3.5" />
)}
<span>
{editMode
? t("executiveMeetings.schedule.saveToggle")
: t("executiveMeetings.schedule.editToggle")}
</span>
</button>
)}
{/* #329: in-day search box. Sits next to the date picker so
the user finds a meeting in the currently visible day
without having to scroll. Hidden in print so PDFs still
show the full schedule. */}
<div className="relative print:hidden">
<Input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("executiveMeetings.schedule.searchPlaceholder")}
aria-label={t("executiveMeetings.schedule.searchAria")}
data-testid="em-schedule-search"
className="h-8 w-44 sm:w-56 text-sm bg-white"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
aria-label={t("executiveMeetings.schedule.searchClear")}
title={t("executiveMeetings.schedule.searchClear")}
data-testid="em-schedule-search-clear"
className={
"absolute top-1/2 -translate-y-1/2 text-muted-foreground hover:text-[#0B1E3F] " +
(isRtl ? "left-1.5" : "right-1.5")
}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
<input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
/>
</label>
</div>
</div>
<div className="hidden print:block text-center mb-2">
<div className="font-bold text-lg">{t("executiveMeetings.title")}</div>
<div className="text-sm">{date}</div>
</div>
{/* Floating bulk-action toolbar. Per spec it appears ONLY when
≥1 row is selected (and in edit mode). The tri-state
"select all" checkbox lives in the table header (see
SortableHeader's selectAllOverlay below), so this bar
contains only the count + the destructive actions.
Hidden in print so it doesn't show up in PDF exports. */}
{effectiveCanMutate && visibleSelectedCount > 0 && (
<div
className="flex items-center gap-3 flex-wrap rounded-md border border-[#0B1E3F]/30 bg-[#0B1E3F]/5 px-3 py-2 mb-2 print:hidden"
data-testid="em-bulk-toolbar"
>
<span
className="text-sm text-[#0B1E3F]/80 font-medium"
data-testid="em-bulk-selection-count"
>
{t("executiveMeetings.schedule.bulkSelectionCount")
.replace("{{n}}", String(visibleSelectedCount))
.replace("{{total}}", String(displayedMeetings.length))}
</span>
<div className="flex items-center gap-2 ms-auto">
<Button
size="sm"
variant="outline"
onClick={() => setSelectedMeetingIds(new Set())}
disabled={bulkDeleting}
data-testid="em-bulk-clear-selection"
>
{t("executiveMeetings.schedule.bulkClearSelection")}
</Button>
<Button
size="sm"
variant="outline"
onClick={openBulkDuplicate}
disabled={bulkDeleting || bulkDuplicating}
className="gap-1"
data-testid="em-bulk-duplicate-selected"
>
<Copy className="w-4 h-4" />
{t("executiveMeetings.schedule.bulkDuplicateSelected")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={deleteSelectedMeetings}
disabled={bulkDeleting || bulkDuplicating}
className="gap-1"
data-testid="em-bulk-delete-selected"
>
<Trash2 className="w-4 h-4" />
{t("executiveMeetings.schedule.bulkDeleteSelected")}
</Button>
</div>
</div>
)}
{/* #267: Floating sticky thead — overlays the column header at
the top of the viewport when the schedule scrolls, with a
translateX kept in sync with the data table's horizontal
scrollLeft so the columns stay aligned. Hidden in print
mode; the actual <thead> below carries the labels for
paginated output. */}
<div
data-testid="em-sticky-thead"
className="sticky z-[6] overflow-hidden bg-white border-x-2 border-t-2 border-[#0B1E3F] rounded-t-md shadow-[0_4px_6px_-4px_rgba(11,30,63,0.15)] print:hidden"
style={{
top: "calc(var(--em-header-h, 0px) + var(--em-heading-h, 0px))",
}}
>
<div
style={{
// `translateX(-scrollLeft)` works in both LTR and RTL:
// RTL browsers report scrollLeft as negative when content
// is scrolled left, so negating it yields a positive
// (rightward) translate that matches the table's
// direction of motion.
transform: `translateX(${-floatingScrollLeft}px)`,
width: floatingTableWidth || undefined,
}}
>
<table
className="border-collapse text-sm md:text-[15px] table-fixed"
dir={isRtl ? "rtl" : "ltr"}
style={tableStyle}
>
<colgroup>
{visibleColumns.map((c, i) => (
<col
key={c.id}
style={{ width: floatingColWidths[i] ?? c.width }}
/>
))}
</colgroup>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onColumnDragEnd}
>
<thead>
<tr className="bg-[#0B1E3F] text-white">
<SortableContext
items={visibleColumns.map((c) => c.id)}
strategy={horizontalListSortingStrategy}
>
{visibleColumns.map((c, idx) => (
<SortableHeader
key={c.id}
col={{
...c,
width: floatingColWidths[idx] ?? c.width,
}}
isLast={idx === visibleColumns.length - 1}
isRtl={isRtl}
t={t}
showResizeHandle={showResizeHandles}
dragEnabled={effectiveCanMutate}
onResize={(delta) =>
setColumnWidth(c.id, c.width + delta)
}
selectAllOverlay={
idx === 0 &&
effectiveCanMutate &&
displayedMeetings.length > 0 ? (
<div
className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={
allSelectedState === "all"
? true
: allSelectedState === "some"
? "indeterminate"
: false
}
onCheckedChange={(v) =>
setAllMeetingsSelected(v === true)
}
aria-label={t(
"executiveMeetings.schedule.bulkSelectAll",
)}
data-testid="em-bulk-select-all"
className="h-4 w-4 bg-white shadow-sm"
/>
</div>
) : undefined
}
/>
))}
</SortableContext>
</tr>
</thead>
</DndContext>
</table>
</div>
</div>
<div
ref={tableWrapperRef}
className="bg-white border-2 border-[#0B1E3F] rounded-md overflow-x-auto shadow-sm print:shadow-none print:border-black print:overflow-hidden"
id="executive-schedule-printable"
>
<table
className="border-collapse text-sm md:text-[15px] w-full table-auto xl:table-fixed print:table-fixed"
dir={isRtl ? "rtl" : "ltr"}
style={tableStyle}
>
{/* Always emit colgroup + th widths so they're available for print
and for ≥xl screens. CSS classes on the <table> decide whether
they're enforced (table-fixed) or treated as hints (table-auto). */}
<colgroup>
{visibleColumns.map((c) => (
<col key={c.id} style={{ width: c.width }} />
))}
</colgroup>
{/* #267: hidden in screen mode (the floating sticky thead
above is the visible/interactive one); kept available
in print mode so paginated output still shows column
labels. Plain <th>s — no testids, no Sortable, no
checkbox — to avoid duplicating those with the floating
thead (which would break getByTestId in tests). */}
<thead className="hidden print:table-header-group">
<tr className="bg-[#0B1E3F] text-white">
{visibleColumns.map((c) => {
// #320: every column header is centered so the schedule's
// print thead stays in sync with the screen thead below.
const align = "text-center";
return (
<th
key={c.id}
className={`border border-[#0B1E3F] px-2 py-2 ${align} font-semibold`}
style={{ width: c.width }}
>
{t(`executiveMeetings.col.${c.id}`)}
</th>
);
})}
</tr>
</thead>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onRowDragEnd}
>
<tbody>
{isLoading && (
<tr>
<td
colSpan={visibleColumns.length}
className="py-10 text-center text-muted-foreground"
>
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && orderedMeetings.length === 0 && (
<tr>
<td
colSpan={visibleColumns.length}
className="py-10 text-center text-muted-foreground"
>
{t("executiveMeetings.noMeetings")}
</td>
</tr>
)}
{!isLoading &&
orderedMeetings.length > 0 &&
displayedMeetings.length === 0 && (
<tr data-testid="em-search-no-matches">
<td
colSpan={visibleColumns.length}
className="py-10 text-center text-muted-foreground"
>
{t("executiveMeetings.schedule.searchNoMatches")}
</td>
</tr>
)}
<SortableContext
items={displayedMeetings.map((m) => m.id)}
strategy={verticalListSortingStrategy}
>
{displayedMeetings.map((m, idx) => (
<MeetingRow
key={m.id}
meeting={m}
displayNumber={idx + 1}
isRtl={isRtl}
t={t}
visibleColumns={visibleColumns}
rowColorKey={rowColors[m.id] ?? "default"}
onPickRowColor={(key) => setRowColor(m.id, key)}
canMutate={effectiveCanMutate}
onSaveTitle={(html) => saveTitle(m, html)}
onSaveAttendeeName={(idx, html) =>
saveAttendeeName(m, idx, html)
}
onSaveTimes={(start, end) => saveTimes(m, start, end)}
onSaveMerge={(merge) => saveMerge(m, merge)}
onDeleteMeeting={() => deleteMeeting(m)}
// Disable every row's delete button while any delete
// is in flight, since deleteMeeting short-circuits
// concurrent calls — keeps the UI honest about which
// clicks will do anything.
isDeleting={deletingMeetingId !== null}
isCurrent={
highlightPrefs.enabled && currentMeetingId === m.id
}
highlightColor={highlightPrefs.color}
reordering={reordering}
autoEditTitle={autoEditTitleId === m.id}
onAutoEditTitleConsumed={clearAutoEditTitleId}
pendingAttendee={pendingAttendee}
onStartAddAttendee={startAddAttendee}
onCommitAddAttendee={(type, html, kind, insertAtIndex) =>
commitAddAttendee(m, type, html, kind, insertAtIndex)
}
onCancelAddAttendee={cancelAddAttendee}
bulkSelectable={effectiveCanMutate}
bulkSelected={selectedMeetingIds.has(m.id)}
onBulkSelectChange={(v) => toggleMeetingSelected(m.id, v)}
/>
))}
</SortableContext>
{effectiveCanMutate && !isLoading && (
<tr
className="print:hidden"
data-testid="em-add-row-tr"
>
<td
colSpan={visibleColumns.length}
className="border border-gray-300 p-0"
>
<button
type="button"
onClick={createRow}
disabled={creatingRow}
aria-label={t("executiveMeetings.schedule.addRow")}
aria-busy={creatingRow}
className="w-full flex items-center justify-center gap-2 py-2.5 text-sm text-[#0B1E3F] hover:bg-blue-50/60 focus:bg-blue-50/60 focus:outline-none transition-colors disabled:opacity-60"
data-testid="em-add-row-button"
>
{creatingRow ? (
<span>{t("common.loading")}</span>
) : (
<Plus className="w-4 h-4" aria-hidden="true" />
)}
</button>
</td>
</tr>
)}
</tbody>
</DndContext>
</table>
</div>
{/* #330: Bulk-duplicate-to-date dialog. Mirrors the per-row
duplicate dialog in ManageSection (single date picker, same
POST endpoint per id) but operates on the visible-selected
subset and aggregates the result into one toast. */}
<Dialog
open={bulkDuplicateOpen}
onOpenChange={(o) => {
if (bulkDuplicating) return;
setBulkDuplicateOpen(o);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("executiveMeetings.schedule.bulkDuplicateTitle").replace(
"{{n}}",
String(visibleSelectedCount),
)}
</DialogTitle>
</DialogHeader>
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
<Input
type="date"
value={bulkDuplicateDate}
onChange={(e) => setBulkDuplicateDate(e.target.value)}
disabled={bulkDuplicating}
data-testid="em-bulk-duplicate-date"
/>
</FormRow>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setBulkDuplicateOpen(false)}
disabled={bulkDuplicating}
>
{t("executiveMeetings.common.cancel")}
</Button>
<Button
onClick={() => void performBulkDuplicate()}
disabled={bulkDuplicating || !bulkDuplicateDate}
className="bg-[#0B1E3F]"
data-testid="em-bulk-duplicate-confirm"
>
{bulkDuplicating
? t("executiveMeetings.schedule.bulkDuplicating")
: t("executiveMeetings.schedule.bulkDuplicateConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ResizeHandle({
isRtl,
onResize,
}: {
isRtl: boolean;
onResize: (delta: number) => void;
}) {
const startXRef = useRef<number | null>(null);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
(e.target as HTMLDivElement).setPointerCapture(e.pointerId);
startXRef.current = e.clientX;
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (startXRef.current === null) return;
const raw = e.clientX - startXRef.current;
// In RTL the trailing edge is on the LEFT, so dragging left makes the
// column wider. Negate the delta in that case.
const delta = isRtl ? -raw : raw;
if (Math.abs(delta) >= 1) {
startXRef.current = e.clientX;
onResize(delta);
}
};
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
startXRef.current = null;
try {
(e.target as HTMLDivElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
};
// Place handle on the trailing (logical end) edge so it works the same in
// both LTR and RTL.
const sideClass = isRtl ? "left-0" : "right-0";
return (
<div
role="separator"
aria-orientation="vertical"
className={`absolute top-0 ${sideClass} h-full w-1.5 cursor-col-resize hover:bg-white/40 active:bg-white/60`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
);
}
function ColumnsCustomizerPanel({
columns,
onChange,
t,
highlightPrefs,
onHighlightPrefsChange,
}: {
columns: ColumnSetting[];
onChange: (next: ColumnSetting[]) => void;
t: (k: string) => string;
highlightPrefs: HighlightPrefs;
onHighlightPrefsChange: (next: HighlightPrefs) => void;
}) {
const toggle = (id: ColumnId, value: boolean) => {
// Always keep at least one column visible.
const visibleCount = columns.filter((c) => c.visible).length;
if (!value && visibleCount <= 1) return;
onChange(columns.map((c) => (c.id === id ? { ...c, visible: value } : c)));
};
const reset = () => onChange(DEFAULT_COLUMNS.map((c) => ({ ...c })));
return (
<div data-testid="em-customize-columns-panel">
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
{t("executiveMeetings.customize.hint")}
</p>
<ul className="space-y-1.5">
{columns.map((c) => (
<li
key={c.id}
className="flex items-center gap-2 rounded border border-gray-100 bg-gray-50 px-2 py-1.5"
data-testid={`em-customize-row-${c.id}`}
>
<Checkbox
id={`em-col-${c.id}`}
checked={c.visible}
onCheckedChange={(v) => toggle(c.id, !!v)}
data-testid={`em-customize-toggle-${c.id}`}
/>
<label
htmlFor={`em-col-${c.id}`}
className="flex-1 text-sm text-[#0B1E3F] cursor-pointer truncate"
>
{t(`executiveMeetings.col.${c.id}`)}
</label>
{!c.visible ? (
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<Eye className="w-3.5 h-3.5 text-[#0B1E3F]" />
)}
</li>
))}
</ul>
<div className="mt-3 flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-xs"
onClick={reset}
data-testid="em-customize-reset"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("executiveMeetings.customize.reset")}
</Button>
<span className="text-[11px] text-muted-foreground">
{t("executiveMeetings.customize.dragHint")}
</span>
</div>
<div className="mt-4 pt-3 border-t border-gray-200">
<div className="flex items-center justify-between mb-2">
<label
htmlFor="em-highlight-toggle"
className="text-sm font-semibold text-[#0B1E3F] cursor-pointer"
>
{t("executiveMeetings.highlight.title")}
</label>
<Switch
id="em-highlight-toggle"
checked={highlightPrefs.enabled}
onCheckedChange={(v) =>
onHighlightPrefsChange({ ...highlightPrefs, enabled: !!v })
}
data-testid="em-highlight-toggle"
/>
</div>
<p className="text-[11px] text-muted-foreground mb-2 leading-relaxed">
{t("executiveMeetings.highlight.hint")}
</p>
<div className="flex items-center gap-1.5 flex-wrap">
{HIGHLIGHT_COLOR_SWATCHES.map((c) => {
const selected = highlightPrefs.color === c;
return (
<button
key={c}
type="button"
onClick={() =>
onHighlightPrefsChange({ ...highlightPrefs, color: c })
}
className={
"w-6 h-6 rounded-full border-2 transition-transform " +
(selected
? "border-[#0B1E3F] scale-110"
: "border-gray-300 hover:scale-110")
}
style={{ backgroundColor: c }}
aria-label={`highlight color ${c}`}
data-testid={`em-highlight-color-${c}`}
disabled={!highlightPrefs.enabled}
/>
);
})}
<button
type="button"
onClick={() =>
onHighlightPrefsChange({ ...DEFAULT_HIGHLIGHT_PREFS })
}
className="ml-1 text-[11px] text-[#0B1E3F] underline hover:no-underline disabled:opacity-50"
data-testid="em-highlight-reset"
>
{t("executiveMeetings.highlight.reset")}
</button>
</div>
</div>
</div>
);
}
/**
* Sortable column header for the schedule table. The whole <th> is the
* drag handle so it stays grabbable in any RTL/LTR mode and on touch. The
* resize handle on the trailing edge stops drag propagation so resizing
* still works.
*/
function SortableHeader({
col,
isLast,
isRtl,
t,
showResizeHandle,
dragEnabled,
onResize,
selectAllOverlay,
}: {
col: ColumnSetting;
isLast: boolean;
isRtl: boolean;
t: (k: string) => string;
showResizeHandle: boolean;
dragEnabled: boolean;
onResize: (delta: number) => void;
// Optional tri-state "select all" checkbox rendered as an absolute
// overlay anchored to the start-corner of this header. The parent
// passes it only for the first visible column so the checkbox lives
// in <thead> per the spec, while still gracefully surviving the
// case where the # column has been hidden via the column customizer.
selectAllOverlay?: ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: col.id, disabled: !dragEnabled });
// #320: every schedule column header is centered (was previously
// start-aligned for everything except # and time). The select-all
// checkbox in the # column is rendered as an absolute overlay
// anchored to the start corner, so it doesn't collide with the
// centered label text.
const align = "text-center";
const style: CSSProperties = {
width: col.width,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
// Only advertise grab cursor when drag is actually enabled — view
// mode renders the header as static text.
cursor: dragEnabled ? "grab" : undefined,
};
return (
<th
ref={setNodeRef}
className={`relative border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none`}
style={style}
data-testid={`em-col-header-${col.id}`}
{...(dragEnabled ? attributes : {})}
{...(dragEnabled ? listeners : {})}
>
{t(`executiveMeetings.col.${col.id}`)}
{selectAllOverlay}
{/* Resize handles only render on large screens AND fine (mouse)
pointers AND when the user is in edit mode. See showResizeHandles. */}
{!isLast && showResizeHandle && (
<ResizeHandle isRtl={isRtl} onResize={onResize} />
)}
</th>
);
}
function RowColorPickerSwatches({
current,
onPick,
t,
}: {
current: string;
onPick: (key: string) => void;
t: (k: string) => string;
}) {
return (
<>
<div className="text-[11px] text-muted-foreground mb-2">
{t("executiveMeetings.rowColor.label")}
</div>
<div className="flex items-center gap-1.5">
{ROW_COLOR_OPTIONS.map((opt) => {
const selected = current === opt.key;
const isDefault = opt.key === "default";
return (
<button
key={opt.key}
type="button"
onClick={() => onPick(opt.key)}
className={
"w-6 h-6 rounded-full border-2 transition-transform " +
(selected
? "border-[#0B1E3F] scale-110"
: "border-gray-300 hover:scale-110")
}
style={{
background: isDefault
? "repeating-linear-gradient(45deg,#fff,#fff 4px,#e5e7eb 4px,#e5e7eb 8px)"
: opt.bg,
}}
aria-label={opt.label}
data-testid={`em-row-color-${opt.key}`}
/>
);
})}
</div>
</>
);
}
// Consolidated row-level actions menu. Replaces the previous three
// separate overlay triggers (Trash, Palette, Combine icons) that all
// floated inside the same narrow cell and visually collided on small
// screens / iPad. Now there is exactly one kebab button per row, and
// it opens a small popover that exposes:
// 1. Delete row — direct action, fires onDelete()
// 2. Row color — expands inline to RowColorPickerSwatches
// 3. Merge cells — expands inline to the merge options list
// Both expansion views show a "Back" link so the user can return to
// the main menu without closing the popover.
//
// Anchor / positioning rules match the old triggers exactly:
// - Hover-reveal on desktop, low-opacity-always on touch
// - Hidden in print
// - Uses `inline-end-1` so RTL layouts flip the button to the
// correct corner.
//
// The host <td> must carry `relative group` for the positioning +
// hover reveal to work — every renderCell branch already does.
//
// Test ids are preserved on the corresponding menu items so existing
// Playwright assertions about presence/absence keep working:
// em-delete-row-{id}, em-row-color-trigger, em-merge-trigger-{id}.
function RowActionsMenu({
meetingId,
isDeleting,
onDelete,
rowColorKey,
onPickRowColor,
hasStoredMerge,
existingMergeText,
mergeColumnLabels,
onChangeMerge,
t,
}: {
meetingId: number;
isDeleting: boolean;
onDelete: () => void;
rowColorKey: string;
onPickRowColor: (key: string) => void;
hasStoredMerge: boolean;
existingMergeText: string | null;
// Localized labels of every column currently spanned by the row's
// stored merge (canonical order). Used by the main-menu Merge cells
// item to render an inline "Merged: …" badge so users can see the
// active range without drilling into the merge sub-view. Null when
// the row has no stored merge.
mergeColumnLabels: string[] | null;
onChangeMerge: (
merge:
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
| null,
) => Promise<void>;
// Widened from `(k: string) => string` so the main menu can pass
// interpolation opts (e.g. {{cols}}) for the merge badge label.
t: (k: string, opts?: Record<string, unknown>) => string;
}) {
const [open, setOpen] = useState(false);
const [view, setView] = useState<"main" | "color" | "merge">("main");
// Always reset to the main menu whenever the popover closes, so the
// next open lands the user back on Delete/Color/Merge instead of a
// half-finished sub-view.
useEffect(() => {
if (!open) setView("main");
}, [open]);
const applyMerge = async (start: ColumnId, end: ColumnId) => {
setOpen(false);
await onChangeMerge({
mergeStartColumn: start,
mergeEndColumn: end,
mergeText: existingMergeText ?? "",
});
};
const itemCls =
"w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
// NOTE: do NOT add onPointerDown stopPropagation here — Radix
// Popover uses pointerdown to open and our handler would
// swallow it. The host cell has no drag listeners (only the
// grip button does), so nothing else needs blocking.
aria-label={t("executiveMeetings.rowActions.label")}
title={t("executiveMeetings.rowActions.label")}
data-testid={`em-row-actions-${meetingId}`}
className="absolute top-1 inline-end-1 p-1 rounded text-muted-foreground hover:text-[#0B1E3F] hover:bg-white/80 opacity-0 group-hover:opacity-100 focus:opacity-100 [@media(hover:none)_and_(pointer:coarse)]:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:p-1.5 transition-opacity print:hidden"
>
<MoreVertical className="w-3.5 h-3.5 [@media(hover:none)_and_(pointer:coarse)]:w-4 [@media(hover:none)_and_(pointer:coarse)]:h-4" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="end">
{view === "main" && (
<>
<button
type="button"
className={`${itemCls} text-destructive`}
disabled={isDeleting}
onClick={(e) => {
e.stopPropagation();
setOpen(false);
onDelete();
}}
data-testid={`em-delete-row-${meetingId}`}
>
<Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
{t("executiveMeetings.schedule.deleteRow")}
</button>
<button
type="button"
className={itemCls}
onClick={() => setView("color")}
data-testid="em-row-color-trigger"
>
<Palette className="w-3.5 h-3.5" aria-hidden="true" />
<span>{t("executiveMeetings.rowColor.label")}</span>
{/* Inline preview of the row's currently-saved colour so
users can tell at a glance which colour is set without
having to expand the swatch sub-view. The "default"
state shows a hatched pattern (matching the swatch
picker) to read clearly as "no colour". Decorative —
the sub-view is the actual control, so this stays
aria-hidden. */}
{(() => {
const opt = ROW_COLOR_OPTIONS.find(
(o) => o.key === rowColorKey,
);
const isDefault = !opt || opt.key === "default";
return (
<span
aria-hidden="true"
data-testid={`em-row-color-indicator-${meetingId}`}
data-color-key={opt?.key ?? "default"}
className="ms-auto w-3 h-3 rounded-full border border-gray-300 shrink-0"
style={{
background: isDefault
? "repeating-linear-gradient(45deg,#fff,#fff 3px,#e5e7eb 3px,#e5e7eb 6px)"
: opt!.bg,
}}
/>
);
})()}
</button>
<button
type="button"
className={itemCls}
onClick={() => setView("merge")}
data-testid={`em-merge-trigger-${meetingId}`}
>
<Combine className="w-3.5 h-3.5" aria-hidden="true" />
<span>{t("executiveMeetings.merge.label")}</span>
{/* Inline indicator of the row's active merge range, so
users see which columns are merged without drilling
into the merge sub-view. Only shown when a merge is
stored in the DB; truncated visually but readable in
full via the title tooltip on hover. */}
{hasStoredMerge && mergeColumnLabels && mergeColumnLabels.length > 0 && (
<span
data-testid={`em-merge-indicator-${meetingId}`}
title={t("executiveMeetings.merge.activeBadge", {
cols: mergeColumnLabels.join(" + "),
})}
className="ms-auto px-1.5 py-0.5 text-[10px] leading-none rounded bg-[#0B1E3F]/10 text-[#0B1E3F] truncate max-w-[120px] shrink-0"
>
{t("executiveMeetings.merge.activeBadge", {
cols: mergeColumnLabels.join(" + "),
})}
</span>
)}
</button>
</>
)}
{view === "color" && (
<>
<BackToMainButton onClick={() => setView("main")} t={t} />
<div className="px-1 pb-1">
<RowColorPickerSwatches
current={rowColorKey}
onPick={(key) => {
onPickRowColor(key);
setOpen(false);
}}
t={t}
/>
</div>
</>
)}
{view === "merge" && (
<>
<BackToMainButton onClick={() => setView("main")} t={t} />
<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={() => applyMerge("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={() => applyMerge("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={() => applyMerge("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={() => {
setOpen(false);
onChangeMerge(null);
}}
>
{t("executiveMeetings.merge.unmerge")}
</button>
)}
</>
)}
</PopoverContent>
</Popover>
);
}
// Small "Back" affordance shown at the top of the row-actions submenu
// views (Row color, Merge cells). RTL-aware: uses ChevronRight in
// Arabic so the arrow points back toward the main menu visually.
function BackToMainButton({
onClick,
t,
}: {
onClick: () => void;
t: (k: string) => string;
}) {
const isRtl =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
const ArrowIcon = isRtl ? ChevronRight : ChevronLeft;
return (
<button
type="button"
className="w-full text-start px-2 py-1.5 text-xs rounded hover:bg-accent text-muted-foreground flex items-center gap-1 mb-0.5"
onClick={onClick}
data-testid="em-row-actions-back"
>
<ArrowIcon className="w-3.5 h-3.5" aria-hidden="true" />
{t("executiveMeetings.rowActions.back")}
</button>
);
}
function MeetingRow({
meeting,
isRtl,
t,
visibleColumns,
rowColorKey,
onPickRowColor,
canMutate,
onSaveTitle,
onSaveAttendeeName,
onSaveTimes,
onSaveMerge,
onDeleteMeeting,
isDeleting,
isCurrent,
highlightColor,
reordering,
autoEditTitle = false,
onAutoEditTitleConsumed,
pendingAttendee,
onStartAddAttendee,
onCommitAddAttendee,
onCancelAddAttendee,
bulkSelectable,
bulkSelected,
onBulkSelectChange,
displayNumber,
}: {
meeting: Meeting;
displayNumber?: number;
isRtl: boolean;
t: (k: string) => string;
visibleColumns: ColumnSetting[];
rowColorKey: string;
onPickRowColor: (key: string) => void;
canMutate: boolean;
onSaveTitle: (html: string) => Promise<void>;
onSaveAttendeeName: (idx: number, html: string) => Promise<void>;
onSaveTimes: (
startTime: string | null,
endTime: string | null,
) => Promise<void>;
onSaveMerge: (
merge:
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
| null,
) => Promise<void>;
onDeleteMeeting: () => void;
isDeleting: boolean;
isCurrent: boolean;
highlightColor: string;
reordering: boolean;
autoEditTitle?: boolean;
onAutoEditTitleConsumed?: () => void;
pendingAttendee?: {
meetingId: number;
attendanceType: Attendee["attendanceType"];
kind: "person" | "subheading";
key: number;
insertAtIndex?: number;
} | null;
onStartAddAttendee?: (
meetingId: number,
attendanceType: Attendee["attendanceType"],
kind?: "person" | "subheading",
insertAtIndex?: number,
) => void;
onCommitAddAttendee?: (
attendanceType: Attendee["attendanceType"],
html: string,
kind?: "person" | "subheading",
insertAtIndex?: number,
) => Promise<void>;
onCancelAddAttendee?: () => void;
bulkSelectable: boolean;
bulkSelected: boolean;
onBulkSelectChange: (next: boolean) => void;
}) {
const title = isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr;
const isCancelled = meeting.status === "cancelled";
const highlight = meeting.isHighlighted === 1;
const rowColor = ROW_COLOR_OPTIONS.find((o) => o.key === rowColorKey);
const rowBg = rowColor?.bg || "";
// Make the row a sortable item. `attributes` get spread onto the <tr> for
// a11y; `listeners` go on the dedicated grip handle in the # cell so a
// normal click on the cell content (to enter inline-edit mode) is still
// possible.
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: meeting.id, disabled: !canMutate });
const numberCellCls =
"border border-gray-300 px-2 py-2 text-center align-middle font-semibold " +
(highlight || isCancelled
? "bg-red-600 text-white"
: "bg-white text-[#0B1E3F]");
// Always provide width + overflow:hidden inline. In `table-auto` mode the
// browser treats width as a hint and shrinks cells to fit; in `table-fixed`
// mode (xl screens AND any print) it enforces them. overflow:hidden + the
// existing break-words on inner divs keeps wrapping behavior correct.
const cellStyle = (col: ColumnSetting): CSSProperties => ({
width: col.width,
overflow: "hidden",
});
// 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;
// Localized labels for the canonical columns spanned by the row's
// stored merge — passed into RowActionsMenu so the main-menu Merge
// cells item can render an inline "Merged: …" preview. Computed
// against CANONICAL_MERGE_ORDER (not visibleColumns) so the badge
// still describes the full saved range even when one of the
// boundary columns is currently hidden.
const mergeColumnLabels: string[] | null = hasStoredMerge
? CANONICAL_MERGE_ORDER.slice(canonStart, canonEnd + 1).map((id) =>
t(`executiveMeetings.col.${id}`),
)
: null;
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);
// Per-row bulk-select checkbox, lifted out of `renderCell` so the
// merged-cell branch can re-use the same JSX. Anchored to the
// top-start corner of whichever cell hosts it (the first visible
// non-merged cell for unmerged rows, or the merged cell itself for
// merged rows). Anchoring as an absolute overlay (rather than
// inline inside the # cell) keeps the checkbox available even when
// the editor has hidden the # column AND leaves the # cell's grip
// handle sitting in its original position so dnd-kit's
// drag-to-reorder isn't disturbed by neighbouring controls.
const bulkSelectOverlay = bulkSelectable ? (
<div
className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={bulkSelected}
onCheckedChange={(v) => onBulkSelectChange(v === true)}
aria-label={t("executiveMeetings.schedule.bulkSelectRow")}
data-testid={`em-row-select-${meeting.id}`}
className="h-4 w-4 bg-white shadow-sm"
/>
</div>
) : null;
const renderCell = (col: ColumnSetting, idx: number): ReactNode => {
// Anchor the consolidated row-actions menu (delete + row color +
// merge) 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
// via the column customizer. The button is rendered as an
// absolutely-positioned overlay inside the cell, so every cell type
// needs `relative group` on its <td> for it to find them.
const showActionsMenu = canMutate && !isMerged && idx === firstUnmergedIdx;
const actionsOverlay = showActionsMenu ? (
<RowActionsMenu
meetingId={meeting.id}
isDeleting={isDeleting}
onDelete={onDeleteMeeting}
rowColorKey={rowColorKey}
onPickRowColor={onPickRowColor}
// 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}
mergeColumnLabels={mergeColumnLabels}
onChangeMerge={onSaveMerge}
t={t}
/>
) : null;
// The bulk-select overlay only attaches to the first visible
// non-merged cell (so it appears once per row, never twice). For
// merged rows the overlay is rendered separately on the merged
// <td> (see `renderCells` below) so it remains reachable even
// when the merge swallows what would otherwise be the first
// visible cell.
const cellBulkOverlay =
!isMerged && idx === firstUnmergedIdx ? bulkSelectOverlay : null;
switch (col.id) {
case "number":
// Priority for the # cell background:
// 1. highlight || isCancelled → solid red badge (className wins)
// 2. tintBg (current meeting wash) → solid highlightColor with
// white text so the number stays legible.
// 3. rowBg (user-picked row color) → match the rest of the row
// so the chosen color wraps the whole row including the
// number cell. The palette is all light tints, so the
// existing dark `text-[#0B1E3F]` from numberCellCls stays
// readable.
// 4. otherwise → fall back to the white background already in
// numberCellCls.
// We omit any inline backgroundColor in case (1) so the
// bg-red-600 utility class isn't overridden by an inline style.
const numberCellInlineStyle: CSSProperties = (() => {
if (highlight || isCancelled) return cellStyle(col);
if (tintBg) {
return {
...cellStyle(col),
backgroundColor: highlightColor,
color: "white",
};
}
if (rowBg) {
return { ...cellStyle(col), backgroundColor: rowBg };
}
return cellStyle(col);
})();
return (
<td
key="number"
className={`relative group ${numberCellCls}`}
style={numberCellInlineStyle}
>
<div className="flex items-center justify-center gap-1">
{canMutate && (
<button
type="button"
// The grip handle is hover-only on devices that have a
// real hover (desktop mouse), but always visible at ~40%
// opacity on touch devices that have no hover state — so
// iPad users can actually find what to press to reorder
// a row. The tap target is also enlarged on touch with a
// small padding so the long-press lands reliably.
// touch-action:none stays on the handle itself so the
// browser hands touch events to dnd-kit instead of
// claiming them as a vertical scroll; the rest of the
// row keeps default touch-action so page scrolling works
// normally outside of the handle.
className="opacity-0 group-hover:opacity-70 hover:opacity-100 [@media(hover:none)_and_(pointer:coarse)]:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:group-hover:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:p-1.5 cursor-grab active:cursor-grabbing touch-none print:hidden"
aria-label={t("executiveMeetings.dragRow")}
data-testid={`em-row-grip-${meeting.id}`}
{...listeners}
{...attributes}
>
<GripVertical className="w-3.5 h-3.5 [@media(hover:none)_and_(pointer:coarse)]:w-4 [@media(hover:none)_and_(pointer:coarse)]:h-4" />
</button>
)}
<span>{displayNumber ?? meeting.dailyNumber}</span>
</div>
{cellBulkOverlay}
{actionsOverlay}
</td>
);
case "meeting":
return (
<td
key="meeting"
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
style={tintedCellStyle(col)}
>
<EditableCell
value={title}
onSave={onSaveTitle}
ariaLabel={t("executiveMeetings.editMeetingTitle")}
dir={isRtl ? "rtl" : "ltr"}
className="font-medium leading-snug break-words"
disabled={!canMutate}
testId={`em-edit-title-${meeting.id}`}
startInEditMode={autoEditTitle}
onStartedEditing={onAutoEditTitleConsumed}
/>
{meeting.location && (
<div className="text-[11px] text-muted-foreground mt-0.5 break-words">
{meeting.location}
</div>
)}
{cellBulkOverlay}
{actionsOverlay}
</td>
);
case "attendees":
return (
<td
key="attendees"
className="border border-gray-300 px-3 py-3 align-middle relative group"
style={tintedCellStyle(col)}
>
<AttendeesCell
meeting={meeting}
t={t}
canMutate={canMutate}
onSaveAttendeeName={onSaveAttendeeName}
pendingAttendee={pendingAttendee ?? null}
onStartAddAttendee={onStartAddAttendee}
onCommitAddAttendee={onCommitAddAttendee}
onCancelAddAttendee={onCancelAddAttendee}
/>
{cellBulkOverlay}
{actionsOverlay}
</td>
);
case "time":
return (
<td
key="time"
className="border border-gray-300 px-3 py-3 align-middle text-center relative group"
style={tintedCellStyle(col)}
>
<TimeRangeCell
meeting={meeting}
t={t}
lang={isRtl ? "ar" : "en"}
canMutate={canMutate}
onSaveTimes={onSaveTimes}
/>
{cellBulkOverlay}
{actionsOverlay}
</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 RowActionsMenu 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}`}
/>
{bulkSelectOverlay}
{canMutate && (
<RowActionsMenu
meetingId={meeting.id}
isDeleting={isDeleting}
onDelete={onDeleteMeeting}
rowColorKey={rowColorKey}
onPickRowColor={onPickRowColor}
hasStoredMerge
existingMergeText={meeting.mergeText ?? null}
mergeColumnLabels={mergeColumnLabels}
onChangeMerge={onSaveMerge}
t={t}
/>
)}
</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).
const baseBg = rowBg || "white";
// The "selected for bulk delete" ring stacks ON TOP of the existing
// current-meeting ring when both apply. Inset shadows compose from
// left to right (top of stack first), so we list the selection ring
// first so it's the visible outer outline. Color is the same brand
// navy used elsewhere in the page so it reads as a deliberate state.
const selectionShadow = bulkSelected ? "inset 0 0 0 2px #0B1E3F" : null;
const currentShadow = isCurrent
? `inset 0 0 0 2px ${highlightColor}`
: null;
const composedShadow = [selectionShadow, currentShadow]
.filter(Boolean)
.join(", ");
const trStyle: CSSProperties = {
backgroundColor: baseBg,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
boxShadow: composedShadow || undefined,
};
return (
<tr
ref={setNodeRef}
className="group"
style={trStyle}
data-testid={`em-row-${meeting.id}`}
data-current-meeting={isCurrent ? "true" : undefined}
data-bulk-selected={bulkSelected ? "true" : undefined}
>
{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})`;
}
function TimeRangeCell({
meeting,
t,
lang,
canMutate,
onSaveTimes,
}: {
meeting: Meeting;
t: (k: string) => string;
lang: "ar" | "en";
canMutate: boolean;
onSaveTimes: (
startTime: string | null,
endTime: string | null,
) => Promise<void>;
}) {
const { toast } = useToast();
const startSaved = meeting.startTime ? meeting.startTime.slice(0, 5) : "";
const endSaved = meeting.endTime ? meeting.endTime.slice(0, 5) : "";
const [editing, setEditing] = useState(false);
const [start, setStart] = useState(startSaved);
const [end, setEnd] = useState(endSaved);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Imperative handles to the start/end pickers. The inline editor
// reads the live draft (typed text + AM/PM toggle) via commit()
// when saving, instead of relying on React state — same
// belt-and-braces pattern task #308 used to dodge "Enter pressed
// before React's batched onChange flush" races.
const startPickerRef = useRef<TimePicker12hHandle | null>(null);
const endPickerRef = useRef<TimePicker12hHandle | null>(null);
// Track the last `startSaved`/`endSaved` we've observed so the sync
// effect below only fires when the *saved props themselves change*,
// not on every `editing` flip. This is the fix for task #316's
// snap-back race: `save()` writes the optimistic times, then
// `setEditing(false)` triggered a re-run of the prior
// `[editing, startSaved, endSaved]` effect that overwrote the
// optimistic draft with the still-stale `startSaved` (the GET
// refetch hadn't landed yet), making the next click on the cell
// re-open the picker with empty fields. Comparing against a ref
// ensures we ONLY re-seed when the upstream meetings cache (or a
// date change) actually delivers new values.
const lastSyncedRef = useRef({ start: startSaved, end: endSaved });
// `editing` flips false synchronously when save() starts, but the
// PATCH (and any setQueryData rollback the parent fires from its
// catch branch) can shuttle `startSaved`/`endSaved` underneath us
// BEFORE our own catch flips `editing` back to true. Without this
// ref, the sync effect below would see `editing=false` plus a new
// `startSaved` (e.g. the rolled-back original) and overwrite the
// user's draft just before we re-open the editor — exactly the
// snap-back the test catches. Mute the sync effect for the entire
// save round-trip and release once we know the final outcome.
const savingRef = useRef(false);
// If the saved times change while we're not editing (e.g. another tab
// updated them, or the user picked a different date), sync local state.
useEffect(() => {
if (editing) return; // never fight the user mid-edit
if (savingRef.current) return; // save round-trip owns the values
if (
lastSyncedRef.current.start === startSaved &&
lastSyncedRef.current.end === endSaved
) {
return; // saved values are unchanged; do not snap-back the draft
}
lastSyncedRef.current = { start: startSaved, end: endSaved };
setStart(startSaved);
setEnd(endSaved);
}, [editing, startSaved, endSaved]);
useEffect(() => {
if (editing) {
// The picker auto-focuses its text input via the autoFocus prop
// on mount; keep an explicit focus() call here too so re-entry
// edge cases (e.g. clicking the cell again while React is mid-
// commit) still land focus on the start input.
startPickerRef.current?.focus();
startPickerRef.current?.select();
}
}, [editing]);
const enterEdit = useCallback(() => {
if (!canMutate) return;
setEditing(true);
}, [canMutate]);
const cancel = useCallback(() => {
setStart(startSaved);
setEnd(endSaved);
setEditing(false);
}, [startSaved, endSaved]);
// If the global Edit toggle flips off (or the user loses permission)
// while the time inputs are open, drop the unsaved draft and exit
// edit mode immediately so the cell snaps back to its read-only
// display instead of stranding stale time inputs in view mode.
useEffect(() => {
if (!canMutate && editing) {
setStart(startSaved);
setEnd(endSaved);
setEditing(false);
}
}, [canMutate, editing, startSaved, endSaved]);
const save = useCallback(async () => {
// Read the live drafts from each picker's commit() handle. This
// returns the canonical "HH:mm" 24h value derived from the typed
// text PLUS the AM/PM toggle — empty input is `value: null,
// error: null`, an unparseable typed value is `error: "invalid"`,
// and a 112 hour without an AM/PM pick is `error: "ambiguous"`
// (the core fix for the "1:15 silently saved as AM" bug).
const startResult = startPickerRef.current?.commit() ?? {
value: null,
error: null as null,
};
const endResult = endPickerRef.current?.commit() ?? {
value: null,
error: null as null,
};
// Surface the picker's validation errors as toasts and keep the
// editor open with focus on the offending input — never silently
// discard the user's typing or guess the period for them.
if (startResult.error === "invalid") {
toast({
title: t("executiveMeetings.schedule.timeParseError"),
variant: "destructive",
});
startPickerRef.current?.focus();
startPickerRef.current?.select();
return;
}
if (startResult.error === "ambiguous") {
toast({
title: t("executiveMeetings.schedule.timeAmbiguousError"),
variant: "destructive",
});
startPickerRef.current?.focus();
return;
}
if (endResult.error === "invalid") {
toast({
title: t("executiveMeetings.schedule.timeParseError"),
variant: "destructive",
});
endPickerRef.current?.focus();
endPickerRef.current?.select();
return;
}
if (endResult.error === "ambiguous") {
toast({
title: t("executiveMeetings.schedule.timeAmbiguousError"),
variant: "destructive",
});
endPickerRef.current?.focus();
return;
}
const newStart = startResult.value;
const newEnd = endResult.value;
if (newStart === (startSaved || null) && newEnd === (endSaved || null)) {
setEditing(false);
return;
}
if (newStart && newEnd && newStart > newEnd) {
// Surface the validation error and keep the user in edit mode so they
// can correct it without losing the values they just typed.
toast({
title: t("executiveMeetings.schedule.timeOrderError"),
variant: "destructive",
});
startPickerRef.current?.focus();
return;
}
// Snap the controlled state to the canonical "HH:MM" so the
// collapsed display (and any subsequent re-open of the editor)
// shows the same value the server now holds, even if the user
// typed a non-canonical shape like "9:30" or "0930".
setStart(newStart ?? "");
setEnd(newEnd ?? "");
// Mark the optimistic values as the "last synced" baseline so the
// sync effect above doesn't immediately undo them when `editing`
// flips false on the next line. The eventual GET refetch will
// deliver the same canonical values and the ref equality check
// will short-circuit.
lastSyncedRef.current = { start: newStart ?? "", end: newEnd ?? "" };
// Close the editor BEFORE awaiting the PATCH round-trip. Combined
// with the parent `saveTimes`'s optimistic cache write, this
// collapses the cell to its read-only display showing the new
// times in the very next React commit — no waiting on the
// network. On failure the catch branch re-opens the editor with
// the user's draft intact (see below).
savingRef.current = true;
setEditing(false);
try {
await onSaveTimes(newStart, newEnd);
savingRef.current = false;
} catch (err) {
savingRef.current = false;
// The parent `saveTimes` handler is responsible for surfacing the
// error toast AND for rolling back the optimistic cache write.
// For the server-side time-order validation error we re-open the
// editor with the user's draft values intact so they can fix
// the typo without re-entering both times, then refocus the
// start input. For any other failure we drop the draft, snap
// back to the previously-saved values, and re-open the editor
// so the user knows the save did not stick and can retry.
if (err instanceof Error && err.name === "TimeOrderError") {
setEditing(true);
startPickerRef.current?.focus();
return;
}
// Preserve the user's draft (`start` / `end` keep the values
// we optimistically wrote above) so they can fix and retry
// without retyping. The PARENT `saveTimes` is responsible for
// rolling back the optimistic cache write so the underlying
// `meeting.startTime` / `meeting.endTime` props snap back to
// the previously-saved values; the sync effect above will not
// overwrite our draft because `editing` is `true` again.
setEditing(true);
}
}, [startSaved, endSaved, onSaveTimes, toast, t]);
const onFocusOut = (e: React.FocusEvent<HTMLDivElement>) => {
if (!editing) return;
const next = e.relatedTarget as Node | null;
if (next && wrapperRef.current?.contains(next)) return;
if (blurTimerRef.current) clearTimeout(blurTimerRef.current);
blurTimerRef.current = setTimeout(() => {
void save();
}, 150);
};
const onFocusIn = () => {
if (blurTimerRef.current) {
clearTimeout(blurTimerRef.current);
blurTimerRef.current = null;
}
};
if (!editing) {
const hasAny = !!(meeting.startTime || meeting.endTime);
const display = hasAny
? `${formatTime(meeting.startTime, lang)} ${formatTime(meeting.endTime, lang)}`
: "—";
return (
<div
role={canMutate ? "button" : undefined}
tabIndex={canMutate ? 0 : -1}
aria-label={t("executiveMeetings.col.time")}
// Force LTR so the time range always reads "10:00 11:00"
// (start on the left, end on the right) regardless of the
// surrounding RTL Arabic layout.
dir="ltr"
className={
"font-mono text-[#0B1E3F] whitespace-nowrap leading-tight inline-block " +
(canMutate
? "cursor-text hover:bg-blue-50/50 focus:bg-blue-50/50 focus:outline-none rounded transition-colors px-1"
: "")
}
onClick={enterEdit}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
enterEdit();
}
}}
data-testid={`em-time-${meeting.id}`}
>
{display}
</div>
);
}
// Stable per-meeting input ids so the <label htmlFor=...> hookup is
// unique across meeting rows in the schedule. Without this, screen
// readers and click-to-focus on the label would jump to the first
// matching id elsewhere in the document.
const startInputId = `em-time-start-input-${meeting.id}`;
const endInputId = `em-time-end-input-${meeting.id}`;
return (
<div
ref={wrapperRef}
onFocus={onFocusIn}
onBlur={onFocusOut}
// Lock the editor to LTR so start-on-the-left / end-on-the-right
// is preserved regardless of the surrounding RTL Arabic page
// layout. Without this, the two time inputs, the dash, and the
// save/cancel buttons get visually mirrored, making it impossible
// for the user to tell which input is which.
dir="ltr"
// `flex-wrap` lets the start picker, the en-dash, and the end
// picker fall onto a second line when the cell is narrower than
// their combined width — this is what makes the editor usable
// on a 375px iPhone viewport, where the prior single-row
// `inline-flex` layout pushed the end picker out of view.
className="flex flex-wrap items-end justify-center gap-x-1.5 gap-y-1 font-mono"
data-testid={`em-time-${meeting.id}`}
data-editing="true"
>
<div className="flex flex-col items-center">
<label
htmlFor={startInputId}
className="text-[11px] font-medium leading-none text-muted-foreground mb-1 select-none"
>
{t("executiveMeetings.schedule.timeStartShort")}
</label>
{/* The 12-hour picker keeps the legacy `em-time-start-${id}`
testid on its hour input so the existing keyboard-editing
e2e suite still resolves it. The new minute input and
AM/PM toggle expose their own per-meeting testids
(`em-time-start-${id}-minute`,
`em-time-start-period-am-${id}` / `-pm-${id}`) for tests
that need to drive them explicitly. */}
<TimePicker12h
ref={startPickerRef}
inputId={startInputId}
value={start}
onChange={setStart}
onCommit={() => void save()}
onCancel={cancel}
ariaLabel={t("executiveMeetings.schedule.timeStart")}
groupLabel={t("executiveMeetings.timeEditor.periodGroupStart")}
amLabel={t("executiveMeetings.timeEditor.am")}
pmLabel={t("executiveMeetings.timeEditor.pm")}
inputTestId={`em-time-start-${meeting.id}`}
minuteTestId={`em-time-start-minute-${meeting.id}`}
amTestId={`em-time-start-period-am-${meeting.id}`}
pmTestId={`em-time-start-period-pm-${meeting.id}`}
autoFocus
size="inline"
/>
</div>
<span className="text-muted-foreground pb-2 self-center hidden sm:inline"></span>
<div className="flex flex-col items-center">
<label
htmlFor={endInputId}
className="text-[11px] font-medium leading-none text-muted-foreground mb-1 select-none"
>
{t("executiveMeetings.schedule.timeEndShort")}
</label>
<TimePicker12h
ref={endPickerRef}
inputId={endInputId}
value={end}
onChange={setEnd}
onCommit={() => void save()}
onCancel={cancel}
ariaLabel={t("executiveMeetings.schedule.timeEnd")}
groupLabel={t("executiveMeetings.timeEditor.periodGroupEnd")}
amLabel={t("executiveMeetings.timeEditor.am")}
pmLabel={t("executiveMeetings.timeEditor.pm")}
inputTestId={`em-time-end-${meeting.id}`}
minuteTestId={`em-time-end-minute-${meeting.id}`}
amTestId={`em-time-end-period-am-${meeting.id}`}
pmTestId={`em-time-end-period-pm-${meeting.id}`}
size="inline"
/>
</div>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => void save()}
aria-label={t("common.save")}
className="ms-0.5 h-8 w-8 inline-flex items-center justify-center rounded text-green-700 hover:bg-green-50 focus:bg-green-50 focus:outline-none self-end"
data-testid={`em-time-save-${meeting.id}`}
>
<Check className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={cancel}
aria-label={t("common.cancel")}
className="h-8 w-8 inline-flex items-center justify-center rounded text-red-700 hover:bg-red-50 focus:bg-red-50 focus:outline-none self-end"
data-testid={`em-time-cancel-${meeting.id}`}
>
<X className="w-4 h-4" />
</button>
</div>
);
}
function AttendeesCell({
meeting,
t,
canMutate = false,
onSaveAttendeeName,
pendingAttendee = null,
onStartAddAttendee,
onCommitAddAttendee,
onCancelAddAttendee,
}: {
meeting: Meeting;
t: (k: string) => string;
canMutate?: boolean;
onSaveAttendeeName?: (idx: number, html: string) => Promise<void>;
pendingAttendee?: {
meetingId: number;
attendanceType: Attendee["attendanceType"];
kind: "person" | "subheading";
key: number;
insertAtIndex?: number;
} | null;
onStartAddAttendee?: (
meetingId: number,
attendanceType: Attendee["attendanceType"],
kind?: "person" | "subheading",
insertAtIndex?: number,
) => void;
onCommitAddAttendee?: (
attendanceType: Attendee["attendanceType"],
html: string,
kind?: "person" | "subheading",
insertAtIndex?: number,
) => 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). 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 subheading belongs to exactly one attendance-type group, so we
// gate the split layout on group existence (any row, person or
// subheading) rather than on person count. Otherwise a group that
// currently contains only subheadings (e.g. after the user deletes
// all of its persons) would silently fold its rows into a sibling
// group and lose the user's intended placement.
const hasSplit =
virtual.length > 0 && (internal.length > 0 || external.length > 0);
const platformLabel: Record<Meeting["platform"], string> = {
none: "",
webex: "Webex",
teams: "Teams",
zoom: "Zoom",
other: t("executiveMeetings.virtual"),
};
// pendingAttendee is GLOBAL — it's the single ghost row open anywhere on
// the page. So we must distinguish:
// * "is the ghost in THIS row, in THIS group?" — controls whether to
// render the ghost EditableCell here.
// * "is a ghost open ANYWHERE on the page?" — controls whether to hide
// the "+" buttons in this row, so the user can't orphan their typing
// by clicking "+" in a different row while one is already open.
const isPendingForThisMeeting =
!!pendingAttendee && pendingAttendee.meetingId === meeting.id;
// 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"],
kind: "person" | "subheading" = "person",
insertAtIndex?: number,
) => onStartAddAttendee(meeting.id, type, kind, insertAtIndex)
: undefined;
const commitAdd = onCommitAddAttendee;
const cancelAdd = onCancelAddAttendee;
const flowProps = {
canMutate,
onSaveAttendeeName,
pendingKind: (pendingAttendee?.kind ?? "person") as "person" | "subheading",
isPending: false,
pendingKey: pendingAttendee?.key ?? 0,
// Tells AttendeeFlow where in the full meeting.attendees array the
// pending ghost belongs. Used both to render the ghost at the right
// slot and to forward the value through to onCommitAdd so the splice
// in commitAddAttendee lands in the same place.
pendingInsertAtIndex: pendingAttendee?.insertAtIndex,
onStartAdd: hasAnyPending ? undefined : startAdd,
chainStartAdd: 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) {
// Split-mode cells render ONE cell-level "+ عنوان فرعي" chip below
// all groups (see Task #234), so each per-group AttendeeFlow must
// suppress its own trailing subheading chip. Per-group "+" person
// chips and the after-section chips are unaffected.
return (
<div className="space-y-2">
{(virtual.length > 0 || isPendingForType("virtual")) && (
<AttendeeGroup
label={
meeting.platform !== "none"
? `${t("executiveMeetings.virtualAttendance")}${platformLabel[meeting.platform]}`
: t("executiveMeetings.virtualAttendance")
}
items={virtual}
addType="virtual"
{...flowProps}
isPending={isPendingForType("virtual")}
suppressTrailingSubheadingChip
/>
)}
{(internal.length > 0 || isPendingForType("internal")) && (
<AttendeeGroup
label={t("executiveMeetings.internalAttendance")}
items={internal}
addType="internal"
{...flowProps}
isPending={isPendingForType("internal")}
suppressTrailingSubheadingChip
/>
)}
{(external.length > 0 || isPendingForType("external")) && (
<AttendeeGroup
label={t("executiveMeetings.externalAttendance")}
items={external}
addType="external"
{...flowProps}
isPending={isPendingForType("external")}
suppressTrailingSubheadingChip
/>
)}
{/* Add chips for groups that don't yet exist for this meeting. */}
{canMutate && !hasAnyPending && startAdd && (
<div className="flex flex-wrap justify-center gap-1.5 print:hidden text-xs">
{virtual.length === 0 && (
<AddAttendeeChip
onClick={() => startAdd("virtual")}
label={t("executiveMeetings.schedule.addVirtualAttendee")}
testId={`em-add-attendee-virtual-${meeting.id}`}
/>
)}
{internal.length === 0 && (
<AddAttendeeChip
onClick={() => startAdd("internal")}
label={t("executiveMeetings.schedule.addInternalAttendee")}
testId={`em-add-attendee-internal-${meeting.id}`}
/>
)}
{external.length === 0 && (
<AddAttendeeChip
onClick={() => startAdd("external")}
label={t("executiveMeetings.schedule.addExternalAttendee")}
testId={`em-add-attendee-external-${meeting.id}`}
/>
)}
</div>
)}
{/* Cell-level "+ عنوان فرعي" chip (Task #234). One per cell,
not one per group. Clicking it routes the new subheading to
the LAST visible group in render order (virtual → internal
→ external). The chip mirrors the per-group hide rules:
only renders when the user can edit, no add ghost is
already in flight, and a startAdd handler exists. */}
{canMutate && !hasAnyPending && startAdd && (
<div className="flex justify-center print:hidden">
<button
type="button"
onClick={() => {
const lastVisibleAddType: Attendee["attendanceType"] =
external.length > 0
? "external"
: internal.length > 0
? "internal"
: "virtual";
startAdd(lastVisibleAddType, "subheading");
}}
data-testid={`em-add-subheading-cell-${meeting.id}`}
aria-label={t("executiveMeetings.schedule.addSubheading")}
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"
>
{t("executiveMeetings.schedule.addSubheading")}
</button>
</div>
)}
</div>
);
}
// Single-group cases: meeting.platform-only virtual list, all-internal,
// all-external, or empty. Pick a sensible default add type so the user
// can add a first attendee without reaching for the request modal.
const singleGroupType: Attendee["attendanceType"] =
meeting.platform !== "none" && virtual.length > 0
? "virtual"
: tagged[0]?.a.attendanceType ?? "internal";
if (meeting.platform !== "none" && virtual.length > 0) {
return (
<AttendeeGroup
label={`${t("executiveMeetings.virtualAttendance")}${platformLabel[meeting.platform]}`}
items={virtual}
addType="virtual"
{...flowProps}
isPending={isPendingForType("virtual")}
/>
);
}
// Empty cell or single-type cell — render flow without a header.
return (
<AttendeeFlow
items={tagged}
addType={singleGroupType}
{...flowProps}
isPending={isPendingForType(singleGroupType)}
/>
);
}
type AttendeeWithIndex = { a: Attendee; i: number };
function AddAttendeeChip({
onClick,
label,
testId,
}: {
onClick: () => void;
label: string;
testId: string;
}) {
return (
<button
type="button"
onClick={onClick}
data-testid={testId}
className="inline-flex items-center rounded-full border border-dashed border-blue-400 px-2.5 py-1 min-h-[1.5rem] text-xs text-blue-700 hover:bg-blue-50 hover:border-blue-500 transition-colors"
>
{label}
</button>
);
}
type AttendeeFlowSharedProps = {
canMutate?: boolean;
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;
// Position in meeting.attendees where the pending ghost should land.
// When undefined the ghost falls back to the legacy "trailing" slot
// (rendered after the last item in the group). When set, the ghost is
// rendered inline before the first item whose original index is >= the
// value, so the user sees the input materialize right where they
// clicked an after-section "+ عنوان فرعي" chip.
pendingInsertAtIndex?: number;
onStartAdd?: (
type: Attendee["attendanceType"],
kind?: "person" | "subheading",
insertAtIndex?: number,
) => void;
/**
* Same shape as `onStartAdd`, but always defined regardless of whether
* a pending row is currently open. Used by the Tab quick-add flow so
* the user can chain new attendees one after another even while a
* pending row is in flight (the parent's `hasAnyPending` guard would
* otherwise hide `onStartAdd` and break the chain).
*/
chainStartAdd?: (
type: Attendee["attendanceType"],
kind?: "person" | "subheading",
insertAtIndex?: number,
) => void;
onCommitAdd?: (
type: Attendee["attendanceType"],
html: string,
kind?: "person" | "subheading",
insertAtIndex?: number,
) => Promise<void>;
onCancelAdd?: () => void;
addLabel: string;
addSubheadingLabel: string;
editAriaLabel: string;
newAriaLabel: string;
editSubheadingAriaLabel: string;
newSubheadingAriaLabel: string;
meetingId: number;
// When true, the trailing "+ عنوان فرعي" <li> at the very end of the
// flow is NOT rendered. Used by split-mode cells (≥2 attendance
// groups visible) so the cell shows ONE cell-level "+ عنوان فرعي"
// chip below all groups, instead of one per group. The trailing
// "+" person <li> is unaffected.
suppressTrailingSubheadingChip?: boolean;
};
function AttendeeGroup({
label,
items,
...flowProps
}: { label: string; items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) {
return (
<div>
{/* The group label is purely informational — make it pointer-events:none
so a near-miss tap on the label still falls through to whatever is
underneath (e.g. an attendee name or the cell background). The
subtle bottom border anchors the label to the list of attendees
beneath it so the user reads it as a sub-section header rather
than a stray line of text. */}
<div
data-testid="em-attendee-group-header"
className="text-sm font-bold text-[#0B1E3F] mt-0.5 mb-1 pb-0.5 text-center pointer-events-none select-none border-b border-[#0B1E3F]/15 print:border-b print:border-gray-400"
>
{label}
</div>
<AttendeeFlow items={items} {...flowProps} />
</div>
);
}
function AttendeeFlow({
items,
canMutate = false,
onSaveAttendeeName,
addType,
isPending,
pendingKind,
pendingKey,
pendingInsertAtIndex,
onStartAdd,
chainStartAdd,
onCommitAdd,
onCancelAdd,
addLabel,
addSubheadingLabel,
editAriaLabel,
newAriaLabel,
editSubheadingAriaLabel,
newSubheadingAriaLabel,
suppressTrailingSubheadingChip = false,
}: { items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) {
const editable = canMutate && !!onSaveAttendeeName;
const showAdd = canMutate && !!onStartAdd && !isPending;
// When Shift+Tab fires inside a person row we ask the previous
// person row to enter edit mode by setting `focusedAttendeeIdx` to
// its meeting-attendees index `i`. The target row's EditableCell is
// re-rendered with `startInEditMode={focusedAttendeeIdx === i}`,
// which trips the existing startInEditMode useEffect and opens it.
// We clear the value back to null via onStartedEditing so click
// interactions on other rows remain free of any stale "force edit"
// pull. null means "no row is being force-focused right now".
const [focusedAttendeeIdx, setFocusedAttendeeIdx] = useState<number | null>(
null,
);
// True when the in-flight ghost is targeted at a specific slot — set
// by clicking an after-section "+ عنوان فرعي" chip. The same flag
// drives inline rendering for the person ghost (still wired through
// the manage dialog flow). When unset we fall back to the legacy
// trailing render so the trailing "+" / "+ subheading" buttons keep
// working.
const hasInlineInsert = isPending && pendingInsertAtIndex !== undefined;
// Each subheading starts a fresh numbered section. Within a section,
// persons are numbered 1..N starting over after every subheading.
// Numbering rules:
// - If the cell has NO subheading: keep the legacy "don't number a
// single name" rule (only show numbers when 2+ persons exist).
// - If the cell HAS any subheading: always show numbers, even for
// solo persons inside a section, so the grouping reads clearly.
// Pre-compute per-list-position metadata so the render loop is O(n).
const hasAnySubheading = items.some(
({ a }) => (a.kind ?? "person") === "subheading",
);
const sectionMeta = items.map(() => ({
sectionPersonCount: 0,
idxInSection: 0,
}));
{
let secStart = 0;
let secCount = 0;
const finalize = (endExclusive: number) => {
for (let k = secStart; k < endExclusive; k++) {
sectionMeta[k].sectionPersonCount = secCount;
}
};
items.forEach(({ a }, idx) => {
if ((a.kind ?? "person") === "subheading") {
finalize(idx);
secStart = idx + 1;
secCount = 0;
} else {
sectionMeta[idx].idxInSection = secCount;
secCount += 1;
}
});
finalize(items.length);
}
// Person count of the trailing (last) section — drives whether the
// pending "+ name" ghost row shows a leading number.
let lastSectionPersonCount = 0;
for (let k = items.length - 1; k >= 0; k--) {
if ((items[k].a.kind ?? "person") === "subheading") break;
lastSectionPersonCount += 1;
}
if (items.length === 0 && !showAdd && !isPending) {
return <span className="text-xs text-gray-500"></span>;
}
// Locate the listIdx where the inline pending ghost should appear, when
// an inline insert is in flight. The ghost lands BEFORE the first item
// whose original index in meeting.attendees is >= pendingInsertAtIndex.
// If no such item exists (e.g. insert after the last item), the ghost
// is rendered after the loop. Computed once up front so the JSX is
// straightforward.
let inlineGhostTargetListIdx: number | null = null;
if (hasInlineInsert) {
for (let k = 0; k < items.length; k++) {
if (items[k].i >= pendingInsertAtIndex!) {
inlineGhostTargetListIdx = k;
break;
}
}
}
// Walk backward from `startListIdx - 1` until we find a person row;
// stop (return null) if we hit a subheading first or run off the top
// of the list. Used by Shift+Tab to decide which row should enter
// edit mode, while staying inside the current section.
const findPrevPersonInSection = (startListIdx: number): number | null => {
for (let j = startListIdx - 1; j >= 0; j--) {
const kind = items[j].a.kind ?? "person";
if (kind === "subheading") return null;
return items[j].i;
}
return null;
};
// The pending subheading ghost <li>. Used for inline rendering when
// the user clicks an after-section "+ عنوان فرعي" chip — the ghost
// materializes at the requested slot rather than the trailing
// fallback so the user sees their click land where expected.
const renderPendingSubheadingLi = (key: string) => {
if (
!(canMutate && isPending && pendingKind === "subheading" && onCommitAdd)
) {
return null;
}
return (
<li
key={key}
className="basis-full text-center font-semibold text-[#0B1E3F]/90 mt-1.5 mb-0.5"
data-testid={`em-add-subheading-pending-${addType}`}
>
<EditableCell
key={`pending-sub-${pendingKey}`}
value=""
onSave={(html) =>
onCommitAdd(addType, html, "subheading", pendingInsertAtIndex)
}
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>
);
};
// The pending person ghost <li>. Defined once so we can render it
// either inline (at the slot the user clicked) or as a trailing row
// (legacy "+" button at the end of the cell). It always shows the next
// section index when the cell has subheadings or the surrounding
// section already has people; otherwise it stays unnumbered just like
// a sole attendee in a no-subheading cell.
const renderPendingPersonLi = (key: string) => {
if (!(canMutate && isPending && pendingKind === "person" && onCommitAdd)) {
return null;
}
// When inserting inline after a subheading at array index K (so
// insertAtIndex = K + 1) we want the ghost to be numbered "1-" so the
// user sees a fresh section starting. Compute the section-local index
// for the inline slot from the per-item meta we already built.
let ghostDisplayNumber: number | null = null;
if (hasInlineInsert && inlineGhostTargetListIdx !== null) {
const target = inlineGhostTargetListIdx;
// The slot's section-local index = sectionMeta[target].idxInSection
// (0-based) for the would-be inserted person. That row pushes the
// current target row down, so the ghost takes the target's slot.
ghostDisplayNumber = sectionMeta[target].idxInSection + 1;
} else if (hasInlineInsert && inlineGhostTargetListIdx === null) {
// Insert at end (after the last item). Number = persons in last
// section + 1, mirroring the trailing case.
ghostDisplayNumber = lastSectionPersonCount + 1;
} else {
// Legacy trailing case.
ghostDisplayNumber =
lastSectionPersonCount > 0 || hasAnySubheading
? lastSectionPersonCount + 1
: null;
}
return (
<li
key={key}
className="min-w-[6rem]"
data-testid={`em-add-attendee-pending-${addType}`}
>
{ghostDisplayNumber !== null && (
<span className="text-gray-500 me-1">{ghostDisplayNumber}-</span>
)}
<EditableCell
key={`pending-${pendingKey}`}
value=""
onSave={(html) =>
onCommitAdd(addType, html, "person", pendingInsertAtIndex)
}
ariaLabel={newAriaLabel}
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-attendee-pending-input-${addType}`}
startInEditMode
forceSaveOnBlur
onCancel={onCancelAdd}
// Tab inside the pending row → commit it, then immediately
// open another pending row right after it. We use
// `chainStartAdd` (not `onStartAdd`) because the parent
// gates `onStartAdd` away while a pending row exists; the
// chain prop bypasses that guard. When `pendingInsertAtIndex`
// is undefined (legacy trailing slot) we leave it undefined
// so the next pending also opens at the trailing slot.
onTabNext={
chainStartAdd
? () =>
chainStartAdd(
addType,
"person",
pendingInsertAtIndex !== undefined
? pendingInsertAtIndex + 1
: undefined,
)
: undefined
}
// Shift+Tab → commit the pending row (onCancelAdd handles the
// empty-blur path; non-empty values commit via onSave) and
// focus the previous person in the same section. The pending
// ghost lives at listIdx = inlineGhostTargetListIdx (when
// inline) or after the very last item (when trailing).
onTabPrev={() => {
const startListIdx =
inlineGhostTargetListIdx !== null
? inlineGhostTargetListIdx
: items.length;
const prevI = findPrevPersonInSection(startListIdx);
if (prevI !== null) setFocusedAttendeeIdx(prevI);
}}
/>
</li>
);
};
return (
<ul className="flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug">
{items.flatMap(({ a, i }, listIdx) => {
const isSub = (a.kind ?? "person") === "subheading";
const nodes: ReactNode[] = [];
if (inlineGhostTargetListIdx === listIdx) {
if (pendingKind === "subheading") {
const ghost = renderPendingSubheadingLi(
`pending-inline-sub-${pendingKey}`,
);
if (ghost) nodes.push(ghost);
} else {
const ghost = renderPendingPersonLi(
`pending-inline-${pendingKey}`,
);
if (ghost) nodes.push(ghost);
}
}
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.
nodes.push(
<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"
>
{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>,
);
// Empty-section boundary: this subheading starts a section
// that has zero persons (the next item is ANOTHER subheading,
// i.e. the section it leads is empty). The user still needs a
// way to insert a NEW subheading between this section and the
// next one — so render the after-section chip right after the
// subheading itself. The trailing-section case (no next item)
// is intentionally skipped: the trailing "+ عنوان فرعي"
// button at the cell's end already covers that slot.
const nextItemForSub = items[listIdx + 1];
const nextSubIsSubheading =
nextItemForSub &&
(nextItemForSub.a.kind ?? "person") === "subheading";
if (showAdd && nextSubIsSubheading) {
nodes.push(
<li
key={`add-sub-after-${i}`}
className="basis-full flex justify-center print:hidden"
data-testid={`em-add-subheading-after-row-${i}`}
>
<button
type="button"
onClick={() =>
onStartAdd!(addType, "subheading", i + 1)
}
data-testid={`em-add-subheading-after-${i}`}
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>,
);
}
return nodes;
}
// Person row — section-local 0-based index pre-computed above.
const meta = sectionMeta[listIdx];
const myDisplayIdx = meta.idxInSection;
nodes.push(
<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]" : "")
}
>
{(meta.sectionPersonCount > 1 || hasAnySubheading) && (
<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}`}
// When Shift+Tab on a sibling row asks us to take focus
// we flip startInEditMode true; the cell's existing
// useEffect enters edit mode, then onStartedEditing
// clears the flag so future clicks elsewhere are not
// hijacked back to this row.
startInEditMode={focusedAttendeeIdx === i}
onStartedEditing={() => setFocusedAttendeeIdx(null)}
// Tab → commit current name then open a pending person
// row immediately after this one in the same cell, so
// the user can keep typing names without mousing.
// We use `chainStartAdd` so the chain is reachable even
// when the parent has just moved into a pending state.
onTabNext={
canMutate && chainStartAdd
? () => chainStartAdd(addType, "person", i + 1)
: undefined
}
// Shift+Tab → commit then jump back to the previous
// person row in the same section (no-op on section's
// first row — saveEdit still runs).
onTabPrev={() => {
const prevI = findPrevPersonInSection(listIdx);
if (prevI !== null) setFocusedAttendeeIdx(prevI);
}}
/>
) : (
<span
data-testid={`em-attendee-name-${i}`}
className="[&_p]:inline [&_p]:m-0"
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
/>
)}
</li>,
);
const nextItemForPerson = items[listIdx + 1];
const nextIsSubheading =
nextItemForPerson &&
(nextItemForPerson.a.kind ?? "person") === "subheading";
// The inter-person "+ person here" chip used to live here
// (originally added in #222). It was removed in #299: with one
// chip rendered between every pair of person rows the cell
// looked noisy and repetitive, and the trailing "+" button at
// the end of the list already covers the add-person flow.
// The "+ subheading" chip just before a subheading row is
// preserved below.
if (showAdd && nextIsSubheading) {
nodes.push(
<li
key={`add-sub-after-${i}`}
className="basis-full flex justify-center print:hidden"
data-testid={`em-add-subheading-after-row-${i}`}
>
<button
type="button"
onClick={() =>
onStartAdd!(addType, "subheading", i + 1)
}
data-testid={`em-add-subheading-after-${i}`}
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>,
);
}
return nodes;
})}
{/* Inline ghost pinned to the trailing slot (insert-at-end case).
Either kind may land here: a person from manage flows, or a
subheading from the trailing "+ عنوان فرعي" button when the
insertAtIndex equals items.length. */}
{hasInlineInsert &&
inlineGhostTargetListIdx === null &&
pendingKind === "person" &&
renderPendingPersonLi(`pending-inline-trailing-${pendingKey}`)}
{hasInlineInsert &&
inlineGhostTargetListIdx === null &&
pendingKind === "subheading" &&
renderPendingSubheadingLi(`pending-inline-trailing-sub-${pendingKey}`)}
{/* Legacy trailing ghost (no inline insert in flight) — used for
the global trailing "+" / "+ subheading" buttons. */}
{!hasInlineInsert &&
renderPendingPersonLi(`pending-trailing-${pendingKey}`)}
{!hasInlineInsert &&
renderPendingSubheadingLi(`pending-trailing-sub-${pendingKey}`)}
{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>
{!suppressTrailingSubheadingChip && (
<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>
);
}
// ============ MANAGE ============
type MeetingFormState = {
id: number | null;
titleAr: string;
titleEn: string;
meetingDate: string;
dailyNumber: string;
startTime: string;
endTime: string;
location: string;
meetingUrl: string;
platform: Meeting["platform"];
status: string;
isHighlighted: boolean;
notes: string;
attendees: Attendee[];
};
// Generate a stable client-only attendee id for the manage-dialog DnD.
// Combines an incrementing counter with a random suffix so we don't
// collide even when several openEdit() calls fire in the same tick (e.g.
// after a duplicate-and-edit gesture). The id never leaves the browser.
let __attendeeSidCounter = 0;
function genAttendeeSid(): string {
__attendeeSidCounter += 1;
return `sid-${__attendeeSidCounter}-${Math.random().toString(36).slice(2, 8)}`;
}
function emptyMeetingForm(date: string): MeetingFormState {
return {
id: null,
titleAr: "",
titleEn: "",
meetingDate: date,
dailyNumber: "",
startTime: "",
endTime: "",
location: "",
meetingUrl: "",
platform: "none",
status: "scheduled",
isHighlighted: false,
notes: "",
attendees: [],
};
}
function ManageSection({
date,
onDateChange,
canMutate,
isRtl,
t,
}: {
date: string;
onDateChange: (d: string) => void;
canMutate: boolean;
isRtl: boolean;
t: (k: string) => string;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const confirm = useConfirm();
const [editing, setEditing] = useState<MeetingFormState | null>(null);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [duplicating, setDuplicating] = useState<{
id: number;
targetDate: string;
} | null>(null);
const [duplicatingBusy, setDuplicatingBusy] = useState(false);
const { data, isLoading } = useQuery<DayResponse>({
queryKey: ["/api/executive-meetings", date],
queryFn: () => apiJson<DayResponse>(`/api/executive-meetings?date=${date}`),
});
const meetings = data?.meetings ?? [];
// #332: in-day search on the Manage tab. Mirrors the Schedule tab
// (see ~L945) — same `normalizeForSearch`/`meetingMatchesSearch`
// helpers, same "reset on date change" behavior, same scoping of
// bulk actions to the visible (filtered) row set.
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
setSearchQuery("");
}, [date]);
const normalizedSearch = useMemo(
() => normalizeForSearch(searchQuery),
[searchQuery],
);
const filteredMeetings = useMemo(() => {
if (!normalizedSearch) return meetings;
return meetings.filter((m) => meetingMatchesSearch(m, normalizedSearch));
}, [meetings, normalizedSearch]);
// #332: bulk-selection state for Manage. The selection set may
// legitimately contain ids hidden by an active search filter (we
// don't auto-prune so clearing the search restores the user's
// selection); every action intersects with `filteredMeetings` at
// call time for safety.
const [selectedMeetingIds, setSelectedMeetingIds] = useState<Set<number>>(
() => new Set(),
);
const toggleMeetingSelected = useCallback((id: number, on: boolean) => {
setSelectedMeetingIds((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}, []);
const toggleSelectAllVisible = useCallback(
(on: boolean) => {
setSelectedMeetingIds((prev) => {
if (!on) return new Set();
const next = new Set(prev);
for (const m of filteredMeetings) next.add(m.id);
return next;
});
},
[filteredMeetings],
);
const allSelectedState: "all" | "some" | "none" = useMemo(() => {
const total = filteredMeetings.length;
if (total === 0) return "none";
let count = 0;
for (const m of filteredMeetings) if (selectedMeetingIds.has(m.id)) count++;
if (count === 0) return "none";
if (count === total) return "all";
return "some";
}, [filteredMeetings, selectedMeetingIds]);
const visibleSelectedCount = useMemo(() => {
let n = 0;
for (const m of filteredMeetings) if (selectedMeetingIds.has(m.id)) n++;
return n;
}, [filteredMeetings, selectedMeetingIds]);
const [bulkDeleting, setBulkDeleting] = useState(false);
const [bulkDuplicateOpen, setBulkDuplicateOpen] = useState(false);
const [bulkDuplicateDate, setBulkDuplicateDate] = useState<string>(date);
const [bulkDuplicating, setBulkDuplicating] = useState(false);
const openBulkDuplicate = useCallback(() => {
setBulkDuplicateDate(date);
setBulkDuplicateOpen(true);
}, [date]);
const performBulkDuplicate = useCallback(async () => {
if (bulkDuplicating) return;
if (!bulkDuplicateDate) return;
const visibleIds = new Set(filteredMeetings.map((m) => m.id));
const ids = Array.from(selectedMeetingIds).filter((id) =>
visibleIds.has(id),
);
if (ids.length === 0) {
setBulkDuplicateOpen(false);
return;
}
setBulkDuplicating(true);
try {
const results = await Promise.allSettled(
ids.map((id) =>
apiJson(`/api/executive-meetings/${id}/duplicate`, {
method: "POST",
body: { targetDate: bulkDuplicateDate },
}),
),
);
const ok = results.filter((r) => r.status === "fulfilled").length;
const failed = results.length - ok;
if (failed === 0) {
toast({
title: t(
"executiveMeetings.schedule.bulkDuplicateResultAll",
).replace("{{n}}", String(ok)),
});
} else if (ok === 0) {
toast({
title: t("executiveMeetings.schedule.bulkDuplicateNone"),
variant: "destructive",
});
} else {
toast({
title: t("executiveMeetings.schedule.bulkDuplicatePartial")
.replace("{{ok}}", String(ok))
.replace("{{total}}", String(results.length))
.replace("{{failed}}", String(failed)),
variant: "destructive",
});
}
setSelectedMeetingIds(new Set());
setBulkDuplicateOpen(false);
if (bulkDuplicateDate === date) {
await qc.invalidateQueries({
queryKey: ["/api/executive-meetings", date],
});
} else {
onDateChange(bulkDuplicateDate);
}
} finally {
setBulkDuplicating(false);
}
}, [
bulkDuplicating,
bulkDuplicateDate,
filteredMeetings,
selectedMeetingIds,
date,
onDateChange,
qc,
toast,
t,
]);
const deleteSelectedMeetings = useCallback(async () => {
if (bulkDeleting) return;
const visibleIds = new Set(filteredMeetings.map((m) => m.id));
const ids = Array.from(selectedMeetingIds).filter((id) =>
visibleIds.has(id),
);
if (ids.length === 0) return;
const message = t(
"executiveMeetings.schedule.bulkDeleteConfirm",
).replace("{{n}}", String(ids.length));
if (!(await confirm({ message, destructive: true }))) return;
setBulkDeleting(true);
try {
const results = await Promise.allSettled(
ids.map((id) =>
apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" }),
),
);
const ok = results.filter((r) => r.status === "fulfilled").length;
const failed = results.length - ok;
if (failed === 0) {
toast({
title: t("executiveMeetings.schedule.bulkDeleteResultAll").replace(
"{{n}}",
String(ok),
),
});
} else if (ok === 0) {
toast({
title: t("executiveMeetings.schedule.bulkDeleteResultNone"),
variant: "destructive",
});
} else {
toast({
title: t("executiveMeetings.schedule.bulkDeletePartial")
.replace("{{ok}}", String(ok))
.replace("{{total}}", String(results.length))
.replace("{{failed}}", String(failed)),
variant: "destructive",
});
}
setSelectedMeetingIds(new Set());
await qc.invalidateQueries({
queryKey: ["/api/executive-meetings", date],
});
} finally {
setBulkDeleting(false);
}
}, [
bulkDeleting,
selectedMeetingIds,
filteredMeetings,
qc,
date,
toast,
t,
]);
function openCreate() {
setEditing(emptyMeetingForm(date));
}
function openDuplicate(m: Meeting) {
setDuplicating({ id: m.id, targetDate: date });
}
async function performDuplicate() {
if (!duplicating) return;
setDuplicatingBusy(true);
try {
await apiJson(`/api/executive-meetings/${duplicating.id}/duplicate`, {
method: "POST",
body: { targetDate: duplicating.targetDate },
});
toast({ title: t("executiveMeetings.manage.duplicated") });
const newDate = duplicating.targetDate;
setDuplicating(null);
if (newDate === date) {
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
} else {
onDateChange(newDate);
}
} catch (err) {
toast({
title: t("executiveMeetings.manage.duplicateFailed"),
description: err instanceof Error ? err.message : String(err),
variant: "destructive",
});
} finally {
setDuplicatingBusy(false);
}
}
function openEdit(m: Meeting) {
setEditing({
id: m.id,
titleAr: m.titleAr,
titleEn: m.titleEn ?? "",
meetingDate: m.meetingDate,
dailyNumber: String(m.dailyNumber),
startTime: m.startTime ? m.startTime.slice(0, 5) : "",
endTime: m.endTime ? m.endTime.slice(0, 5) : "",
location: m.location ?? "",
meetingUrl: m.meetingUrl ?? "",
platform: m.platform,
status: m.status,
isHighlighted: m.isHighlighted === 1,
notes: m.notes ?? "",
// Stamp every attendee with a stable client-side id used by the
// dnd-kit SortableContext in the manage dialog. Persisted attendees
// also have a server `id` we could reuse, but new rows added inside
// the dialog don't have one until after save, so we always assign
// `_sid` here for uniformity. The save projection enumerates wire
// fields and naturally drops `_sid`.
attendees: m.attendees.map((a) => ({ ...a, _sid: genAttendeeSid() })),
});
}
async function save(timeOverrides?: { startTime: string; endTime: string }) {
if (!editing) return;
if (!editing.titleAr.trim()) {
toast({
title: t("executiveMeetings.manage.errors.titleRequired"),
variant: "destructive",
});
return;
}
// #322: the English-title input was removed from the dialog. Fall
// back to the Arabic title when the form's English value is blank
// so the API's `titleEn` (min length 1) requirement is satisfied
// for new meetings, while preserving any pre-existing English
// title on edits (the dialog hides the field but openEdit still
// loads it into MeetingFormState).
const titleEn = editing.titleEn.trim() || editing.titleAr.trim();
// Prefer the dialog-supplied canonical values from the time
// picker's commit() result over the controlled `editing.*`
// state — the picker's onChange suppresses ambiguous/invalid
// drafts so `editing.startTime` / `editing.endTime` may still
// hold the previous saved value when the user typed but never
// resolved the AM/PM. Task #315: prevent silent fallback.
const startTime = timeOverrides
? timeOverrides.startTime || null
: editing.startTime || null;
const endTime = timeOverrides
? timeOverrides.endTime || null
: editing.endTime || null;
setSaving(true);
const body: Record<string, unknown> = {
titleAr: editing.titleAr.trim(),
titleEn,
meetingDate: editing.meetingDate,
startTime,
endTime,
location: editing.location.trim() || null,
meetingUrl: editing.meetingUrl.trim() || null,
platform: editing.platform,
status: editing.status,
isHighlighted: editing.isHighlighted ? 1 : 0,
notes: editing.notes.trim() || null,
attendees: editing.attendees.map((a, idx) => ({
name: a.name,
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);
try {
if (editing.id == null) {
await apiJson(`/api/executive-meetings`, { method: "POST", body });
} else {
await apiJson(`/api/executive-meetings/${editing.id}`, {
method: "PATCH",
body,
});
}
toast({ title: t("executiveMeetings.common.saved") });
setEditing(null);
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("executiveMeetings.manage.errors.saveFailed"),
description: translateSaveError(msg, t),
variant: "destructive",
});
} finally {
setSaving(false);
}
}
async function confirmDelete(id: number) {
if (
!(await confirm({
message: t("executiveMeetings.manage.deleteConfirm"),
destructive: true,
}))
)
return;
setDeletingId(id);
try {
await apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" });
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
} finally {
setDeletingId(null);
}
}
// #334: publish the live height of the Manage heading bar as a CSS
// var so the sticky bulk-action toolbar below can sit flush under
// it without overlap. Mirrors the Schedule tab's pattern at L2155
// for `--em-heading-h`. We use a distinct var name
// (`--em-manage-heading-h`) so the two tabs don't collide when the
// user switches between them. Cleared on unmount so the Schedule
// tab doesn't inherit a stale offset.
const headingRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const heading = headingRef.current;
if (!heading) return;
const root = heading.closest<HTMLElement>("[data-em-root]");
if (!root) return;
const update = () => {
root.style.setProperty(
"--em-manage-heading-h",
`${heading.offsetHeight}px`,
);
};
update();
const ro = new ResizeObserver(update);
ro.observe(heading);
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
root.style.setProperty("--em-manage-heading-h", "0px");
};
}, []);
if (!canMutate) {
return <NoPermission t={t} />;
}
return (
<div className="space-y-4">
{/* #321: sticky toolbar — date + Add Meeting stay pinned just
under the app's top header while the meeting list scrolls,
mirroring `em-schedule-heading-bar` on the Schedule tab. */}
<div
ref={headingRef}
data-testid="em-manage-heading-bar"
className="sticky z-30 bg-[#f4f6fb] -mx-3 sm:-mx-6 px-3 sm:px-6 py-2 shadow-[0_4px_6px_-4px_rgba(11,30,63,0.15)] flex items-center justify-between gap-3 flex-wrap"
style={{ top: "var(--em-header-h, 0px)" }}
>
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.manage.heading")}
</h2>
<div className="flex items-center gap-2 flex-wrap">
{/* #332: in-day search box for the Manage tab. Reuses the
same locale strings as the Schedule tab so wording stays
identical across tabs. */}
<div className="relative">
<Input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("executiveMeetings.schedule.searchPlaceholder")}
aria-label={t("executiveMeetings.schedule.searchAria")}
data-testid="em-manage-search"
className="h-8 w-44 sm:w-56 text-sm bg-white"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
aria-label={t("executiveMeetings.schedule.searchClear")}
title={t("executiveMeetings.schedule.searchClear")}
data-testid="em-manage-search-clear"
className={
"absolute top-1/2 -translate-y-1/2 text-muted-foreground hover:text-[#0B1E3F] " +
(isRtl ? "left-1.5" : "right-1.5")
}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
<input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
/>
</label>
<Button onClick={openCreate} className="bg-[#0B1E3F] gap-1" data-testid="em-add-meeting">
<Plus className="w-4 h-4" />
{t("executiveMeetings.manage.addMeeting")}
</Button>
</div>
</div>
{/* #332: floating bulk-action toolbar — appears only when ≥1
visible row is selected. Mirrors the Schedule tab's toolbar
(count + clear + duplicate-to-date + delete).
#334: sticky just under `em-manage-heading-bar` so the
actions stay reachable while the user scrolls through a
long day. Same horizontal bleed + `#f4f6fb` page-bg as the
heading bar so the two visually stack as one header unit;
`print:hidden` matches the Schedule counterpart. */}
{visibleSelectedCount > 0 && (
<div
className="sticky z-20 !mt-0 -mx-3 sm:-mx-6 px-3 sm:px-6 py-2 flex items-center gap-3 flex-wrap border-b border-[#0B1E3F]/15 bg-[#f4f6fb] shadow-[0_4px_6px_-4px_rgba(11,30,63,0.15)] print:hidden"
style={{
top: "calc(var(--em-header-h, 0px) + var(--em-manage-heading-h, 0px))",
}}
data-testid="em-manage-bulk-toolbar"
>
<span
className="text-sm text-[#0B1E3F]/80 font-medium"
data-testid="em-manage-bulk-selection-count"
>
{t("executiveMeetings.schedule.bulkSelectionCount")
.replace("{{n}}", String(visibleSelectedCount))
.replace("{{total}}", String(filteredMeetings.length))}
</span>
<div className="flex items-center gap-2 ms-auto">
<Button
size="sm"
variant="outline"
onClick={() => setSelectedMeetingIds(new Set())}
disabled={bulkDeleting || bulkDuplicating}
data-testid="em-manage-bulk-clear-selection"
>
{t("executiveMeetings.schedule.bulkClearSelection")}
</Button>
<Button
size="sm"
variant="outline"
onClick={openBulkDuplicate}
disabled={bulkDeleting || bulkDuplicating}
className="gap-1"
data-testid="em-manage-bulk-duplicate"
>
<Copy className="w-4 h-4" />
{t("executiveMeetings.schedule.bulkDuplicateSelected")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={deleteSelectedMeetings}
disabled={bulkDeleting || bulkDuplicating}
className="gap-1"
data-testid="em-manage-bulk-delete"
>
<Trash2 className="w-4 h-4" />
{t("executiveMeetings.schedule.bulkDeleteSelected")}
</Button>
</div>
</div>
)}
<div className="bg-white border border-gray-200 rounded-md overflow-hidden">
<table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
<thead className="bg-gray-50 text-[#0B1E3F]">
<tr>
<th className="px-3 py-2 text-start w-10">
{/* #332: tri-state "select all visible" header
checkbox. Operates on filteredMeetings so an
active search scopes the action. */}
<Checkbox
checked={
allSelectedState === "all"
? true
: allSelectedState === "some"
? "indeterminate"
: false
}
onCheckedChange={(v) =>
toggleSelectAllVisible(v === true)
}
disabled={filteredMeetings.length === 0}
aria-label={t(
"executiveMeetings.schedule.bulkClearSelection",
)}
data-testid="em-manage-select-all"
/>
</th>
<th className="px-3 py-2 text-start w-12">#</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.col.meeting")}</th>
<th className="px-3 py-2 text-center w-28">{t("executiveMeetings.col.time")}</th>
<th className="px-3 py-2 text-center w-32 border-s border-gray-200">{t("executiveMeetings.common.actions")}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={4} className="py-8 text-center text-gray-500">
{t("common.loading")}
</td>
{/* #337: keep the time/actions divider visible even in
loading/empty rows so the column boundary stays
consistent with the data rows. */}
<td className="border-s border-gray-200" aria-hidden="true" />
</tr>
)}
{!isLoading && filteredMeetings.length === 0 && (
<tr>
<td colSpan={4} className="py-8 text-center text-gray-500">
{t("executiveMeetings.manage.noMeetings")}
</td>
<td className="border-s border-gray-200" aria-hidden="true" />
</tr>
)}
{filteredMeetings.map((m) => (
<tr key={m.id} className="border-t border-gray-100">
<td className="px-3 py-2 align-middle">
<Checkbox
checked={selectedMeetingIds.has(m.id)}
onCheckedChange={(v) =>
toggleMeetingSelected(m.id, v === true)
}
aria-label={t(
"executiveMeetings.schedule.bulkClearSelection",
)}
data-testid={`em-manage-row-select-${m.id}`}
/>
</td>
<td className="px-3 py-2 align-middle">{m.dailyNumber}</td>
<td className="px-3 py-2 align-middle">
{/* #321: titles are stored as sanitized rich-text
HTML (Tiptap output). Render via safeHtml so the
user sees formatted text instead of literal
<p>/<span style="…"> tokens. */}
<div
className="font-medium text-[#0B1E3F]"
dangerouslySetInnerHTML={{
__html: safeHtml(
isRtl ? m.titleAr : m.titleEn || m.titleAr,
),
}}
/>
{/* #336: just the attendee count — the raw
`m.platform` token (e.g. "none") was removed
because it surfaced an English enum value next
to Arabic content with no value to the user.
The platform is still shown contextually inside
the meeting card via the "Virtual attendance —
{platformLabel}" group header in AttendeeFlow. */}
<div className="text-xs text-gray-500">
{
m.attendees.filter(
(a) => (a.kind ?? "person") === "person",
).length
}
</div>
</td>
<td className="px-3 py-2 align-middle font-mono text-xs whitespace-nowrap text-center" dir="ltr">
{formatTime(m.startTime, isRtl ? "ar" : "en")} {formatTime(m.endTime, isRtl ? "ar" : "en")}
</td>
<td className="px-3 py-2 align-middle text-center border-s border-gray-200">
<div className="inline-flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => openEdit(m)}
data-testid={`em-edit-${m.id}`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => openDuplicate(m)}
title={t("executiveMeetings.manage.duplicateToDate")}
data-testid={`em-duplicate-${m.id}`}
>
<Copy className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => confirmDelete(m.id)}
disabled={deletingId === m.id}
data-testid={`em-delete-${m.id}`}
>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{editing && (
<MeetingFormDialog
state={editing}
onChange={setEditing}
// Pass the committed (canonical) start/end through so the
// dialog's Enter/click-Save path validates the time pickers
// synchronously (per task #315: typing "1:15" without an
// AM/PM pick must surface as an error, NOT silently fall
// back to the previous saved value or AM). The values
// arriving here are already canonical "HH:mm" or "" — we
// hand them straight into the save body so a stale React
// state batch from the picker's onChange can't lose them.
onSave={(committedStart, committedEnd) =>
save({ startTime: committedStart, endTime: committedEnd })
}
onClose={() => setEditing(null)}
saving={saving}
t={t}
/>
)}
<Dialog
open={duplicating !== null}
onOpenChange={(o) => !o && setDuplicating(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("executiveMeetings.manage.duplicateToDate")}
</DialogTitle>
</DialogHeader>
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
<Input
type="date"
value={duplicating?.targetDate ?? ""}
onChange={(e) =>
setDuplicating(
duplicating
? { ...duplicating, targetDate: e.target.value }
: null,
)
}
data-testid="em-duplicate-date"
/>
</FormRow>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDuplicating(null)}
disabled={duplicatingBusy}
>
{t("executiveMeetings.common.cancel")}
</Button>
<Button
onClick={performDuplicate}
disabled={duplicatingBusy || !duplicating?.targetDate}
className="bg-[#0B1E3F]"
data-testid="em-duplicate-confirm"
>
{t("executiveMeetings.manage.duplicate")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* #332: bulk-duplicate-to-date dialog. Mirrors the Schedule
tab's dialog (~L2648) — single date picker, same POST per
id, aggregated toast. */}
<Dialog
open={bulkDuplicateOpen}
onOpenChange={(o) => {
if (bulkDuplicating) return;
setBulkDuplicateOpen(o);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("executiveMeetings.schedule.bulkDuplicateTitle").replace(
"{{n}}",
String(visibleSelectedCount),
)}
</DialogTitle>
</DialogHeader>
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
<Input
type="date"
value={bulkDuplicateDate}
onChange={(e) => setBulkDuplicateDate(e.target.value)}
disabled={bulkDuplicating}
data-testid="em-manage-bulk-duplicate-date"
/>
</FormRow>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setBulkDuplicateOpen(false)}
disabled={bulkDuplicating}
>
{t("executiveMeetings.common.cancel")}
</Button>
<Button
onClick={() => void performBulkDuplicate()}
disabled={bulkDuplicating || !bulkDuplicateDate}
className="bg-[#0B1E3F]"
data-testid="em-manage-bulk-duplicate-confirm"
>
{bulkDuplicating
? t("executiveMeetings.schedule.bulkDuplicating")
: t("executiveMeetings.schedule.bulkDuplicateConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// One row inside the manage-dialog attendee list. Uses dnd-kit's
// useSortable so the row can be picked up by its dedicated drag handle
// (the GripVertical button on the side). The visible up/down chevrons,
// inputs and delete button continue to fire their callbacks normally —
// the drag handle is the only element that initiates a drag.
function SortableAttendeeRow({
attendee,
index,
last,
t,
onMoveUp,
onMoveDown,
onChangeName,
onRemove,
}: {
attendee: Attendee;
index: number;
last: boolean;
t: (k: string) => string;
onMoveUp: () => void;
onMoveDown: () => void;
onChangeName: (v: string) => void;
onRemove: () => void;
}) {
const sid = attendee._sid ?? `__missing-sid-${index}`;
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: sid });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
// Subtle lift while the row is being dragged so the user sees the
// active item clearly above the others.
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.85 : undefined,
};
const isSub = (attendee.kind ?? "person") === "subheading";
return (
<div
ref={setNodeRef}
style={style}
className={
"flex items-center gap-2" +
(isSub ? " bg-blue-50/60 rounded px-2 py-1" : "")
}
data-testid={`em-attendee-row-${index}`}
data-attendee-kind={attendee.kind ?? "person"}
data-attendee-sid={sid}
>
{/* Drag handle — the ONLY element wired to dnd-kit listeners, so
the row's inputs and buttons remain interactive. */}
<button
type="button"
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-700 px-1 touch-none"
aria-label={t("executiveMeetings.manage.attendees.dragHandle")}
data-testid={`em-attendee-drag-${index}`}
{...attributes}
{...listeners}
>
<GripVertical className="w-4 h-4" />
</button>
<div className="flex flex-col">
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={onMoveUp}
disabled={index === 0}
aria-label={t("executiveMeetings.manage.attendees.moveUp")}
data-testid={`em-attendee-up-${index}`}
>
<ChevronUp className="w-3 h-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={onMoveDown}
disabled={last}
aria-label={t("executiveMeetings.manage.attendees.moveDown")}
data-testid={`em-attendee-down-${index}`}
>
<ChevronDown className="w-3 h-3" />
</Button>
</div>
{isSub && (
<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-${index}`}
>
{t("executiveMeetings.manage.attendees.subheadingBadge")}
</span>
)}
{/* #322: name is the only attendee field exposed in the dialog.
The parenthesized title and the internal/virtual/external
select were removed; existing rows keep their stored values
and new rows default to title=null + attendanceType="internal"
(see addAttendee). */}
<Input
className="flex-1"
placeholder={
isSub
? t("executiveMeetings.manage.attendees.subheadingName")
: t("executiveMeetings.manage.attendees.name")
}
value={attendee.name}
onChange={(e) => onChangeName(e.target.value)}
/>
<Button size="icon" variant="ghost" onClick={onRemove}>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
);
}
function MeetingFormDialog({
state,
onChange,
onSave,
onClose,
saving,
t,
}: {
state: MeetingFormState;
onChange: (s: MeetingFormState) => void;
// Receives the canonical "HH:mm" 24h start/end values that the
// time pickers commit() resolved. The dialog blocks Save when
// either picker reports `invalid` or `ambiguous`, so the parent
// never has to second-guess: if onSave fires, the times have
// been validated.
onSave: (committedStart: string, committedEnd: string) => void;
onClose: () => void;
saving: boolean;
t: (k: string) => string;
}) {
const { toast } = useToast();
const confirm = useConfirm();
// Imperative handles to the start/end pickers so the Save click
// can read the live draft (typed text + AM/PM toggle) and surface
// ambiguous/invalid input as a toast rather than silently saving
// a stale value. Same belt-and-braces pattern the inline schedule
// editor uses (TimeRangeCell).
const startPickerRef = useRef<TimePicker12hHandle | null>(null);
const endPickerRef = useRef<TimePicker12hHandle | null>(null);
function handleSaveClick() {
const startResult = startPickerRef.current?.commit() ?? {
value: null,
error: null as null,
};
const endResult = endPickerRef.current?.commit() ?? {
value: null,
error: null as null,
};
if (startResult.error === "invalid") {
toast({
title: t("executiveMeetings.schedule.timeParseError"),
variant: "destructive",
});
startPickerRef.current?.focus();
startPickerRef.current?.select();
return;
}
if (startResult.error === "ambiguous") {
toast({
title: t("executiveMeetings.schedule.timeAmbiguousError"),
variant: "destructive",
});
startPickerRef.current?.focus();
return;
}
if (endResult.error === "invalid") {
toast({
title: t("executiveMeetings.schedule.timeParseError"),
variant: "destructive",
});
endPickerRef.current?.focus();
endPickerRef.current?.select();
return;
}
if (endResult.error === "ambiguous") {
toast({
title: t("executiveMeetings.schedule.timeAmbiguousError"),
variant: "destructive",
});
endPickerRef.current?.focus();
return;
}
onSave(startResult.value ?? "", endResult.value ?? "");
}
function update<K extends keyof MeetingFormState>(k: K, v: MeetingFormState[K]) {
onChange({ ...state, [k]: v });
}
function addAttendee() {
onChange({
...state,
attendees: [
...state.attendees,
{
name: "",
title: null,
attendanceType: "internal",
sortOrder: state.attendees.length,
kind: "person",
_sid: genAttendeeSid(),
},
],
});
}
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",
_sid: genAttendeeSid(),
},
],
});
}
function removeAttendee(i: number) {
const arr = state.attendees.slice();
arr.splice(i, 1);
onChange({ ...state, attendees: arr });
}
// Bulk-clear: empties the in-memory attendee list. Persists only when
// the user clicks the dialog's Save button (just like the per-row
// remove). Confirmation surfaces the count so users don't nuke a long
// list by accident.
async function removeAllAttendees() {
const count = state.attendees.length;
if (count === 0) return;
const message = t(
"executiveMeetings.manage.attendees.removeAllConfirm",
).replace("{{count}}", String(count));
if (!(await confirm({ message, destructive: true }))) return;
onChange({ ...state, attendees: [] });
}
function setAttendee(i: number, patch: Partial<Attendee>) {
const arr = state.attendees.slice();
arr[i] = { ...arr[i], ...patch } as Attendee;
onChange({ ...state, attendees: arr });
}
function moveAttendee(i: number, dir: -1 | 1) {
const j = i + dir;
if (j < 0 || j >= state.attendees.length) return;
const arr = state.attendees.slice();
[arr[i], arr[j]] = [arr[j], arr[i]];
onChange({ ...state, attendees: arr });
}
// Drag-and-drop reorder driven by dnd-kit. Items are keyed by their
// client `_sid` so the SortableContext stays stable across renames,
// attendance-type changes, etc. We resolve the over/active sids to
// their current array positions inside the handler so consecutive
// drags compose correctly.
function reorderAttendeesByDrag(activeSid: string, overSid: string) {
if (activeSid === overSid) return;
const arr = state.attendees;
const from = arr.findIndex((a) => a._sid === activeSid);
const to = arr.findIndex((a) => a._sid === overSid);
if (from < 0 || to < 0) return;
const next = arr.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
onChange({ ...state, attendees: next });
}
// Pointer + keyboard sensors with a small activation distance so that
// clicking the inputs / buttons inside a row doesn't start a drag.
const dragSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Memoize the id list so SortableContext only re-resolves when the
// attendee order actually changes.
const sortableIds = useMemo(
() =>
state.attendees.map(
(a, i) => a._sid ?? `__missing-sid-${i}`,
),
[state.attendees],
);
return (
<Dialog open={true} onOpenChange={(o) => !o && onClose()}>
{/* #324: rebuild DialogContent as a flex column so the header,
a single scrollable middle, and the footer stack predictably
on mobile. Without this the whole sheet scrolled and the
Save/Cancel buttons slid below the visible 90vh viewport on
phones. Reduced mobile padding/gaps; desktop unchanged. */}
<DialogContent className="max-w-3xl max-h-[90vh] p-4 sm:p-6 flex flex-col gap-3 sm:gap-4 overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle>
{state.id == null
? t("executiveMeetings.manage.addMeeting")
: t("executiveMeetings.manage.editMeeting")}
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-3 sm:space-y-4">
{/* #322 polish (grid): title spans full width. Date + daily
number always share one row (both are short). Time pickers
are full-width on mobile so the AM/PM controls don't get
cramped, but share a row on tablet/desktop. */}
<div className="grid grid-cols-2 gap-2 sm:gap-3">
<FormRow
label={t("executiveMeetings.manage.field.titleAr")}
full
>
<Input
value={state.titleAr}
onChange={(e) => update("titleAr", e.target.value)}
data-testid="em-form-titleAr"
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
<Input
type="date"
value={state.meetingDate}
onChange={(e) => update("meetingDate", e.target.value)}
/>
</FormRow>
<FormRow
label={t("executiveMeetings.manage.field.dailyNumber")}
hint={t("executiveMeetings.manage.field.dailyNumberHint")}
>
<Input
type="number"
min={1}
value={state.dailyNumber}
onChange={(e) => update("dailyNumber", e.target.value)}
/>
</FormRow>
<FormRow
label={t("executiveMeetings.manage.field.startTime")}
mobileFull
>
<TimePicker12h
ref={startPickerRef}
value={state.startTime}
onChange={(v) => update("startTime", v)}
ariaLabel={t("executiveMeetings.manage.field.startTime")}
groupLabel={t("executiveMeetings.timeEditor.periodGroupStart")}
amLabel={t("executiveMeetings.timeEditor.am")}
pmLabel={t("executiveMeetings.timeEditor.pm")}
size="form"
inputTestId="em-form-startTime"
minuteTestId="em-form-startTime-minute"
amTestId="em-form-startTime-am"
pmTestId="em-form-startTime-pm"
/>
</FormRow>
<FormRow
label={t("executiveMeetings.manage.field.endTime")}
mobileFull
>
<TimePicker12h
ref={endPickerRef}
value={state.endTime}
onChange={(v) => update("endTime", v)}
ariaLabel={t("executiveMeetings.manage.field.endTime")}
groupLabel={t("executiveMeetings.timeEditor.periodGroupEnd")}
amLabel={t("executiveMeetings.timeEditor.am")}
pmLabel={t("executiveMeetings.timeEditor.pm")}
size="form"
inputTestId="em-form-endTime"
minuteTestId="em-form-endTime-minute"
amTestId="em-form-endTime-am"
pmTestId="em-form-endTime-pm"
/>
</FormRow>
{/* #322: location, meetingUrl, platform, status,
isHighlighted, and notes inputs were removed from the
dialog. Their values still round-trip through
MeetingFormState and the save body so existing data is
preserved for meetings being edited. */}
</div>
<div className="border-t border-gray-200 pt-3">
<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")} (
{
// 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 && (
<Button
size="sm"
variant="outline"
onClick={removeAllAttendees}
className="gap-1 border-red-300 text-red-700 hover:bg-red-50 hover:text-red-800"
data-testid="em-attendee-remove-all"
>
<Trash2 className="w-4 h-4" />
{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"
onClick={addAttendee}
className="gap-1"
data-testid="em-attendee-add"
>
<Plus className="w-4 h-4" />
{t("executiveMeetings.manage.attendees.add")}
</Button>
</div>
</div>
<div className="space-y-2">
{/* Drag-and-drop reordering wraps the whole list. The
up/down arrow buttons inside each row continue to work
in parallel — DnD is an additive convenience, not a
replacement, so keyboard-only flows keep functioning
even if the SortableContext fails to mount. */}
<DndContext
sensors={dragSensors}
collisionDetection={closestCenter}
onDragEnd={(e: DragEndEvent) => {
const { active, over } = e;
if (!over) return;
reorderAttendeesByDrag(
String(active.id),
String(over.id),
);
}}
>
<SortableContext
items={sortableIds}
strategy={verticalListSortingStrategy}
>
{state.attendees.map((a, i) => (
<SortableAttendeeRow
key={a._sid ?? `__missing-sid-${i}`}
attendee={a}
index={i}
last={i === state.attendees.length - 1}
t={t}
onMoveUp={() => moveAttendee(i, -1)}
onMoveDown={() => moveAttendee(i, 1)}
onChangeName={(v) => setAttendee(i, { name: v })}
onRemove={() => removeAttendee(i)}
/>
))}
</SortableContext>
</DndContext>
{state.attendees.length === 0 && (
<div className="text-xs text-gray-500"></div>
)}
</div>
</div>
</div>
{/* #324: footer sits outside the scroll area so it stays
pinned to the bottom of the dialog even after many
attendees are added. */}
<DialogFooter className="shrink-0">
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("executiveMeetings.common.cancel")}
</Button>
<Button
onClick={handleSaveClick}
disabled={saving}
className="bg-[#0B1E3F]"
data-testid="em-form-save"
>
{saving ? t("common.loading") : t("executiveMeetings.common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function FormRow({
label,
children,
full,
mobileFull,
hint,
}: {
label: string;
children: React.ReactNode;
/** Always span both columns of a 2-col grid. Safe in single-column
* grids too (col-span-2 just clamps to the available width). */
full?: boolean;
/** Span both columns on mobile but a single column from sm+. Used
* by #324 so the time pickers don't get squeezed on phones. */
mobileFull?: boolean;
hint?: string;
}) {
const span = full
? "col-span-2"
: mobileFull
? "col-span-2 sm:col-span-1"
: "";
return (
<div className={span}>
<Label className="text-xs text-gray-700">{label}</Label>
<div className="mt-1">{children}</div>
{hint && <p className="text-[11px] text-gray-500 mt-0.5">{hint}</p>}
</div>
);
}
// ============ AUDIT ============
function AuditDiffSummary({
oldValue,
newValue,
t,
}: {
oldValue: unknown;
newValue: unknown;
t: (k: string) => string;
}) {
const interesting = [
"titleAr",
"titleEn",
"meetingDate",
"dailyNumber",
"startTime",
"endTime",
"location",
"meetingUrl",
"platform",
"status",
"isHighlighted",
"notes",
"assignedTo",
"taskType",
"dueAt",
"reviewDecision",
"reviewNotes",
] as const;
const oldObj =
oldValue && typeof oldValue === "object"
? (oldValue as Record<string, unknown>)
: null;
const newObj =
newValue && typeof newValue === "object"
? (newValue as Record<string, unknown>)
: null;
const fmt = (v: unknown): string => {
if (v === null || v === undefined || v === "") return "—";
if (typeof v === "string") return v.length > 40 ? `${v.slice(0, 40)}` : v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
try {
const s = JSON.stringify(v);
return s.length > 40 ? `${s.slice(0, 40)}` : s;
} catch {
return String(v);
}
};
if (!oldObj && !newObj) return <span className="text-gray-400"></span>;
if (oldObj && newObj) {
const diffs: { key: string; from: unknown; to: unknown }[] = [];
for (const k of interesting) {
const a = oldObj[k];
const b = newObj[k];
if (JSON.stringify(a) !== JSON.stringify(b)) {
diffs.push({ key: k, from: a, to: b });
}
}
if (diffs.length === 0) {
return <span className="text-gray-400"></span>;
}
return (
<ul className="space-y-0.5">
{diffs.slice(0, 6).map((d) => (
<li key={d.key} className="leading-snug">
<span className="font-semibold text-[#0B1E3F]">{d.key}</span>:{" "}
<span className="text-red-700 line-through">{fmt(d.from)}</span>
<span className="mx-1 text-gray-400"></span>
<span className="text-green-700">{fmt(d.to)}</span>
</li>
))}
{diffs.length > 6 && (
<li className="text-gray-500">
+{diffs.length - 6} {t("executiveMeetings.audit.moreChanges")}
</li>
)}
</ul>
);
}
const single = (newObj ?? oldObj) as Record<string, unknown>;
const label = newObj
? t("executiveMeetings.audit.created")
: t("executiveMeetings.audit.removed");
const items = interesting
.filter((k) => single[k] !== undefined && single[k] !== null && single[k] !== "")
.slice(0, 6);
return (
<div>
<div className="font-semibold text-[#0B1E3F] mb-0.5">{label}</div>
<ul className="space-y-0.5">
{items.map((k) => (
<li key={k} className="leading-snug">
<span className="font-semibold">{k}</span>: {fmt(single[k])}
</li>
))}
</ul>
</div>
);
}
function AuditSection({
canView,
isRtl,
t,
}: {
canView: boolean;
isRtl: boolean;
t: (k: string) => string;
}) {
const [date, setDate] = useState("");
const [dateTo, setDateTo] = useState("");
const [entityType, setEntityType] = useState("all");
const [actionFilter, setActionFilter] = useState("all");
const [actorFilter, setActorFilter] = useState("");
const [page, setPage] = useState(0);
const PAGE_SIZE = 50;
useEffect(() => {
setPage(0);
}, [date, dateTo, entityType, actionFilter, actorFilter]);
const { data, isLoading } = useQuery<{
entries: AuditEntry[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
}>({
queryKey: [
"/api/executive-meetings/audit-logs",
date,
dateTo,
entityType,
actionFilter,
actorFilter,
page,
],
queryFn: () => {
const qs = new URLSearchParams();
if (date) qs.set("dateFrom", date);
if (dateTo) qs.set("dateTo", dateTo);
if (entityType !== "all") qs.set("entityType", entityType);
if (actionFilter !== "all") qs.set("action", actionFilter);
if (actorFilter.trim()) qs.set("actorId", actorFilter.trim());
qs.set("limit", String(PAGE_SIZE));
qs.set("offset", String(page * PAGE_SIZE));
return apiJson(`/api/executive-meetings/audit-logs?${qs}`);
},
enabled: canView,
});
if (!canView) return <NoPermission t={t} />;
const entries = data?.entries ?? [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.audit.heading")}
</h2>
<div className="flex items-center gap-2 flex-wrap">
<label className="text-xs text-gray-500 flex items-center gap-1">
{t("executiveMeetings.audit.filter.from")}
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
data-testid="em-audit-date-from"
/>
</label>
<label className="text-xs text-gray-500 flex items-center gap-1">
{t("executiveMeetings.audit.filter.to")}
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
data-testid="em-audit-date-to"
/>
</label>
<Input
type="number"
placeholder={t("executiveMeetings.audit.filter.actorId")}
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
className="w-32 h-9"
data-testid="em-audit-actor"
/>
<Select value={actionFilter} onValueChange={setActionFilter}>
<SelectTrigger className="w-36 h-9" data-testid="em-audit-action">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("executiveMeetings.audit.all")}</SelectItem>
{(["create", "update", "delete", "approve", "reject"] as const).map(
(a) => (
<SelectItem key={a} value={a}>
{tWithFallback(t, `executiveMeetings.audit.action.${a}`, a)}
</SelectItem>
),
)}
</SelectContent>
</Select>
<Select value={entityType} onValueChange={setEntityType}>
<SelectTrigger className="w-36 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("executiveMeetings.audit.all")}</SelectItem>
{(["meeting", "attendee", "request", "task", "font_settings"] as const).map(
(e) => (
<SelectItem key={e} value={e}>
{t(`executiveMeetings.audit.entity.${e}`)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-md overflow-hidden">
<table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
<thead className="bg-gray-50 text-[#0B1E3F]">
<tr>
<th className="px-3 py-2 text-start w-44">{t("executiveMeetings.audit.col.when")}</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.audit.col.actor")}</th>
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.action")}</th>
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.entity")}</th>
<th className="px-3 py-2 text-start w-20">{t("executiveMeetings.audit.col.entityId")}</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.audit.col.changes")}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && entries.length === 0 && (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
{t("executiveMeetings.audit.empty")}
</td>
</tr>
)}
{entries.map((e) => {
const actor = e.actor
? isRtl
? e.actor.displayNameAr || e.actor.username
: e.actor.displayNameEn || e.actor.username
: "—";
return (
<tr
key={e.id}
className="border-t border-gray-100 align-top"
data-testid={`em-audit-row-${e.id}`}
>
<td className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap">
{new Date(e.performedAt).toLocaleString()}
</td>
<td className="px-3 py-2">{actor}</td>
<td className="px-3 py-2 text-xs">
{tWithFallback(
t,
`executiveMeetings.audit.action.${e.action}`,
e.action,
)}
</td>
<td className="px-3 py-2 text-xs">
{tWithFallback(
t,
`executiveMeetings.audit.entity.${e.entityType}`,
e.entityType,
)}
</td>
<td className="px-3 py-2 text-xs">{e.entityId ?? "—"}</td>
<td className="px-3 py-2 text-xs">
<AuditDiffSummary
oldValue={e.oldValue}
newValue={e.newValue}
t={t}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-xs text-gray-600">
<span data-testid="em-audit-count">
{t("executiveMeetings.audit.pageInfo")}{" "}
{data
? `${(data.offset ?? 0) + (entries.length > 0 ? 1 : 0)}${
(data.offset ?? 0) + entries.length
} / ${data.total ?? 0}`
: "—"}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
data-testid="em-audit-prev"
>
{t("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={!data?.hasMore}
onClick={() => setPage((p) => p + 1)}
data-testid="em-audit-next"
>
{t("common.next")}
</Button>
</div>
</div>
</div>
);
}
// ============ NOTIFICATIONS ============
function NotificationsSection({
canView,
date,
isRtl,
t,
}: {
canView: boolean;
date: string;
isRtl: boolean;
t: (k: string) => string;
}) {
const { data, isLoading } = useQuery<{ notifications: NotificationRow[] }>({
queryKey: ["/api/executive-meetings/notifications", date],
queryFn: () =>
apiJson(
`/api/executive-meetings/notifications${
date ? `?date=${encodeURIComponent(date)}` : ""
}`,
),
enabled: canView,
});
if (!canView) return <NoPermission t={t} />;
const rows = data?.notifications ?? [];
return (
<div className="space-y-4">
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.notificationsPage.heading")}
</h2>
<p className="text-sm text-gray-600 max-w-2xl">
{t("executiveMeetings.notificationsPage.intro")}
</p>
<NotificationPrefsCard isRtl={isRtl} t={t} />
<div className="bg-white border border-gray-200 rounded-md overflow-hidden">
<table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
<thead className="bg-gray-50 text-[#0B1E3F]">
<tr>
<th className="px-3 py-2 text-start w-24">{t("executiveMeetings.notificationsPage.col.meeting")}</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.notificationsPage.col.user")}</th>
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.notificationsPage.col.type")}</th>
<th className="px-3 py-2 text-start w-44">{t("executiveMeetings.notificationsPage.col.scheduledAt")}</th>
<th className="px-3 py-2 text-start w-24">{t("executiveMeetings.notificationsPage.col.status")}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && rows.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
{t("executiveMeetings.notificationsPage.empty")}
</td>
</tr>
)}
{rows.map((n) => {
const user = n.user
? isRtl
? n.user.displayNameAr || n.user.username
: n.user.displayNameEn || n.user.username
: "—";
return (
<tr key={n.id} className="border-t border-gray-100">
<td className="px-3 py-2">{n.meetingId ? `#${n.meetingId}` : "—"}</td>
<td className="px-3 py-2">{user}</td>
<td className="px-3 py-2 text-xs">
{tWithFallback(
t,
`executiveMeetings.notificationsPage.type.${n.notificationType}`,
n.notificationType,
)}
</td>
<td className="px-3 py-2 text-xs">
{n.scheduledAt ? new Date(n.scheduledAt).toLocaleString() : "—"}
</td>
<td className="px-3 py-2 text-xs">
{tWithFallback(
t,
`executiveMeetings.notificationsPage.status.${n.status}`,
n.status,
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
type NotificationPref = {
notificationType: string;
inApp: boolean;
email: boolean;
};
type NotificationPrefsResponse = {
types: string[];
prefs: NotificationPref[];
};
/**
* Per-user notification preferences card. Toggles in-app vs email
* delivery for each event type the Executive Meetings module fans out.
*
* Rendering rules:
* - One row per event type returned by the server (server is the
* source of truth for "which event types exist").
* - Both channels default ON; an explicit off toggle persists a
* mute-row server-side so the fan-out helpers skip the user.
* - Saves are batched: we accumulate edits in local state and flush
* them together via "Save preferences".
*/
function NotificationPrefsCard({
isRtl,
t,
}: {
isRtl: boolean;
t: (k: string) => string;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const { data, isLoading } = useQuery<NotificationPrefsResponse>({
queryKey: ["/api/executive-meetings/notification-prefs"],
queryFn: () =>
apiJson<NotificationPrefsResponse>(
"/api/executive-meetings/notification-prefs",
),
});
const [draft, setDraft] = useState<Map<string, NotificationPref> | null>(
null,
);
// When the server payload arrives (or refetches), seed local edit state.
// We only seed when local state is null so that an in-flight edit isn't
// clobbered by a background refetch.
useEffect(() => {
if (!data || draft) return;
const m = new Map<string, NotificationPref>();
for (const p of data.prefs) m.set(p.notificationType, { ...p });
setDraft(m);
}, [data, draft]);
const dirty = useMemo(() => {
if (!data || !draft) return false;
for (const p of data.prefs) {
const cur = draft.get(p.notificationType);
if (!cur) continue;
if (cur.inApp !== p.inApp || cur.email !== p.email) return true;
}
return false;
}, [data, draft]);
function update(type: string, channel: "inApp" | "email", v: boolean) {
setDraft((prev) => {
if (!prev) return prev;
const next = new Map(prev);
const cur = next.get(type);
if (!cur) return prev;
next.set(type, { ...cur, [channel]: v });
return next;
});
}
async function save() {
if (!draft) return;
const prefs = Array.from(draft.values());
try {
await apiJson("/api/executive-meetings/notification-prefs", {
method: "PUT",
body: { prefs },
});
await qc.invalidateQueries({
queryKey: ["/api/executive-meetings/notification-prefs"],
});
// Clear local state so the next render reseeds from the freshly
// refetched payload — keeps client and server in lockstep.
setDraft(null);
toast({ title: t("executiveMeetings.notificationsPage.prefs.saved") });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
}
}
function reset() {
if (!data) return;
const m = new Map<string, NotificationPref>();
for (const p of data.prefs) m.set(p.notificationType, { ...p });
setDraft(m);
}
// #236: wipe every server-side pref row for the current user. The GET
// handler then synthesizes the defaults (in_app=true, email=true) for
// every event type, so a single round-trip restores the factory state
// without the client having to PUT a full payload of `true` toggles.
async function restoreDefaults() {
try {
await apiJson("/api/executive-meetings/notification-prefs", {
method: "DELETE",
});
// #236: invalidate FIRST so the refetch lands before we drop the
// local draft. If we cleared draft first, the reseed effect at
// line ~6284 would fire against the still-cached pre-delete
// payload, repopulating draft from stale data; the later refetch
// would then no-op because draft is non-null again.
await qc.invalidateQueries({
queryKey: ["/api/executive-meetings/notification-prefs"],
});
setDraft(null);
toast({ title: t("executiveMeetings.notificationsPage.prefs.restored") });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
}
}
const types = data?.types ?? [];
return (
<div
className="bg-white border border-gray-200 rounded-md p-4"
dir={isRtl ? "rtl" : "ltr"}
data-testid="em-notification-prefs"
>
<div className="flex items-center justify-between gap-3 mb-1">
<h3 className="text-sm sm:text-base font-semibold text-[#0B1E3F]">
{t("executiveMeetings.notificationsPage.prefs.heading")}
</h3>
</div>
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
{t("executiveMeetings.notificationsPage.prefs.intro")}
</p>
{isLoading || !draft ? (
<div className="py-4 text-sm text-gray-500">{t("common.loading")}</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-gray-500">
<tr>
<th className="px-2 py-1 text-start font-medium">
{t("executiveMeetings.notificationsPage.prefs.col.event")}
</th>
<th className="px-2 py-1 text-center font-medium w-24">
{t("executiveMeetings.notificationsPage.prefs.col.inApp")}
</th>
<th className="px-2 py-1 text-center font-medium w-24">
{t("executiveMeetings.notificationsPage.prefs.col.email")}
</th>
</tr>
</thead>
<tbody>
{types.map((type) => {
const cur = draft.get(type);
if (!cur) return null;
return (
<tr key={type} className="border-t border-gray-100">
<td className="px-2 py-2 text-[#0B1E3F]">
{tWithFallback(
t,
`executiveMeetings.notificationsPage.type.${type}`,
type,
)}
</td>
<td className="px-2 py-2 text-center">
<Switch
checked={cur.inApp}
onCheckedChange={(v) => update(type, "inApp", !!v)}
aria-label={t(
"executiveMeetings.notificationsPage.prefs.col.inApp",
)}
data-testid={`em-pref-inapp-${type}`}
/>
</td>
<td className="px-2 py-2 text-center">
<Switch
checked={cur.email}
onCheckedChange={(v) => update(type, "email", !!v)}
aria-label={t(
"executiveMeetings.notificationsPage.prefs.col.email",
)}
data-testid={`em-pref-email-${type}`}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex items-center gap-2 mt-3">
<Button
size="sm"
onClick={save}
disabled={!dirty}
className="bg-[#0B1E3F]"
data-testid="em-pref-save"
>
{t("executiveMeetings.notificationsPage.prefs.save")}
</Button>
<Button
size="sm"
variant="outline"
onClick={reset}
disabled={!dirty}
data-testid="em-pref-reset"
>
{t("executiveMeetings.notificationsPage.prefs.reset")}
</Button>
{/* #236: restore-defaults wipes server rows even when the
local draft is clean, so it must NOT be gated on `dirty`. */}
<Button
size="sm"
variant="outline"
onClick={restoreDefaults}
data-testid="em-pref-restore-defaults"
>
{t("executiveMeetings.notificationsPage.prefs.restoreDefaults")}
</Button>
</div>
</>
)}
</div>
);
}
// ============ PDF / PRINT ============
function PdfSection({
date,
onDateChange,
lang,
t,
}: {
date: string;
onDateChange: (d: string) => void;
lang: "ar" | "en";
t: (k: string) => string;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const [busy, setBusy] = useState(false);
const archivesQuery = useQuery<{ archives: PdfArchive[] }>({
queryKey: ["/api/executive-meetings/pdf-archives", date],
queryFn: async () =>
apiJson(`/api/executive-meetings/pdf-archives?date=${date}`),
enabled: !!date,
});
// Stream a binary response from the API to the browser's download
// stack. Throws with the server's error message on a non-2xx so the
// toast surfaces a useful reason instead of a silent failure.
async function downloadBinary(url: string, filename: string): Promise<void> {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const j = (await res.json()) as { error?: string };
if (j.error) message = j.error;
} catch {
/* response wasn't JSON — keep the HTTP status */
}
throw new Error(message);
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
} finally {
// Free the blob URL after the click — Safari needs a tick before
// it actually starts the download, so defer slightly.
setTimeout(() => URL.revokeObjectURL(objectUrl), 4000);
}
}
async function downloadPdf() {
if (!date) return;
setBusy(true);
try {
const url = `/api/executive-meetings/pdf?date=${encodeURIComponent(
date,
)}&lang=${encodeURIComponent(lang)}`;
await downloadBinary(url, `executive-meetings-${date}.pdf`);
// Refresh archives so the row created by the GET appears.
await qc.invalidateQueries({
queryKey: ["/api/executive-meetings/pdf-archives", date],
});
toast({ title: t("executiveMeetings.pdf.downloaded") });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
} finally {
setBusy(false);
}
}
async function downloadArchive(archive: PdfArchive) {
if (!archive.filePath?.startsWith("/objects/")) return;
try {
// /objects/<id> resolves to GET /api/storage/objects/<id> on the
// server. The route is auth-gated behind the same session, so
// credentials must be included.
const url = `/api${archive.filePath.replace(/^\/objects/, "/storage/objects")}`;
await downloadBinary(
url,
`executive-meetings-${archive.archiveDate}-v${archive.version}.pdf`,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
}
}
const archives = archivesQuery.data?.archives ?? [];
return (
<div className="space-y-4 max-w-2xl">
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.pdf.heading")}
</h2>
<p className="text-sm text-gray-600">{t("executiveMeetings.pdf.intro")}</p>
<div className="bg-white border border-gray-200 rounded-md p-4 space-y-3">
<FormRow label={t("executiveMeetings.pdf.selectDate")}>
<Input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
/>
</FormRow>
<div className="flex flex-wrap gap-2">
<Button
onClick={downloadPdf}
disabled={busy || !date}
className="bg-[#0B1E3F] gap-1"
data-testid="em-download-pdf"
>
<FileText className="w-4 h-4" />
{t("executiveMeetings.pdf.downloadPdf")}
</Button>
</div>
<p className="text-xs text-gray-500">
{t("executiveMeetings.pdf.downloadHint")}
</p>
</div>
<div className="bg-white border border-gray-200 rounded-md">
<div className="px-4 py-2 border-b border-gray-200 text-sm font-semibold text-[#0B1E3F]">
{t("executiveMeetings.pdf.archivesHeading")}
</div>
{archivesQuery.isLoading ? (
<div className="p-4 text-sm text-gray-500">{t("common.loading")}</div>
) : archives.length === 0 ? (
<div className="p-4 text-sm text-gray-500">
{t("executiveMeetings.pdf.noArchives")}
</div>
) : (
<ul className="divide-y divide-gray-100" data-testid="em-archives-list">
{archives.map((a) => {
const who =
a.generator
? lang === "ar"
? a.generator.displayNameAr || a.generator.username
: a.generator.displayNameEn || a.generator.username
: "—";
return (
<li
key={a.id}
className="px-4 py-2 text-sm flex items-center justify-between gap-3"
>
<div>
<div className="font-medium text-[#0B1E3F]">
v{a.version} {a.archiveDate}
</div>
<div className="text-xs text-gray-500">
{new Date(a.generatedAt).toLocaleString(
lang === "ar" ? "ar" : "en",
)}{" "}
· {who}
</div>
</div>
<div className="flex items-center gap-2">
{a.filePath?.startsWith("/objects/") ? (
<Button
size="sm"
variant="outline"
onClick={() => downloadArchive(a)}
data-testid={`em-archive-download-${a.id}`}
>
{t("executiveMeetings.pdf.downloadArchive")}
</Button>
) : (
<span
className="text-xs text-gray-400"
data-testid={`em-archive-legacy-${a.id}`}
>
{t("executiveMeetings.pdf.legacyArchive")}
</span>
)}
{typeof a.byteSize === "number" ? (
<span className="text-xs text-gray-400">
{formatBytesForDisplay(a.byteSize)}
</span>
) : null}
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
// ============ SETTINGS ============
// #265: Unified settings tab — wraps font preferences plus the column /
// highlight customizer that used to live in a popover above the schedule.
function SettingsSection({
font,
canEditGlobalFont,
columns,
onColumnsChange,
highlightPrefs,
onHighlightPrefsChange,
t,
}: {
font: FontPrefs;
canEditGlobalFont: boolean;
columns: ColumnSetting[];
onColumnsChange: (next: ColumnSetting[]) => void;
highlightPrefs: HighlightPrefs;
onHighlightPrefsChange: (next: HighlightPrefs) => void;
t: (k: string) => string;
}) {
return (
<div className="space-y-6">
<section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
<h2 className="text-base font-semibold text-[#0B1E3F] mb-3 flex items-center gap-2">
<Sliders className="w-4 h-4" />
{t("executiveMeetings.customize.title")}
</h2>
<ColumnsCustomizerPanel
columns={columns}
onChange={onColumnsChange}
t={t}
highlightPrefs={highlightPrefs}
onHighlightPrefsChange={onHighlightPrefsChange}
/>
</section>
<section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
<AlertPrefsCard t={t} />
</section>
<section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
<FontSettingsSection
font={font}
canEditGlobal={canEditGlobalFont}
t={t}
/>
</section>
</div>
);
}
// ============ ALERT POPUP PREFS ============
//
// Lets each user toggle the floating "upcoming meeting" alert on/off
// and pick a color preset for it. Prefs live entirely client-side
// (localStorage) and the floating alert in App.tsx subscribes via the
// shared `useAlertPrefs` hook so changes apply instantly without a
// page reload. See `lib/upcoming-alert-prefs.ts` for the contract.
function AlertPrefsCard({ t }: { t: (k: string) => string }) {
const [prefs, setPrefs] = useAlertPrefs();
const isPresetActive = (
p: (typeof ALERT_COLOR_PRESETS)[number],
): boolean =>
p.bg.toLowerCase() === prefs.bg.toLowerCase() &&
p.accent.toLowerCase() === prefs.accent.toLowerCase() &&
p.fg.toLowerCase() === prefs.fg.toLowerCase();
const applyPreset = (p: (typeof ALERT_COLOR_PRESETS)[number]) => {
const next: AlertPrefs = {
enabled: prefs.enabled,
bg: p.bg,
accent: p.accent,
fg: p.fg,
};
setPrefs(next);
};
const reset = () => setPrefs({ ...DEFAULT_ALERT_PREFS });
return (
<div className="space-y-4 max-w-2xl" data-testid="em-alert-prefs">
<div>
<h2 className="text-base font-semibold text-[#0B1E3F] flex items-center gap-2">
<Bell className="w-4 h-4" />
{t("executiveMeetings.alertPrefs.title")}
</h2>
<p className="text-xs text-gray-500 mt-1 leading-relaxed">
{t("executiveMeetings.alertPrefs.hint")}
</p>
</div>
<div className="flex items-center justify-between gap-3 border-t border-gray-200 pt-3">
<label
htmlFor="em-alert-enabled-toggle"
className="text-sm font-medium text-[#0B1E3F] cursor-pointer"
>
{t("executiveMeetings.alertPrefs.enabledLabel")}
</label>
<Switch
id="em-alert-enabled-toggle"
checked={prefs.enabled}
onCheckedChange={(v) => setPrefs({ ...prefs, enabled: !!v })}
data-testid="em-alert-enabled-toggle"
/>
</div>
<div className="border-t border-gray-200 pt-3">
<div className="text-sm font-medium text-[#0B1E3F] mb-2">
{t("executiveMeetings.alertPrefs.colorTitle")}
</div>
<div className="flex items-center gap-2 flex-wrap">
{ALERT_COLOR_PRESETS.map((p) => {
const active = isPresetActive(p);
return (
<button
key={p.key}
type="button"
onClick={() => applyPreset(p)}
disabled={!prefs.enabled}
className={
"relative h-8 w-14 rounded-md border-2 transition-transform " +
(active
? "border-[#0B1E3F] scale-105"
: "border-gray-300 hover:scale-105") +
" disabled:opacity-50 disabled:cursor-not-allowed"
}
style={{ backgroundColor: p.bg }}
aria-label={t(`executiveMeetings.alertPrefs.preset.${p.key}`)}
aria-pressed={active}
data-testid={`em-alert-preset-${p.key}`}
title={t(`executiveMeetings.alertPrefs.preset.${p.key}`)}
>
<span
className="absolute inset-y-1 right-1 w-3 rounded-sm"
style={{ backgroundColor: p.accent }}
aria-hidden="true"
/>
</button>
);
})}
<button
type="button"
onClick={reset}
className="ml-1 text-[11px] text-[#0B1E3F] underline hover:no-underline"
data-testid="em-alert-prefs-reset"
>
{t("executiveMeetings.alertPrefs.reset")}
</button>
</div>
</div>
</div>
);
}
// ============ FONT SETTINGS ============
function FontSettingsSection({
font,
canEditGlobal,
t,
}: {
font: FontPrefs;
canEditGlobal: boolean;
t: (k: string) => string;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const [scope, setScope] = useState<"user" | "global">("user");
const [prefs, setPrefs] = useState<FontPrefs>(font);
const [saving, setSaving] = useState(false);
useEffect(() => {
setPrefs(font);
}, [font]);
async function save() {
setSaving(true);
try {
await apiJson(`/api/executive-meetings/font-settings`, {
method: "PATCH",
body: { scope, ...prefs },
});
toast({ title: t("executiveMeetings.fontSettingsPage.saved") });
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/font-settings"] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
} finally {
setSaving(false);
}
}
function reset() {
setPrefs(DEFAULT_FONT);
}
return (
<div className="space-y-4 max-w-2xl">
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.fontSettingsPage.heading")}
</h2>
<p className="text-sm text-gray-600">{t("executiveMeetings.fontSettingsPage.intro")}</p>
<div className="bg-white border border-gray-200 rounded-md p-4 space-y-3">
{canEditGlobal && (
<FormRow label={t("executiveMeetings.fontSettingsPage.scope")}>
<Select value={scope} onValueChange={(v) => setScope(v as "user" | "global")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">
{t("executiveMeetings.fontSettingsPage.scopeUser")}
</SelectItem>
<SelectItem value="global">
{t("executiveMeetings.fontSettingsPage.scopeGlobal")}
</SelectItem>
</SelectContent>
</Select>
</FormRow>
)}
<FormRow label={t("executiveMeetings.fontSettingsPage.fontFamily")}>
<Select
value={prefs.fontFamily}
onValueChange={(v) => setPrefs({ ...prefs, fontFamily: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{/*
Only families that ship locally via `custom-fonts.css`
(DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic,
Helvetica Neue, Majalla) plus the special "system"
option (= use whatever the site's CSS default is, which
is currently DIN Next LT Arabic). The previous list
included Cairo / Noto Naskh Arabic / Amiri but those
were never registered with @font-face, so picking them
produced no visible change. Keep this list in sync with
the FONT_FAMILIES enum on the API server, otherwise the
PATCH font-settings request will be rejected by Zod.
*/}
{[
"system",
"DIN Next LT Arabic",
"Tajawal",
"Helvetica Neue LT Arabic",
"Helvetica Neue",
"Majalla",
].map((f) => (
<SelectItem key={f} value={f}>
{f}
</SelectItem>
))}
</SelectContent>
</Select>
</FormRow>
<FormRow label={`${t("executiveMeetings.fontSettingsPage.fontSize")}: ${prefs.fontSize}px`}>
<input
type="range"
min={12}
max={22}
value={prefs.fontSize}
onChange={(e) => setPrefs({ ...prefs, fontSize: Number(e.target.value) })}
className="w-full"
/>
</FormRow>
<FormRow label={t("executiveMeetings.fontSettingsPage.fontWeight")}>
<Select
value={prefs.fontWeight}
onValueChange={(v) => setPrefs({ ...prefs, fontWeight: v as FontPrefs["fontWeight"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["regular", "bold"] as const).map((w) => (
<SelectItem key={w} value={w}>
{t(`executiveMeetings.fontSettingsPage.weight.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormRow>
<FormRow label={t("executiveMeetings.fontSettingsPage.alignment")}>
<Select
value={prefs.alignment}
onValueChange={(v) => setPrefs({ ...prefs, alignment: v as FontPrefs["alignment"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["start", "center"] as const).map((a) => (
<SelectItem key={a} value={a}>
{t(`executiveMeetings.fontSettingsPage.align.${a}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormRow>
<div className="border-t border-gray-200 pt-3">
<Label className="text-xs text-gray-700">
{t("executiveMeetings.fontSettingsPage.preview")}
</Label>
<div
className="mt-2 p-4 border border-gray-200 rounded bg-[#fafbff]"
style={buildFontStyle(prefs)}
>
{t("executiveMeetings.fontSettingsPage.previewText")}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={reset}>
{t("executiveMeetings.fontSettingsPage.reset")}
</Button>
<Button
onClick={save}
disabled={saving}
className="bg-[#0B1E3F]"
data-testid="em-font-save"
>
{t("executiveMeetings.fontSettingsPage.save")}
</Button>
</div>
</div>
</div>
);
}
// ============ SHARED ============
function NoPermission({ t }: { t: (k: string) => string }) {
return (
<div className="bg-white border border-gray-200 rounded-md p-10 sm:p-16 text-center">
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-[#0B1E3F]/10 flex items-center justify-center text-[#0B1E3F]">
<SettingsIcon className="w-6 h-6" />
</div>
<p className="text-sm text-gray-600 max-w-md mx-auto">
{t("executiveMeetings.common.noPermission")}
</p>
</div>
);
}