import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type Dispatch,
  type ReactNode,
  type SetStateAction,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation, useSearch } 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,
  Search,
  Clock,
  Maximize2,
  Minimize2,
} from "lucide-react";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { AppDock } from "@/components/app-dock";
import { useIsMobile } from "@/hooks/use-mobile";
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,
  PopoverAnchor,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { useToast } from "@/hooks/use-toast";
import { ToastAction } from "@/components/ui/toast";
import {
  DndContext,
  PointerSensor,
  MouseSensor,
  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 { Composer } from "@/components/notes/composer";
import { SendNoteDialog } from "@/components/notes/send-note-dialog";
import { useNoteLabels, type UserSummary } from "@/lib/notes-api";
import { cn } from "@/lib/utils";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command";
// #486: reuse the same dialog the upcoming-alert pops, so postponing
// from the schedule row quick-actions menu shares one implementation.
import { PostponeDialog } from "@/components/executive-meetings/upcoming-meeting-alert";
import { useNow } from "@/components/clock";
import { formatDate, formatTime as formatTimeI18n, formatWeekday } from "@/lib/i18n-format";
import { safeHtml, htmlToPlainText, wrapAsParagraph } 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;
  // #635: External meeting flag. When true, the row is force-tinted red
  // and the alert-postpone button is disabled (the server also rejects
  // /postpone-minutes with `external_meeting_no_auto_postpone`).
  isExternal?: boolean;
  // #486: optimistic-lock token used by /executive-meetings/swap-times
  // (the schedule row quick-actions Move up / Move down popover) and
  // by the existing PostponeDialog when reused from the schedule.
  updatedAt: string;
};

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;
};

// #616-followup: localized 12h timestamp for the audit log + PDF archive
// listing. Format: dd/mm/yyyy h:mm <ص|م> in Arabic, or h:mm AM/PM in
// English. Seconds dropped to keep rows compact, day before month so
// Arabic readers don't have to mentally re-order the US m/d/y default.
function formatAuditDateTime(iso: string, lang: string): string {
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return iso;
  const dd = String(d.getDate()).padStart(2, "0");
  const mm = String(d.getMonth() + 1).padStart(2, "0");
  const yyyy = d.getFullYear();
  let h = d.getHours();
  const min = String(d.getMinutes()).padStart(2, "0");
  const isPm = h >= 12;
  h = h % 12 || 12;
  const suffix = lang === "ar" ? (isPm ? "م" : "ص") : isPm ? "PM" : "AM";
  return `${dd}/${mm}/${yyyy} ${h}:${min} ${suffix}`;
}

type FontPrefs = {
  fontFamily: string;
  fontSize: number;
  fontWeight: "regular" | "bold";
  alignment: "start" | "center";
  // #RRGGBB persisted with font prefs.
  fontColor: string;
};

// Brand asset stored only on the global-scope row. Per-user rows return
// null here; the UI hides the upload control unless the viewer has
// global edit rights.
type GlobalFontExtras = { logoObjectPath: string | null };

type FontSettingsRow = {
  fontFamily: string | null;
  fontSize: number | null;
  fontWeight: string | null;
  alignment: string | null;
  fontColor: string | null;
};

type FontSettingsResponse = {
  global:
    | ({ scope: "global"; userId: null } & FontSettingsRow & GlobalFontExtras)
    | null;
  user: ({ scope: "user"; userId: number } & FontSettingsRow) | 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: "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 "audit":
      return me.canViewAudit;
    default:
      return false;
  }
}

const DEFAULT_FONT: FontPrefs = {
  fontFamily: "system",
  fontSize: 14,
  fontWeight: "regular",
  alignment: "start",
  fontColor: "#000000",
};

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; border: string; label: string }[] = [
  { key: "default", bg: "", border: "", label: "default" },
  { key: "red", bg: "#fee2e2", border: "#ef4444", label: "red" },
  { key: "amber", bg: "#fef3c7", border: "#f59e0b", label: "amber" },
  { key: "green", bg: "#dcfce7", border: "#22c55e", label: "green" },
  { key: "blue", bg: "#dbeafe", border: "#3b82f6", label: "blue" },
  { key: "violet", bg: "#ede9fe", border: "#8b5cf6", label: "violet" },
  { key: "gray", bg: "#f3f4f6", border: "#9ca3af", 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";
// #484: edit-mode is intentionally NOT persisted. It always starts off
// when the Meetings page mounts so users never come back to a stuck
// half-open editor state after navigating away.

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 ids of all meetings currently in progress on `dateIso`.
 * A meeting is "current" when the wall clock falls within
 * [startTime, endTime) and the displayed date matches today.
 * Multiple meetings may overlap, so every match is included.
 */
const EMPTY_MEETING_SET: ReadonlySet<number> = new Set<number>();

function computeCurrentMeetingIds(
  meetings: Meeting[],
  dateIso: string,
  nowMs: number,
): ReadonlySet<number> {
  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 EMPTY_MEETING_SET;
  const minutesNow = now.getHours() * 60 + now.getMinutes();
  let ids: Set<number> | null = null;
  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) {
      if (!ids) ids = new Set();
      ids.add(m.id);
    }
  }
  return ids ?? EMPTY_MEETING_SET;
}

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,
    color: prefs.fontColor,
  };
}

