import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "wouter"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, ArrowRight, CalendarClock, FileDown, Type, Languages, ListChecks, ClipboardList, CheckSquare, ListTodo, Bell, ScrollText, FileText, Settings as SettingsIcon, Plus, Pencil, Trash2, Check, X, ChevronUp, ChevronDown, Copy, Eye, EyeOff, RotateCcw, Palette, Sliders, Combine, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { useToast } from "@/hooks/use-toast"; import { DndContext, PointerSensor, KeyboardSensor, TouchSensor, useSensor, useSensors, closestCenter, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, horizontalListSortingStrategy, verticalListSortingStrategy, useSortable, sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GripVertical } from "lucide-react"; import { EditableCell } from "@/components/editable-cell"; import { safeHtml } from "@/lib/safe-html"; import { formatTime as i18nFormatTime } from "@/lib/i18n-format"; type Attendee = { id?: number; meetingId?: number; name: string; title: string | null; attendanceType: "internal" | "virtual" | "external"; sortOrder: number; }; 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; }; type DayResponse = { date: string; meetings: Meeting[] }; type MeRoles = { userId: number; roles: string[]; canRead: boolean; canMutate: boolean; canApprove: boolean; canSubmitRequest: boolean; canViewAudit: boolean; canViewTasks: boolean; canViewAllTasks: boolean; }; type RequestRow = { id: number; meetingId: number | null; requestedBy: number | null; requestType: string; requestDetails: unknown; status: "new" | "approved" | "rejected" | "withdrawn"; reviewedBy: number | null; reviewDecision: string | null; reviewNotes: string | null; createdAt: string; updatedAt: string; requester: { username: string; displayNameAr: string | null; displayNameEn: string | null; } | null; }; type TaskRow = { id: number; requestId: number | null; meetingId: number | null; assignedTo: number | null; taskType: string; status: "pending" | "in_progress" | "completed" | "cancelled"; dueAt: string | null; completedAt: string | null; notes: string | null; createdAt: string; updatedAt: string; assignee: { username: string; displayNameAr: string | null; displayNameEn: string | null; } | null; }; type AuditEntry = { id: number; action: string; entityType: string; entityId: number | null; oldValue: unknown; newValue: unknown; performedBy: number | null; performedAt: string; actor: { username: string; displayNameAr: string | null; displayNameEn: string | null; } | null; }; type NotificationRow = { id: number; meetingId: number | null; userId: number | null; notificationType: string; scheduledAt: string | null; sentAt: string | null; status: string; createdAt: string; user: { username: string; displayNameAr: string | null; displayNameEn: string | null; } | null; }; type FontPrefs = { fontFamily: string; fontSize: number; fontWeight: "regular" | "bold"; alignment: "start" | "center"; }; type FontSettingsResponse = { global: ({ scope: "global"; userId: null } & FontPrefs) | null; user: ({ scope: "user"; userId: number } & FontPrefs) | null; }; type PdfArchive = { id: number; archiveDate: string; filePath: string; version: number; // Bytes recorded by the server-side generator. Null on legacy rows // that were created before we wrote the PDF to object storage. byteSize: number | null; generatedBy: number | null; generatedAt: string; generator: { username: string; displayNameAr: string | null; displayNameEn: string | null; } | null; }; function formatBytesForDisplay(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; const units = ["B", "KB", "MB"]; let value = bytes; let unitIdx = 0; while (value >= 1024 && unitIdx < units.length - 1) { value /= 1024; unitIdx += 1; } return `${value < 10 && unitIdx > 0 ? value.toFixed(1) : Math.round(value)} ${units[unitIdx]}`; } const SECTIONS = [ { key: "schedule", icon: CalendarClock }, { key: "manage", icon: ListChecks }, { key: "requests", icon: ClipboardList }, { key: "approvals", icon: CheckSquare }, { key: "tasks", icon: ListTodo }, { key: "notifications", icon: Bell }, { key: "audit", icon: ScrollText }, { key: "pdf", icon: FileText }, { key: "fontSettings", icon: SettingsIcon }, ] as const; type SectionKey = (typeof SECTIONS)[number]["key"]; type MeCapabilities = { userId: number; roles: string[]; canRead: boolean; canMutate: boolean; canApprove: boolean; canSubmitRequest: boolean; canViewAudit: boolean; canViewTasks: boolean; canViewAllTasks: boolean; }; 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 "fontSettings": return me.canRead; case "manage": return me.canMutate; case "requests": return me.canSubmitRequest || me.canApprove; case "approvals": return me.canApprove; case "tasks": // CEO and viewer roles never see it. return me.canViewTasks; case "notifications": return me.canRead; case "audit": return me.canViewAudit; default: return false; } } const DEFAULT_FONT: FontPrefs = { fontFamily: "system", fontSize: 14, fontWeight: "regular", alignment: "start", }; type ColumnId = "number" | "meeting" | "attendees" | "time"; type ColumnSetting = { id: ColumnId; visible: boolean; width: number; }; const DEFAULT_COLUMNS: ColumnSetting[] = [ { id: "number", visible: true, width: 56 }, { id: "meeting", visible: true, width: 320 }, { id: "attendees", visible: true, width: 600 }, // Wide enough to fit "HH:MM – HH:MM" on one line, plus the two // controls when the cell enters edit mode. { id: "time", visible: true, width: 200 }, ]; const MIN_COL_WIDTH = 56; const MAX_COL_WIDTH = 1200; const ROW_COLOR_OPTIONS: { key: string; bg: string; label: string }[] = [ { key: "default", bg: "", label: "default" }, { key: "red", bg: "#fee2e2", label: "red" }, { key: "amber", bg: "#fef3c7", label: "amber" }, { key: "green", bg: "#dcfce7", label: "green" }, { key: "blue", bg: "#dbeafe", label: "blue" }, { key: "violet", bg: "#ede9fe", label: "violet" }, { key: "gray", bg: "#f3f4f6", label: "gray" }, ]; const COLS_STORAGE_KEY = "em-schedule-cols-v1"; const ROW_COLORS_STORAGE_KEY = "em-schedule-row-colors-v1"; const HIGHLIGHT_STORAGE_KEY = "em-current-meeting-highlight-v1"; type HighlightPrefs = { enabled: boolean; color: string }; const DEFAULT_HIGHLIGHT_PREFS: HighlightPrefs = { enabled: true, color: "#16a34a", }; const HIGHLIGHT_COLOR_SWATCHES = [ "#16a34a", "#2563eb", "#dc2626", "#ea580c", "#7c3aed", "#0B1E3F", ]; function parseTimeToMinutes(t: string | null): number | null { if (!t) return null; const m = /^(\d{2}):(\d{2})/.exec(t); if (!m) return null; const h = Number(m[1]); const mm = Number(m[2]); if (!Number.isFinite(h) || !Number.isFinite(mm)) return null; return h * 60 + mm; } /** * Returns the id of the meeting that is currently in progress on `dateIso`, * or null. A meeting is "current" when the wall clock falls within * [startTime, endTime) and the displayed date matches today. */ function computeCurrentMeetingId( meetings: Meeting[], dateIso: string, nowMs: number, ): number | null { const now = new Date(nowMs); const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; if (todayIso !== dateIso) return null; const minutesNow = now.getHours() * 60 + now.getMinutes(); for (const m of meetings) { const s = parseTimeToMinutes(m.startTime); const e = parseTimeToMinutes(m.endTime); if (s === null || e === null) continue; if (s <= minutesNow && minutesNow < e) return m.id; } return null; } function formatTime(t: string | null, lang: "ar" | "en"): string { if (!t) return ""; // DB stores HH:mm:ss (24h). Build a Date anchored to a fixed day so // Intl can format the time-of-day in the user's language with AM/PM // (English) or ص/م (Arabic). Latin digits are enforced via the shared // formatTime helper. 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; return i18nFormatTime(parsed, lang, { hour: "numeric", minute: "2-digit", hour12: true, }); } 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, }; } function tWithFallback( t: (key: string) => string, key: string, fallback: string, ): string { const value = t(key); return value === key ? fallback : value; } async function apiJson( url: string, init?: Omit & { body?: unknown }, ): Promise { const { body: rawBody, headers: rawHeaders, ...rest } = init ?? {}; const opts: RequestInit = { credentials: "include", ...rest, headers: { "Content-Type": "application/json", ...(rawHeaders ?? {}) }, }; if (rawBody !== undefined) { opts.body = typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody); } const res = await fetch(url, opts); if (res.status === 204) return undefined as T; const data = (await res.json().catch(() => ({}))) as T & { error?: string }; if (!res.ok) { throw new Error((data as { error?: string }).error || `HTTP ${res.status}`); } return data; } function readJsonFromStorage(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(); const out: ColumnSetting[] = []; for (const entry of value) { if (!entry || typeof entry !== "object") continue; const e = entry as Partial; 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; } export default function ExecutiveMeetingsPage() { 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("schedule"); const [date, setDate] = useState(todayIso()); const { data, isLoading } = useQuery({ 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({ queryKey: ["/api/executive-meetings/me"], queryFn: () => apiJson("/api/executive-meetings/me"), }); const { data: fontResp } = useQuery({ queryKey: ["/api/executive-meetings/font-settings"], queryFn: () => apiJson("/api/executive-meetings/font-settings"), }); const effectiveFont: FontPrefs = useMemo(() => { const u = fontResp?.user; const g = fontResp?.global; const src = u ?? g; if (!src) return DEFAULT_FONT; return { fontFamily: src.fontFamily, fontSize: src.fontSize, fontWeight: src.fontWeight, alignment: src.alignment, }; }, [fontResp]); const meetings = data?.meetings ?? []; return (
{t("executiveMeetings.titleAr")}
{t("executiveMeetings.titleEn")}
{section === "schedule" && ( )} {section === "manage" && me && ( )} {section === "requests" && me && ( )} {section === "approvals" && me && ( )} {section === "tasks" && me && ( )} {section === "notifications" && me && ( )} {section === "audit" && me && ( )} {section === "pdf" && ( )} {section === "fontSettings" && me && ( )}
); } // ============ SCHEDULE ============ function ScheduleSection({ meetings, date, onDateChange, isLoading, isRtl, t, font, canMutate, }: { meetings: Meeting[]; date: string; onDateChange: (d: string) => void; isLoading: boolean; isRtl: boolean; t: (k: string) => string; font: FontPrefs; canMutate: boolean; }) { const qc = useQueryClient(); const { toast } = useToast(); const tableStyle = buildFontStyle(font); const [columns, setColumns] = useState(() => normalizeColumns(readJsonFromStorage(COLS_STORAGE_KEY, DEFAULT_COLUMNS)), ); const [rowColors, setRowColors] = useState>(() => readJsonFromStorage>(ROW_COLORS_STORAGE_KEY, {}), ); const [highlightPrefs, setHighlightPrefs] = useState(() => readJsonFromStorage( HIGHLIGHT_STORAGE_KEY, DEFAULT_HIGHLIGHT_PREFS, ), ); useEffect(() => { writeJsonToStorage(HIGHLIGHT_STORAGE_KEY, highlightPrefs); }, [highlightPrefs]); const [nowTick, setNowTick] = useState(() => Date.now()); useEffect(() => { let intervalId: ReturnType | null = null; const msToNextMinute = 60_000 - (Date.now() % 60_000); const timeoutId = setTimeout(() => { setNowTick(Date.now()); intervalId = setInterval(() => setNowTick(Date.now()), 60_000); }, msToNextMinute); return () => { clearTimeout(timeoutId); if (intervalId) clearInterval(intervalId); }; }, []); const currentMeetingId = useMemo( () => computeCurrentMeetingId(meetings, date, nowTick), [meetings, date, nowTick], ); // Optimistic local override so we can render the new row order before the // server round-trip finishes. Cleared on each new meetings prop. const [optimisticOrder, setOptimisticOrder] = useState(null); const [reordering, setReordering] = useState(false); const reorderingRef = useRef(false); useEffect(() => { setOptimisticOrder(null); }, [meetings, date]); const orderedMeetings = useMemo(() => { if (!optimisticOrder) return meetings; const byId = new Map(meetings.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 meetings not in the optimistic order (defensive). for (const m of meetings) { if (!optimisticOrder.includes(m.id)) out.push(m); } return out; }, [meetings, optimisticOrder]); const refreshDay = useCallback(() => { void qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date], }); }, [qc, date]); const saveTitle = useCallback( async (meeting: Meeting, html: string) => { const body = isRtl ? { titleAr: html } : { titleEn: html }; try { await apiJson(`/api/executive-meetings/${meeting.id}`, { method: "PATCH", body, }); refreshDay(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast({ title: t("common.error"), description: msg, variant: "destructive", }); throw err; } }, [isRtl, refreshDay, toast, t], ); const saveAttendeeName = useCallback( async (meeting: Meeting, attendeeIdx: number, html: string) => { // Detect "truly empty" content: Tiptap empty paragraphs,  , 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. const isEmpty = html .replace(/<[^>]*>/g, "") .replace(/ /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, ); 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, })), }, }); refreshDay(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast({ title: t("common.error"), description: msg, variant: "destructive", }); throw err; } }, [refreshDay, toast, t], ); // ------------------------------------------------------------------- // "+ 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. type PendingAttendee = { meetingId: number; attendanceType: Attendee["attendanceType"]; // Bumped each time the user clicks "+" so React remounts the ghost // EditableCell (giving it a fresh editor and focus). key: number; }; const [pendingAttendee, setPendingAttendee] = useState(null); const startAddAttendee = useCallback( (meetingId: number, attendanceType: Attendee["attendanceType"]) => { // 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, key: Date.now() }, ); }, [], ); const cancelAddAttendee = useCallback(() => { setPendingAttendee(null); }, []); const commitAddAttendee = useCallback( async ( meeting: Meeting, attendanceType: Attendee["attendanceType"], html: string, ) => { // Always clear pending — whether we end up persisting or not. setPendingAttendee(null); const isEmpty = html .replace(/<[^>]*>/g, "") .replace(/ /g, " ") .replace(/\u00a0/g, " ") .trim() === ""; if (isEmpty) return; const maxSort = meeting.attendees.reduce( (m, a) => Math.max(m, a.sortOrder ?? 0), -1, ); const next = [ ...meeting.attendees.map((a) => ({ name: a.name, title: a.title, attendanceType: a.attendanceType, sortOrder: a.sortOrder, })), { name: html, title: null as string | null, attendanceType, 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, ) => { try { await apiJson(`/api/executive-meetings/${meeting.id}`, { method: "PATCH", body: { startTime, endTime }, }); refreshDay(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast({ title: t("common.error"), description: msg, variant: "destructive", }); throw err; } }, [refreshDay, toast, t], ); // PATCH the cell-merge overlay on a meeting. `merge === null` clears // the overlay and restores the original cells; otherwise it sets a // new range + free text that the row will display in place of the // spanned cells. const saveMerge = useCallback( async ( meeting: Meeting, merge: | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string } | null, ) => { try { await apiJson(`/api/executive-meetings/${meeting.id}`, { method: "PATCH", body: { merge }, }); refreshDay(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast({ title: t("common.error"), description: msg, variant: "destructive", }); throw err; } }, [refreshDay, toast, t], ); // 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( 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(/ /g, " ") .replace(/\u00a0/g, " ") .trim(); const message = t("executiveMeetings.schedule.deleteRowConfirm").replace( "{{title}}", plainTitle, ); if (!window.confirm(message)) 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); } }, [deletingMeetingId, isRtl, refreshDay, t, toast], ); 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(null); const createRow = useCallback(async () => { if (creatingRow) return; setCreatingRow(true); try { const created = (await apiJson(`/api/executive-meetings`, { method: "POST", body: { titleAr: t("executiveMeetings.schedule.newMeetingDefaultAr"), titleEn: t("executiveMeetings.schedule.newMeetingDefaultEn"), meetingDate: date, }, })) as Meeting | undefined; if (created?.id) setAutoEditTitleId(created.id); refreshDay(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast({ title: t("common.error"), description: msg, variant: "destructive", }); } finally { setCreatingRow(false); } }, [creatingRow, date, refreshDay, toast, t]); // Once the auto-edit target meeting is in the list, scroll it into view. // The MeetingRow itself receives `startInEditMode` so its EditableCell // opens immediately and clears `autoEditTitleId` via `onStartedEditing`. // We also schedule a fallback clear so the state never goes stale if the // title cell never mounts (e.g. column hidden, row filtered out). useEffect(() => { if (autoEditTitleId == null) return; if (!meetings.some((m) => m.id === autoEditTitleId)) return; const rafId = window.requestAnimationFrame(() => { const row = document.querySelector( `[data-testid="em-row-${autoEditTitleId}"]`, ); if (row && "scrollIntoView" in row) { (row as HTMLElement).scrollIntoView({ block: "center", behavior: "smooth", }); } }); const fallback = window.setTimeout(() => { setAutoEditTitleId(null); }, 1500); return () => { window.cancelAnimationFrame(rafId); window.clearTimeout(fallback); }; }, [autoEditTitleId, meetings]); const clearAutoEditTitleId = useCallback(() => { setAutoEditTitleId(null); }, []); const reorderRows = useCallback( async (fromId: number, toId: number) => { if (fromId === toId) return; if (reorderingRef.current) return; const ids = meetings.map((m) => m.id); const fromIdx = ids.indexOf(fromId); const toIdx = ids.indexOf(toId); if (fromIdx < 0 || toIdx < 0) return; const newOrder = ids.slice(); newOrder.splice(fromIdx, 1); newOrder.splice(toIdx, 0, fromId); setOptimisticOrder(newOrder); reorderingRef.current = true; setReordering(true); try { await apiJson(`/api/executive-meetings/reorder`, { method: "POST", body: { meetingDate: date, orderedIds: newOrder }, }); refreshDay(); } catch (err) { setOptimisticOrder(null); const msg = err instanceof Error ? err.message : String(err); toast({ title: t("common.error"), description: msg, variant: "destructive", }); } finally { reorderingRef.current = false; setReordering(false); } }, [meetings, date, refreshDay, toast, t], ); useEffect(() => { writeJsonToStorage(COLS_STORAGE_KEY, columns); }, [columns]); useEffect(() => { writeJsonToStorage(ROW_COLORS_STORAGE_KEY, rowColors); }, [rowColors]); 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, ), ); }, []); const setRowColor = useCallback((meetingId: number, colorKey: string) => { setRowColors((prev) => { const next = { ...prev }; if (colorKey === "default") { delete next[meetingId]; } else { next[meetingId] = colorKey; } return next; }); }, []); // Responsive breakpoints: // < md (<768px) → stacked card layout (phones) // md..=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)"); const showResizeHandles = isXl && isFinePointer; // dnd-kit: sortable column headers + sortable rows. // // - PointerSensor for desktop mouse: a 6px activation distance keeps a // normal click on cell content from being mistaken for a drag (so the // inline editors on title/attendee/time cells still open on click). // - TouchSensor for iPad and other touch devices: PointerSensor alone // does not work on touch because the browser claims the gesture as a // vertical scroll before the finger has moved 6px, so the drag never // activates. A long-press activation (delay+tolerance) lets the user // press-and-hold the row's grip handle to start dragging while still // leaving normal scrolling intact everywhere else on the row. // - KeyboardSensor for accessibility / keyboard reordering. const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const onColumnDragEnd = useCallback( (e: DragEndEvent) => { const { active, over } = e; if (!over || active.id === over.id) return; setColumns((prev) => { const ids = prev.map((c) => c.id); const fromIdx = ids.indexOf(active.id as ColumnId); const toIdx = ids.indexOf(over.id as ColumnId); if (fromIdx < 0 || toIdx < 0) return prev; const next = prev.slice(); const [moved] = next.splice(fromIdx, 1); if (!moved) return prev; next.splice(toIdx, 0, moved); return next; }); }, [], ); const onRowDragEnd = useCallback( (e: DragEndEvent) => { const { active, over } = e; if (!over || active.id === over.id) return; void reorderRows(Number(active.id), Number(over.id)); }, [reorderRows], ); return (