// #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) {
    const message =
      (data as { error?: string }).error || `HTTP ${res.status}`;
    const err = new Error(message) as Error & {
      code?: string;
      status?: number;
    };
    const rawCode = (data as { code?: unknown }).code;
    if (typeof rawCode === "string") err.code = rawCode;
    err.status = res.status;
    throw err;
  }
  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 HeaderClock({ lang, date }: { lang: "ar" | "en"; date: string }) {
  // Per user request the analog dial was removed — header now shows a
  // live 12-hour digital time (e.g. "م 03:50") next to the weekday +
  // date. Re-renders every second via useNow.
  const now = useNow(1000);
  const time = formatTimeI18n(now, lang, {
    hour: "2-digit",
    minute: "2-digit",
    hour12: true,
  });
  return (
    <div
      className="flex flex-col items-end leading-tight print:hidden"
      data-testid="em-header-clock"
      aria-live="off"
    >
      <div className="text-[#0B1E3F] font-semibold text-sm sm:text-base" data-testid="em-header-weekday">
        {formatWeekday(date, lang, "long")}
      </div>
      <div className="text-xs sm:text-sm text-muted-foreground tabular-nums" data-testid="em-header-date">
        {formatDate(date, lang)}
      </div>
      <div className="text-xs sm:text-sm text-[#0B1E3F] font-semibold tabular-nums" data-testid="em-header-time">
        {time}
      </div>
    </div>
  );
}

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());
  // Inline quick-note panel toggled by the yellow side tab. Kept as
  // transient local state (not sessionStorage) — it's a quick capture,
  // not a view preference. Auto-closes when leaving the schedule tab.
  const [quickNoteOpen, setQuickNoteOpen] = useState<boolean>(false);
  // Bumped each time the yellow side tab opens the panel, so the
  // shared <Composer> expands and focuses its textarea on open (it
  // reacts to changes in this numeric trigger).
  const [quickNoteTrigger, setQuickNoteTrigger] = useState<number>(0);
  // When the user hits the paper-plane Send inside the composer we
  // save the note first, then mount <SendNoteDialog> for that id —
  // same flow as on /notes.
  const [sendForNoteId, setSendForNoteId] = useState<number | null>(null);
  const { data: quickNoteLabels = [] } = useNoteLabels();
  useEffect(() => {
    if (section !== "schedule") setQuickNoteOpen(false);
  }, [section]);

  // #583 fullscreen toggle for the schedule view. Hides the page header
  // and toolbar chrome so the meetings table can fill the viewport.
  // Persisted in sessionStorage so accidental refreshes don't drop the
  // user out of fullscreen, and tied to the schedule section only.
  const [isFullscreen, setIsFullscreen] = useState<boolean>(() => {
    if (typeof window === "undefined") return false;
    try {
      return window.sessionStorage.getItem("em-fullscreen") === "1";
    } catch {
      return false;
    }
  });
  useEffect(() => {
    try {
      if (isFullscreen) {
        window.sessionStorage.setItem("em-fullscreen", "1");
      } else {
        window.sessionStorage.removeItem("em-fullscreen");
      }
    } catch {
      /* storage may be unavailable */
    }
  }, [isFullscreen]);
  useEffect(() => {
    if (!isFullscreen) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") setIsFullscreen(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [isFullscreen]);
  // Force out of fullscreen the moment the user switches away from the
  // schedule tab — otherwise the global Esc handler keeps firing on an
  // empty body.
  useEffect(() => {
    if (section !== "schedule" && isFullscreen) setIsFullscreen(false);
  }, [section, isFullscreen]);

  // The schedule section publishes whether its bulk-action toolbar is
  // currently visible (≥1 row selected in edit mode) so the page-level
  // quick-note header button can step out of the way while the user
  // works with destructive bulk actions.
  const [bulkToolbarActive, setBulkToolbarActive] = useState(false);
  useEffect(() => {
    if (section !== "schedule" && bulkToolbarActive) setBulkToolbarActive(false);
  }, [section, bulkToolbarActive]);

  // 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"),
  });

  // #572: "President view" — a focused, presentation-style layout for users
  // whose primary role is the CEO/president (`executive_ceo`) and who are
  // NOT also wearing an admin hat (admin / office_manager). They get the
  // schedule table only — no sub-nav tabs, no PDF shortcut, no audit log —
  // plus an analog clock + weekday/date label in the toolbar. Their
  // permissions on the backend are untouched; this is purely a UI gate.
  const isPresidentView = useMemo(() => {
    const roles = me?.roles ?? [];
    if (!roles.includes("executive_ceo")) return false;
    return !roles.some((r) => r === "admin" || r === "executive_office_manager");
  }, [me]);

  // Force the schedule section when the president lands on the page or
  // their role flips on mid-session, so they never get stuck on a now-
  // hidden tab.
  useEffect(() => {
    // #596: President is normally pinned to "schedule", but we now
    // expose a gear-icon entry point to "settings" in the header so
    // they can edit their own font. Allow that one extra section here,
    // otherwise this effect would immediately bounce them back the
    // moment they tap the gear.
    if (isPresidentView && section !== "schedule" && section !== "settings") {
      setSection("schedule");
    }
  }, [isPresidentView, section]);

  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;
    if (!u && !g) return DEFAULT_FONT;
    return {
      fontFamily: u?.fontFamily ?? g?.fontFamily ?? DEFAULT_FONT.fontFamily,
      fontSize: u?.fontSize ?? g?.fontSize ?? DEFAULT_FONT.fontSize,
      fontWeight: (u?.fontWeight ?? g?.fontWeight ?? DEFAULT_FONT.fontWeight) as FontPrefs["fontWeight"],
      alignment: (u?.alignment ?? g?.alignment ?? DEFAULT_FONT.alignment) as FontPrefs["alignment"],
      fontColor: u?.fontColor ?? g?.fontColor ?? DEFAULT_FONT.fontColor,
    };
  }, [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" }}
    >
      {/* #600: yellow floating side tab removed — entry point is now
          the amber "Take a note" button in the header action cluster. */}
      {isFullscreen && (
        <button
          type="button"
          onClick={() => setIsFullscreen(false)}
          data-testid="em-fullscreen-exit"
          aria-label={t("executiveMeetings.fullscreen.exit", "Exit fullscreen")}
          title={t("executiveMeetings.fullscreen.exit", "Exit fullscreen")}
          className="fixed top-3 end-3 z-50 inline-flex items-center justify-center rounded-full bg-[#0B1E3F] text-white w-9 h-9 shadow-lg hover:bg-[#0B1E3F]/90 print:hidden"
        >
          <Minimize2 className="w-5 h-5" />
        </button>
      )}
      {/* #602: floating amber quick-note pill — the only entry point to
          the quick-note panel while the page header is hidden in
          fullscreen. Same gating + handler as the header button at
          ~L1067 so the two behave identically; positioned inboard of
          the exit pill (end-14 sits to the left of end-3 in LTR and to
          the right in RTL). Hidden when the panel is already open or
          while the bulk-actions toolbar is competing for attention. */}
      {isFullscreen &&
        section === "schedule" &&
        !bulkToolbarActive &&
        !quickNoteOpen && (
          <button
            type="button"
            onClick={() => {
              setQuickNoteOpen(true);
              setQuickNoteTrigger((n) => n + 1);
            }}
            data-testid="em-fullscreen-quicknote"
            aria-label={t(
              "executiveMeetings.quickNote.label",
              lang === "ar" ? "ملاحظة" : "Note",
            )}
            title={t(
              "executiveMeetings.quickNote.label",
              lang === "ar" ? "ملاحظة" : "Note",
            )}
            className="fixed top-3 end-14 z-50 inline-flex items-center gap-1.5 rounded-full bg-amber-100 hover:bg-amber-200 border border-amber-300 text-[#0B1E3F] h-9 px-3 text-sm font-medium shadow-lg print:hidden"
          >
            <Plus className="w-4 h-4" />
            <span>
              {t(
                "executiveMeetings.quickNote.label",
                lang === "ar" ? "ملاحظة" : "Note",
              )}
            </span>
          </button>
        )}
      <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" +
          (isFullscreen ? " hidden" : "")
        }
      >
        <div className="relative 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" />

          {/* The absolutely-centered weekday+date+analog clock collides
              with the title and action buttons on phones/small tablets
              (it spans the full row width and sits on top of wrapped
              content). Hide it below `sm` — the date is already shown
              in the schedule heading directly below the header — and
              keep the centered overlay from `sm` upward where there's
              real horizontal room. */}
          <div className="hidden sm:flex pointer-events-none absolute inset-x-0 top-0 bottom-0 items-center justify-center print:hidden">
            <div className="pointer-events-auto">
              <HeaderClock lang={lang} date={date} />
            </div>
          </div>

          <div className="flex items-center gap-2">
            {/* #600: quick-note entry point moved off the page edge
                into the header. Same gating as the old floating tab. */}
            {section === "schedule" && !isFullscreen && !bulkToolbarActive && !quickNoteOpen && (
              <Button
                variant="outline"
                size="sm"
                className="gap-1.5 bg-amber-100 hover:bg-amber-200 border-amber-300 text-[#0B1E3F]"
                onClick={() => {
                  setQuickNoteOpen(true);
                  setQuickNoteTrigger((n) => n + 1);
                }}
                aria-label={t("executiveMeetings.quickNote.label", lang === "ar" ? "ملاحظة" : "Note")}
                title={t("executiveMeetings.quickNote.label", lang === "ar" ? "ملاحظة" : "Note")}
                data-testid="em-quick-note-header"
              >
                <Plus className="w-4 h-4" />
                <span className="hidden sm:inline">
                  {t("executiveMeetings.quickNote.label", lang === "ar" ? "ملاحظة" : "Note")}
                </span>
              </Button>
            )}
            {!isPresidentView && (
              <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>
            )}
            {/* #598: gear + back-arrow added in #596 removed — the
                global bottom AppDock (mounted at page root) now
                provides cross-app navigation, including the Admin /
                Settings entry. The L902 effect that snaps section
                back to "schedule" outside (schedule|settings) stays;
                it still gates the hidden Manage / Audit tabs for the
                president. */}
          </div>
        </div>

        {/* Tabs row. The inner div scrolls horizontally on narrow
            phones; the absolute gradients at each edge are a visual
            "more tabs →" affordance so users don't think the row is
            simply cut off. */}
        <nav className="relative border-t border-gray-100 bg-white">
          <div className="max-w-screen-2xl mx-auto px-2 sm:px-4 flex items-center gap-1 sm:gap-2 overflow-x-auto scrollbar-thin">
            <div className="flex gap-1 sm:gap-2 min-w-max">
              {!isPresidentView && 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>
            <div className="flex-1" />
            <div id="em-toolbar-portal" className="flex items-center gap-2 py-1 print:hidden" />
          </div>
          {/* Edge fade affordances — only visible on narrow screens
              where the tab row actually scrolls. pointer-events-none
              so they never intercept taps on the tab buttons below. */}
          <div
            className="pointer-events-none absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-white to-transparent sm:hidden"
            aria-hidden="true"
          />
          <div
            className="pointer-events-none absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-white to-transparent sm:hidden"
            aria-hidden="true"
          />
        </nav>
      </header>

      <main
        className={
          isFullscreen
            ? // Extra top padding in fullscreen so the floating "Exit
              // fullscreen" pill (fixed top-3 end-3) doesn't overlap the
              // schedule table's header row (especially the time column).
              "w-full px-2 sm:px-3 pt-12 sm:pt-14 pb-2"
            : "max-w-screen-2xl mx-auto px-3 sm:px-6 py-4 sm:py-6"
        }
      >
        {section === "schedule" && quickNoteOpen && (
          // Reuse the real notes <Composer> instead of a custom textarea
          // so the meetings quick-note matches /notes exactly (yellow
          // card, color picker, label menu, checklist toggle, Save +
          // paper-plane Send). folderSelection "all" → unfiled, so the
          // saved note shows up at the top of the Notes app.
          <div
            data-testid="em-quick-note-panel"
            className="relative mb-3 sm:mb-4 print:hidden"
          >
            <button
              type="button"
              onClick={() => setQuickNoteOpen(false)}
              aria-label={t("common.cancel", lang === "ar" ? "إلغاء" : "Cancel")}
              data-testid="em-quick-note-close"
              className="absolute z-10 top-1 end-1 rounded-full p-1 text-[#0B1E3F]/70 hover:bg-black/5 hover:text-[#0B1E3F]"
            >
              <X className="w-4 h-4" />
            </button>
            <Composer
              labels={quickNoteLabels}
              folderSelection={{ kind: "all" }}
              onSend={(id) => {
                setSendForNoteId(id);
                setQuickNoteOpen(false);
              }}
              openTrigger={quickNoteTrigger}
              // Composer fires onClose when it auto-resets — i.e.
              // after Save, outside-click save, empty-reset, or send
              // success. Mirroring that into the page state so the
              // yellow side tab returns instead of leaving the panel
              // showing a stale collapsed "+ Take a note..." pill.
              onClose={() => setQuickNoteOpen(false)}
            />
          </div>
        )}
        {sendForNoteId !== null && (
          <SendNoteDialog
            noteId={sendForNoteId}
            onClose={() => setSendForNoteId(null)}
          />
        )}
        {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}
            isPresidentView={isPresidentView}
            lang={lang}
            isFullscreen={isFullscreen}
            onFullscreenChange={setIsFullscreen}
            onBulkToolbarActiveChange={setBulkToolbarActive}
          />
        )}
        {section === "manage" && me && (
          <ManageSection date={date} onDateChange={setDate} canMutate={me.canMutate} 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}
            globalFont={fontResp?.global ? {
              fontFamily: fontResp.global.fontFamily ?? DEFAULT_FONT.fontFamily,
              fontSize: fontResp.global.fontSize ?? DEFAULT_FONT.fontSize,
              fontWeight: (fontResp.global.fontWeight ?? DEFAULT_FONT.fontWeight) as FontPrefs["fontWeight"],
              alignment: (fontResp.global.alignment ?? DEFAULT_FONT.alignment) as FontPrefs["alignment"],
              fontColor: fontResp.global.fontColor ?? DEFAULT_FONT.fontColor,
            } : null}
            globalLogoObjectPath={fontResp?.global?.logoObjectPath ?? null}
            canEditGlobalFont={me.canEditGlobalFontSettings}
            columns={columns}
            onColumnsChange={setColumns}
            highlightPrefs={highlightPrefs}
            onHighlightPrefsChange={setHighlightPrefs}
            t={t}
          />
        )}
      </main>
      {/* #598: global app switcher. Hidden in schedule-fullscreen so
          the calendar can fill the screen unobstructed. */}
      {!isFullscreen && <AppDock currentSlug="executive-meetings" />}
    </div>
  );
}

// ============ SCHEDULE ============

function ScheduleSection({
  meetings,
  date,
  onDateChange,
  isLoading,
  isRtl,
  t,
  font,
  canMutate,
  userId,
  columns,
  onColumnsChange,
  highlightPrefs,
  isPresidentView = false,
  lang = "en",
  isFullscreen = false,
  onFullscreenChange,
  onBulkToolbarActiveChange,
}: {
  meetings: Meeting[];
  date: string;
  onDateChange: (d: string) => void;
  isLoading: boolean;
  isRtl: boolean;
  t: (k: string, opts?: Record<string, unknown>) => string;
  font: FontPrefs;
  canMutate: boolean;
  userId: number | null;
  columns: ColumnSetting[];
  onColumnsChange: Dispatch<SetStateAction<ColumnSetting[]>>;
  highlightPrefs: HighlightPrefs;
  isPresidentView?: boolean;
  lang?: "ar" | "en";
  isFullscreen?: boolean;
  onFullscreenChange?: (next: boolean) => void;
  onBulkToolbarActiveChange?: (active: boolean) => void;
}) {
  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) {
      // #635: external meetings are always tinted red regardless of
      // (and overriding) any manually-set rowColor, so the visual cue
      // matches the flag even on legacy rows whose rowColor was set
      // before isExternal existed.
      if (m.isExternal) out[m.id] = "red";
      else 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.
  //
  // #484: the toggle is NOT persisted. Every fresh page mount — whether
  // from a reload or from navigating back to Meetings from another app —
  // starts in view mode so the user never returns to a stuck half-open
  // editor state. The in-memory state still flips normally during a
  // single visit. We also force back to false the moment a user loses
  // mutate permission so they never see a stale "on".
  const [editMode, setEditModeState] = useState<boolean>(false);
  useEffect(() => {
    if (!canMutate) setEditModeState(false);
  }, [canMutate]);
  const setEditMode = useCallback(
    (next: boolean) => {
      if (!canMutate) return;
      setEditModeState(next);
    },
    [canMutate],
  );
  // 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);
    const onVisibilityChange = () => {
      if (document.visibilityState === "visible") {
        setNowTick(Date.now());
      }
    };
    document.addEventListener("visibilitychange", onVisibilityChange);
    return () => {
      clearTimeout(timeoutId);
      if (intervalId) clearInterval(intervalId);
      document.removeEventListener("visibilitychange", onVisibilityChange);
    };
  }, []);

  const currentMeetingIds = useMemo(
    () => computeCurrentMeetingIds(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.
  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]);
  // Mirror the bulk toolbar's visibility condition (see the
  // `effectiveCanMutate && visibleSelectedCount > 0` render below) up
  // to the page so the floating quick-note tab can step aside while
  // bulk actions are in progress.
  const bulkToolbarVisible = effectiveCanMutate && visibleSelectedCount > 0;
  useEffect(() => {
    onBulkToolbarActiveChange?.(bulkToolbarVisible);
  }, [bulkToolbarVisible, onBulkToolbarActiveChange]);
  const [bulkDeleting, setBulkDeleting] = useState(false);
  // Sequential bulk-delete progress (mirrors duplicate UX).
  const [bulkDeleteProgress, setBulkDeleteProgress] = useState<
    { current: number; total: number } | null
  >(null);
  const bulkDeleteCancelRef = useRef(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);
  // Sequential bulk-duplicate progress to avoid the parallel
  // race that produced 409s on shared dailyNumber slots. Tracks
  // `{current,total}` so the dialog can render a progress bar
  // and percentage instead of a static "Duplicating…" label.
  const [bulkDuplicateProgress, setBulkDuplicateProgress] = useState<
    { current: number; total: number } | null
  >(null);
  const bulkDuplicateCancelRef = useRef(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);
    bulkDuplicateCancelRef.current = false;
    setBulkDuplicateProgress({ current: 0, total: ids.length });
    let ok = 0;
    let failed = 0;
    try {
      // Sequential loop (was Promise.allSettled). The server's
      // /duplicate endpoint reads nextDailyNumber then inserts;
      // running 10 in parallel made all 10 read the same number,
      // and the unique (date, dailyNumber) constraint collapsed
      // most into 409s. One-at-a-time avoids the race entirely.
      for (const id of ids) {
        if (bulkDuplicateCancelRef.current) break;
        try {
          await apiJson(`/api/executive-meetings/${id}/duplicate`, {
            method: "POST",
            body: { targetDate: bulkDuplicateDate },
          });
          ok++;
        } catch {
          failed++;
        }
        setBulkDuplicateProgress({ current: ok + failed, total: ids.length });
      }
      const total = ok + failed;
      if (total > 0) {
        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(total))
              .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);
      setBulkDuplicateProgress(null);
      bulkDuplicateCancelRef.current = 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);
    bulkDeleteCancelRef.current = false;
    setBulkDeleteProgress({ current: 0, total: ids.length });
    // Sequential to mirror the duplicate UX (progress + cancel).
    // DELETE has no race like duplicate, but consistent UX wins.
    const okIds: number[] = [];
    const failedIds: number[] = [];
    try {
      for (const id of ids) {
        if (bulkDeleteCancelRef.current) break;
        try {
          await apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" });
          okIds.push(id);
        } catch {
          failedIds.push(id);
        }
        setBulkDeleteProgress({
          current: okIds.length + failedIds.length,
          total: ids.length,
        });
      }
      const ok = okIds.length;
      const failed = failedIds.length;
      const total = ok + failed;
      if (total === 0) return;
      const okIdsSet = new Set(okIds);
      const okSnapshots = snapshots.filter((s) => okIdsSet.has(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(total))
            .replace("{{failed}}", String(failed)),
          variant: "destructive",
          action: undoAction,
        });
      }
      setSelectedMeetingIds(new Set());
      refreshDay();
    } finally {
      setBulkDeleting(false);
      setBulkDeleteProgress(null);
      bulkDeleteCancelRef.current = 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);
  }, []);

  // #576: When the user picks "Edit" from a row's quick-actions
  // dialog we no longer open a small Meeting edit Dialog — instead we
  // flip the WHOLE schedule into edit mode (same surface as the
  // toolbar pencil button) and gently scroll the originating row into
  // view with a temporary accent ring so the user immediately sees
  // which meeting their tap targeted. The ring auto-clears after
  // ~1.6s — long enough to register, short enough to not linger as
  // visual noise while the user starts editing other rows.
  const [highlightEditRowId, setHighlightEditRowId] = useState<number | null>(
    null,
  );
  useEffect(() => {
    if (highlightEditRowId == null) return;
    if (!meetings.some((m) => m.id === highlightEditRowId)) return;
    const rafId = window.requestAnimationFrame(() => {
      const row = document.querySelector(
        `[data-testid="em-row-${highlightEditRowId}"]`,
      );
      if (row && "scrollIntoView" in row) {
        (row as HTMLElement).scrollIntoView({
          block: "center",
          behavior: "smooth",
        });
      }
    });
    const clear = window.setTimeout(() => {
      setHighlightEditRowId(null);
    }, 1600);
    return () => {
      window.cancelAnimationFrame(rafId);
      window.clearTimeout(clear);
    };
  }, [highlightEditRowId, meetings]);

  // --- Deep-link: /meetings?meeting=<id> (optionally &date=YYYY-MM-DD) ---
  // Opened from a notification (in-app list or system Web Push). We switch
  // the schedule to the meeting's day, then scroll + flash its row reusing
  // the same accent-ring as the row "Edit" affordance. When no date is
  // supplied we resolve it from the meeting id via the API so the same link
  // works regardless of which day is currently shown.
  const deepLinkSearch = useSearch();
  const deepLinkHandledRef = useRef<string | null>(null);
  const [pendingDeepLinkMeetingId, setPendingDeepLinkMeetingId] = useState<
    number | null
  >(null);
  useEffect(() => {
    const params = new URLSearchParams(deepLinkSearch);
    const meetingParam = params.get("meeting");
    if (!meetingParam) return;
    if (deepLinkHandledRef.current === meetingParam) return;
    const id = Number(meetingParam);
    if (!Number.isInteger(id) || id <= 0) return;
    deepLinkHandledRef.current = meetingParam;
    setPendingDeepLinkMeetingId(id);
    const dateParam = params.get("date");
    if (dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam)) {
      if (dateParam !== date) onDateChange(dateParam);
      return;
    }
    let cancelled = false;
    void apiJson<{ meetingDate?: string }>(`/api/executive-meetings/${id}`)
      .then((m) => {
        if (cancelled) return;
        if (m?.meetingDate && m.meetingDate !== date) onDateChange(m.meetingDate);
      })
      .catch(() => {
        // Meeting may have been deleted/moved — leave the user on the
        // current day rather than surfacing an error for a stale link.
      });
    return () => {
      cancelled = true;
    };
  }, [deepLinkSearch, date, onDateChange]);
  useEffect(() => {
    if (pendingDeepLinkMeetingId == null) return;
    if (!meetings.some((m) => m.id === pendingDeepLinkMeetingId)) return;
    setHighlightEditRowId(pendingDeepLinkMeetingId);
    setPendingDeepLinkMeetingId(null);
    deepLinkHandledRef.current = null;
    if (typeof window !== "undefined") {
      const params = new URLSearchParams(window.location.search);
      params.delete("meeting");
      params.delete("date");
      const qs = params.toString();
      const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
      window.history.replaceState(null, "", next);
    }
  }, [pendingDeepLinkMeetingId, meetings]);
  useEffect(() => {
    if (pendingDeepLinkMeetingId == null) return;
    // Safety net for stale links: if the target meeting never materializes
    // (deleted, moved to another day we can't resolve, or simply not in the
    // loaded set), clear the pending state and strip the param after a grace
    // period so the URL doesn't linger and a later valid link still works.
    const stale = window.setTimeout(() => {
      setPendingDeepLinkMeetingId(null);
      deepLinkHandledRef.current = null;
      if (typeof window !== "undefined") {
        const params = new URLSearchParams(window.location.search);
        params.delete("meeting");
        params.delete("date");
        const qs = params.toString();
        window.history.replaceState(
          null,
          "",
          `${window.location.pathname}${qs ? `?${qs}` : ""}`,
        );
      }
    }, 8000);
    return () => window.clearTimeout(stale);
  }, [pendingDeepLinkMeetingId]);

  // #489: Drag-from-anywhere on the row rotates meeting CONTENT through
  // the day's fixed time slots. The (startTime, endTime, dailyNumber)
  // tuple of each chronological slot stays anchored to its physical
  // row — only the meeting content (title, attendees, notes, color,
  // status, merge) shifts. Mirrors the optimistic-locked pattern used
  // by swap-times / postpone-minutes: we send each visible meeting's
  // last-known `updatedAt` so the server can detect concurrent edits
  // and 409 with a `stale_meeting` conflict that the client surfaces
  // as a destructive toast and rolls back from.
  //
  // Optimistic UI: we patch the day cache so the i-th visible row
  // (chronologically) immediately receives the (startTime, endTime,
  // dailyNumber) from `orderedMeetings[i]` — i.e. each slot keeps its
  // original tuple while the meeting content rotates into the new
  // position. This matches what the server will write.
  const rotateContent = useCallback(
    async (fromId: number, toId: number) => {
      if (fromId === toId) return;
      if (reorderingRef.current) return;
      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);
      // Build the optimistic patch: chronological slot[i] (= the
      // (startTime, endTime, dailyNumber) of orderedMeetings[i]) is
      // assigned to the meeting at newOrder[i]. Cancelled rows are
      // never in `orderedMeetings`, so they keep their existing tuple.
      const queryKey = ["/api/executive-meetings", date] as const;
      const previous = qc.getQueryData<DayResponse>(queryKey);
      const previousOptimisticOrder = optimisticOrder;
      const slotById = new Map<number, { startTime: string | null; endTime: string | null; dailyNumber: number }>();
      newOrder.forEach((id, i) => {
        const slotMeeting = orderedMeetings[i];
        if (!slotMeeting) return;
        slotById.set(id, {
          startTime: slotMeeting.startTime,
          endTime: slotMeeting.endTime,
          dailyNumber: slotMeeting.dailyNumber,
        });
      });
      qc.setQueryData<DayResponse>(queryKey, (prev) =>
        prev
          ? {
              ...prev,
              meetings: prev.meetings.map((m) => {
                const slot = slotById.get(m.id);
                if (!slot) return m;
                return {
                  ...m,
                  startTime: slot.startTime,
                  endTime: slot.endTime,
                  dailyNumber: slot.dailyNumber,
                };
              }),
            }
          : prev,
      );
      // #489: also drive the rendered row order so slot[i] visually
      // shows newOrder[i]. Without this, the array order in cache
      // remains the pre-drag order and times appear rotated against
      // the wrong row content (e.g. 11:00, 09:00, 10:00). Cleared on
      // refetch by the useEffect on `meetings`.
      setOptimisticOrder(newOrder);
      const expectedUpdatedAt: Record<string, string> = {};
      for (const m of orderedMeetings) {
        expectedUpdatedAt[String(m.id)] = m.updatedAt;
      }
      reorderingRef.current = true;
      setReordering(true);
      try {
        await apiJson(`/api/executive-meetings/rotate-content`, {
          method: "POST",
          body: {
            meetingDate: date,
            orderedIds: newOrder,
            expectedUpdatedAt,
          },
        });
        refreshDay();
      } catch (err) {
        if (previous !== undefined) {
          qc.setQueryData<DayResponse>(queryKey, previous);
        }
        // #489: restore the prior optimistic order on rollback so a
        // failed rotate doesn't leave the UI in the post-drop order.
        setOptimisticOrder(previousOptimisticOrder);
        // #497: the previous `no_time_window` toast branch was
        // removed — the server no longer rejects rotations that
        // include a row with a NULL time tuple, so this code path is
        // unreachable. Surface whatever message the error carries.
        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, optimisticOrder, date, qc, refreshDay, toast, t],
  );

  // #497: the missing-time drag block (originally #492) was lifted —
  // meetings without a (startTime, endTime) tuple are a normal state
  // and the server's /rotate-content now accepts them. The
  // `missingTimeCount` / `dayRotatable` memos that gated drag are
  // gone along with the warning button + dimmed-row affordance.

  // #486 → #489: Move up / Move down were retired in favour of
  // dragging the whole row (`rotateContent` above). The schedule's
  // row click popover now keeps only the Postpone button. The
  // swap-times helper that powered the old buttons is gone, but
  // POST /executive-meetings/swap-times still exists server-side
  // for any external caller that hasn't migrated yet.
  const [postponeMeetingId, setPostponeMeetingId] = useState<number | null>(
    null,
  );
  // #570: editing state for the in-place Meeting edit dialog opened
  // from the row's quick-actions Edit button. We host the dialog in
  // ScheduleSection (rather than switching the user over to the
  // Manage tab) so the edit flow feels local to the row the user
  // just tapped. Mirrors ManageSection's `editing` shape so we can
  // reuse the same MeetingFormDialog + save body unchanged.
  const [editingMeeting, setEditingMeeting] =
    useState<MeetingFormState | null>(null);
  const [editingSaving, setEditingSaving] = useState(false);
  const postponeMeeting = useMemo(() => {
    if (postponeMeetingId == null) return null;
    return meetings.find((m) => m.id === postponeMeetingId) ?? null;
  }, [meetings, postponeMeetingId]);
  // PostponeDialog renders cascade-prompt rows by daily number, so it
  // needs the same id→# map the upcoming-alert builds. Mirror that
  // logic from `meetings` directly (#486 reuse).
  const meetingNumbersById = useMemo(() => {
    const m = new Map<number, number>();
    for (const x of meetings) m.set(x.id, x.dailyNumber);
    return m;
  }, [meetings]);

  // #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.
  //
  // - MouseSensor for desktop mouse only: 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). We deliberately do NOT use PointerSensor here because on
  //   iOS Safari (iPad) pointer events fire for touch too, and a
  //   distance-based PointerSensor competes with the OS scroll claim:
  //   the browser reclaims the gesture as a vertical scroll before the
  //   finger has moved 6px, so the drag never activates. Splitting into
  //   MouseSensor + TouchSensor lets each input modality use the
  //   activation constraint that actually works for it.
  // - TouchSensor for iPad and other touch devices: a long-press
  //   activation (delay+tolerance) lets the user press-and-hold the row
  //   to start dragging while still leaving normal vertical scrolling
  //   intact everywhere on the row when not long-pressing.
  // - KeyboardSensor for accessibility / keyboard reordering.
  const sensors = useSensors(
    useSensor(MouseSensor, { 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 rotateContent(Number(active.id), Number(over.id));
    },
    [rotateContent],
  );

  useEffect(() => {
    const root = document.querySelector<HTMLElement>("[data-em-root]");
    if (root) root.style.setProperty("--em-heading-h", "0px");
    return () => {
      if (root) 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]);

  const portalTarget = typeof document !== "undefined" ? document.getElementById("em-toolbar-portal") : null;

  const toolbarControls = (
    <>
      {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 justify-center rounded-md border w-8 h-8 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-4 h-4" />
          ) : (
            <Pencil className="w-4 h-4" />
          )}
        </button>
      )}
      {/* #596: President view hides the search box (presentation-
          style focus). The underlying searchQuery state stays "" so
          meetingMatchesSearch is a no-op and the full day shows. */}
      {!isPresidentView && (
        <SearchToggle
          searchQuery={searchQuery}
          onSearchChange={setSearchQuery}
          placeholder={t("executiveMeetings.schedule.searchPlaceholder")}
          ariaLabel={t("executiveMeetings.schedule.searchAria")}
          clearLabel={t("executiveMeetings.schedule.searchClear")}
          isRtl={isRtl}
        />
      )}
      <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>
      {onFullscreenChange && (
        <button
          type="button"
          onClick={() => onFullscreenChange(!isFullscreen)}
          aria-pressed={isFullscreen}
          aria-label={
            isFullscreen
              ? t("executiveMeetings.fullscreen.exit")
              : t("executiveMeetings.fullscreen.enter")
          }
          title={
            isFullscreen
              ? t("executiveMeetings.fullscreen.exit")
              : t("executiveMeetings.fullscreen.enter")
          }
          data-testid="em-fullscreen-toggle"
          className={
            "inline-flex items-center justify-center rounded-md border w-8 h-8 transition-colors focus:outline-none focus:ring-2 focus:ring-[#0B1E3F]/40 " +
            (isFullscreen
              ? "bg-[#0B1E3F] text-white border-[#0B1E3F] hover:bg-[#0B1E3F]/90"
              : "bg-white text-[#0B1E3F] border-gray-300 hover:bg-gray-50")
          }
        >
          {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
        </button>
      )}
    </>
  );

  return (
    <div className="space-y-4 print:space-y-2">
      {portalTarget && createPortal(toolbarControls, portalTarget)}

      <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>

      {/* #496/#497: the amber missing-time hint banner (#492) and the
          per-row drag block + warning button were both removed —
          meetings without a start/end time are a normal state and
          rotate freely alongside timed rows. */}

      {/* 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}
                    // #489: drag must work for mutators regardless
                    // of edit mode — pass raw mutate permission.
                    dragEnabled={canMutate}
                    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 && currentMeetingIds.has(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)}
                    quickActionsCanMutate={canMutate && !editMode}
                    onQuickPostpone={() => setPostponeMeetingId(m.id)}
                    // #576: "Edit" from the quick-actions sheet now
                    // turns the FULL schedule into edit mode (same as
                    // the toolbar pencil) and asks the page to scroll
                    // the originating row into view with a brief
                    // accent ring, instead of opening a small Meeting
                    // edit Dialog. Matches the behaviour the user
                    // requested (image 3 in #576) where they see the
                    // whole editable list, not a popup form.
                    onQuickEdit={
                      canMutate && !editMode
                        ? () => {
                            setEditMode(true);
                            setHighlightEditRowId(m.id);
                          }
                        : undefined
                    }
                    highlightEdit={highlightEditRowId === m.id}
                  />
                ))}
              </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>

      {/* #486: Page-level PostponeDialog mount, opened by clicking the
          Postpone item in any row's quick-actions popover. Reuses the
          same component the upcoming-meeting alert renders so the
          postpone-minutes / reschedule / cancel flows + the 409
          stale_meeting handling stay in one place. */}
      {/* #570: In-place Meeting edit dialog hosted by ScheduleSection,
          opened from the row's quick-actions Edit button. Save body
          mirrors ManageSection's `save()` exactly (PATCH with the
          same field projection) so an edit started here writes the
          same shape the Manage tab would. Invalidates the same
          react-query key the rest of the page uses so the row
          reflects new values instantly. */}
      {editingMeeting && (
        <MeetingFormDialog
          state={editingMeeting}
          onChange={setEditingMeeting}
          onSave={async (committedStart, committedEnd) => {
            if (!editingMeeting) return;
            if (!editingMeeting.titleAr.trim()) {
              toast({
                title: t("executiveMeetings.manage.errors.titleRequired"),
                variant: "destructive",
              });
              return;
            }
            const titleEnPlain =
              editingMeeting.titleEn.trim() || editingMeeting.titleAr.trim();
            setEditingSaving(true);
            const body: Record<string, unknown> = {
              titleAr: wrapAsParagraph(editingMeeting.titleAr),
              titleEn: wrapAsParagraph(titleEnPlain),
              meetingDate: editingMeeting.meetingDate,
              startTime: committedStart || null,
              endTime: committedEnd || null,
              location: editingMeeting.location.trim() || null,
              meetingUrl: editingMeeting.meetingUrl.trim() || null,
              platform: editingMeeting.platform,
              status: editingMeeting.status,
              isHighlighted: editingMeeting.isHighlighted ? 1 : 0,
              notes: editingMeeting.notes.trim() || null,
              attendees: editingMeeting.attendees.map((a, idx) => ({
                name: wrapAsParagraph(a.name),
                title: a.title,
                attendanceType: a.attendanceType,
                sortOrder: idx,
                kind: a.kind ?? "person",
              })),
              // #635/#639: external meeting flag — coerce to real
              // boolean so an undefined state never silently drops
              // the field from the PATCH body.
              isExternal: Boolean(editingMeeting.isExternal),
            };
            try {
              await apiJson(
                `/api/executive-meetings/${editingMeeting.id}`,
                { method: "PATCH", body },
              );
              toast({ title: t("executiveMeetings.common.saved") });
              setEditingMeeting(null);
              refreshDay();
            } 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 {
              setEditingSaving(false);
            }
          }}
          onClose={() => setEditingMeeting(null)}
          saving={editingSaving}
          t={t}
        />
      )}

      {postponeMeeting && canMutate ? (
        <PostponeDialog
          open={postponeMeetingId !== null}
          onOpenChange={(o) => {
            if (!o) setPostponeMeetingId(null);
          }}
          meeting={postponeMeeting}
          isRtl={isRtl}
          meetingNumbersById={meetingNumbersById}
          accent={highlightPrefs.color}
          onSaved={async () => {
            setPostponeMeetingId(null);
            refreshDay();
          }}
        />
      ) : null}

      {/* #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>
          {bulkDuplicating && bulkDuplicateProgress ? (
            <div className="space-y-2 pt-2">
              <div className="text-sm text-muted-foreground text-center">
                {t("executiveMeetings.schedule.bulkDuplicateProgress")
                  .replace("{{current}}", String(bulkDuplicateProgress.current))
                  .replace("{{total}}", String(bulkDuplicateProgress.total))
                  .replace(
                    "{{percent}}",
                    String(
                      Math.round(
                        (bulkDuplicateProgress.current /
                          Math.max(bulkDuplicateProgress.total, 1)) *
                          100,
                      ),
                    ),
                  )}
              </div>
              <Progress
                value={
                  (bulkDuplicateProgress.current /
                    Math.max(bulkDuplicateProgress.total, 1)) *
                  100
                }
              />
              <div className="flex justify-end pt-1">
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={() => {
                    bulkDuplicateCancelRef.current = true;
                  }}
                >
                  {t("executiveMeetings.common.cancel")}
                </Button>
              </div>
            </div>
          ) : (
            <DialogFooter>
              <Button
                variant="ghost"
                onClick={() => setBulkDuplicateOpen(false)}
              >
                {t("executiveMeetings.common.cancel")}
              </Button>
              <Button
                onClick={() => void performBulkDuplicate()}
                disabled={!bulkDuplicateDate}
                className="bg-[#0B1E3F]"
                data-testid="em-bulk-duplicate-confirm"
              >
                {t("executiveMeetings.schedule.bulkDuplicateConfirm")}
              </Button>
            </DialogFooter>
          )}
        </DialogContent>
      </Dialog>

      {/* Bulk-delete progress dialog. Opens once the user has
          confirmed via the destructive confirm() modal and the
          sequential DELETE loop is in flight. */}
      <Dialog
        open={bulkDeleting && bulkDeleteProgress !== null}
        onOpenChange={() => {
          /* Non-dismissible while running. */
        }}
      >
        <DialogContent>
          <DialogHeader>
            <DialogTitle>
              {t("executiveMeetings.schedule.bulkDeleteSelected")}
            </DialogTitle>
          </DialogHeader>
          {bulkDeleteProgress && (
            <div className="space-y-2 pt-2">
              <div className="text-sm text-muted-foreground text-center">
                {t("executiveMeetings.schedule.bulkDeleteProgress")
                  .replace("{{current}}", String(bulkDeleteProgress.current))
                  .replace("{{total}}", String(bulkDeleteProgress.total))
                  .replace(
                    "{{percent}}",
                    String(
                      Math.round(
                        (bulkDeleteProgress.current /
                          Math.max(bulkDeleteProgress.total, 1)) *
                          100,
                      ),
                    ),
                  )}
              </div>
              <Progress
                value={
                  (bulkDeleteProgress.current /
                    Math.max(bulkDeleteProgress.total, 1)) *
                  100
                }
              />
              <div className="flex justify-end pt-1">
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={() => {
                    bulkDeleteCancelRef.current = true;
                  }}
                >
                  {t("executiveMeetings.common.cancel")}
                </Button>
              </div>
            </div>
          )}
        </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>
  );
}