{t("executiveMeetings.scheduleHeading")}

{t("executiveMeetings.titleAr")}
{date}
{/* Always emit colgroup + th widths so they're available for print and for ≥xl screens. CSS classes on the
decide whether they're enforced (table-fixed) or treated as hints (table-auto). */} {visibleColumns.map((c) => ( ))} c.id)} strategy={horizontalListSortingStrategy} > {visibleColumns.map((c, idx) => ( setColumnWidth(c.id, c.width + delta)} /> ))} {isLoading && ( )} {!isLoading && orderedMeetings.length === 0 && ( )} m.id)} strategy={verticalListSortingStrategy} > {orderedMeetings.map((m) => ( setRowColor(m.id, key)} canMutate={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 && currentMeetingId === m.id } highlightColor={highlightPrefs.color} reordering={reordering} autoEditTitle={autoEditTitleId === m.id} onAutoEditTitleConsumed={clearAutoEditTitleId} pendingAttendee={pendingAttendee} onStartAddAttendee={startAddAttendee} onCommitAddAttendee={(type, html) => commitAddAttendee(m, type, html) } onCancelAddAttendee={cancelAddAttendee} /> ))} {canMutate && !isLoading && ( )}
{t("common.loading")}
{t("executiveMeetings.noMeetings")}
); } function ResizeHandle({ isRtl, onResize, }: { isRtl: boolean; onResize: (delta: number) => void; }) { const startXRef = useRef(null); const onPointerDown = (e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); (e.target as HTMLDivElement).setPointerCapture(e.pointerId); startXRef.current = e.clientX; }; const onPointerMove = (e: React.PointerEvent) => { 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) => { 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 (
); } function ColumnsCustomizer({ 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 (
{t("executiveMeetings.customize.title")}

{t("executiveMeetings.customize.hint")}

    {columns.map((c) => (
  • toggle(c.id, !!v)} data-testid={`em-customize-toggle-${c.id}`} /> {!c.visible ? ( ) : ( )}
  • ))}
{t("executiveMeetings.customize.dragHint")}
onHighlightPrefsChange({ ...highlightPrefs, enabled: !!v }) } data-testid="em-highlight-toggle" />

{t("executiveMeetings.highlight.hint")}

{HIGHLIGHT_COLOR_SWATCHES.map((c) => { const selected = highlightPrefs.color === c; return (
); } /** * Sortable column header for the schedule table. The whole 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, onResize, }: { col: ColumnSetting; isLast: boolean; isRtl: boolean; t: (k: string) => string; showResizeHandle: boolean; onResize: (delta: number) => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: col.id }); const align = col.id === "number" || col.id === "time" ? "text-center" : "text-start"; const style: CSSProperties = { width: col.width, transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, cursor: "grab", }; return ( {t(`executiveMeetings.col.${col.id}`)} {/* Resize handles only render on large screens AND fine (mouse) pointers — see comment on showResizeHandles. */} {!isLast && showResizeHandle && ( )} ); } function RowColorPickerSwatches({ current, onPick, t, }: { current: string; onPick: (key: string) => void; t: (k: string) => string; }) { return ( <>
{t("executiveMeetings.rowColor.label")}
{ROW_COLOR_OPTIONS.map((opt) => { const selected = current === opt.key; const isDefault = opt.key === "default"; return (
); } function RowColorPicker({ current, onPick, t, }: { current: string; onPick: (key: string) => void; t: (k: string) => string; }) { return ( ); } // Reusable row-level "delete entire meeting" trigger. Rendered as an // absolutely-positioned overlay inside whichever cell is the row's // current anchor (first visible cell on unmerged rows, the merged // cell on merged rows) so it stays available regardless of which // columns the user has hidden via the column customizer. The host // must carry `relative group` for the positioning + hover // reveal to work, which every renderCell branch already does. function DeleteRowButton({ meetingId, isDeleting, onDelete, label, }: { meetingId: number; isDeleting: boolean; onDelete: () => void; label: string; }) { return ( ); } 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, }: { meeting: Meeting; isRtl: boolean; t: (k: string) => string; visibleColumns: ColumnSetting[]; rowColorKey: string; onPickRowColor: (key: string) => void; canMutate: boolean; onSaveTitle: (html: string) => Promise; onSaveAttendeeName: (idx: number, html: string) => Promise; onSaveTimes: ( startTime: string | null, endTime: string | null, ) => Promise; onSaveMerge: ( merge: | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string } | null, ) => Promise; onDeleteMeeting: () => void; isDeleting: boolean; isCurrent: boolean; highlightColor: string; reordering: boolean; autoEditTitle?: boolean; onAutoEditTitleConsumed?: () => void; pendingAttendee?: { meetingId: number; attendanceType: Attendee["attendanceType"]; key: number; } | null; onStartAddAttendee?: ( meetingId: number, attendanceType: Attendee["attendanceType"], ) => void; onCommitAddAttendee?: ( attendanceType: Attendee["attendanceType"], html: string, ) => Promise; onCancelAddAttendee?: () => void; }) { const title = isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr; const isCancelled = meeting.status === "cancelled"; const highlight = meeting.isHighlighted === 1; const rowColor = ROW_COLOR_OPTIONS.find((o) => o.key === rowColorKey); const rowBg = rowColor?.bg || ""; // Make the row a sortable item. `attributes` get spread onto the for // a11y; `listeners` go on the dedicated grip handle in the # cell so a // normal click on the cell content (to enter inline-edit mode) is still // possible. const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: meeting.id, disabled: !canMutate }); const numberCellCls = "border border-gray-300 px-2 py-2 text-center align-middle font-semibold " + (highlight || isCancelled ? "bg-red-600 text-white" : "bg-white text-[#0B1E3F]"); // Always provide width + overflow:hidden inline. In `table-auto` mode the // browser treats width as a hint and shrinks cells to fit; in `table-fixed` // mode (xl screens AND any print) it enforces them. overflow:hidden + the // existing break-words on inner divs keeps wrapping behavior correct. const cellStyle = (col: ColumnSetting): CSSProperties => ({ width: col.width, overflow: "hidden", }); // Compute the cell-merge overlay for this row. We resolve the stored // start/end column ids against the *canonical* schedule column order // (not against `visibleColumns`) so that a merge whose start or end // column is currently hidden still renders across whichever columns // in the range remain visible. This guarantees a row that has a // merge in the DB never silently falls back to "unmerged" just // because the user toggled off one of the boundary columns. const canonStart = canonicalMergeIndex(meeting.mergeStartColumn); const canonEnd = canonicalMergeIndex(meeting.mergeEndColumn); const hasStoredMerge = canonStart >= 0 && canonEnd >= canonStart && typeof meeting.mergeText === "string"; const visibleMergeIndices: number[] = []; if (hasStoredMerge) { visibleColumns.forEach((c, idx) => { const ci = canonicalMergeIndex(c.id); if (ci >= canonStart && ci <= canonEnd) visibleMergeIndices.push(idx); }); } // Headers are themselves drag-sortable, so users can reorder the // schedule columns. If a previously-set merge ends up spanning // *non-contiguous* visible indices after a reorder, rendering one // colSpan cell at the first index would silently swallow the wrong // columns. In that case we degrade to "unmerged" rendering — the DB // merge state stays intact, and unmerging or restoring canonical // order brings the merged cell back. const visibleMergeContiguous = visibleMergeIndices.length === 0 || visibleMergeIndices[visibleMergeIndices.length - 1] - visibleMergeIndices[0] + 1 === visibleMergeIndices.length; const isMerged = hasStoredMerge && visibleMergeIndices.length > 0 && visibleMergeContiguous; const mergeFirstIdx = isMerged ? visibleMergeIndices[0] : -1; const mergeSkipSet = isMerged ? new Set(visibleMergeIndices.slice(1)) : null; // First visible cell that's NOT part of a merge. The merge trigger // for an unmerged row is anchored here so editors can still open the // merge menu even when the # column is hidden. We look for the // first index in visibleColumns whose canonical id exists (which is // every schedule column), so this collapses to `0` in practice but // would skip non-mergeable columns if they're added later. const firstUnmergedIdx = isMerged ? -1 : visibleColumns.length > 0 ? 0 : -1; // Light tint for the current meeting on the editable cells (meeting, // attendees, time). The number cell keeps its solid white background // and original text colour so it stays legible — only the rest of the // row gets a soft wash of highlightColor. We keep the original text // colour everywhere so the row remains readable. const tintBg = isCurrent && !(highlight || isCancelled) ? hexToRgba(highlightColor, 0.13) : null; const tintedCellStyle = (col: ColumnSetting): CSSProperties => tintBg ? { ...cellStyle(col), backgroundColor: tintBg } : cellStyle(col); const renderCell = (col: ColumnSetting, idx: number): ReactNode => { // Anchor the merge-menu trigger to whichever cell is the first // visible one on an unmerged row. Using "first visible cell" // (instead of always the # cell) keeps the trigger reachable when // editors hide the number column. The trigger is rendered as an // absolutely-positioned overlay inside the cell, so every cell type // needs `relative group` on its for the overlay to find it. const showMergeTrigger = canMutate && !isMerged && idx === firstUnmergedIdx; const mergeOverlay = showMergeTrigger ? ( ) : null; // Delete trigger uses the same "first visible cell" anchoring as the // merge trigger so the action stays available even when editors hide // the # column via the column customizer. Only one button per row. // The merged-cell branch (in renderCells below) renders its own copy. const showDeleteTrigger = canMutate && !isMerged && idx === firstUnmergedIdx; const deleteOverlay = showDeleteTrigger ? ( ) : null; switch (col.id) { case "number": return (
{canMutate && ( )} {meeting.dailyNumber}
{deleteOverlay} {mergeOverlay} ); case "meeting": return ( {meeting.location && (
{meeting.location}
)} {deleteOverlay} {mergeOverlay} ); case "attendees": return ( {deleteOverlay} {mergeOverlay} ); case "time": return ( {deleteOverlay} {mergeOverlay} ); } }; // When a merge is active we render a single with an // EditableCell for `mergeText`, and skip the cells the merge covers // after the first. The merged cell hosts its own MergeMenu so users // can always unmerge or re-merge — even when the # column is hidden. // Originals stay in the DB so unmerge restores them. const renderCells = (): ReactNode[] => { if (!isMerged) { return visibleColumns.map((c, idx) => renderCell(c, idx)); } const cells: ReactNode[] = []; const span = visibleMergeIndices.length; visibleColumns.forEach((col, idx) => { if (mergeSkipSet?.has(idx)) return; if (idx === mergeFirstIdx) { // Sum widths of the spanned visible columns so layout in // table-fixed mode reserves the right horizontal space. const totalWidth = visibleMergeIndices.reduce( (sum, i) => sum + (visibleColumns[i].width || 0), 0, ); cells.push( { await onSaveMerge({ mergeStartColumn: meeting.mergeStartColumn as ColumnId, mergeEndColumn: meeting.mergeEndColumn as ColumnId, mergeText: html, }); }} ariaLabel={t("executiveMeetings.merge.editLabel")} dir={isRtl ? "rtl" : "ltr"} className="font-medium leading-snug break-words" disabled={!canMutate} placeholder={t("executiveMeetings.merge.placeholder")} testId={`em-merge-edit-${meeting.id}`} /> {canMutate && ( )} {canMutate && ( )} , ); } else { cells.push(renderCell(col, idx)); } }); return cells; }; // Combine row background with highlight tint. When the row is the current // meeting we apply a soft tint (color at low opacity) plus a coloured ring // via box-shadow (rings on don't render across cells; box-shadow does). const baseBg = rowBg || "white"; const trStyle: CSSProperties = { backgroundColor: baseBg, transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : reordering ? 0.7 : 1, boxShadow: isCurrent ? `inset 0 0 0 2px ${highlightColor}` : undefined, }; return ( {renderCells()} ); } // Canonical schedule column order for cell-merge ranges. MUST stay in // sync with the API's MERGE_COLUMNS array in // artifacts/api-server/src/routes/executive-meetings.ts. We use this // (instead of `visibleColumns`) when computing whether a stored merge // overlaps the currently visible columns, so that hiding one boundary // of the range still lets the merged cell render across whichever // columns inside the range remain visible. const CANONICAL_MERGE_ORDER: ColumnId[] = [ "number", "meeting", "attendees", "time", ]; function canonicalMergeIndex(id: ColumnId | string | null | undefined): number { if (id == null) return -1; return CANONICAL_MERGE_ORDER.indexOf(id as ColumnId); } // Convert a #RRGGBB or #RGB hex color into an rgba() string with the // given alpha. Used to derive the soft tint applied to the current // meeting row from the user's highlight color preference. Falls back // to the input string unchanged if it isn't a recognised hex. function hexToRgba(hex: string, alpha: number): string { const m3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(hex); const m6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); let r = 0; let g = 0; let b = 0; if (m6) { r = parseInt(m6[1], 16); g = parseInt(m6[2], 16); b = parseInt(m6[3], 16); } else if (m3) { r = parseInt(m3[1] + m3[1], 16); g = parseInt(m3[2] + m3[2], 16); b = parseInt(m3[3] + m3[3], 16); } else { return hex; } return `rgba(${r}, ${g}, ${b}, ${alpha})`; } // Small popover-driven control attached to a row (in the # cell when // not merged, or to the merged cell itself) that lets editors pick a // merge range or clear an existing one. Each option corresponds to a // fixed [start..end] column range. function MergeMenu({ meetingId, // True iff the row is rendered as a merged cell *right now*. Used // for testid wiring only. isMerged = false, // True iff the row has a stored merge in the DB (regardless of // whether it's currently renderable as merged). Drives the // "Unmerge" option so users can always clear stored merges, even // when the visible columns aren't contiguous. hasStoredMerge, // Existing merge text from the DB, preserved when the user picks a // new merge range so we never silently overwrite their content. existingMergeText, t, onChange, }: { meetingId: number; isMerged?: boolean; hasStoredMerge: boolean; existingMergeText: string | null; t: (k: string) => string; onChange: ( merge: | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string } | null, ) => Promise; }) { // Re-merging keeps the existing text so users don't accidentally lose // what they had typed. New merges (no stored text) start empty. const apply = async (start: ColumnId, end: ColumnId) => { await onChange({ mergeStartColumn: start, mergeEndColumn: end, mergeText: existingMergeText ?? "", }); }; return ( {hasStoredMerge && ( )} ); } function TimeRangeCell({ meeting, t, lang, canMutate, onSaveTimes, }: { meeting: Meeting; t: (k: string) => string; lang: "ar" | "en"; canMutate: boolean; onSaveTimes: ( startTime: string | null, endTime: string | null, ) => Promise; }) { 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(null); const blurTimerRef = useRef | null>(null); const startInputRef = useRef(null); // 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) { setStart(startSaved); setEnd(endSaved); } }, [editing, startSaved, endSaved]); useEffect(() => { if (editing) { startInputRef.current?.focus(); } }, [editing]); const enterEdit = useCallback(() => { if (!canMutate) return; setEditing(true); }, [canMutate]); const cancel = useCallback(() => { setStart(startSaved); setEnd(endSaved); setEditing(false); }, [startSaved, endSaved]); const save = useCallback(async () => { const newStart = start || null; const newEnd = end || null; 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", }); startInputRef.current?.focus(); return; } setEditing(false); try { await onSaveTimes(newStart, newEnd); } catch { // The parent `saveTimes` handler is responsible for surfacing the // error toast; we only need to roll back the local draft state so // the cell shows the previously-saved values. setStart(startSaved); setEnd(endSaved); } }, [start, end, startSaved, endSaved, onSaveTimes]); const onFocusOut = (e: React.FocusEvent) => { 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 (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); enterEdit(); } }} data-testid={`em-time-${meeting.id}`} > {display}
); } const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); cancel(); } else if (e.key === "Enter") { e.preventDefault(); void save(); } }; return (
setStart(e.target.value)} onKeyDown={onKeyDown} aria-label={t("executiveMeetings.schedule.timeStart")} className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white" data-testid={`em-time-start-${meeting.id}`} /> setEnd(e.target.value)} onKeyDown={onKeyDown} aria-label={t("executiveMeetings.schedule.timeEnd")} className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white" data-testid={`em-time-end-${meeting.id}`} />
); } 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; pendingAttendee?: { meetingId: number; attendanceType: Attendee["attendanceType"]; key: number; } | null; onStartAddAttendee?: ( meetingId: number, attendanceType: Attendee["attendanceType"], ) => void; onCommitAddAttendee?: ( attendanceType: Attendee["attendanceType"], html: string, ) => Promise; 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). 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"); const hasSplit = virtual.length > 0 && (internal.length > 0 || external.length > 0); const platformLabel: Record = { 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; const isPendingFor = (type: Attendee["attendanceType"]) => isPendingForThisMeeting && pendingAttendee!.attendanceType === type; const hasAnyPending = !!pendingAttendee; const startAdd = onStartAddAttendee ? (type: Attendee["attendanceType"]) => onStartAddAttendee(meeting.id, type) : undefined; const commitAdd = onCommitAddAttendee; const cancelAdd = onCancelAddAttendee; const flowProps = { canMutate, onSaveAttendeeName, isPending: false, pendingKey: pendingAttendee?.key ?? 0, onStartAdd: hasAnyPending ? undefined : startAdd, onCommitAdd: commitAdd, onCancelAdd: cancelAdd, addLabel: t("executiveMeetings.schedule.addAttendee"), editAriaLabel: t("executiveMeetings.schedule.editAttendeeAria"), newAriaLabel: t("executiveMeetings.schedule.newAttendeeAria"), meetingId: meeting.id, }; if (hasSplit) { return (
{(virtual.length > 0 || isPendingFor("virtual")) && ( )} {(internal.length > 0 || isPendingFor("internal")) && ( )} {(external.length > 0 || isPendingFor("external")) && ( )} {/* Add chips for groups that don't yet exist for this meeting. */} {canMutate && !hasAnyPending && startAdd && (
{virtual.length === 0 && ( startAdd("virtual")} label={t("executiveMeetings.schedule.addVirtualAttendee")} testId={`em-add-attendee-virtual-${meeting.id}`} /> )} {internal.length === 0 && ( startAdd("internal")} label={t("executiveMeetings.schedule.addInternalAttendee")} testId={`em-add-attendee-internal-${meeting.id}`} /> )} {external.length === 0 && ( startAdd("external")} label={t("executiveMeetings.schedule.addExternalAttendee")} testId={`em-add-attendee-external-${meeting.id}`} /> )}
)}
); } // 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 ( ); } // Empty cell or single-type cell — render flow without a header. return ( ); } type AttendeeWithIndex = { a: Attendee; i: number }; function AddAttendeeChip({ onClick, label, testId, }: { onClick: () => void; label: string; testId: string; }) { return ( ); } type AttendeeFlowSharedProps = { canMutate?: boolean; onSaveAttendeeName?: (idx: number, html: string) => Promise; addType: Attendee["attendanceType"]; isPending: boolean; pendingKey: number; onStartAdd?: (type: Attendee["attendanceType"]) => void; onCommitAdd?: ( type: Attendee["attendanceType"], html: string, ) => Promise; onCancelAdd?: () => void; addLabel: string; editAriaLabel: string; newAriaLabel: string; meetingId: number; }; function AttendeeGroup({ label, items, ...flowProps }: { label: string; items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) { return (
{/* 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). */}
— {label} —
); } function AttendeeFlow({ items, canMutate = false, onSaveAttendeeName, addType, isPending, pendingKey, onStartAdd, onCommitAdd, onCancelAdd, addLabel, editAriaLabel, newAriaLabel, }: { items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) { const editable = canMutate && !!onSaveAttendeeName; const showAdd = canMutate && !!onStartAdd && !isPending; if (items.length === 0 && !showAdd && !isPending) { return ; } return (
    {items.map(({ a, i }, displayIdx) => (
  • {displayIdx + 1}- {editable ? ( onSaveAttendeeName!(i, html)} ariaLabel={editAriaLabel} dir="auto" placeholder="…" // Always-visible dashed underline gives each attendee name a // discoverable click target even when names wrap onto multiple // lines. The vertical padding + min-height widen the tap // target so a near-miss tap on an iPad/touch device still // lands on the editor. Hidden in edit mode (the editor draws // its own border) and on print. className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 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}`} /> ) : ( )}
  • ))} {isPending && onCommitAdd && (
  • {items.length + 1}- onCommitAdd(addType, html)} 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 // Force a save call on blur even when the user typed nothing, // so the parent can clear the pending ghost row. forceSaveOnBlur // Escape should also clear the ghost. onCancel={onCancelAdd} />
  • )} {showAdd && (
  • )}
); } // ============ MANAGE ============ type MeetingFormState = { id: number | null; titleAr: string; titleEn: string; meetingDate: string; dailyNumber: string; startTime: string; endTime: string; location: string; meetingUrl: string; platform: Meeting["platform"]; status: string; isHighlighted: boolean; notes: string; attendees: Attendee[]; }; function emptyMeetingForm(date: string): MeetingFormState { return { id: null, titleAr: "", titleEn: "", meetingDate: date, dailyNumber: "", startTime: "", endTime: "", location: "", meetingUrl: "", platform: "none", status: "scheduled", isHighlighted: false, notes: "", attendees: [], }; } function ManageSection({ date, onDateChange, canMutate, isRtl, t, }: { date: string; onDateChange: (d: string) => void; canMutate: boolean; isRtl: boolean; t: (k: string) => string; }) { const qc = useQueryClient(); const { toast } = useToast(); const [editing, setEditing] = useState(null); const [saving, setSaving] = useState(false); const [deletingId, setDeletingId] = useState(null); const [duplicating, setDuplicating] = useState<{ id: number; targetDate: string; } | null>(null); const [duplicatingBusy, setDuplicatingBusy] = useState(false); const { data, isLoading } = useQuery({ queryKey: ["/api/executive-meetings", date], queryFn: () => apiJson(`/api/executive-meetings?date=${date}`), }); const meetings = data?.meetings ?? []; function openCreate() { setEditing(emptyMeetingForm(date)); } function openDuplicate(m: Meeting) { setDuplicating({ id: m.id, targetDate: date }); } async function performDuplicate() { if (!duplicating) return; setDuplicatingBusy(true); try { await apiJson(`/api/executive-meetings/${duplicating.id}/duplicate`, { method: "POST", body: { targetDate: duplicating.targetDate }, }); toast({ title: t("executiveMeetings.manage.duplicated") }); const newDate = duplicating.targetDate; setDuplicating(null); if (newDate === date) { await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] }); } else { onDateChange(newDate); } } catch (err) { toast({ title: t("executiveMeetings.manage.duplicateFailed"), description: err instanceof Error ? err.message : String(err), variant: "destructive", }); } finally { setDuplicatingBusy(false); } } function openEdit(m: Meeting) { setEditing({ id: m.id, titleAr: m.titleAr, titleEn: m.titleEn ?? "", meetingDate: m.meetingDate, dailyNumber: String(m.dailyNumber), startTime: m.startTime ? m.startTime.slice(0, 5) : "", endTime: m.endTime ? m.endTime.slice(0, 5) : "", location: m.location ?? "", meetingUrl: m.meetingUrl ?? "", platform: m.platform, status: m.status, isHighlighted: m.isHighlighted === 1, notes: m.notes ?? "", attendees: m.attendees.map((a) => ({ ...a })), }); } async function save() { if (!editing) return; if (!editing.titleAr.trim() || !editing.titleEn.trim()) { toast({ title: t("executiveMeetings.manage.errors.titleRequired"), variant: "destructive", }); return; } setSaving(true); const body: Record = { titleAr: editing.titleAr.trim(), titleEn: editing.titleEn.trim(), meetingDate: editing.meetingDate, startTime: editing.startTime || null, endTime: editing.endTime || null, location: editing.location.trim() || null, meetingUrl: editing.meetingUrl.trim() || null, platform: editing.platform, status: editing.status, isHighlighted: editing.isHighlighted ? 1 : 0, notes: editing.notes.trim() || null, attendees: editing.attendees.map((a, idx) => ({ name: a.name, title: a.title, attendanceType: a.attendanceType, sortOrder: idx, })), }; if (editing.dailyNumber.trim()) body.dailyNumber = Number(editing.dailyNumber); try { if (editing.id == null) { await apiJson(`/api/executive-meetings`, { method: "POST", body }); } else { await apiJson(`/api/executive-meetings/${editing.id}`, { method: "PATCH", body, }); } toast({ title: t("executiveMeetings.common.saved") }); setEditing(null); await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast({ title: t("executiveMeetings.manage.errors.saveFailed"), description: msg, variant: "destructive", }); } finally { setSaving(false); } } async function confirmDelete(id: number) { if (!window.confirm(t("executiveMeetings.manage.deleteConfirm"))) 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); } } if (!canMutate) { return ; } return (

{t("executiveMeetings.manage.heading")}

{isLoading && ( )} {!isLoading && meetings.length === 0 && ( )} {meetings.map((m) => ( ))}
# {t("executiveMeetings.col.meeting")} {t("executiveMeetings.col.time")} {t("executiveMeetings.manage.field.status")} {t("executiveMeetings.common.actions")}
{t("common.loading")}
{t("executiveMeetings.manage.noMeetings")}
{m.dailyNumber}
{isRtl ? m.titleAr : m.titleEn || m.titleAr}
{m.attendees.length} · {m.platform}
{formatTime(m.startTime, isRtl ? "ar" : "en")} — {formatTime(m.endTime, isRtl ? "ar" : "en")} {t(`executiveMeetings.manage.status.${m.status}`)}
{editing && ( setEditing(null)} saving={saving} t={t} /> )} !o && setDuplicating(null)} > {t("executiveMeetings.manage.duplicateToDate")} setDuplicating( duplicating ? { ...duplicating, targetDate: e.target.value } : null, ) } data-testid="em-duplicate-date" />
); } function MeetingFormDialog({ state, onChange, onSave, onClose, saving, t, }: { state: MeetingFormState; onChange: (s: MeetingFormState) => void; onSave: () => void; onClose: () => void; saving: boolean; t: (k: string) => string; }) { function update(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 }, ], }); } function removeAttendee(i: number) { const arr = state.attendees.slice(); arr.splice(i, 1); onChange({ ...state, attendees: arr }); } function setAttendee(i: number, patch: Partial) { 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 }); } return ( !o && onClose()}> {state.id == null ? t("executiveMeetings.manage.addMeeting") : t("executiveMeetings.manage.editMeeting")}
update("titleAr", e.target.value)} data-testid="em-form-titleAr" /> update("titleEn", e.target.value)} /> update("meetingDate", e.target.value)} /> update("dailyNumber", e.target.value)} /> update("startTime", e.target.value)} /> update("endTime", e.target.value)} /> update("location", e.target.value)} /> update("meetingUrl", e.target.value)} /> update("isHighlighted", v)} />