// #497: the touch-friendly `DragBlockedTooltipButton` (the per-row
// amber warning surface introduced in #492) was removed alongside
// the missing-time drag gate. Meetings without a start/end time now
// drag freely.

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,
  dragEnabled,
  quickActionsCanMutate,
  onQuickPostpone,
  onQuickEdit,
  highlightEdit = false,
}: {
  meeting: Meeting;
  displayNumber?: number;
  isRtl: boolean;
  t: (k: string) => string;
  visibleColumns: ColumnSetting[];
  rowColorKey: string;
  onPickRowColor: (key: string) => void;
  canMutate: boolean;
  // #489: raw (un-gated by editMode) mutate permission used to enable
  // ROW DRAG. The task spec explicitly requires that any user with
  // mutate permission can drag a row to a new position regardless of
  // edit mode, so drag must NOT be gated on `effectiveCanMutate`
  // (which equals canMutate && editMode and powers inline-editing
  // affordances on cells).
  dragEnabled: boolean;
  // #486/#496: mutate permission for the row's quick-actions surface
  // (centered Postpone dialog opened on row click). #486 originally
  // wired this un-gated by editMode; #496 narrows it to
  // `canMutate && !editMode` at the call site so the dialog cannot
  // open while the schedule is in edit mode (inline cell editors take
  // over the row instead). The dialog itself still respects the same
  // MUTATE_ROLES server-side gating as the postpone-minutes endpoint.
  quickActionsCanMutate?: boolean;
  onQuickPostpone?: () => void;
  // #570: opens the full Meeting edit dialog (same one used in
  // ManageSection) and closes the quick-actions surface. Wired by
  // ScheduleSection to its own `setEditingMeeting` lifted state so
  // the Schedule tab can host the edit dialog without switching the
  // user to the Manage tab.
  onQuickEdit?: () => void;
  // #576: Transient visual cue applied when the user entered edit
  // mode via this row's quick-actions "Edit" button. ScheduleSection
  // sets it for ~1.6s on the originating row only so the user can
  // visually confirm which meeting their tap targeted now that the
  // whole schedule is editable.
  highlightEdit?: 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 || "";
  const rowBorder = rowColor?.border || "";

  // Make the row a sortable item. `attributes` get spread onto the <tr>
  // for a11y; `listeners` are wrapped (see `safeRowDragListeners` below)
  // so the user can grab the row from anywhere on it — except over an
  // interactive control (button, link, input, contenteditable, time
  // picker, row-actions menu) where the existing handler should win.
  // The 6px MouseSensor distance + 200ms TouchSensor delay configured
  // at the page level keep clicks/taps on inert cell area from being
  // mistaken for drags, so the quick-actions popover still opens on
  // tap-and-release.
  // #497: drag is gated only by the user's mutate permission. The
  // missing-time block (#492) is gone — every visible row is
  // draggable when the user has mutate access, regardless of whether
  // the meetings on the day have start/end times.
  const effectiveDragEnabled = dragEnabled;
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: meeting.id, disabled: !effectiveDragEnabled });
  const isSkipDragTarget = useCallback(
    (target: EventTarget | null): boolean => {
      const el = target as HTMLElement | null;
      if (!el) return false;
      // Important: useSortable spreads `role="button"` onto our own
      // <tr>, so a naive `closest("[role='button']")` would match the
      // row itself and disable drag for the whole row. Find the
      // closest match and only count it as a skip if it's a DESCENDANT
      // of this row (i.e. not the row tr).
      const rowSelector = `tr[data-testid="em-row-${meeting.id}"]`;
      const interactive = el.closest(
        "button, a, input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='menuitem'], [role='button'], [role='checkbox'], [role='switch'], [role='combobox'], [role='dialog']",
      );
      if (interactive && !interactive.matches(rowSelector)) return true;
      const testid = el.closest(
        [
          `[data-testid="em-row-actions-${meeting.id}"]`,
          `[data-testid="em-row-select-${meeting.id}"]`,
          `[data-testid^="em-edit-"]`,
          `[data-testid^="em-merge-edit-"]`,
          `[data-testid^="em-time-"]`,
        ].join(", "),
      );
      if (testid && !testid.matches(rowSelector)) return true;
      return false;
    },
    [meeting.id],
  );
  const safeRowDragListeners = useMemo(() => {
    if (!listeners || !effectiveDragEnabled) return undefined;
    const out: Record<string, (e: { target: EventTarget | null }) => void> = {};
    for (const k of Object.keys(listeners)) {
      const handler = (listeners as Record<string, (e: unknown) => void>)[k];
      if (typeof handler !== "function") continue;
      out[k] = (e: { target: EventTarget | null }) => {
        if (isSkipDragTarget(e?.target ?? null)) return;
        handler(e);
      };
    }
    return out;
  }, [listeners, effectiveDragEnabled, isSkipDragTarget]);

  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, applyRowColor = false): CSSProperties => {
    const bg = tintBg || (applyRowColor && rowBg) || undefined;
    const bc = (applyRowColor && rowBorder) || undefined;
    if (bg || bc) {
      return { ...cellStyle(col), ...(bg ? { backgroundColor: bg } : null), ...(bc ? { borderColor: bc } : null) };
    }
    return 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",
              ...(rowBorder ? { borderColor: rowBorder } : null),
            };
          }
          if (rowBorder) {
            return { ...cellStyle(col), backgroundColor: rowBorder, borderColor: rowBorder, color: "white" };
          }
          if (rowBg) {
            return { ...cellStyle(col), backgroundColor: rowBg };
          }
          return cellStyle(col);
        })();
        return (
          <td
            key="number"
            className={`relative group ${numberCellCls}`}
            style={numberCellInlineStyle}
          >
            {/*
              #489: The dedicated grip handle is gone — the user now
              drags any meeting from anywhere on its row to a new
              position (the time column + daily numbers stay anchored
              to the physical row, only the meeting content rotates).
              The drag listeners are spread directly onto the <tr>
              below, with a skip-list so clicks on inline editors and
              row-action buttons still reach their own handlers.
            */}
            <div className="flex items-center justify-center gap-1">
              <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, true)}
          >
            <EditableCell
              value={title}
              onSave={onSaveTitle}
              ariaLabel={t("executiveMeetings.editMeetingTitle")}
              dir={isRtl ? "rtl" : "ltr"}
              // #641: pin to cell-start alignment (right in AR, left in EN)
              // for the view-mode wrapper AND any inner `<p>` Tiptap saved —
              // `safeHtml` keeps `style` attributes (so users can colour /
              // size text), which means a stray `text-align: center` saved
              // by the editor would otherwise leak into the cell. The
              // `!` modifier outranks the inline style so the title always
              // reads from the start of the cell regardless of what's
              // stored.
              className="font-medium leading-snug break-words text-start [&_p]:!text-start"
              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, false)}
          >
            <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, false)}
          >
            <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, ...(rowBorder ? { borderColor: rowBorder } : null) }
                : rowBg
                  ? { backgroundColor: rowBg, ...(rowBorder ? { borderColor: rowBorder } : null) }
                  : 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 = (isMerged && 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.
  // #486 → #491: Quick-actions surface. Clicking anywhere on the row
  // that is NOT an interactive control (button, link, input,
  // contenteditable cell, drag handle, row-actions menu, edit
  // affordance, bulk-select checkbox, etc.) opens it. #491 promoted
  // this from a row-anchored popover to a centered Dialog so the
  // action is easy to find regardless of where the row is on screen,
  // and added a coloured frame around the selected row while the
  // dialog is open. Gated on raw `quickActionsCanMutate` (not
  // editMode) per spec.
  const [quickOpen, setQuickOpen] = useState(false);
  // #496: ScheduleSection now passes `quickActionsCanMutate=false`
  // whenever editMode is on. If the user opened the quick-actions
  // dialog and then toggled into edit mode, the Dialog itself
  // unmounts (the conditional below renders the non-mutate branch),
  // but `quickOpen` would stay true and the row would keep its
  // accented `quickOpenShadow` frame + `data-quick-open="true"`
  // attribute — visibly confusing and a stale visual signal. Force-
  // close the local state whenever capability flips off so the row
  // resets to its normal frame.
  useEffect(() => {
    if (!quickActionsCanMutate && quickOpen) setQuickOpen(false);
  }, [quickActionsCanMutate, quickOpen]);

  // #570: bulk-selected rows previously only got a 2px inset ring in
  // navy, which is easy to miss on iPad against the table grid. We now
  // *also* paint a visible navy tint behind the row so the selected
  // state reads at a glance. The tint composes after the per-row
  // `baseBg` so user-picked row colours still win when nothing is
  // selected, but loses to `quickOpenBg` (the open quick-actions
  // surface is a transient, action-scoped state) — see the
  // `backgroundColor` composition in `trStyle` below.
  const selectionShadow = bulkSelected ? "inset 0 0 0 2px #0B1E3F" : null;
  const selectionBg = bulkSelected ? "rgba(11, 30, 63, 0.18)" : null;
  const currentShadow = isCurrent
    ? `inset 0 0 0 2px ${highlightColor}`
    : null;
  // #491: while the row's quick-actions surface is open we draw a
  // thicker accented frame around the row so the user can see at a
  // glance which meeting they're about to act on. Listed FIRST so it
  // becomes the outermost ring and visibly wins over the current-
  // meeting / bulk-select inner rings while open.
  // #496: belt-and-suspenders — also gate on capability so a stale
  // `quickOpen=true` from a render before the effect runs cannot
  // paint the accented frame in edit mode.
  // #563: thicker inset ring + outer glow + tinted background so the
  // originating row is unmistakable on iPad where the default 3px
  // inset is easy to miss against the table grid. The outer
  // `0 0 0 2px` ring sits OUTSIDE the row box (no inset), so the row
  // appears framed on all four sides even when adjacent rows would
  // otherwise visually clip the inset ring.
  const quickOpenShadow = quickActionsCanMutate && quickOpen
    ? `inset 0 0 0 4px ${highlightColor}, 0 0 0 2px ${highlightColor}`
    : null;
  const quickOpenBg = quickActionsCanMutate && quickOpen
    ? hexToRgba(highlightColor, 0.08)
    : null;
  // #499: iPad long-press feedback. dnd-kit's TouchSensor has a 200ms
  // activation delay before a long-press becomes a drag — during that
  // window the user holds the row but sees nothing happen, so they
  // often lift early thinking the schedule didn't register the touch.
  // We track an `isPressing` flag set on touch pointerdown (gated on
  // `pointerType === "touch"` so mouse/pen pointers aren't affected,
  // and on `effectiveDragEnabled` so non-mutators / disabled rows
  // don't get a misleading "you can drag this" cue) and cleared on
  // pointerup / pointercancel. The cue is rendered as a subtle
  // low-alpha inset ring composed AFTER the existing quickOpen /
  // selection / current-meeting rings so it never visually outranks
  // a real selection or current-meeting state. The ring is 2px (same
  // as selection/current) but at 0.45 alpha of `highlightColor`, so
  // a real selection ring (solid `#0B1E3F`) and the current ring
  // (solid `highlightColor`) still read as the dominant visual.
  const [isPressing, setIsPressing] = useState(false);
  // Defensive cleanup: if drag becomes disabled mid-press (capability
  // change, edit-mode flip), clear the stale press cue so we don't
  // paint a "held" ring on a row that can no longer be dragged.
  useEffect(() => {
    if (!effectiveDragEnabled && isPressing) setIsPressing(false);
  }, [effectiveDragEnabled, isPressing]);
  // Per task spec, the cue must appear during the 200ms hold AND
  // continue while the drag is active — so we DO NOT clear
  // `isPressing` on the rising edge of `isDragging`. We only clear
  // on the falling edge (drag just ended) as defensive cleanup for
  // release paths where dnd-kit absorbs the touch sequence and our
  // row's own `pointerup` / `pointercancel` capture listeners never
  // fire (e.g. drop outside the row, sensor-managed cancel). In
  // the normal release path our pointerup handler clears `isPressing`
  // first; this effect is a belt-and-suspenders guard against stale
  // visual state.
  const prevDraggingRef = useRef(false);
  useEffect(() => {
    if (prevDraggingRef.current && !isDragging && isPressing) {
      setIsPressing(false);
    }
    prevDraggingRef.current = isDragging;
  }, [isDragging, isPressing]);
  const pressShadow = isPressing
    ? `inset 0 0 0 2px ${hexToRgba(highlightColor, 0.45)}`
    : null;
  // #576: Brief accent ring on the row the user just opened from the
  // quick-actions "Edit" button. Mirrors quickOpenShadow's "outer
  // ring + inset" composition so it reads as clearly as the open
  // quick-actions surface did, but is purely transient — ScheduleSection
  // clears `highlightEdit` after ~1.6s.
  const editHighlightShadow = highlightEdit
    ? `inset 0 0 0 4px ${highlightColor}, 0 0 0 2px ${highlightColor}`
    : null;
  const editHighlightBg = highlightEdit
    ? hexToRgba(highlightColor, 0.08)
    : null;
  const composedShadow = [
    editHighlightShadow,
    quickOpenShadow,
    selectionShadow,
    currentShadow,
    pressShadow,
  ]
    .filter(Boolean)
    .join(", ");
  const trStyle: CSSProperties = {
    backgroundColor:
      editHighlightBg ?? quickOpenBg ?? selectionBg ?? baseBg,
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
    boxShadow: composedShadow || undefined,
  };
  const handleRowClick = useCallback(
    (e: React.MouseEvent<HTMLTableRowElement>) => {
      if (!quickActionsCanMutate) return;
      const target = e.target as HTMLElement | null;
      if (!target) return;
      // #563: outside edit mode (which is the only state where
      // handleRowClick runs — `quickActionsCanMutate` is force-false
      // in edit mode, see line ~2948), the user expects ANY click
      // anywhere in the row to open the quick-actions surface, not
      // just the attendees cell. We only skip clicks that landed on
      // a *truly* interactive native control (buttons, inputs,
      // links, menu/checkbox/switch/combobox/dialog roles) — the
      // `[role='button']` rule is intentionally DROPPED because
      // TimeRangeCell and EditableInlineText render with role='button'
      // to mark themselves as keyboard-activatable, but their actual
      // edit affordance is gated on `editMode === true` — outside
      // edit mode they are display-only and clicks should bubble.
      // #489 still holds: scope to descendants so the row's own
      // dnd-kit `role="button"` doesn't swallow every click.
      const interactive = target.closest(
        "button, a, input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='menuitem'], [role='checkbox'], [role='switch'], [role='combobox'], [role='dialog']",
      );
      if (interactive && interactive !== e.currentTarget) {
        return;
      }
      // Keep only the affordances that are visible OUTSIDE edit mode
      // and must not trigger quick-actions: the 3-dot row menu and
      // the bulk-select checkbox. The previous `em-edit-*`,
      // `em-merge-edit-*`, and `em-time-*` guards were dropped because
      // those affordances only do anything in edit mode (where this
      // handler never fires).
      if (
        target.closest(
          [
            `[data-testid="em-row-actions-${meeting.id}"]`,
            `[data-testid="em-row-select-${meeting.id}"]`,
          ].join(", "),
        )
      ) {
        return;
      }
      // #491: the quick-actions surface is now a Dialog rendered via
      // Radix's portal, so clicks inside the surface no longer bubble
      // through this row's onClick — the old `closest('em-row-quick-')`
      // guard against re-trigger from popover content is no longer
      // needed.
      setQuickOpen(true);
    },
    [meeting.id, quickActionsCanMutate],
  );
  return (
    <>
      <tr
        ref={setNodeRef}
        className={
          "group" +
          (effectiveDragEnabled ? " cursor-grab active:cursor-grabbing" : "")
        }
        style={trStyle}
        data-testid={`em-row-${meeting.id}`}
        data-current-meeting={isCurrent ? "true" : undefined}
        data-bulk-selected={bulkSelected ? "true" : undefined}
        data-quick-open={quickActionsCanMutate && quickOpen ? "true" : undefined}
        data-pressing={isPressing ? "true" : undefined}
        onClick={quickActionsCanMutate ? handleRowClick : undefined}
        // #499: capture-phase pointer listeners drive the long-press
        // visual cue. We deliberately use the *Capture variants so
        // these handlers don't collide with `safeRowDragListeners`
        // below, which spreads dnd-kit's bubble-phase `onPointerDown`
        // (last spread wins for same-phase props in JSX). Capture
        // fires before bubble, so we set/clear `isPressing` first
        // and dnd-kit's sensor logic still runs after.
        onPointerDownCapture={(e) => {
          if (e.pointerType === "touch" && effectiveDragEnabled) {
            setIsPressing(true);
          }
        }}
        onPointerUpCapture={() => {
          if (isPressing) setIsPressing(false);
        }}
        onPointerCancelCapture={() => {
          if (isPressing) setIsPressing(false);
        }}
        {...(dragEnabled ? attributes : {})}
        {...(safeRowDragListeners ?? {})}
      >
        {renderCells()}
      </tr>
      {quickActionsCanMutate ? (
        // #491: centered Dialog (was a row-anchored Popover). Renders
        // through Radix's portal so the surface always lands in the
        // middle of the viewport regardless of where the row sits, and
        // the row outline (boxShadow above) makes the originating row
        // unmistakable. Test ID kept as `em-row-quick-${id}` so existing
        // e2e specs continue to target the same surface and Postpone
        // trigger.
        <Dialog open={quickOpen} onOpenChange={setQuickOpen}>
          <DialogContent
            className="sm:max-w-sm"
            data-testid={`em-row-quick-${meeting.id}`}
            dir={isRtl ? "rtl" : "ltr"}
          >
            {/* #499: reserve `pr-8` (~2rem) on the header so the
                title text never extends under the Radix close button,
                which is positioned at physical `right-4 top-4` inside
                the DialogContent. The close stays at physical right
                in BOTH AR (RTL) and EN (LTR) because Radix uses CSS
                physical positioning, so padding the header on the
                right is the correct fix for both languages. In AR the
                title `text-right`-aligns to the inside edge of that
                padding (visibly clear of the X); in LTR it stays
                left-aligned with extra trailing room. */}
            <DialogHeader className="pr-8">
              <DialogTitle className={isRtl ? "text-right" : "text-left"}>
                {t("executiveMeetings.quickActions.label")}
              </DialogTitle>
            </DialogHeader>
            {/* #490: polished Postpone button — primary-styled, larger
                tap target for iPad, icon flips with locale via flex
                direction so it stays adjacent to the label in RTL.
                #570: added an Edit (pencil) button alongside Postpone
                that opens the same Meeting edit dialog ManageSection
                uses. Wrapped in a flex row + `flex-wrap` so the two
                buttons sit side by side on iPad/desktop and stack on
                very narrow viewports. Edit button only renders when a
                handler is wired (defensive — quickActionsCanMutate
                already gates the surface itself). */}
            {/* #576: side-by-side, equal-width buttons (was flex-wrap +
                min-w-[11rem], which forced the buttons to STACK on the
                iPhone 6.1" dialog width and gave the Edit button a
                much larger visual weight than Postpone). `flex-1` +
                `min-w-0` lets both buttons share the available row
                width on every viewport (iPhone, iPad, desktop) with
                no wrapping. */}
            <div className="flex justify-center gap-2 pt-2">
              {onQuickEdit ? (
                <Button
                  type="button"
                  variant="outline"
                  size="sm"
                  onClick={() => {
                    setQuickOpen(false);
                    onQuickEdit();
                  }}
                  className={`flex-1 min-w-0 gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
                  data-testid={`em-row-quick-edit-${meeting.id}`}
                >
                  <Pencil className="w-4 h-4 shrink-0" aria-hidden="true" />
                  <span className="truncate">{t("executiveMeetings.quickActions.edit")}</span>
                </Button>
              ) : null}
              <Button
                type="button"
                variant="default"
                size="sm"
                onClick={() => {
                  setQuickOpen(false);
                  onQuickPostpone?.();
                }}
                disabled={meeting.isExternal}
                title={
                  meeting.isExternal
                    ? t("executiveMeetings.alert.externalCannotPostpone")
                    : undefined
                }
                className={`flex-1 min-w-0 gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
                data-testid={`em-row-quick-postpone-${meeting.id}`}
              >
                <Clock className="w-4 h-4 shrink-0" aria-hidden="true" />
                <span className="truncate">{t("executiveMeetings.quickActions.postpone")}</span>
              </Button>
            </div>
          </DialogContent>
        </Dialog>
      ) : null}
    </>
  );
}

// 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 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'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 1–12 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), 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. 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-start 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-start 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-start 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-start 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;
  startTime: string;
  endTime: string;
  location: string;
  meetingUrl: string;
  platform: Meeting["platform"];
  status: string;
  isHighlighted: boolean;
  notes: string;
  attendees: Attendee[];
  // #635: external meeting flag — when true the row is force-tinted red
  // and the alert-postpone button is disabled.
  isExternal: boolean;
};

// 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,
    startTime: "",
    endTime: "",
    location: "",
    meetingUrl: "",
    platform: "none",
    status: "scheduled",
    isHighlighted: false,
    notes: "",
    attendees: [],
    isExternal: false,
  };
}

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);
  // Sequential bulk-delete progress (mirrors duplicate UX).
  const [bulkDeleteProgress, setBulkDeleteProgress] = useState<
    { current: number; total: number } | null
  >(null);
  const bulkDeleteCancelRef = useRef(false);
  const [bulkDuplicateOpen, setBulkDuplicateOpen] = useState(false);
  const [bulkDuplicateDate, setBulkDuplicateDate] = useState<string>(date);
  const [bulkDuplicating, setBulkDuplicating] = useState(false);
  // See ScheduleSection's performBulkDuplicate comment for the
  // race rationale — same fix applied here in ManageSection.
  const [bulkDuplicateProgress, setBulkDuplicateProgress] = useState<
    { current: number; total: number } | null
  >(null);
  const bulkDuplicateCancelRef = useRef(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);
    bulkDuplicateCancelRef.current = false;
    setBulkDuplicateProgress({ current: 0, total: ids.length });
    let ok = 0;
    let failed = 0;
    try {
      for (const id of ids) {
        if (bulkDuplicateCancelRef.current) break;
        try {
          await apiJson(`/api/executive-meetings/${id}/duplicate`, {
            method: "POST",
            body: { targetDate: bulkDuplicateDate },
          });
          ok++;
        } catch {
          failed++;
        }
        setBulkDuplicateProgress({ current: ok + failed, total: ids.length });
      }
      const total = ok + failed;
      if (total > 0) {
        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(total))
              .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);
      setBulkDuplicateProgress(null);
      bulkDuplicateCancelRef.current = 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);
    bulkDeleteCancelRef.current = false;
    setBulkDeleteProgress({ current: 0, total: ids.length });
    let ok = 0;
    let failed = 0;
    try {
      for (const id of ids) {
        if (bulkDeleteCancelRef.current) break;
        try {
          await apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" });
          ok++;
        } catch {
          failed++;
        }
        setBulkDeleteProgress({ current: ok + failed, total: ids.length });
      }
      const total = ok + failed;
      if (total > 0) {
        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(total))
              .replace("{{failed}}", String(failed)),
            variant: "destructive",
          });
        }
      }
      setSelectedMeetingIds(new Set());
      await qc.invalidateQueries({
        queryKey: ["/api/executive-meetings", date],
      });
    } finally {
      setBulkDeleting(false);
      setBulkDeleteProgress(null);
      bulkDeleteCancelRef.current = false;
    }
  }, [
    bulkDeleting,
    selectedMeetingIds,
    filteredMeetings,
    qc,
    date,
    confirm,
    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: htmlToPlainText(m.titleAr),
      titleEn: htmlToPlainText(m.titleEn ?? ""),
      meetingDate: m.meetingDate,
      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,
        name: htmlToPlainText(a.name),
        _sid: genAttendeeSid(),
      })),
      isExternal: m.isExternal ?? false,
    });
  }

  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 titleEnPlain = 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.: 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: wrapAsParagraph(editing.titleAr),
      titleEn: wrapAsParagraph(titleEnPlain),
      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: wrapAsParagraph(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",
      })),
      // #635/#639: send the external flag as a real boolean so the
      // server can force rowColor='red' and the cascade/postpone rules
      // know to skip it. Coerce defensively — a stale render where
      // state.isExternal is undefined would otherwise silently send
      // `undefined` and the server PATCH would skip the column.
      isExternal: Boolean(editing.isExternal),
    };
    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 flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-3"
        style={{ top: "var(--em-header-h, 0px)" }}
      >
        <h2 className="text-lg sm:text-2xl font-bold text-[#0B1E3F]">
          {t("executiveMeetings.manage.heading")}
        </h2>
        {/* #332: in-day search box. Full-width on mobile so the user
            can read what they're typing; sm: shrinks back to inline. */}
        <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-2 w-full sm:w-auto">
          <div className="relative w-full sm:w-auto">
            <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-9 w-full 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-2" : "right-2")
                }
              >
                <X className="w-3.5 h-3.5" />
              </button>
            )}
          </div>
          <div className="flex items-center gap-2">
            <label className="flex items-center gap-2 text-sm flex-1 sm:flex-none">
              <span className="text-muted-foreground hidden sm:inline">{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.5 text-sm bg-white w-full sm:w-auto"
              />
            </label>
            <Button onClick={openCreate} className="bg-[#0B1E3F] gap-1 shrink-0" data-testid="em-add-meeting">
              <Plus className="w-4 h-4" />
              <span>{t("executiveMeetings.manage.addMeeting")}</span>
            </Button>
          </div>
        </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 (: 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>
          {bulkDuplicating && bulkDuplicateProgress ? (
            <div className="space-y-2 pt-2">
              <div className="text-sm text-muted-foreground text-center">
                {t("executiveMeetings.schedule.bulkDuplicateProgress")
                  .replace("{{current}}", String(bulkDuplicateProgress.current))
                  .replace("{{total}}", String(bulkDuplicateProgress.total))
                  .replace(
                    "{{percent}}",
                    String(
                      Math.round(
                        (bulkDuplicateProgress.current /
                          Math.max(bulkDuplicateProgress.total, 1)) *
                          100,
                      ),
                    ),
                  )}
              </div>
              <Progress
                value={
                  (bulkDuplicateProgress.current /
                    Math.max(bulkDuplicateProgress.total, 1)) *
                  100
                }
              />
              <div className="flex justify-end pt-1">
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={() => {
                    bulkDuplicateCancelRef.current = true;
                  }}
                >
                  {t("executiveMeetings.common.cancel")}
                </Button>
              </div>
            </div>
          ) : (
            <DialogFooter>
              <Button
                variant="ghost"
                onClick={() => setBulkDuplicateOpen(false)}
              >
                {t("executiveMeetings.common.cancel")}
              </Button>
              <Button
                onClick={() => void performBulkDuplicate()}
                disabled={!bulkDuplicateDate}
                className="bg-[#0B1E3F]"
                data-testid="em-manage-bulk-duplicate-confirm"
              >
                {t("executiveMeetings.schedule.bulkDuplicateConfirm")}
              </Button>
            </DialogFooter>
          )}
        </DialogContent>
      </Dialog>

      {/* Bulk-delete progress dialog (ManageSection mirror). */}
      <Dialog
        open={bulkDeleting && bulkDeleteProgress !== null}
        onOpenChange={() => {
          /* Non-dismissible while running. */
        }}
      >
        <DialogContent>
          <DialogHeader>
            <DialogTitle>
              {t("executiveMeetings.schedule.bulkDeleteSelected")}
            </DialogTitle>
          </DialogHeader>
          {bulkDeleteProgress && (
            <div className="space-y-2 pt-2">
              <div className="text-sm text-muted-foreground text-center">
                {t("executiveMeetings.schedule.bulkDeleteProgress")
                  .replace("{{current}}", String(bulkDeleteProgress.current))
                  .replace("{{total}}", String(bulkDeleteProgress.total))
                  .replace(
                    "{{percent}}",
                    String(
                      Math.round(
                        (bulkDeleteProgress.current /
                          Math.max(bulkDeleteProgress.total, 1)) *
                          100,
                      ),
                    ),
                  )}
              </div>
              <Progress
                value={
                  (bulkDeleteProgress.current /
                    Math.max(bulkDeleteProgress.total, 1)) *
                  100
                }
              />
              <div className="flex justify-end pt-1">
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={() => {
                    bulkDeleteCancelRef.current = true;
                  }}
                >
                  {t("executiveMeetings.common.cancel")}
                </Button>
              </div>
            </div>
          )}
        </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="w-[calc(100vw-1rem)] sm:w-full max-w-3xl max-h-[92vh] p-3 sm:p-6 flex flex-col gap-2 sm:gap-4 overflow-hidden rounded-2xl">
        <DialogHeader className="shrink-0">
          <DialogTitle className="text-base sm:text-lg pe-8">
            {state.id == null
              ? t("executiveMeetings.manage.addMeeting")
              : t("executiveMeetings.manage.editMeeting")}
          </DialogTitle>
        </DialogHeader>
        <div className="flex-1 min-h-0 overflow-y-auto -mx-3 px-3 sm:-mx-6 sm:px-6 space-y-3 sm:space-y-4">
        {/* #322 polish (grid): title spans full width. On phones every
            field stacks (single column) so labels/inputs don't get
            crammed and hints stay tied to their own input. From sm+
            date+daily-number share a row and time pickers share a row. */}
        <div className="grid grid-cols-1 sm: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>
          {/* #635/#639: external-meeting toggle replaces the
              daily-number input (numbering is now fully auto-assigned
              by the server). When ON the row is force-tinted red and
              the alert-postpone button is disabled.
              `full` so it spans both grid columns on its own row —
              prevents the toggle from sharing a row with the date
              input and colliding with the time pickers below on
              narrow screens. */}
          <FormRow
            label={t("executiveMeetings.manage.field.isExternal")}
            hint={t("executiveMeetings.manage.field.isExternalHint")}
            full
          >
            <div className="flex items-center gap-3 py-1">
              <Switch
                checked={state.isExternal}
                onCheckedChange={(v) => update("isExternal", Boolean(v))}
                data-testid="em-form-isExternal"
              />
              <span className="text-sm text-gray-700">
                {state.isExternal
                  ? t("executiveMeetings.manage.field.isExternalOn")
                  : t("executiveMeetings.manage.field.isExternalOff")}
              </span>
            </div>
          </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 SearchToggle({
  searchQuery,
  onSearchChange,
  placeholder,
  ariaLabel,
  clearLabel,
  isRtl,
}: {
  searchQuery: string;
  onSearchChange: (v: string) => void;
  placeholder: string;
  ariaLabel: string;
  clearLabel: string;
  isRtl: boolean;
}) {
  const [open, setOpen] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (open) inputRef.current?.focus();
  }, [open]);

  if (!open && !searchQuery) {
    return (
      <button
        type="button"
        onClick={() => setOpen(true)}
        aria-label={ariaLabel}
        title={ariaLabel}
        className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white text-[#0B1E3F] w-8 h-8 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-[#0B1E3F]/40 print:hidden"
      >
        <Search className="w-4 h-4" />
      </button>
    );
  }

  return (
    <div className="relative print:hidden flex items-center gap-1">
      <Search className="w-4 h-4 text-muted-foreground shrink-0" />
      <Input
        ref={inputRef}
        type="search"
        value={searchQuery}
        onChange={(e) => onSearchChange(e.target.value)}
        placeholder={placeholder}
        aria-label={ariaLabel}
        data-testid="em-schedule-search"
        className="h-8 w-36 sm:w-48 text-sm bg-white"
      />
      <button
        type="button"
        onClick={() => { onSearchChange(""); setOpen(false); }}
        aria-label={clearLabel}
        title={clearLabel}
        data-testid="em-schedule-search-clear"
        className="text-muted-foreground hover:text-[#0B1E3F]"
      >
        <X className="w-3.5 h-3.5" />
      </button>
    </div>
  );
}

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"
      : "";
  // `min-w-0` is required so children with `w-full` (or intrinsic widths,
  // like iOS native `<input type="date">`) can't push the grid column
  // wider than its track. Without it, RTL grids on iPad landscape end up
  // with one cell overflowing across its neighbour. `w-full` on the
  // inner wrapper makes sure the input fills its column.
  return (
    <div className={`${span} min-w-0`}>
      <Label className="text-xs text-gray-700">{label}</Label>
      <div className="mt-1 w-full min-w-0">{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",
    "isExternal",
    "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;

  // Translate a field name (e.g. "titleAr") into the user's locale,
  // falling back to the raw key so a missing label never breaks the row.
  const labelForKey = (k: string): string =>
    tWithFallback(t, `executiveMeetings.audit.field.${k}`, k);

  // Translate enum-style values inside the diff (platform/status tokens,
  // booleans for the highlighted flag) so an Arabic UI doesn't surface
  // raw English database tokens like "scheduled" or "webex".
  const fmtValue = (key: string, v: unknown): string => {
    if (v === null || v === undefined || v === "") return "—";
    if (key === "platform" && typeof v === "string") {
      return tWithFallback(t, `executiveMeetings.manage.platform.${v}`, v);
    }
    if (key === "status" && typeof v === "string") {
      return tWithFallback(t, `executiveMeetings.manage.status.${v}`, v);
    }
    if (key === "isHighlighted") {
      const truthy = v === true || v === 1 || v === "1" || v === "true";
      return tWithFallback(
        t,
        truthy ? "executiveMeetings.common.yes" : "executiveMeetings.common.no",
        truthy ? "Yes" : "No",
      );
    }
    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]">
              {labelForKey(d.key)}
            </span>
            :{" "}
            <span className="text-red-700 line-through">
              {fmtValue(d.key, d.from)}
            </span>
            <span className="mx-1 text-gray-400">→</span>
            <span className="text-green-700">{fmtValue(d.key, 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">{labelForKey(k)}</span>:{" "}
            {fmtValue(k, single[k])}
          </li>
        ))}
      </ul>
    </div>
  );
}


function AuditSection({
  canView,
  isRtl,
  t,
}: {
  canView: boolean;
  isRtl: boolean;
  t: (k: string) => string;
}) {
  const isMobile = useIsMobile();
  const lang = isRtl ? "ar" : "en";
  const [date, setDate] = useState("");
  const [dateTo, setDateTo] = useState("");
  const [entityType, setEntityType] = useState("all");
  const [actionFilter, setActionFilter] = useState("all");
  // #616-followup: actor filter is now a user id picked from a searchable
  // combobox of active users (replaces the old free-form numeric input —
  // no one memorises raw user IDs). Empty string = no filter.
  const [actorFilter, setActorFilter] = useState<number | "">("");
  const [actorPickerOpen, setActorPickerOpen] = useState(false);
  const { data: directory } = useQuery<UserSummary[]>({
    queryKey: ["users", "directory"],
    queryFn: () => apiJson<UserSummary[]>("/api/users/directory"),
    enabled: canView,
  });
  const actors = directory ?? [];
  const selectedActor =
    actorFilter !== "" ? actors.find((u) => u.id === actorFilter) ?? null : null;
  const actorDisplay = (u: UserSummary) =>
    (lang === "ar"
      ? u.displayNameAr ?? u.displayNameEn
      : u.displayNameEn ?? u.displayNameAr) ?? u.username;
  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 !== "") qs.set("actorId", String(actorFilter));
      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>
          <Popover open={actorPickerOpen} onOpenChange={setActorPickerOpen}>
            <PopoverTrigger asChild>
              <button
                type="button"
                aria-haspopup="listbox"
                aria-expanded={actorPickerOpen}
                data-testid="em-audit-actor"
                className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9 w-44 text-start flex items-center justify-between gap-2 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
              >
                <span
                  className={cn(
                    "truncate",
                    selectedActor ? "text-foreground" : "text-muted-foreground",
                  )}
                >
                  {selectedActor
                    ? actorDisplay(selectedActor)
                    : t("executiveMeetings.audit.filter.actorPlaceholder")}
                </span>
                <ChevronDown size={14} className="shrink-0 opacity-60" />
              </button>
            </PopoverTrigger>
            <PopoverContent className="w-[260px] p-0" align="start">
              <Command
                filter={(value, search) => {
                  if (!search) return 1;
                  return value.toLowerCase().includes(search.toLowerCase())
                    ? 1
                    : 0;
                }}
              >
                <CommandInput
                  placeholder={t("executiveMeetings.audit.filter.actorSearch")}
                  data-testid="em-audit-actor-search"
                />
                <CommandList>
                  <CommandEmpty>
                    {t("executiveMeetings.audit.filter.actorEmpty")}
                  </CommandEmpty>
                  <CommandGroup>
                    <CommandItem
                      value="__any__"
                      onSelect={() => {
                        setActorFilter("");
                        setActorPickerOpen(false);
                      }}
                      data-testid="em-audit-actor-option-any"
                    >
                      <span className="text-muted-foreground">
                        {t("executiveMeetings.audit.filter.actorPlaceholder")}
                      </span>
                    </CommandItem>
                    {actors.map((u) => {
                      const display = actorDisplay(u);
                      return (
                        <CommandItem
                          key={u.id}
                          value={`${display} ${u.username} @${u.username}`}
                          onSelect={() => {
                            setActorFilter(u.id);
                            setActorPickerOpen(false);
                          }}
                          data-testid={`em-audit-actor-option-${u.id}`}
                        >
                          <div className="min-w-0 flex-1">
                            <div className="text-sm text-foreground truncate">
                              {display}
                            </div>
                            <div className="text-[11px] text-muted-foreground truncate">
                              @{u.username}
                            </div>
                          </div>
                        </CommandItem>
                      );
                    })}
                  </CommandGroup>
                </CommandList>
              </Command>
            </PopoverContent>
          </Popover>
          <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>

      {/* Desktop / tablet ≥ md: full table. Phone < md: stacked cards
          so 5 columns don't get squashed into a 390px viewport. */}
      {!isMobile ? (
        <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">{t("executiveMeetings.audit.col.actor")}</th>
                <th className="px-3 py-2 text-start w-44">{t("executiveMeetings.audit.col.when")}</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">{t("executiveMeetings.audit.col.changes")}</th>
              </tr>
            </thead>
            <tbody>
              {isLoading && (
                <tr>
                  <td colSpan={5} className="py-8 text-center text-gray-500">
                    {t("common.loading")}
                  </td>
                </tr>
              )}
              {!isLoading && entries.length === 0 && (
                <tr>
                  <td colSpan={5} 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">{actor}</td>
                    <td className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap">
                      {formatAuditDateTime(e.performedAt, lang)}
                    </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">
                      <AuditDiffSummary
                        oldValue={e.oldValue}
                        newValue={e.newValue}
                        t={t}
                      />
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      ) : (
        <div className="space-y-2" dir={isRtl ? "rtl" : "ltr"}>
          {isLoading && (
            <div className="bg-white border border-gray-200 rounded-md py-8 text-center text-sm text-gray-500">
              {t("common.loading")}
            </div>
          )}
          {!isLoading && entries.length === 0 && (
            <div className="bg-white border border-gray-200 rounded-md py-8 text-center text-sm text-gray-500">
              {t("executiveMeetings.audit.empty")}
            </div>
          )}
          {entries.map((e) => {
            const actor = e.actor
              ? isRtl
                ? e.actor.displayNameAr || e.actor.username
                : e.actor.displayNameEn || e.actor.username
              : "—";
            return (
              <div
                key={e.id}
                className="bg-white border border-gray-200 rounded-md p-3 space-y-2 shadow-sm"
                data-testid={`em-audit-row-${e.id}`}
              >
                <div className="flex items-start justify-between gap-3">
                  <div className="font-semibold text-[#0B1E3F] text-sm leading-tight">
                    {actor}
                  </div>
                  <div className="text-[11px] text-gray-500 tabular-nums whitespace-nowrap shrink-0">
                    {formatAuditDateTime(e.performedAt, lang)}
                  </div>
                </div>
                <div className="flex items-center gap-2 flex-wrap">
                  <span className="inline-flex items-center text-[11px] font-medium bg-[#0B1E3F]/10 text-[#0B1E3F] rounded px-1.5 py-0.5">
                    {tWithFallback(
                      t,
                      `executiveMeetings.audit.action.${e.action}`,
                      e.action,
                    )}
                  </span>
                  <span className="inline-flex items-center text-[11px] text-gray-600 bg-gray-100 rounded px-1.5 py-0.5">
                    {tWithFallback(
                      t,
                      `executiveMeetings.audit.entity.${e.entityType}`,
                      e.entityType,
                    )}
                  </span>
                </div>
                <div className="text-xs text-gray-700 border-t border-gray-100 pt-2">
                  <AuditDiffSummary
                    oldValue={e.oldValue}
                    newValue={e.newValue}
                    t={t}
                  />
                </div>
              </div>
            );
          })}
        </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>
  );
}

// ============ 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)}`;
      const dayName = new Intl.DateTimeFormat(lang === "ar" ? "ar-SA" : "en-US", { weekday: "long" }).format(new Date(date + "T00:00:00"));
      const fileName = lang === "ar" ? `قائمة الاجتماعات يوم ${dayName}.pdf` : `meetings-${dayName}.pdf`;
      await downloadBinary(url, fileName);
      // 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">
                      {formatAuditDateTime(a.generatedAt, lang)} · {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,
  globalFont,
  globalLogoObjectPath,
  canEditGlobalFont,
  columns,
  onColumnsChange,
  highlightPrefs,
  onHighlightPrefsChange,
  t,
}: {
  font: FontPrefs;
  globalFont: FontPrefs | null;
  globalLogoObjectPath: string | null;
  canEditGlobalFont: boolean;
  columns: ColumnSetting[];
  onColumnsChange: (next: ColumnSetting[]) => void;
  highlightPrefs: HighlightPrefs;
  onHighlightPrefsChange: (next: HighlightPrefs) => void;
  t: (k: string) => string;
}) {
  return (
    <div className="space-y-6">
      <Collapsible asChild>
        <section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
          <CollapsibleTrigger asChild>
            <button type="button" className="w-full flex items-center justify-between group cursor-pointer">
              <h2 className="text-base font-semibold text-[#0B1E3F] flex items-center gap-2">
                <Sliders className="w-4 h-4" />
                {t("executiveMeetings.customize.title")}
              </h2>
              <ChevronDown className="w-4 h-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
            </button>
          </CollapsibleTrigger>
          <CollapsibleContent className="mt-3">
            <ColumnsCustomizerPanel
              columns={columns}
              onChange={onColumnsChange}
              t={t}
              highlightPrefs={highlightPrefs}
              onHighlightPrefsChange={onHighlightPrefsChange}
            />
          </CollapsibleContent>
        </section>
      </Collapsible>
      <Collapsible asChild>
        <section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
          <CollapsibleTrigger asChild>
            <button type="button" className="w-full flex items-center justify-between group cursor-pointer">
              <h2 className="text-base font-semibold text-[#0B1E3F] flex items-center gap-2">
                <Bell className="w-4 h-4" />
                {t("executiveMeetings.alertPrefs.title")}
              </h2>
              <ChevronDown className="w-4 h-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
            </button>
          </CollapsibleTrigger>
          <CollapsibleContent className="mt-3">
            <AlertPrefsCard t={t} />
          </CollapsibleContent>
        </section>
      </Collapsible>
      <Collapsible asChild>
        <section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
          <CollapsibleTrigger asChild>
            <button type="button" className="w-full flex items-center justify-between group cursor-pointer">
              <h2 className="text-base font-semibold text-[#0B1E3F] flex items-center gap-2">
                <Sliders className="w-4 h-4" />
                {t("executiveMeetings.fontSettingsPage.heading")}
              </h2>
              <ChevronDown className="w-4 h-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
            </button>
          </CollapsibleTrigger>
          <CollapsibleContent className="mt-3">
            <FontSettingsSection
              font={font}
              globalFont={globalFont}
              globalLogoObjectPath={globalLogoObjectPath}
              canEditGlobal={canEditGlobalFont}
              t={t}
            />
          </CollapsibleContent>
        </section>
      </Collapsible>
    </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 className="flex items-center justify-between gap-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,
  globalFont,
  globalLogoObjectPath,
  canEditGlobal,
  t,
}: {
  font: FontPrefs;
  globalFont: FontPrefs | null;
  globalLogoObjectPath: string | null;
  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 [logoPath, setLogoPath] = useState<string | null>(globalLogoObjectPath);
  const [saving, setSaving] = useState(false);

  useEffect(() => {
    setPrefs(scope === "global" ? (globalFont ?? DEFAULT_FONT) : font);
  }, [font, globalFont, scope]);
  useEffect(() => {
    setLogoPath(globalLogoObjectPath);
  }, [globalLogoObjectPath]);

  // Logo is global-only; persisted on Save.
  const { uploadFile: uploadLogo, isUploading: isUploadingLogo } = useUpload({
    onSuccess: (resp: UploadResponse) => setLogoPath(resp.objectPath),
    onError: (err: Error) =>
      toast({
        title: t("executiveMeetings.fontSettingsPage.logo.uploadFailed"),
        description: err.message,
        variant: "destructive",
      }),
  });

  async function save() {
    setSaving(true);
    try {
      const body: Record<string, unknown> = { scope, ...prefs };
      if (canEditGlobal) {
        body.logoObjectPath = logoPath;
      }
      await apiJson(`/api/executive-meetings/font-settings`, {
        method: "PATCH",
        body,
      });
      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);
  }

  function onLogoFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    // Reset input so re-picking the same file fires onChange.
    e.target.value = "";
    if (!file) return;
    void uploadLogo(file);
  }

  return (
    <div className="space-y-4 max-w-2xl">
      <div className="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>
        <FormRow label={t("executiveMeetings.fontSettingsPage.fontColor")}>
          <div className="flex items-center gap-2">
            {/*
              Native color picker keeps the UI dependency-free; we mirror
              its value into a text input so users (and screen readers,
              and Playwright tests) can read/type the hex directly. The
              text input is loosely validated — the API re-validates with
              a strict /^#[0-9a-fA-F]{6}$/ regex.
            */}
            <input
              type="color"
              value={prefs.fontColor}
              onChange={(e) => setPrefs({ ...prefs, fontColor: e.target.value })}
              className="h-9 w-12 rounded border border-gray-300 cursor-pointer"
              data-testid="em-font-color"
              aria-label={t("executiveMeetings.fontSettingsPage.fontColor")}
            />
            <Input
              value={prefs.fontColor}
              onChange={(e) => setPrefs({ ...prefs, fontColor: e.target.value })}
              className="font-mono text-sm w-32"
              maxLength={7}
            />
          </div>
        </FormRow>
        {canEditGlobal && (
          <FormRow label={t("executiveMeetings.fontSettingsPage.logo.label")}>
            <div className="flex flex-col gap-2">
              {logoPath && (
                <div className="flex items-center gap-3">
                  {/*
                    Stored object paths look like `/objects/<id>` but
                    the API actually serves them at
                    `/api/storage/objects/<id>`. resolveServiceImageUrl
                    centralises that prefix mapping (same helper the
                    chat avatars use) so the preview thumbnail loads.
                  */}
                  <img
                    src={resolveServiceImageUrl(logoPath) ?? ""}
                    alt="logo"
                    className="h-14 w-14 object-contain border border-gray-200 rounded bg-white"
                  />
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => setLogoPath(null)}
                    data-testid="em-logo-remove"
                  >
                    {t("executiveMeetings.fontSettingsPage.logo.remove")}
                  </Button>
                </div>
              )}
              <label className="inline-flex items-center gap-2 cursor-pointer text-sm text-[#0B1E3F] hover:underline">
                <input
                  type="file"
                  accept="image/png,image/jpeg"
                  className="hidden"
                  onChange={onLogoFileChange}
                  disabled={isUploadingLogo}
                  data-testid="em-logo-file"
                />
                <span>
                  {isUploadingLogo
                    ? t("executiveMeetings.fontSettingsPage.logo.uploading")
                    : logoPath
                      ? t("executiveMeetings.fontSettingsPage.logo.replace")
                      : t("executiveMeetings.fontSettingsPage.logo.upload")}
                </span>
              </label>
              <p className="text-xs text-gray-500">
                {t("executiveMeetings.fontSettingsPage.logo.globalOnly")}
              </p>
            </div>
          </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>
  );
}
