Files
TX/artifacts/tx-os/src/pages/executive-meetings.tsx
T
riyadhafraa afa79fb618 Hide attendee number when only one person is listed
Conditionally render the attendee index span element only when the number of items in the list exceeds one.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 6a614115-a39d-4b50-ac9b-9d97294fc18d
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/fEDT27c
Replit-Helium-Checkpoint-Created: true
2026-04-30 09:30:19 +00:00

5793 lines
199 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
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,
MoreVertical,
ChevronLeft,
ChevronRight,
} 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
// <input type="time"> controls when the cell enters edit mode.
{ id: "time", visible: true, width: 200 },
];
const MIN_COL_WIDTH = 56;
const MAX_COL_WIDTH = 1200;
const ROW_COLOR_OPTIONS: { key: string; bg: string; label: string }[] = [
{ key: "default", bg: "", label: "default" },
{ key: "red", bg: "#fee2e2", label: "red" },
{ key: "amber", bg: "#fef3c7", label: "amber" },
{ key: "green", bg: "#dcfce7", label: "green" },
{ key: "blue", bg: "#dbeafe", label: "blue" },
{ key: "violet", bg: "#ede9fe", label: "violet" },
{ key: "gray", bg: "#f3f4f6", label: "gray" },
];
const COLS_STORAGE_KEY = "em-schedule-cols-v1";
const ROW_COLORS_STORAGE_KEY = "em-schedule-row-colors-v1";
const HIGHLIGHT_STORAGE_KEY = "em-current-meeting-highlight-v1";
const EDIT_MODE_STORAGE_KEY = "em-schedule-edit-mode-v1";
type HighlightPrefs = { enabled: boolean; color: string };
const DEFAULT_HIGHLIGHT_PREFS: HighlightPrefs = {
enabled: true,
color: "#16a34a",
};
const HIGHLIGHT_COLOR_SWATCHES = [
"#16a34a",
"#2563eb",
"#dc2626",
"#ea580c",
"#7c3aed",
"#0B1E3F",
];
function parseTimeToMinutes(t: string | null): number | null {
if (!t) return null;
const m = /^(\d{2}):(\d{2})/.exec(t);
if (!m) return null;
const h = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
return h * 60 + mm;
}
/**
* Returns the id of the meeting that is currently in progress on `dateIso`,
* or null. A meeting is "current" when the wall clock falls within
* [startTime, endTime) and the displayed date matches today.
*/
function computeCurrentMeetingId(
meetings: Meeting[],
dateIso: string,
nowMs: number,
): number | null {
const now = new Date(nowMs);
const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
if (todayIso !== dateIso) return null;
const minutesNow = now.getHours() * 60 + now.getMinutes();
for (const m of meetings) {
const s = parseTimeToMinutes(m.startTime);
const e = parseTimeToMinutes(m.endTime);
if (s === null || e === null) continue;
if (s <= minutesNow && minutesNow < e) return m.id;
}
return null;
}
function formatTime(t: string | null, lang: "ar" | "en"): string {
if (!t) return "";
// DB stores HH:mm:ss (24h). Build a Date anchored to a fixed day so
// Intl can format the time-of-day in the user's language. We use 24h
// (no AM/PM) so the schedule shows a clean "10:00 11:00" without
// the ص/م suffix in 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: "2-digit",
minute: "2-digit",
hour12: false,
});
}
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<T>(
url: string,
init?: Omit<RequestInit, "body"> & { body?: unknown },
): Promise<T> {
const { body: rawBody, headers: rawHeaders, ...rest } = init ?? {};
const opts: RequestInit = {
credentials: "include",
...rest,
headers: { "Content-Type": "application/json", ...(rawHeaders ?? {}) },
};
if (rawBody !== undefined) {
opts.body =
typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody);
}
const res = await fetch(url, opts);
if (res.status === 204) return undefined as T;
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
if (!res.ok) {
throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
}
return data;
}
function readJsonFromStorage<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
function writeJsonToStorage(key: string, value: unknown): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* ignore */
}
}
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
setMatches(mql.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
function normalizeColumns(value: unknown): ColumnSetting[] {
if (!Array.isArray(value)) return DEFAULT_COLUMNS;
const seen = new Set<ColumnId>();
const out: ColumnSetting[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") continue;
const e = entry as Partial<ColumnSetting>;
if (typeof e.id !== "string") continue;
if (!DEFAULT_COLUMNS.some((d) => d.id === e.id)) continue;
if (seen.has(e.id as ColumnId)) continue;
seen.add(e.id as ColumnId);
const fallback = DEFAULT_COLUMNS.find((d) => d.id === e.id)!;
const width =
typeof e.width === "number" && Number.isFinite(e.width)
? Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, e.width))
: fallback.width;
out.push({
id: e.id as ColumnId,
visible: typeof e.visible === "boolean" ? e.visible : true,
width,
});
}
// Append any defaults that were missing (e.g. new columns added later).
for (const def of DEFAULT_COLUMNS) {
if (!seen.has(def.id)) out.push({ ...def });
}
return out;
}
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<SectionKey>("schedule");
const [date, setDate] = useState<string>(todayIso());
const { data, isLoading } = useQuery<DayResponse>({
queryKey: ["/api/executive-meetings", date],
queryFn: async () => {
const res = await fetch(`/api/executive-meetings?date=${date}`, {
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
});
const { data: me } = useQuery<MeRoles>({
queryKey: ["/api/executive-meetings/me"],
queryFn: () => apiJson<MeRoles>("/api/executive-meetings/me"),
});
const { data: fontResp } = useQuery<FontSettingsResponse>({
queryKey: ["/api/executive-meetings/font-settings"],
queryFn: () => apiJson<FontSettingsResponse>("/api/executive-meetings/font-settings"),
});
const effectiveFont: FontPrefs = useMemo(() => {
const u = fontResp?.user;
const g = fontResp?.global;
const src = u ?? g;
if (!src) return DEFAULT_FONT;
return {
fontFamily: src.fontFamily,
fontSize: src.fontSize,
fontWeight: src.fontWeight,
alignment: src.alignment,
};
}, [fontResp]);
const meetings = data?.meetings ?? [];
return (
<div
className="min-h-screen bg-[#f4f6fb] text-foreground"
dir={isRtl ? "rtl" : "ltr"}
>
<header className="bg-white border-b border-gray-200 shadow-sm print:hidden">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-3 sm:gap-4 flex-wrap">
<Button
variant="ghost"
size="icon"
onClick={() => setLocation("/")}
aria-label={t("common.back")}
className="text-foreground"
>
<BackIcon className="w-5 h-5" />
</Button>
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<div className="w-10 h-10 sm:w-11 sm:h-11 rounded-md bg-[#0B1E3F] flex items-center justify-center text-white shrink-0">
<CalendarClock className="w-6 h-6" />
</div>
<div className="min-w-0 leading-tight">
<div className="text-sm sm:text-base font-semibold text-[#0B1E3F] truncate">
{t("executiveMeetings.titleAr")}
</div>
<div className="text-[11px] sm:text-xs text-muted-foreground truncate">
{t("executiveMeetings.titleEn")}
</div>
</div>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => setSection("pdf")}
data-testid="em-export-shortcut"
>
<FileDown className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => setSection("fontSettings")}
>
<Type className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.fontSettings")}</span>
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => i18n.changeLanguage(isRtl ? "en" : "ar")}
>
<Languages className="w-4 h-4" />
<span className="text-xs">{isRtl ? "EN" : "ع"}</span>
</Button>
</div>
</div>
<nav className="border-t border-gray-100 bg-white overflow-x-auto">
<div className="max-w-screen-2xl mx-auto px-2 sm:px-4 flex gap-1 sm:gap-2 min-w-max">
{SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
({ key, icon: Icon }) => {
const active = section === key;
return (
<button
key={key}
type="button"
onClick={() => setSection(key)}
className={
"flex items-center gap-1.5 px-3 sm:px-4 py-2.5 text-xs sm:text-sm whitespace-nowrap border-b-2 transition-colors " +
(active
? "border-[#0B1E3F] text-[#0B1E3F] font-semibold"
: "border-transparent text-muted-foreground hover:text-foreground")
}
data-testid={`em-nav-${key}`}
>
<Icon className="w-4 h-4" />
{t(`executiveMeetings.nav.${key}`)}
</button>
);
},
)}
</div>
</nav>
</header>
<main className="max-w-screen-2xl mx-auto px-3 sm:px-6 py-4 sm:py-6">
{section === "schedule" && (
<ScheduleSection
meetings={meetings}
date={date}
onDateChange={setDate}
isLoading={isLoading}
isRtl={isRtl}
t={t}
font={effectiveFont}
canMutate={!!me?.canMutate}
userId={me?.userId ?? null}
/>
)}
{section === "manage" && me && (
<ManageSection date={date} onDateChange={setDate} canMutate={me.canMutate} isRtl={isRtl} t={t} />
)}
{section === "requests" && me && (
<RequestsSection me={me} isRtl={isRtl} t={t} />
)}
{section === "approvals" && me && (
<ApprovalsSection canApprove={me.canApprove} isRtl={isRtl} t={t} />
)}
{section === "tasks" && me && (
<TasksSection
canMutate={me.canMutate}
currentUserId={me.userId}
canViewAllTasks={me.canViewAllTasks}
isRtl={isRtl}
t={t}
/>
)}
{section === "notifications" && me && (
<NotificationsSection
canView={me.canRead}
date={date}
isRtl={isRtl}
t={t}
/>
)}
{section === "audit" && me && (
<AuditSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
)}
{section === "pdf" && (
<PdfSection date={date} onDateChange={setDate} lang={lang} t={t} />
)}
{section === "fontSettings" && me && (
<FontSettingsSection
font={effectiveFont}
canEditGlobal={me.canApprove}
t={t}
/>
)}
</main>
</div>
);
}
// ============ SCHEDULE ============
function ScheduleSection({
meetings,
date,
onDateChange,
isLoading,
isRtl,
t,
font,
canMutate,
userId,
}: {
meetings: Meeting[];
date: string;
onDateChange: (d: string) => void;
isLoading: boolean;
isRtl: boolean;
t: (k: string) => string;
font: FontPrefs;
canMutate: boolean;
userId: number | null;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const tableStyle = buildFontStyle(font);
const [columns, setColumns] = useState<ColumnSetting[]>(() =>
normalizeColumns(readJsonFromStorage(COLS_STORAGE_KEY, DEFAULT_COLUMNS)),
);
const [rowColors, setRowColors] = useState<Record<number, string>>(() =>
readJsonFromStorage<Record<number, string>>(ROW_COLORS_STORAGE_KEY, {}),
);
const [highlightPrefs, setHighlightPrefs] = useState<HighlightPrefs>(() =>
readJsonFromStorage<HighlightPrefs>(
HIGHLIGHT_STORAGE_KEY,
DEFAULT_HIGHLIGHT_PREFS,
),
);
useEffect(() => {
writeJsonToStorage(HIGHLIGHT_STORAGE_KEY, highlightPrefs);
}, [highlightPrefs]);
// Global "Edit / View" toggle for the schedule. The default is view mode
// (no edit affordances). When a user with edit permission flips it on,
// every per-cell editor and per-row action (merge, color swatch, drag
// handle, add/delete row, inline edit, column drag, column resize) is
// re-enabled. We persist the user's last choice in localStorage so the
// toggle survives reloads + date changes, but we *also* always start in
// view mode for users without permission so they never see a stale "on"
// state from a previous session where they had access. Hydrating the
// localStorage value happens inside an effect to keep SSR-safe and to
// avoid reading from a non-mutator's namespace.
//
// The storage key is namespaced by the current user's id so a shared
// browser profile can't leak one editor's last toggle state into the
// next account that signs in. When userId is null (no /me yet) we
// fall back to view mode without touching storage.
const editModeStorageKey = useMemo(
() => (userId != null ? `${EDIT_MODE_STORAGE_KEY}:${userId}` : null),
[userId],
);
const [editMode, setEditModeState] = useState<boolean>(false);
useEffect(() => {
if (!canMutate || !editModeStorageKey) {
setEditModeState(false);
return;
}
try {
const raw = window.localStorage.getItem(editModeStorageKey);
setEditModeState(raw === "true");
} catch {
// localStorage may be unavailable (private mode); fall through to
// the default false.
}
}, [canMutate, editModeStorageKey]);
const setEditMode = useCallback(
(next: boolean) => {
if (!canMutate || !editModeStorageKey) return;
setEditModeState(next);
try {
window.localStorage.setItem(editModeStorageKey, next ? "true" : "false");
} catch {
// Persistence is best-effort; the in-memory state still flips.
}
},
[canMutate, editModeStorageKey],
);
// Anything the user can mutate must AND with editMode. We pass this
// derived flag down to every row + cell so they don't each have to
// know about the toggle. The actual `canMutate` permission stays in
// scope for the toggle button visibility check.
const effectiveCanMutate = canMutate && editMode;
const [nowTick, setNowTick] = useState(() => Date.now());
useEffect(() => {
let intervalId: ReturnType<typeof setInterval> | null = null;
const msToNextMinute = 60_000 - (Date.now() % 60_000);
const timeoutId = setTimeout(() => {
setNowTick(Date.now());
intervalId = setInterval(() => setNowTick(Date.now()), 60_000);
}, msToNextMinute);
return () => {
clearTimeout(timeoutId);
if (intervalId) clearInterval(intervalId);
};
}, []);
const currentMeetingId = useMemo(
() => computeCurrentMeetingId(meetings, date, nowTick),
[meetings, date, nowTick],
);
// Optimistic local override so we can render the new row order before the
// server round-trip finishes. Cleared on each new meetings prop.
const [optimisticOrder, setOptimisticOrder] = useState<number[] | null>(null);
const [reordering, setReordering] = useState(false);
const reorderingRef = useRef(false);
useEffect(() => {
setOptimisticOrder(null);
}, [meetings, date]);
const orderedMeetings = useMemo(() => {
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, &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.
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,
);
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<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"]) => {
// 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(/&nbsp;/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);
// 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;
}
},
[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<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 (!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],
);
// ─── 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) => {
setSelectedMeetingIds(
on ? new Set(orderedMeetings.map((m) => m.id)) : new Set(),
);
},
[orderedMeetings],
);
// Tri-state hint for the "select all" checkbox in the toolbar.
// "all" → fully checked, "some" → indeterminate, "none" → unchecked.
const allSelectedState: "all" | "some" | "none" = useMemo(() => {
const total = orderedMeetings.length;
if (total === 0) return "none";
let count = 0;
for (const m of orderedMeetings) if (selectedMeetingIds.has(m.id)) count++;
if (count === 0) return "none";
if (count === total) return "all";
return "some";
}, [orderedMeetings, selectedMeetingIds]);
const [bulkDeleting, setBulkDeleting] = useState(false);
const deleteSelectedMeetings = useCallback(async () => {
if (bulkDeleting) return;
const ids = Array.from(selectedMeetingIds);
if (ids.length === 0) return;
const message = t(
"executiveMeetings.schedule.bulkDeleteConfirm",
).replace("{{n}}", String(ids.length));
if (!window.confirm(message)) return;
setBulkDeleting(true);
try {
// Deliberately allSettled, not all: a single failed DELETE shouldn't
// skip the others. We aggregate the outcome into one toast so the
// user sees a single, actionable result instead of N toasts.
const results = await Promise.allSettled(
ids.map((id) =>
apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" }),
),
);
const ok = results.filter((r) => r.status === "fulfilled").length;
const failed = results.length - ok;
if (failed === 0) {
toast({
title: t("executiveMeetings.schedule.bulkDeleteResultAll").replace(
"{{n}}",
String(ok),
),
});
} else if (ok === 0) {
toast({
title: t("executiveMeetings.schedule.bulkDeleteResultNone"),
variant: "destructive",
});
} else {
toast({
title: t("executiveMeetings.schedule.bulkDeletePartial")
.replace("{{ok}}", String(ok))
.replace("{{total}}", String(results.length))
.replace("{{failed}}", String(failed)),
variant: "destructive",
});
}
setSelectedMeetingIds(new Set());
refreshDay();
} finally {
setBulkDeleting(false);
}
}, [bulkDeleting, selectedMeetingIds, t, toast, refreshDay]);
const [creatingRow, setCreatingRow] = useState(false);
// When the user clicks "Add row" we capture the new meeting id here so
// that, once the refreshed list contains it, we can scroll the row into
// view and auto-open the title cell in inline-edit mode.
const [autoEditTitleId, setAutoEditTitleId] = useState<number | null>(null);
const createRow = useCallback(async () => {
if (creatingRow) return;
setCreatingRow(true);
try {
const created = (await apiJson(`/api/executive-meetings`, {
method: "POST",
body: {
titleAr: t("executiveMeetings.schedule.newMeetingDefaultAr"),
titleEn: t("executiveMeetings.schedule.newMeetingDefaultEn"),
meetingDate: date,
},
})) as Meeting | undefined;
if (created?.id) setAutoEditTitleId(created.id);
refreshDay();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
} finally {
setCreatingRow(false);
}
}, [creatingRow, date, refreshDay, toast, t]);
// Once the auto-edit target meeting is in the list, scroll it into view.
// The MeetingRow itself receives `startInEditMode` so its EditableCell
// opens immediately and clears `autoEditTitleId` via `onStartedEditing`.
// We also schedule a fallback clear so the state never goes stale if the
// title cell never mounts (e.g. column hidden, row filtered out).
useEffect(() => {
if (autoEditTitleId == null) return;
if (!meetings.some((m) => m.id === autoEditTitleId)) return;
const rafId = window.requestAnimationFrame(() => {
const row = document.querySelector(
`[data-testid="em-row-${autoEditTitleId}"]`,
);
if (row && "scrollIntoView" in row) {
(row as HTMLElement).scrollIntoView({
block: "center",
behavior: "smooth",
});
}
});
const fallback = window.setTimeout(() => {
setAutoEditTitleId(null);
}, 1500);
return () => {
window.cancelAnimationFrame(rafId);
window.clearTimeout(fallback);
};
}, [autoEditTitleId, meetings]);
const clearAutoEditTitleId = useCallback(() => {
setAutoEditTitleId(null);
}, []);
const reorderRows = useCallback(
async (fromId: number, toId: number) => {
if (fromId === toId) return;
if (reorderingRef.current) return;
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..<xl → fluid table, browser shrinks columns to fit (tablets)
// ≥ xl (≥1280px) → fixed-width table with resize handles (desktop)
// print → fixed-width table at all viewports (preserves the
// historical print layout from any screen size).
// Whether the configured pixel widths are *enforced* is controlled purely
// by CSS: `table-auto xl:table-fixed print:table-fixed`. We always emit the
// colgroup and per-cell widths so they're available whenever the table is
// in fixed-layout mode (xl screens AND any print). In `table-auto` mode,
// the browser treats those widths as hints and shrinks columns to fit.
// Resize handles only appear on large screens AND fine pointers (mouse).
// Touch devices — including iPad Pro at >=1280 CSS px — get no handles
// since drag-resizing with a finger fights touch scrolling.
const isXl = useMediaQuery("(min-width: 1280px)");
const isFinePointer = useMediaQuery("(pointer: fine)");
// Resize handles only render when the user is in edit mode AND on a
// large screen with a fine pointer. View mode hides them entirely so
// the table feels frozen until the editor opts in.
const showResizeHandles = isXl && isFinePointer && effectiveCanMutate;
// dnd-kit: sortable column headers + sortable rows.
//
// - PointerSensor for desktop mouse: a 6px activation distance keeps a
// normal click on cell content from being mistaken for a drag (so the
// inline editors on title/attendee/time cells still open on click).
// - TouchSensor for iPad and other touch devices: PointerSensor alone
// does not work on touch because the browser claims the gesture as a
// vertical scroll before the finger has moved 6px, so the drag never
// activates. A long-press activation (delay+tolerance) lets the user
// press-and-hold the row's grip handle to start dragging while still
// leaving normal scrolling intact everywhere else on the row.
// - KeyboardSensor for accessibility / keyboard reordering.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 8 },
}),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const onColumnDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
setColumns((prev) => {
const ids = prev.map((c) => c.id);
const fromIdx = ids.indexOf(active.id as ColumnId);
const toIdx = ids.indexOf(over.id as ColumnId);
if (fromIdx < 0 || toIdx < 0) return prev;
const next = prev.slice();
const [moved] = next.splice(fromIdx, 1);
if (!moved) return prev;
next.splice(toIdx, 0, moved);
return next;
});
},
[],
);
const onRowDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
void reorderRows(Number(active.id), Number(over.id));
},
[reorderRows],
);
return (
<div className="space-y-4 print:space-y-2">
<div className="flex items-center justify-between gap-3 flex-wrap print:hidden">
<h2
className="text-xl sm:text-2xl font-bold text-[#0B1E3F]"
data-testid="em-schedule-heading"
>
{t("executiveMeetings.scheduleHeading")}
</h2>
<div className="flex items-center gap-2 flex-wrap">
{canMutate && (
<button
type="button"
onClick={() => setEditMode(!editMode)}
aria-pressed={editMode}
aria-label={
editMode
? t("executiveMeetings.schedule.saveToggleAria")
: t("executiveMeetings.schedule.editToggleAria")
}
title={
editMode
? t("executiveMeetings.schedule.saveToggleOn")
: t("executiveMeetings.schedule.saveToggleOff")
}
data-testid="em-edit-mode-toggle"
className={
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[#0B1E3F]/40 " +
(editMode
? "bg-[#0B1E3F] text-white border-[#0B1E3F] hover:bg-[#0B1E3F]/90"
: "bg-white text-[#0B1E3F] border-gray-300 hover:bg-gray-50")
}
>
{editMode ? (
<Check className="w-3.5 h-3.5" />
) : (
<Pencil className="w-3.5 h-3.5" />
)}
<span>
{editMode
? t("executiveMeetings.schedule.saveToggle")
: t("executiveMeetings.schedule.editToggle")}
</span>
</button>
)}
<ColumnsCustomizer
columns={columns}
onChange={setColumns}
t={t}
highlightPrefs={highlightPrefs}
onHighlightPrefsChange={setHighlightPrefs}
/>
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
<input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
/>
</label>
</div>
</div>
<div className="hidden print:block text-center mb-2">
<div className="font-bold text-lg">{t("executiveMeetings.titleAr")}</div>
<div className="text-sm">{date}</div>
</div>
{/* Floating bulk-action toolbar. Per spec it appears ONLY when
≥1 row is selected (and in edit mode). The tri-state
"select all" checkbox lives in the table header (see
SortableHeader's selectAllOverlay below), so this bar
contains only the count + the destructive actions.
Hidden in print so it doesn't show up in PDF exports. */}
{effectiveCanMutate && selectedMeetingIds.size > 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(selectedMeetingIds.size))
.replace("{{total}}", String(orderedMeetings.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="destructive"
onClick={deleteSelectedMeetings}
disabled={bulkDeleting}
className="gap-1"
data-testid="em-bulk-delete-selected"
>
<Trash2 className="w-4 h-4" />
{t("executiveMeetings.schedule.bulkDeleteSelected")}
</Button>
</div>
</div>
)}
<div
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>
<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}
isLast={idx === visibleColumns.length - 1}
isRtl={isRtl}
t={t}
showResizeHandle={showResizeHandles}
dragEnabled={effectiveCanMutate}
onResize={(delta) => setColumnWidth(c.id, c.width + delta)}
selectAllOverlay={
// Tri-state "select all" lives in the first
// visible header (typically # but falls back
// gracefully when the user has hidden #) and
// only renders in edit mode with ≥1 row to
// act on.
idx === 0 &&
effectiveCanMutate &&
orderedMeetings.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>
<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>
)}
<SortableContext
items={orderedMeetings.map((m) => m.id)}
strategy={verticalListSortingStrategy}
>
{orderedMeetings.map((m) => (
<MeetingRow
key={m.id}
meeting={m}
isRtl={isRtl}
t={t}
visibleColumns={visibleColumns}
rowColorKey={rowColors[m.id] ?? "default"}
onPickRowColor={(key) => setRowColor(m.id, key)}
canMutate={effectiveCanMutate}
onSaveTitle={(html) => saveTitle(m, html)}
onSaveAttendeeName={(idx, html) =>
saveAttendeeName(m, idx, html)
}
onSaveTimes={(start, end) => saveTimes(m, start, end)}
onSaveMerge={(merge) => saveMerge(m, merge)}
onDeleteMeeting={() => deleteMeeting(m)}
// Disable every row's delete button while any delete
// is in flight, since deleteMeeting short-circuits
// concurrent calls — keeps the UI honest about which
// clicks will do anything.
isDeleting={deletingMeetingId !== null}
isCurrent={
highlightPrefs.enabled && currentMeetingId === m.id
}
highlightColor={highlightPrefs.color}
reordering={reordering}
autoEditTitle={autoEditTitleId === m.id}
onAutoEditTitleConsumed={clearAutoEditTitleId}
pendingAttendee={pendingAttendee}
onStartAddAttendee={startAddAttendee}
onCommitAddAttendee={(type, html) =>
commitAddAttendee(m, type, html)
}
onCancelAddAttendee={cancelAddAttendee}
bulkSelectable={effectiveCanMutate}
bulkSelected={selectedMeetingIds.has(m.id)}
onBulkSelectChange={(v) => toggleMeetingSelected(m.id, v)}
/>
))}
</SortableContext>
{effectiveCanMutate && !isLoading && (
<tr
className="print:hidden"
data-testid="em-add-row-tr"
>
<td
colSpan={visibleColumns.length}
className="border border-gray-300 p-0"
>
<button
type="button"
onClick={createRow}
disabled={creatingRow}
aria-label={t("executiveMeetings.schedule.addRow")}
aria-busy={creatingRow}
className="w-full flex items-center justify-center gap-2 py-2.5 text-sm text-[#0B1E3F] hover:bg-blue-50/60 focus:bg-blue-50/60 focus:outline-none transition-colors disabled:opacity-60"
data-testid="em-add-row-button"
>
{creatingRow ? (
<span>{t("common.loading")}</span>
) : (
<Plus className="w-4 h-4" aria-hidden="true" />
)}
</button>
</td>
</tr>
)}
</tbody>
</DndContext>
</table>
</div>
</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 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 (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5"
data-testid="em-customize-columns-trigger"
>
<Sliders className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.customize.title")}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-3" align="end">
<div className="text-sm font-semibold text-[#0B1E3F] mb-2">
{t("executiveMeetings.customize.title")}
</div>
<p className="text-[11px] 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>
</PopoverContent>
</Popover>
);
}
/**
* 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 });
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,
// 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,
onChangeMerge,
t,
}: {
meetingId: number;
isDeleting: boolean;
onDelete: () => void;
rowColorKey: string;
onPickRowColor: (key: string) => void;
hasStoredMerge: boolean;
existingMergeText: string | null;
onChangeMerge: (
merge:
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
| null,
) => Promise<void>;
t: (k: string) => 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" />
{t("executiveMeetings.rowColor.label")}
</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" />
{t("executiveMeetings.merge.label")}
</button>
</>
)}
{view === "color" && (
<>
<BackToMainButton onClick={() => setView("main")} t={t} />
<div className="px-1 pb-1">
<RowColorPickerSwatches
current={rowColorKey}
onPick={(key) => {
onPickRowColor(key);
setOpen(false);
}}
t={t}
/>
</div>
</>
)}
{view === "merge" && (
<>
<BackToMainButton onClick={() => setView("main")} t={t} />
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
data-testid={`em-merge-opt-meeting-attendees-${meetingId}`}
onClick={() => applyMerge("meeting", "attendees")}
>
{t("executiveMeetings.merge.meetingAttendees")}
</button>
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
data-testid={`em-merge-opt-meeting-time-${meetingId}`}
onClick={() => applyMerge("meeting", "time")}
>
{t("executiveMeetings.merge.meetingTime")}
</button>
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
data-testid={`em-merge-opt-all-${meetingId}`}
onClick={() => applyMerge("number", "time")}
>
{t("executiveMeetings.merge.all")}
</button>
{hasStoredMerge && (
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent text-destructive"
data-testid={`em-merge-opt-unmerge-${meetingId}`}
onClick={() => {
setOpen(false);
onChangeMerge(null);
}}
>
{t("executiveMeetings.merge.unmerge")}
</button>
)}
</>
)}
</PopoverContent>
</Popover>
);
}
// Small "Back" affordance shown at the top of the row-actions submenu
// views (Row color, Merge cells). RTL-aware: uses ChevronRight in
// Arabic so the arrow points back toward the main menu visually.
function BackToMainButton({
onClick,
t,
}: {
onClick: () => void;
t: (k: string) => string;
}) {
const isRtl =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
const ArrowIcon = isRtl ? ChevronRight : ChevronLeft;
return (
<button
type="button"
className="w-full text-start px-2 py-1.5 text-xs rounded hover:bg-accent text-muted-foreground flex items-center gap-1 mb-0.5"
onClick={onClick}
data-testid="em-row-actions-back"
>
<ArrowIcon className="w-3.5 h-3.5" aria-hidden="true" />
{t("executiveMeetings.rowActions.back")}
</button>
);
}
function MeetingRow({
meeting,
isRtl,
t,
visibleColumns,
rowColorKey,
onPickRowColor,
canMutate,
onSaveTitle,
onSaveAttendeeName,
onSaveTimes,
onSaveMerge,
onDeleteMeeting,
isDeleting,
isCurrent,
highlightColor,
reordering,
autoEditTitle = false,
onAutoEditTitleConsumed,
pendingAttendee,
onStartAddAttendee,
onCommitAddAttendee,
onCancelAddAttendee,
bulkSelectable,
bulkSelected,
onBulkSelectChange,
}: {
meeting: Meeting;
isRtl: boolean;
t: (k: string) => string;
visibleColumns: ColumnSetting[];
rowColorKey: string;
onPickRowColor: (key: string) => void;
canMutate: boolean;
onSaveTitle: (html: string) => Promise<void>;
onSaveAttendeeName: (idx: number, html: string) => Promise<void>;
onSaveTimes: (
startTime: string | null,
endTime: string | null,
) => Promise<void>;
onSaveMerge: (
merge:
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
| null,
) => Promise<void>;
onDeleteMeeting: () => void;
isDeleting: boolean;
isCurrent: boolean;
highlightColor: string;
reordering: boolean;
autoEditTitle?: boolean;
onAutoEditTitleConsumed?: () => void;
pendingAttendee?: {
meetingId: number;
attendanceType: Attendee["attendanceType"];
key: number;
} | null;
onStartAddAttendee?: (
meetingId: number,
attendanceType: Attendee["attendanceType"],
) => void;
onCommitAddAttendee?: (
attendanceType: Attendee["attendanceType"],
html: string,
) => Promise<void>;
onCancelAddAttendee?: () => void;
bulkSelectable: boolean;
bulkSelected: boolean;
onBulkSelectChange: (next: boolean) => void;
}) {
const title = isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr;
const isCancelled = meeting.status === "cancelled";
const highlight = meeting.isHighlighted === 1;
const rowColor = ROW_COLOR_OPTIONS.find((o) => o.key === rowColorKey);
const rowBg = rowColor?.bg || "";
// Make the row a sortable item. `attributes` get spread onto the <tr> for
// a11y; `listeners` go on the dedicated grip handle in the # cell so a
// normal click on the cell content (to enter inline-edit mode) is still
// possible.
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: meeting.id, disabled: !canMutate });
const numberCellCls =
"border border-gray-300 px-2 py-2 text-center align-middle font-semibold " +
(highlight || isCancelled
? "bg-red-600 text-white"
: "bg-white text-[#0B1E3F]");
// Always provide width + overflow:hidden inline. In `table-auto` mode the
// browser treats width as a hint and shrinks cells to fit; in `table-fixed`
// mode (xl screens AND any print) it enforces them. overflow:hidden + the
// existing break-words on inner divs keeps wrapping behavior correct.
const cellStyle = (col: ColumnSetting): CSSProperties => ({
width: col.width,
overflow: "hidden",
});
// Compute the cell-merge overlay for this row. We resolve the stored
// start/end column ids against the *canonical* schedule column order
// (not against `visibleColumns`) so that a merge whose start or end
// column is currently hidden still renders across whichever columns
// in the range remain visible. This guarantees a row that has a
// merge in the DB never silently falls back to "unmerged" just
// because the user toggled off one of the boundary columns.
const canonStart = canonicalMergeIndex(meeting.mergeStartColumn);
const canonEnd = canonicalMergeIndex(meeting.mergeEndColumn);
const hasStoredMerge =
canonStart >= 0 && canonEnd >= canonStart && typeof meeting.mergeText === "string";
const visibleMergeIndices: number[] = [];
if (hasStoredMerge) {
visibleColumns.forEach((c, idx) => {
const ci = canonicalMergeIndex(c.id);
if (ci >= canonStart && ci <= canonEnd) visibleMergeIndices.push(idx);
});
}
// Headers are themselves drag-sortable, so users can reorder the
// schedule columns. If a previously-set merge ends up spanning
// *non-contiguous* visible indices after a reorder, rendering one
// colSpan cell at the first index would silently swallow the wrong
// columns. In that case we degrade to "unmerged" rendering — the DB
// merge state stays intact, and unmerging or restoring canonical
// order brings the merged cell back.
const visibleMergeContiguous =
visibleMergeIndices.length === 0 ||
visibleMergeIndices[visibleMergeIndices.length - 1] -
visibleMergeIndices[0] +
1 ===
visibleMergeIndices.length;
const isMerged =
hasStoredMerge && visibleMergeIndices.length > 0 && visibleMergeContiguous;
const mergeFirstIdx = isMerged ? visibleMergeIndices[0] : -1;
const mergeSkipSet = isMerged ? new Set(visibleMergeIndices.slice(1)) : null;
// First visible cell that's NOT part of a merge. The merge trigger
// for an unmerged row is anchored here so editors can still open the
// merge menu even when the # column is hidden. We look for the
// first index in visibleColumns whose canonical id exists (which is
// every schedule column), so this collapses to `0` in practice but
// would skip non-mergeable columns if they're added later.
const firstUnmergedIdx = isMerged ? -1 : visibleColumns.length > 0 ? 0 : -1;
// Light tint for the current meeting on the editable cells (meeting,
// attendees, time). The number cell keeps its solid white background
// and original text colour so it stays legible — only the rest of the
// row gets a soft wash of highlightColor. We keep the original text
// colour everywhere so the row remains readable.
const tintBg =
isCurrent && !(highlight || isCancelled)
? hexToRgba(highlightColor, 0.13)
: null;
const tintedCellStyle = (col: ColumnSetting): CSSProperties =>
tintBg ? { ...cellStyle(col), backgroundColor: tintBg } : cellStyle(col);
// Per-row bulk-select checkbox, lifted out of `renderCell` so the
// merged-cell branch can re-use the same JSX. Anchored to the
// top-start corner of whichever cell hosts it (the first visible
// non-merged cell for unmerged rows, or the merged cell itself for
// merged rows). Anchoring as an absolute overlay (rather than
// inline inside the # cell) keeps the checkbox available even when
// the editor has hidden the # column AND leaves the # cell's grip
// handle sitting in its original position so dnd-kit's
// drag-to-reorder isn't disturbed by neighbouring controls.
const bulkSelectOverlay = bulkSelectable ? (
<div
className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={bulkSelected}
onCheckedChange={(v) => onBulkSelectChange(v === true)}
aria-label={t("executiveMeetings.schedule.bulkSelectRow")}
data-testid={`em-row-select-${meeting.id}`}
className="h-4 w-4 bg-white shadow-sm"
/>
</div>
) : null;
const renderCell = (col: ColumnSetting, idx: number): ReactNode => {
// Anchor the consolidated row-actions menu (delete + row color +
// merge) to whichever cell is the first visible one on an unmerged
// row. Using "first visible cell" (instead of always the # cell)
// keeps the trigger reachable when editors hide the number column
// via the column customizer. The button is rendered as an
// absolutely-positioned overlay inside the cell, so every cell type
// needs `relative group` on its <td> for it to find them.
const showActionsMenu = canMutate && !isMerged && idx === firstUnmergedIdx;
const actionsOverlay = showActionsMenu ? (
<RowActionsMenu
meetingId={meeting.id}
isDeleting={isDeleting}
onDelete={onDeleteMeeting}
rowColorKey={rowColorKey}
onPickRowColor={onPickRowColor}
// Offer Unmerge whenever the row has a stored merge in the DB,
// even if we're in degraded (non-contiguous) rendering — that's
// the only way the user can clear the stored state.
hasStoredMerge={hasStoredMerge}
existingMergeText={meeting.mergeText ?? null}
onChangeMerge={onSaveMerge}
t={t}
/>
) : null;
// The bulk-select overlay only attaches to the first visible
// non-merged cell (so it appears once per row, never twice). For
// merged rows the overlay is rendered separately on the merged
// <td> (see `renderCells` below) so it remains reachable even
// when the merge swallows what would otherwise be the first
// visible cell.
const cellBulkOverlay =
!isMerged && idx === firstUnmergedIdx ? bulkSelectOverlay : null;
switch (col.id) {
case "number":
// Priority for the # cell background:
// 1. highlight || isCancelled → solid red badge (className wins)
// 2. tintBg (current meeting wash) → solid highlightColor with
// white text so the number stays legible.
// 3. rowBg (user-picked row color) → match the rest of the row
// so the chosen color wraps the whole row including the
// number cell. The palette is all light tints, so the
// existing dark `text-[#0B1E3F]` from numberCellCls stays
// readable.
// 4. otherwise → fall back to the white background already in
// numberCellCls.
// We omit any inline backgroundColor in case (1) so the
// bg-red-600 utility class isn't overridden by an inline style.
const numberCellInlineStyle: CSSProperties = (() => {
if (highlight || isCancelled) return cellStyle(col);
if (tintBg) {
return {
...cellStyle(col),
backgroundColor: highlightColor,
color: "white",
};
}
if (rowBg) {
return { ...cellStyle(col), backgroundColor: rowBg };
}
return cellStyle(col);
})();
return (
<td
key="number"
className={`relative group ${numberCellCls}`}
style={numberCellInlineStyle}
>
<div className="flex items-center justify-center gap-1">
{canMutate && (
<button
type="button"
// The grip handle is hover-only on devices that have a
// real hover (desktop mouse), but always visible at ~40%
// opacity on touch devices that have no hover state — so
// iPad users can actually find what to press to reorder
// a row. The tap target is also enlarged on touch with a
// small padding so the long-press lands reliably.
// touch-action:none stays on the handle itself so the
// browser hands touch events to dnd-kit instead of
// claiming them as a vertical scroll; the rest of the
// row keeps default touch-action so page scrolling works
// normally outside of the handle.
className="opacity-0 group-hover:opacity-70 hover:opacity-100 [@media(hover:none)_and_(pointer:coarse)]:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:group-hover:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:p-1.5 cursor-grab active:cursor-grabbing touch-none print:hidden"
aria-label={t("executiveMeetings.dragRow")}
data-testid={`em-row-grip-${meeting.id}`}
{...listeners}
{...attributes}
>
<GripVertical className="w-3.5 h-3.5 [@media(hover:none)_and_(pointer:coarse)]:w-4 [@media(hover:none)_and_(pointer:coarse)]:h-4" />
</button>
)}
<span>{meeting.dailyNumber}</span>
</div>
{cellBulkOverlay}
{actionsOverlay}
</td>
);
case "meeting":
return (
<td
key="meeting"
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
style={tintedCellStyle(col)}
>
<EditableCell
value={title}
onSave={onSaveTitle}
ariaLabel={t("executiveMeetings.editMeetingTitle")}
dir={isRtl ? "rtl" : "ltr"}
className="font-medium leading-snug break-words"
disabled={!canMutate}
testId={`em-edit-title-${meeting.id}`}
startInEditMode={autoEditTitle}
onStartedEditing={onAutoEditTitleConsumed}
/>
{meeting.location && (
<div className="text-[11px] text-muted-foreground mt-0.5 break-words">
{meeting.location}
</div>
)}
{cellBulkOverlay}
{actionsOverlay}
</td>
);
case "attendees":
return (
<td
key="attendees"
className="border border-gray-300 px-3 py-3 align-middle relative group"
style={tintedCellStyle(col)}
>
<AttendeesCell
meeting={meeting}
t={t}
canMutate={canMutate}
onSaveAttendeeName={onSaveAttendeeName}
pendingAttendee={pendingAttendee ?? null}
onStartAddAttendee={onStartAddAttendee}
onCommitAddAttendee={onCommitAddAttendee}
onCancelAddAttendee={onCancelAddAttendee}
/>
{cellBulkOverlay}
{actionsOverlay}
</td>
);
case "time":
return (
<td
key="time"
className="border border-gray-300 px-3 py-3 align-middle text-center relative group"
style={tintedCellStyle(col)}
>
<TimeRangeCell
meeting={meeting}
t={t}
lang={isRtl ? "ar" : "en"}
canMutate={canMutate}
onSaveTimes={onSaveTimes}
/>
{cellBulkOverlay}
{actionsOverlay}
</td>
);
}
};
// When a merge is active we render a single <td colSpan=N> with an
// EditableCell for `mergeText`, and skip the cells the merge covers
// after the first. The merged cell hosts its own RowActionsMenu so users
// can always unmerge or re-merge — even when the # column is hidden.
// Originals stay in the DB so unmerge restores them.
const renderCells = (): ReactNode[] => {
if (!isMerged) {
return visibleColumns.map((c, idx) => renderCell(c, idx));
}
const cells: ReactNode[] = [];
const span = visibleMergeIndices.length;
visibleColumns.forEach((col, idx) => {
if (mergeSkipSet?.has(idx)) return;
if (idx === mergeFirstIdx) {
// Sum widths of the spanned visible columns so layout in
// table-fixed mode reserves the right horizontal space.
const totalWidth = visibleMergeIndices.reduce(
(sum, i) => sum + (visibleColumns[i].width || 0),
0,
);
cells.push(
<td
key="merge"
colSpan={span}
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
style={{
width: totalWidth,
overflow: "hidden",
...(tintBg ? { backgroundColor: tintBg } : null),
}}
data-testid={`em-merge-cell-${meeting.id}`}
>
<EditableCell
value={meeting.mergeText ?? ""}
onSave={async (html) => {
await onSaveMerge({
mergeStartColumn: meeting.mergeStartColumn as ColumnId,
mergeEndColumn: meeting.mergeEndColumn as ColumnId,
mergeText: html,
});
}}
ariaLabel={t("executiveMeetings.merge.editLabel")}
dir={isRtl ? "rtl" : "ltr"}
className="font-medium leading-snug break-words"
disabled={!canMutate}
placeholder={t("executiveMeetings.merge.placeholder")}
testId={`em-merge-edit-${meeting.id}`}
/>
{bulkSelectOverlay}
{canMutate && (
<RowActionsMenu
meetingId={meeting.id}
isDeleting={isDeleting}
onDelete={onDeleteMeeting}
rowColorKey={rowColorKey}
onPickRowColor={onPickRowColor}
hasStoredMerge
existingMergeText={meeting.mergeText ?? null}
onChangeMerge={onSaveMerge}
t={t}
/>
)}
</td>,
);
} else {
cells.push(renderCell(col, idx));
}
});
return cells;
};
// Combine row background with highlight tint. When the row is the current
// meeting we apply a soft tint (color at low opacity) plus a coloured ring
// via box-shadow (rings on <tr> don't render across cells; box-shadow does).
const baseBg = rowBg || "white";
// The "selected for bulk delete" ring stacks ON TOP of the existing
// current-meeting ring when both apply. Inset shadows compose from
// left to right (top of stack first), so we list the selection ring
// first so it's the visible outer outline. Color is the same brand
// navy used elsewhere in the page so it reads as a deliberate state.
const selectionShadow = bulkSelected ? "inset 0 0 0 2px #0B1E3F" : null;
const currentShadow = isCurrent
? `inset 0 0 0 2px ${highlightColor}`
: null;
const composedShadow = [selectionShadow, currentShadow]
.filter(Boolean)
.join(", ");
const trStyle: CSSProperties = {
backgroundColor: baseBg,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
boxShadow: composedShadow || undefined,
};
return (
<tr
ref={setNodeRef}
className="group"
style={trStyle}
data-testid={`em-row-${meeting.id}`}
data-current-meeting={isCurrent ? "true" : undefined}
data-bulk-selected={bulkSelected ? "true" : undefined}
>
{renderCells()}
</tr>
);
}
// Canonical schedule column order for cell-merge ranges. MUST stay in
// sync with the API's MERGE_COLUMNS array in
// artifacts/api-server/src/routes/executive-meetings.ts. We use this
// (instead of `visibleColumns`) when computing whether a stored merge
// overlaps the currently visible columns, so that hiding one boundary
// of the range still lets the merged cell render across whichever
// columns inside the range remain visible.
const CANONICAL_MERGE_ORDER: ColumnId[] = [
"number",
"meeting",
"attendees",
"time",
];
function canonicalMergeIndex(id: ColumnId | string | null | undefined): number {
if (id == null) return -1;
return CANONICAL_MERGE_ORDER.indexOf(id as ColumnId);
}
// Convert a #RRGGBB or #RGB hex color into an rgba() string with the
// given alpha. Used to derive the soft tint applied to the current
// meeting row from the user's highlight color preference. Falls back
// to the input string unchanged if it isn't a recognised hex.
function hexToRgba(hex: string, alpha: number): string {
const m3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(hex);
const m6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
let r = 0;
let g = 0;
let b = 0;
if (m6) {
r = parseInt(m6[1], 16);
g = parseInt(m6[2], 16);
b = parseInt(m6[3], 16);
} else if (m3) {
r = parseInt(m3[1] + m3[1], 16);
g = parseInt(m3[2] + m3[2], 16);
b = parseInt(m3[3] + m3[3], 16);
} else {
return hex;
}
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function TimeRangeCell({
meeting,
t,
lang,
canMutate,
onSaveTimes,
}: {
meeting: Meeting;
t: (k: string) => string;
lang: "ar" | "en";
canMutate: boolean;
onSaveTimes: (
startTime: string | null,
endTime: string | null,
) => Promise<void>;
}) {
const { toast } = useToast();
const startSaved = meeting.startTime ? meeting.startTime.slice(0, 5) : "";
const endSaved = meeting.endTime ? meeting.endTime.slice(0, 5) : "";
const [editing, setEditing] = useState(false);
const [start, setStart] = useState(startSaved);
const [end, setEnd] = useState(endSaved);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startInputRef = useRef<HTMLInputElement | null>(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]);
// 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 () => {
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;
}
try {
await onSaveTimes(newStart, newEnd);
setEditing(false);
} catch (err) {
// The parent `saveTimes` handler is responsible for surfacing the
// error toast. For the server-side time-order validation error we
// stay in edit mode 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 and
// return to the previously-saved values.
if (err instanceof Error && err.name === "TimeOrderError") {
startInputRef.current?.focus();
return;
}
setStart(startSaved);
setEnd(endSaved);
setEditing(false);
}
}, [start, end, startSaved, endSaved, onSaveTimes]);
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>
);
}
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if (e.key === "Enter") {
e.preventDefault();
void save();
}
};
return (
<div
ref={wrapperRef}
onFocus={onFocusIn}
onBlur={onFocusOut}
className="flex items-center justify-center gap-1 font-mono whitespace-nowrap"
data-testid={`em-time-${meeting.id}`}
data-editing="true"
>
<input
ref={startInputRef}
type="time"
value={start}
onChange={(e) => 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}`}
/>
<span className="text-muted-foreground"></span>
<input
type="time"
value={end}
onChange={(e) => 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}`}
/>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => void save()}
aria-label={t("common.save")}
className="ms-0.5 p-0.5 rounded text-green-700 hover:bg-green-50 focus:bg-green-50 focus:outline-none"
data-testid={`em-time-save-${meeting.id}`}
>
<Check className="w-3.5 h-3.5" />
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={cancel}
aria-label={t("common.cancel")}
className="p-0.5 rounded text-red-700 hover:bg-red-50 focus:bg-red-50 focus:outline-none"
data-testid={`em-time-cancel-${meeting.id}`}
>
<X className="w-3.5 h-3.5" />
</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"];
key: number;
} | null;
onStartAddAttendee?: (
meetingId: number,
attendanceType: Attendee["attendanceType"],
) => void;
onCommitAddAttendee?: (
attendanceType: Attendee["attendanceType"],
html: string,
) => 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).
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<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;
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 (
<div className="space-y-2">
{(virtual.length > 0 || isPendingFor("virtual")) && (
<AttendeeGroup
label={
meeting.platform !== "none"
? `${t("executiveMeetings.virtualAttendance")}${platformLabel[meeting.platform]}`
: t("executiveMeetings.virtualAttendance")
}
items={virtual}
addType="virtual"
{...flowProps}
isPending={isPendingFor("virtual")}
/>
)}
{(internal.length > 0 || isPendingFor("internal")) && (
<AttendeeGroup
label={t("executiveMeetings.internalAttendance")}
items={internal}
addType="internal"
{...flowProps}
isPending={isPendingFor("internal")}
/>
)}
{(external.length > 0 || isPendingFor("external")) && (
<AttendeeGroup
label={t("executiveMeetings.externalAttendance")}
items={external}
addType="external"
{...flowProps}
isPending={isPendingFor("external")}
/>
)}
{/* 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>
)}
</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={isPendingFor("virtual")}
/>
);
}
// Empty cell or single-type cell — render flow without a header.
return (
<AttendeeFlow
items={tagged}
addType={singleGroupType}
{...flowProps}
isPending={isPendingFor(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;
pendingKey: number;
onStartAdd?: (type: Attendee["attendanceType"]) => void;
onCommitAdd?: (
type: Attendee["attendanceType"],
html: string,
) => Promise<void>;
onCancelAdd?: () => void;
addLabel: string;
editAriaLabel: string;
newAriaLabel: string;
meetingId: number;
};
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). */}
<div className="text-xs font-semibold text-[#0B1E3F] mb-0.5 text-center pointer-events-none select-none">
{label}
</div>
<AttendeeFlow items={items} {...flowProps} />
</div>
);
}
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 <span className="text-xs text-gray-500"></span>;
}
return (
<ul className="flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug">
{items.map(({ a, i }, displayIdx) => (
<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]" : "")
}
>
{items.length > 1 && (
<span
data-testid={`em-attendee-index-${i}`}
className="text-gray-500 me-1"
>
{displayIdx + 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}`}
/>
) : (
<span
data-testid={`em-attendee-name-${i}`}
className="[&_p]:inline [&_p]:m-0"
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
/>
)}
</li>
))}
{canMutate && isPending && onCommitAdd && (
<li className="min-w-[6rem]" data-testid={`em-add-attendee-pending-${addType}`}>
<span className="text-gray-500 me-1">{items.length + 1}-</span>
<EditableCell
// pendingKey changes every time the user clicks "+" so React
// remounts the editor with a fresh focus.
key={`pending-${pendingKey}`}
value=""
onSave={(html) => 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}
/>
</li>
)}
{showAdd && (
<li className="print:hidden">
<button
type="button"
onClick={() => onStartAdd!(addType)}
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>
)}
</ul>
);
}
// ============ MANAGE ============
type MeetingFormState = {
id: number | null;
titleAr: string;
titleEn: string;
meetingDate: string;
dailyNumber: string;
startTime: string;
endTime: string;
location: string;
meetingUrl: string;
platform: Meeting["platform"];
status: string;
isHighlighted: boolean;
notes: string;
attendees: Attendee[];
};
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<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 ?? [];
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<string, unknown> = {
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 <NoPermission t={t} />;
}
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.manage.heading")}
</h2>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
<input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
/>
</label>
<Button onClick={openCreate} className="bg-[#0B1E3F] gap-1" data-testid="em-add-meeting">
<Plus className="w-4 h-4" />
{t("executiveMeetings.manage.addMeeting")}
</Button>
</div>
</div>
<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-12">#</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.col.meeting")}</th>
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.col.time")}</th>
<th className="px-3 py-2 text-start w-24">{t("executiveMeetings.manage.field.status")}</th>
<th className="px-3 py-2 text-end w-32">{t("executiveMeetings.common.actions")}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && meetings.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
{t("executiveMeetings.manage.noMeetings")}
</td>
</tr>
)}
{meetings.map((m) => (
<tr key={m.id} className="border-t border-gray-100">
<td className="px-3 py-2 align-middle">{m.dailyNumber}</td>
<td className="px-3 py-2 align-middle">
<div className="font-medium text-[#0B1E3F]">
{isRtl ? m.titleAr : m.titleEn || m.titleAr}
</div>
<div className="text-xs text-gray-500">
{m.attendees.length} · {m.platform}
</div>
</td>
<td className="px-3 py-2 align-middle font-mono text-xs whitespace-nowrap" dir="ltr">
{formatTime(m.startTime, isRtl ? "ar" : "en")} {formatTime(m.endTime, isRtl ? "ar" : "en")}
</td>
<td className="px-3 py-2 align-middle text-xs">
{t(`executiveMeetings.manage.status.${m.status}`)}
</td>
<td className="px-3 py-2 align-middle text-end">
<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}
onSave={save}
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>
</div>
);
}
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 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 },
],
});
}
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.
function removeAllAttendees() {
const count = state.attendees.length;
if (count === 0) return;
const message = t(
"executiveMeetings.manage.attendees.removeAllConfirm",
).replace("{{count}}", String(count));
if (!window.confirm(message)) 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 });
}
return (
<Dialog open={true} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{state.id == null
? t("executiveMeetings.manage.addMeeting")
: t("executiveMeetings.manage.editMeeting")}
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormRow label={t("executiveMeetings.manage.field.titleAr")}>
<Input
value={state.titleAr}
onChange={(e) => update("titleAr", e.target.value)}
data-testid="em-form-titleAr"
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.titleEn")}>
<Input value={state.titleEn} onChange={(e) => update("titleEn", e.target.value)} />
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
<Input
type="date"
value={state.meetingDate}
onChange={(e) => update("meetingDate", e.target.value)}
/>
</FormRow>
<FormRow
label={t("executiveMeetings.manage.field.dailyNumber")}
hint={t("executiveMeetings.manage.field.dailyNumberHint")}
>
<Input
type="number"
min={1}
value={state.dailyNumber}
onChange={(e) => update("dailyNumber", e.target.value)}
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.startTime")}>
<Input
type="time"
value={state.startTime}
onChange={(e) => update("startTime", e.target.value)}
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.endTime")}>
<Input
type="time"
value={state.endTime}
onChange={(e) => update("endTime", e.target.value)}
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.location")}>
<Input value={state.location} onChange={(e) => update("location", e.target.value)} />
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.meetingUrl")}>
<Input
value={state.meetingUrl}
onChange={(e) => update("meetingUrl", e.target.value)}
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.platform")}>
<Select
value={state.platform}
onValueChange={(v) => update("platform", v as Meeting["platform"])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["none", "webex", "teams", "zoom", "other"] as const).map((p) => (
<SelectItem key={p} value={p}>
{t(`executiveMeetings.manage.platform.${p}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.status")}>
<Select value={state.status} onValueChange={(v) => update("status", v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["scheduled", "cancelled", "completed", "postponed"] as const).map((s) => (
<SelectItem key={s} value={s}>
{t(`executiveMeetings.manage.status.${s}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.isHighlighted")}>
<Switch
checked={state.isHighlighted}
onCheckedChange={(v) => update("isHighlighted", v)}
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.notes")} full>
<Textarea
value={state.notes}
onChange={(e) => update("notes", e.target.value)}
rows={3}
/>
</FormRow>
</div>
<div className="border-t border-gray-200 pt-3 mt-1">
<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")} ({state.attendees.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={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">
{state.attendees.map((a, i) => (
<div
key={i}
className="flex items-center gap-2"
data-testid={`em-attendee-row-${i}`}
>
<div className="flex flex-col">
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => moveAttendee(i, -1)}
disabled={i === 0}
aria-label={t("executiveMeetings.manage.attendees.moveUp")}
data-testid={`em-attendee-up-${i}`}
>
<ChevronUp className="w-3 h-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => moveAttendee(i, 1)}
disabled={i === state.attendees.length - 1}
aria-label={t("executiveMeetings.manage.attendees.moveDown")}
data-testid={`em-attendee-down-${i}`}
>
<ChevronDown className="w-3 h-3" />
</Button>
</div>
<Input
className="flex-1"
placeholder={t("executiveMeetings.manage.attendees.name")}
value={a.name}
onChange={(e) => setAttendee(i, { name: e.target.value })}
/>
<Input
className="flex-1"
placeholder={t("executiveMeetings.manage.attendees.title")}
value={a.title ?? ""}
onChange={(e) =>
setAttendee(i, { title: e.target.value || null })
}
data-testid={`em-attendee-title-${i}`}
/>
<Select
value={a.attendanceType}
onValueChange={(v) =>
setAttendee(i, { attendanceType: v as Attendee["attendanceType"] })
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["internal", "virtual", "external"] as const).map((tp) => (
<SelectItem key={tp} value={tp}>
{t(`executiveMeetings.manage.attendanceType.${tp}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="icon" variant="ghost" onClick={() => removeAttendee(i)}>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
))}
{state.attendees.length === 0 && (
<div className="text-xs text-gray-500"></div>
)}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("executiveMeetings.common.cancel")}
</Button>
<Button onClick={onSave} disabled={saving} className="bg-[#0B1E3F]" data-testid="em-form-save">
{saving ? t("common.loading") : t("executiveMeetings.common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function FormRow({
label,
children,
full,
hint,
}: {
label: string;
children: React.ReactNode;
full?: boolean;
hint?: string;
}) {
return (
<div className={full ? "sm:col-span-2" : ""}>
<Label className="text-xs text-gray-700">{label}</Label>
<div className="mt-1">{children}</div>
{hint && <p className="text-[11px] text-gray-500 mt-0.5">{hint}</p>}
</div>
);
}
// ============ REQUESTS ============
function RequestsSection({
me,
isRtl,
t,
}: {
me: MeRoles;
isRtl: boolean;
t: (k: string) => string;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const [filterStatus, setFilterStatus] = useState<string>("all");
const [scope, setScope] = useState<"all" | "mine">(
me.canApprove ? "all" : "mine",
);
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState<{
requestType: string;
meetingId: string;
details: string;
meetingDate: string;
startTime: string;
endTime: string;
location: string;
meetingUrl: string;
platform: string;
attendeeName: string;
attendeeTitle: string;
attendeeType: string;
attendeeId: string;
}>({
requestType: "edit",
meetingId: "",
details: "",
meetingDate: "",
startTime: "",
endTime: "",
location: "",
meetingUrl: "",
platform: "none",
attendeeName: "",
attendeeTitle: "",
attendeeType: "internal",
attendeeId: "",
});
const queryKey = ["/api/executive-meetings/requests", filterStatus, scope];
const { data, isLoading } = useQuery<{ requests: RequestRow[] }>({
queryKey,
queryFn: () => {
const qs = new URLSearchParams();
if (filterStatus !== "all") qs.set("status", filterStatus);
if (scope === "mine") qs.set("mine", "1");
return apiJson(`/api/executive-meetings/requests?${qs}`);
},
});
function buildRequestDetails(): Record<string, unknown> {
const note = form.details.trim();
const out: Record<string, unknown> = {};
if (note) out.note = note;
switch (form.requestType) {
case "reschedule":
if (form.meetingDate) out.meetingDate = form.meetingDate;
if (form.startTime) out.startTime = form.startTime;
if (form.endTime) out.endTime = form.endTime;
break;
case "change_location":
if (form.location.trim()) out.location = form.location.trim();
if (form.meetingUrl.trim()) out.meetingUrl = form.meetingUrl.trim();
if (form.platform) out.platform = form.platform;
break;
case "add_attendee":
if (form.attendeeName.trim()) out.name = form.attendeeName.trim();
if (form.attendeeTitle.trim()) out.title = form.attendeeTitle.trim();
if (form.attendeeType) out.attendanceType = form.attendeeType;
break;
case "remove_attendee":
if (form.attendeeId.trim()) out.attendeeId = Number(form.attendeeId);
break;
default:
break;
}
return out;
}
async function submit() {
if (!me.canSubmitRequest) return;
setSubmitting(true);
try {
const body: Record<string, unknown> = {
requestType: form.requestType,
requestDetails: buildRequestDetails(),
};
if (form.meetingId.trim()) body.meetingId = Number(form.meetingId);
await apiJson(`/api/executive-meetings/requests`, { method: "POST", body });
toast({ title: t("executiveMeetings.requests.submitted") });
setOpen(false);
setForm({
requestType: "edit",
meetingId: "",
details: "",
meetingDate: "",
startTime: "",
endTime: "",
location: "",
meetingUrl: "",
platform: "none",
attendeeName: "",
attendeeTitle: "",
attendeeType: "internal",
attendeeId: "",
});
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("executiveMeetings.requests.submitFailed"),
description: msg,
variant: "destructive",
});
} finally {
setSubmitting(false);
}
}
async function withdraw(id: number) {
try {
await apiJson(`/api/executive-meetings/requests/${id}`, {
method: "PATCH",
body: { action: "withdraw" },
});
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
}
}
const requests = data?.requests ?? [];
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.requests.heading")}
</h2>
<div className="flex items-center gap-2 flex-wrap">
<Select value={scope} onValueChange={(v) => setScope(v as "all" | "mine")}>
<SelectTrigger className="w-40 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("executiveMeetings.requests.allRequests")}</SelectItem>
<SelectItem value="mine">{t("executiveMeetings.requests.myRequests")}</SelectItem>
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-36 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("executiveMeetings.common.all")}</SelectItem>
{(
[
"new",
"needs_edit",
"approved",
"rejected",
"withdrawn",
"done",
] as const
).map((s) => (
<SelectItem key={s} value={s}>
{t(`executiveMeetings.requests.status.${s}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{me.canSubmitRequest && (
<Button
onClick={() => setOpen(true)}
className="bg-[#0B1E3F] gap-1"
data-testid="em-new-request"
>
<Plus className="w-4 h-4" />
{t("executiveMeetings.requests.newRequest")}
</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-32">{t("executiveMeetings.requests.requestedAt")}</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.requests.requestedBy")}</th>
<th className="px-3 py-2 text-start w-32">{t("executiveMeetings.manage.field.status")}</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.requests.type.create")}</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.requests.details")}</th>
<th className="px-3 py-2 text-end w-24">{t("executiveMeetings.common.actions")}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && requests.length === 0 && (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
{t("executiveMeetings.requests.empty")}
</td>
</tr>
)}
{requests.map((r) => (
<RequestListRow
key={r.id}
row={r}
isRtl={isRtl}
t={t}
canWithdraw={me.canSubmitRequest}
onWithdraw={withdraw}
/>
))}
</tbody>
</table>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("executiveMeetings.requests.newRequest")}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<FormRow label={t("executiveMeetings.requests.type.create")}>
<Select
value={form.requestType}
onValueChange={(v) => setForm({ ...form, requestType: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(
[
"create",
"edit",
"delete",
"reschedule",
"add_attendee",
"remove_attendee",
"change_location",
"cancel_meeting",
"note",
"highlight",
"unhighlight",
"other",
] as const
).map((tp) => (
<SelectItem key={tp} value={tp}>
{t(`executiveMeetings.requests.type.${tp}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormRow>
<FormRow label={t("executiveMeetings.requests.submitFor")}>
<Input
type="number"
placeholder={t("executiveMeetings.requests.noTargetMeeting")}
value={form.meetingId}
onChange={(e) => setForm({ ...form, meetingId: e.target.value })}
/>
</FormRow>
{form.requestType === "reschedule" && (
<>
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
<Input
type="date"
value={form.meetingDate}
onChange={(e) =>
setForm({ ...form, meetingDate: e.target.value })
}
data-testid="em-req-date"
/>
</FormRow>
<div className="grid grid-cols-2 gap-3">
<FormRow label={t("executiveMeetings.manage.field.startTime")}>
<Input
type="time"
value={form.startTime}
onChange={(e) =>
setForm({ ...form, startTime: e.target.value })
}
data-testid="em-req-start"
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.endTime")}>
<Input
type="time"
value={form.endTime}
onChange={(e) =>
setForm({ ...form, endTime: e.target.value })
}
data-testid="em-req-end"
/>
</FormRow>
</div>
</>
)}
{form.requestType === "change_location" && (
<>
<FormRow label={t("executiveMeetings.manage.field.location")}>
<Input
value={form.location}
onChange={(e) =>
setForm({ ...form, location: e.target.value })
}
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.meetingUrl")}>
<Input
value={form.meetingUrl}
onChange={(e) =>
setForm({ ...form, meetingUrl: e.target.value })
}
/>
</FormRow>
</>
)}
{form.requestType === "add_attendee" && (
<>
<FormRow label={t("executiveMeetings.manage.attendees.name")}>
<Input
value={form.attendeeName}
onChange={(e) =>
setForm({ ...form, attendeeName: e.target.value })
}
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.attendees.title")}>
<Input
value={form.attendeeTitle}
onChange={(e) =>
setForm({ ...form, attendeeTitle: e.target.value })
}
/>
</FormRow>
</>
)}
{form.requestType === "remove_attendee" && (
<FormRow label={t("executiveMeetings.manage.attendees.id")}>
<Input
type="number"
value={form.attendeeId}
onChange={(e) =>
setForm({ ...form, attendeeId: e.target.value })
}
/>
</FormRow>
)}
<FormRow label={t("executiveMeetings.requests.details")}>
<Textarea
rows={3}
value={form.details}
placeholder={t("executiveMeetings.requests.detailsPlaceholder")}
onChange={(e) => setForm({ ...form, details: e.target.value })}
/>
</FormRow>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
{t("executiveMeetings.common.cancel")}
</Button>
<Button
onClick={submit}
disabled={submitting}
className="bg-[#0B1E3F]"
data-testid="em-submit-request"
>
{t("executiveMeetings.common.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function RequestListRow({
row,
isRtl,
t,
canWithdraw,
onWithdraw,
}: {
row: RequestRow;
isRtl: boolean;
t: (k: string) => string;
canWithdraw: boolean;
onWithdraw: (id: number) => void;
}) {
const note =
row.requestDetails && typeof row.requestDetails === "object"
? (row.requestDetails as { note?: string }).note ?? ""
: "";
const requesterName = row.requester
? isRtl
? row.requester.displayNameAr || row.requester.displayNameEn || row.requester.username
: row.requester.displayNameEn || row.requester.displayNameAr || row.requester.username
: "—";
const showWithdraw = canWithdraw && row.status === "new";
return (
<tr className="border-t border-gray-100">
<td className="px-3 py-2 align-middle text-xs text-gray-600">
{new Date(row.createdAt).toLocaleString()}
</td>
<td className="px-3 py-2 align-middle">{requesterName}</td>
<td className="px-3 py-2 align-middle text-xs">
{t(`executiveMeetings.requests.status.${row.status}`)}
</td>
<td className="px-3 py-2 align-middle text-xs">
{t(`executiveMeetings.requests.type.${row.requestType}`)}
{row.meetingId ? <span className="text-gray-500"> · #{row.meetingId}</span> : null}
</td>
<td className="px-3 py-2 align-middle text-xs whitespace-pre-wrap break-words max-w-md">
{note}
{row.reviewNotes && (
<div className="mt-1 text-gray-500 italic">
{row.reviewNotes}
</div>
)}
</td>
<td className="px-3 py-2 align-middle text-end">
{showWithdraw && (
<Button size="sm" variant="ghost" onClick={() => onWithdraw(row.id)}>
<X className="w-4 h-4" />
</Button>
)}
</td>
</tr>
);
}
// ============ APPROVALS ============
function ApprovalsSection({
canApprove,
isRtl,
t,
}: {
canApprove: boolean;
isRtl: boolean;
t: (k: string) => string;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const [notes, setNotes] = useState<Record<number, string>>({});
const { data, isLoading } = useQuery<{ requests: RequestRow[] }>({
queryKey: ["/api/executive-meetings/requests", "new", "all"],
queryFn: () => apiJson(`/api/executive-meetings/requests?status=new`),
});
const requests = data?.requests ?? [];
async function review(
id: number,
status: "approved" | "rejected" | "needs_edit",
) {
try {
await apiJson(`/api/executive-meetings/requests/${id}`, {
method: "PATCH",
body: { status, reviewNotes: notes[id] ?? "" },
});
toast({
title:
status === "approved"
? t("executiveMeetings.approvals.approved")
: status === "rejected"
? t("executiveMeetings.approvals.rejected")
: t("executiveMeetings.approvals.needsEditDone"),
});
await Promise.all([
qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] }),
qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] }),
qc.invalidateQueries({
predicate: (q) =>
Array.isArray(q.queryKey) &&
q.queryKey[0] === "/api/executive-meetings",
}),
]);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("executiveMeetings.approvals.actionFailed"),
description: msg,
variant: "destructive",
});
}
}
if (!canApprove) return <NoPermission t={t} />;
return (
<div className="space-y-4">
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.approvals.heading")}
</h2>
{isLoading && <div className="text-center text-gray-500 py-8">{t("common.loading")}</div>}
{!isLoading && requests.length === 0 && (
<div className="bg-white border border-gray-200 rounded-md p-10 text-center text-gray-500">
{t("executiveMeetings.approvals.empty")}
</div>
)}
{requests.map((r) => {
const note =
r.requestDetails && typeof r.requestDetails === "object"
? (r.requestDetails as { note?: string }).note ?? ""
: "";
const requesterName = r.requester
? isRtl
? r.requester.displayNameAr || r.requester.username
: r.requester.displayNameEn || r.requester.username
: "—";
return (
<div
key={r.id}
className="bg-white border border-gray-200 rounded-md p-4 space-y-3"
data-testid={`em-approval-${r.id}`}
>
<div className="flex justify-between gap-3 flex-wrap">
<div className="text-sm">
<div className="font-semibold text-[#0B1E3F]">
{t(`executiveMeetings.requests.type.${r.requestType}`)}
{r.meetingId ? <span className="text-gray-500"> · #{r.meetingId}</span> : null}
</div>
<div className="text-xs text-gray-500">
{requesterName} · {new Date(r.createdAt).toLocaleString()}
</div>
</div>
</div>
<div className="text-sm whitespace-pre-wrap text-gray-700">{note || "—"}</div>
<Textarea
placeholder={t("executiveMeetings.approvals.reviewNotesPlaceholder")}
rows={2}
value={notes[r.id] ?? ""}
onChange={(e) => setNotes({ ...notes, [r.id]: e.target.value })}
/>
<div className="flex gap-2 justify-end flex-wrap">
<Button
variant="outline"
onClick={() => review(r.id, "needs_edit")}
className="gap-1"
data-testid={`em-needs-edit-${r.id}`}
>
{t("executiveMeetings.approvals.needsEdit")}
</Button>
<Button
variant="outline"
onClick={() => review(r.id, "rejected")}
className="gap-1"
data-testid={`em-reject-${r.id}`}
>
<X className="w-4 h-4" />
{t("executiveMeetings.approvals.reject")}
</Button>
<Button
onClick={() => review(r.id, "approved")}
className="bg-[#0B1E3F] gap-1"
data-testid={`em-approve-${r.id}`}
>
<Check className="w-4 h-4" />
{t("executiveMeetings.approvals.approve")}
</Button>
</div>
</div>
);
})}
</div>
);
}
// ============ TASKS ============
function TasksSection({
canMutate,
currentUserId,
canViewAllTasks,
isRtl,
t,
}: {
canMutate: boolean;
currentUserId: number;
canViewAllTasks: boolean;
isRtl: boolean;
t: (k: string) => string;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
taskType: "",
meetingId: "",
assignedTo: "",
dueAt: "",
notes: "",
});
const [filterStatus, setFilterStatus] = useState<string>("all");
const { data, isLoading } = useQuery<{ tasks: TaskRow[] }>({
queryKey: ["/api/executive-meetings/tasks", filterStatus],
queryFn: () => {
const qs = new URLSearchParams();
if (filterStatus !== "all") qs.set("status", filterStatus);
return apiJson(`/api/executive-meetings/tasks?${qs}`);
},
});
const tasks = data?.tasks ?? [];
async function createTask() {
if (!form.taskType.trim()) return;
try {
const body: Record<string, unknown> = { taskType: form.taskType.trim() };
if (form.meetingId) body.meetingId = Number(form.meetingId);
if (form.assignedTo) body.assignedTo = Number(form.assignedTo);
if (form.dueAt) body.dueAt = form.dueAt;
if (form.notes) body.notes = form.notes;
await apiJson(`/api/executive-meetings/tasks`, { method: "POST", body });
toast({ title: t("executiveMeetings.tasks.saved") });
setOpen(false);
setForm({ taskType: "", meetingId: "", assignedTo: "", dueAt: "", notes: "" });
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
}
}
async function setStatus(id: number, status: TaskRow["status"]) {
try {
await apiJson(`/api/executive-meetings/tasks/${id}`, {
method: "PATCH",
body: { status },
});
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
}
}
const [reassignTarget, setReassignTarget] = useState<TaskRow | null>(null);
const [reassignForm, setReassignForm] = useState({ assignedTo: "", notes: "" });
const [reassignBusy, setReassignBusy] = useState(false);
function openReassign(task: TaskRow) {
setReassignTarget(task);
setReassignForm({
assignedTo: task.assignedTo ? String(task.assignedTo) : "",
notes: task.notes ?? "",
});
}
async function submitReassign() {
if (!reassignTarget) return;
const body: Record<string, unknown> = {};
const newAssignee = reassignForm.assignedTo.trim();
if (newAssignee) body.assignedTo = Number(newAssignee);
if (reassignForm.notes !== (reassignTarget.notes ?? "")) {
body.notes = reassignForm.notes;
}
if (Object.keys(body).length === 0) {
setReassignTarget(null);
return;
}
setReassignBusy(true);
try {
await apiJson(`/api/executive-meetings/tasks/${reassignTarget.id}`, {
method: "PATCH",
body,
});
toast({ title: t("executiveMeetings.tasks.reassigned") });
setReassignTarget(null);
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
} finally {
setReassignBusy(false);
}
}
async function deleteTask(id: number) {
if (!window.confirm(t("executiveMeetings.tasks.deleteConfirm"))) return;
try {
await apiJson(`/api/executive-meetings/tasks/${id}`, { method: "DELETE" });
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
}
}
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.tasks.heading")}
{!canViewAllTasks && (
<span
className="ms-2 inline-block rounded-full bg-gray-100 text-gray-700 text-xs px-2 py-0.5 align-middle font-normal"
data-testid="em-tasks-mine-badge"
title={t("executiveMeetings.tasks.myTasksOnlyHelp")}
>
{t("executiveMeetings.tasks.myTasksOnly")}
</span>
)}
</h2>
<div className="flex items-center gap-2">
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-36 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("executiveMeetings.common.all")}</SelectItem>
{(["pending", "in_progress", "completed", "cancelled"] as const).map((s) => (
<SelectItem key={s} value={s}>
{t(`executiveMeetings.tasks.status.${s}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{canMutate && (
<Button
onClick={() => setOpen(true)}
className="bg-[#0B1E3F] gap-1"
data-testid="em-add-task"
>
<Plus className="w-4 h-4" />
{t("executiveMeetings.tasks.addTask")}
</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">{t("executiveMeetings.tasks.field.taskType")}</th>
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.tasks.field.meeting")}</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.tasks.field.assignee")}</th>
<th className="px-3 py-2 text-start w-32">{t("executiveMeetings.tasks.field.dueDate")}</th>
<th className="px-3 py-2 text-start w-32">{t("executiveMeetings.tasks.field.status")}</th>
<th className="px-3 py-2 text-end w-44">{t("executiveMeetings.common.actions")}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && tasks.length === 0 && (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
{t("executiveMeetings.tasks.empty")}
</td>
</tr>
)}
{tasks.map((task) => {
const assignee = task.assignee
? isRtl
? task.assignee.displayNameAr || task.assignee.username
: task.assignee.displayNameEn || task.assignee.username
: "—";
return (
<tr key={task.id} className="border-t border-gray-100">
<td className="px-3 py-2 align-middle">{task.taskType}</td>
<td className="px-3 py-2 align-middle text-xs text-gray-600">
{task.meetingId ? `#${task.meetingId}` : "—"}
</td>
<td className="px-3 py-2 align-middle">{assignee}</td>
<td className="px-3 py-2 align-middle text-xs">
{task.dueAt ? new Date(task.dueAt).toLocaleDateString() : "—"}
</td>
<td className="px-3 py-2 align-middle text-xs">
{t(`executiveMeetings.tasks.status.${task.status}`)}
</td>
<td className="px-3 py-2 align-middle text-end">
<div className="inline-flex gap-1">
{(canMutate || task.assignedTo === currentUserId) && (
<>
{task.status !== "completed" && (
<Button
size="sm"
variant="ghost"
onClick={() => setStatus(task.id, "completed")}
title={t("executiveMeetings.tasks.markCompleted")}
>
<Check className="w-4 h-4" />
</Button>
)}
{task.status === "pending" && (
<Button
size="sm"
variant="ghost"
onClick={() => setStatus(task.id, "in_progress")}
title={t("executiveMeetings.tasks.markInProgress")}
>
<ListTodo className="w-4 h-4" />
</Button>
)}
</>
)}
{canMutate && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => openReassign(task)}
title={t("executiveMeetings.tasks.reassign")}
data-testid={`em-task-reassign-${task.id}`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteTask(task.id)}
>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<Dialog
open={!!reassignTarget}
onOpenChange={(v) => !v && setReassignTarget(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("executiveMeetings.tasks.reassign")}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<FormRow label={t("executiveMeetings.tasks.field.assignee")}>
<Input
type="number"
value={reassignForm.assignedTo}
onChange={(e) =>
setReassignForm({ ...reassignForm, assignedTo: e.target.value })
}
data-testid="em-task-reassign-assignee"
/>
</FormRow>
<FormRow label={t("executiveMeetings.tasks.field.notes")}>
<Textarea
rows={3}
value={reassignForm.notes}
onChange={(e) =>
setReassignForm({ ...reassignForm, notes: e.target.value })
}
data-testid="em-task-reassign-notes"
/>
</FormRow>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setReassignTarget(null)}>
{t("common.cancel")}
</Button>
<Button
onClick={submitReassign}
disabled={reassignBusy}
data-testid="em-task-reassign-save"
>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("executiveMeetings.tasks.addTask")}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<FormRow label={t("executiveMeetings.tasks.field.taskType")}>
<Input
value={form.taskType}
onChange={(e) => setForm({ ...form, taskType: e.target.value })}
data-testid="em-task-type"
/>
</FormRow>
<div className="grid grid-cols-2 gap-3">
<FormRow label={t("executiveMeetings.tasks.field.meeting")}>
<Input
type="number"
value={form.meetingId}
onChange={(e) => setForm({ ...form, meetingId: e.target.value })}
/>
</FormRow>
<FormRow label={t("executiveMeetings.tasks.field.assignee")}>
<Input
type="number"
placeholder={t("executiveMeetings.tasks.field.assigneeIdPlaceholder")}
value={form.assignedTo}
onChange={(e) => setForm({ ...form, assignedTo: e.target.value })}
/>
</FormRow>
</div>
<FormRow label={t("executiveMeetings.tasks.field.dueDate")}>
<Input
type="date"
value={form.dueAt}
onChange={(e) => setForm({ ...form, dueAt: e.target.value })}
/>
</FormRow>
<FormRow label={t("executiveMeetings.tasks.field.notes")}>
<Textarea
rows={3}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</FormRow>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
{t("executiveMeetings.common.cancel")}
</Button>
<Button onClick={createTask} className="bg-[#0B1E3F]" data-testid="em-task-save">
{t("executiveMeetings.common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ============ AUDIT ============
function AuditDiffSummary({
oldValue,
newValue,
t,
}: {
oldValue: unknown;
newValue: unknown;
t: (k: string) => string;
}) {
const interesting = [
"titleAr",
"titleEn",
"meetingDate",
"dailyNumber",
"startTime",
"endTime",
"location",
"meetingUrl",
"platform",
"status",
"isHighlighted",
"notes",
"assignedTo",
"taskType",
"dueAt",
"reviewDecision",
"reviewNotes",
] as const;
const oldObj =
oldValue && typeof oldValue === "object"
? (oldValue as Record<string, unknown>)
: null;
const newObj =
newValue && typeof newValue === "object"
? (newValue as Record<string, unknown>)
: null;
const fmt = (v: unknown): string => {
if (v === null || v === undefined || v === "") return "—";
if (typeof v === "string") return v.length > 40 ? `${v.slice(0, 40)}` : v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
try {
const s = JSON.stringify(v);
return s.length > 40 ? `${s.slice(0, 40)}` : s;
} catch {
return String(v);
}
};
if (!oldObj && !newObj) return <span className="text-gray-400"></span>;
if (oldObj && newObj) {
const diffs: { key: string; from: unknown; to: unknown }[] = [];
for (const k of interesting) {
const a = oldObj[k];
const b = newObj[k];
if (JSON.stringify(a) !== JSON.stringify(b)) {
diffs.push({ key: k, from: a, to: b });
}
}
if (diffs.length === 0) {
return <span className="text-gray-400"></span>;
}
return (
<ul className="space-y-0.5">
{diffs.slice(0, 6).map((d) => (
<li key={d.key} className="leading-snug">
<span className="font-semibold text-[#0B1E3F]">{d.key}</span>:{" "}
<span className="text-red-700 line-through">{fmt(d.from)}</span>
<span className="mx-1 text-gray-400"></span>
<span className="text-green-700">{fmt(d.to)}</span>
</li>
))}
{diffs.length > 6 && (
<li className="text-gray-500">
+{diffs.length - 6} {t("executiveMeetings.audit.moreChanges")}
</li>
)}
</ul>
);
}
const single = (newObj ?? oldObj) as Record<string, unknown>;
const label = newObj
? t("executiveMeetings.audit.created")
: t("executiveMeetings.audit.removed");
const items = interesting
.filter((k) => single[k] !== undefined && single[k] !== null && single[k] !== "")
.slice(0, 6);
return (
<div>
<div className="font-semibold text-[#0B1E3F] mb-0.5">{label}</div>
<ul className="space-y-0.5">
{items.map((k) => (
<li key={k} className="leading-snug">
<span className="font-semibold">{k}</span>: {fmt(single[k])}
</li>
))}
</ul>
</div>
);
}
function AuditSection({
canView,
isRtl,
t,
}: {
canView: boolean;
isRtl: boolean;
t: (k: string) => string;
}) {
const [date, setDate] = useState("");
const [dateTo, setDateTo] = useState("");
const [entityType, setEntityType] = useState("all");
const [actionFilter, setActionFilter] = useState("all");
const [actorFilter, setActorFilter] = useState("");
const [page, setPage] = useState(0);
const PAGE_SIZE = 50;
useEffect(() => {
setPage(0);
}, [date, dateTo, entityType, actionFilter, actorFilter]);
const { data, isLoading } = useQuery<{
entries: AuditEntry[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
}>({
queryKey: [
"/api/executive-meetings/audit-logs",
date,
dateTo,
entityType,
actionFilter,
actorFilter,
page,
],
queryFn: () => {
const qs = new URLSearchParams();
if (date) qs.set("dateFrom", date);
if (dateTo) qs.set("dateTo", dateTo);
if (entityType !== "all") qs.set("entityType", entityType);
if (actionFilter !== "all") qs.set("action", actionFilter);
if (actorFilter.trim()) qs.set("actorId", actorFilter.trim());
qs.set("limit", String(PAGE_SIZE));
qs.set("offset", String(page * PAGE_SIZE));
return apiJson(`/api/executive-meetings/audit-logs?${qs}`);
},
enabled: canView,
});
if (!canView) return <NoPermission t={t} />;
const entries = data?.entries ?? [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.audit.heading")}
</h2>
<div className="flex items-center gap-2 flex-wrap">
<label className="text-xs text-gray-500 flex items-center gap-1">
{t("executiveMeetings.audit.filter.from")}
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
data-testid="em-audit-date-from"
/>
</label>
<label className="text-xs text-gray-500 flex items-center gap-1">
{t("executiveMeetings.audit.filter.to")}
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
data-testid="em-audit-date-to"
/>
</label>
<Input
type="number"
placeholder={t("executiveMeetings.audit.filter.actorId")}
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
className="w-32 h-9"
data-testid="em-audit-actor"
/>
<Select value={actionFilter} onValueChange={setActionFilter}>
<SelectTrigger className="w-36 h-9" data-testid="em-audit-action">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("executiveMeetings.audit.all")}</SelectItem>
{(["create", "update", "delete", "approve", "reject"] as const).map(
(a) => (
<SelectItem key={a} value={a}>
{tWithFallback(t, `executiveMeetings.audit.action.${a}`, a)}
</SelectItem>
),
)}
</SelectContent>
</Select>
<Select value={entityType} onValueChange={setEntityType}>
<SelectTrigger className="w-36 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("executiveMeetings.audit.all")}</SelectItem>
{(["meeting", "attendee", "request", "task", "font_settings"] as const).map(
(e) => (
<SelectItem key={e} value={e}>
{t(`executiveMeetings.audit.entity.${e}`)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-md overflow-hidden">
<table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
<thead className="bg-gray-50 text-[#0B1E3F]">
<tr>
<th className="px-3 py-2 text-start w-44">{t("executiveMeetings.audit.col.when")}</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.audit.col.actor")}</th>
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.action")}</th>
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.entity")}</th>
<th className="px-3 py-2 text-start w-20">{t("executiveMeetings.audit.col.entityId")}</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.audit.col.changes")}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && entries.length === 0 && (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
{t("executiveMeetings.audit.empty")}
</td>
</tr>
)}
{entries.map((e) => {
const actor = e.actor
? isRtl
? e.actor.displayNameAr || e.actor.username
: e.actor.displayNameEn || e.actor.username
: "—";
return (
<tr
key={e.id}
className="border-t border-gray-100 align-top"
data-testid={`em-audit-row-${e.id}`}
>
<td className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap">
{new Date(e.performedAt).toLocaleString()}
</td>
<td className="px-3 py-2">{actor}</td>
<td className="px-3 py-2 text-xs">
{tWithFallback(
t,
`executiveMeetings.audit.action.${e.action}`,
e.action,
)}
</td>
<td className="px-3 py-2 text-xs">
{tWithFallback(
t,
`executiveMeetings.audit.entity.${e.entityType}`,
e.entityType,
)}
</td>
<td className="px-3 py-2 text-xs">{e.entityId ?? "—"}</td>
<td className="px-3 py-2 text-xs">
<AuditDiffSummary
oldValue={e.oldValue}
newValue={e.newValue}
t={t}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-xs text-gray-600">
<span data-testid="em-audit-count">
{t("executiveMeetings.audit.pageInfo")}{" "}
{data
? `${(data.offset ?? 0) + (entries.length > 0 ? 1 : 0)}${
(data.offset ?? 0) + entries.length
} / ${data.total ?? 0}`
: "—"}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
data-testid="em-audit-prev"
>
{t("common.previous")}
</Button>
<Button
size="sm"
variant="outline"
disabled={!data?.hasMore}
onClick={() => setPage((p) => p + 1)}
data-testid="em-audit-next"
>
{t("common.next")}
</Button>
</div>
</div>
</div>
);
}
// ============ NOTIFICATIONS ============
function NotificationsSection({
canView,
date,
isRtl,
t,
}: {
canView: boolean;
date: string;
isRtl: boolean;
t: (k: string) => string;
}) {
const { data, isLoading } = useQuery<{ notifications: NotificationRow[] }>({
queryKey: ["/api/executive-meetings/notifications", date],
queryFn: () =>
apiJson(
`/api/executive-meetings/notifications${
date ? `?date=${encodeURIComponent(date)}` : ""
}`,
),
enabled: canView,
});
if (!canView) return <NoPermission t={t} />;
const rows = data?.notifications ?? [];
return (
<div className="space-y-4">
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.notificationsPage.heading")}
</h2>
<p className="text-sm text-gray-600 max-w-2xl">
{t("executiveMeetings.notificationsPage.intro")}
</p>
<div className="bg-white border border-gray-200 rounded-md overflow-hidden">
<table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
<thead className="bg-gray-50 text-[#0B1E3F]">
<tr>
<th className="px-3 py-2 text-start w-24">{t("executiveMeetings.notificationsPage.col.meeting")}</th>
<th className="px-3 py-2 text-start">{t("executiveMeetings.notificationsPage.col.user")}</th>
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.notificationsPage.col.type")}</th>
<th className="px-3 py-2 text-start w-44">{t("executiveMeetings.notificationsPage.col.scheduledAt")}</th>
<th className="px-3 py-2 text-start w-24">{t("executiveMeetings.notificationsPage.col.status")}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && rows.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-gray-500">
{t("executiveMeetings.notificationsPage.empty")}
</td>
</tr>
)}
{rows.map((n) => {
const user = n.user
? isRtl
? n.user.displayNameAr || n.user.username
: n.user.displayNameEn || n.user.username
: "—";
return (
<tr key={n.id} className="border-t border-gray-100">
<td className="px-3 py-2">{n.meetingId ? `#${n.meetingId}` : "—"}</td>
<td className="px-3 py-2">{user}</td>
<td className="px-3 py-2 text-xs">
{tWithFallback(
t,
`executiveMeetings.notificationsPage.type.${n.notificationType}`,
n.notificationType,
)}
</td>
<td className="px-3 py-2 text-xs">
{n.scheduledAt ? new Date(n.scheduledAt).toLocaleString() : "—"}
</td>
<td className="px-3 py-2 text-xs">
{tWithFallback(
t,
`executiveMeetings.notificationsPage.status.${n.status}`,
n.status,
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ============ PDF / PRINT ============
function PdfSection({
date,
onDateChange,
lang,
t,
}: {
date: string;
onDateChange: (d: string) => void;
lang: "ar" | "en";
t: (k: string) => string;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const [busy, setBusy] = useState(false);
const archivesQuery = useQuery<{ archives: PdfArchive[] }>({
queryKey: ["/api/executive-meetings/pdf-archives", date],
queryFn: async () =>
apiJson(`/api/executive-meetings/pdf-archives?date=${date}`),
enabled: !!date,
});
// Stream a binary response from the API to the browser's download
// stack. Throws with the server's error message on a non-2xx so the
// toast surfaces a useful reason instead of a silent failure.
async function downloadBinary(url: string, filename: string): Promise<void> {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const j = (await res.json()) as { error?: string };
if (j.error) message = j.error;
} catch {
/* response wasn't JSON — keep the HTTP status */
}
throw new Error(message);
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
} finally {
// Free the blob URL after the click — Safari needs a tick before
// it actually starts the download, so defer slightly.
setTimeout(() => URL.revokeObjectURL(objectUrl), 4000);
}
}
async function downloadPdf() {
if (!date) return;
setBusy(true);
try {
const url = `/api/executive-meetings/pdf?date=${encodeURIComponent(
date,
)}&lang=${encodeURIComponent(lang)}`;
await downloadBinary(url, `executive-meetings-${date}.pdf`);
// Refresh archives so the row created by the GET appears.
await qc.invalidateQueries({
queryKey: ["/api/executive-meetings/pdf-archives", date],
});
toast({ title: t("executiveMeetings.pdf.downloaded") });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
} finally {
setBusy(false);
}
}
async function downloadArchive(archive: PdfArchive) {
if (!archive.filePath?.startsWith("/objects/")) return;
try {
// /objects/<id> resolves to GET /api/storage/objects/<id> on the
// server. The route is auth-gated behind the same session, so
// credentials must be included.
const url = `/api${archive.filePath.replace(/^\/objects/, "/storage/objects")}`;
await downloadBinary(
url,
`executive-meetings-${archive.archiveDate}-v${archive.version}.pdf`,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
}
}
const archives = archivesQuery.data?.archives ?? [];
return (
<div className="space-y-4 max-w-2xl">
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.pdf.heading")}
</h2>
<p className="text-sm text-gray-600">{t("executiveMeetings.pdf.intro")}</p>
<div className="bg-white border border-gray-200 rounded-md p-4 space-y-3">
<FormRow label={t("executiveMeetings.pdf.selectDate")}>
<Input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
/>
</FormRow>
<div className="flex flex-wrap gap-2">
<Button
onClick={downloadPdf}
disabled={busy || !date}
className="bg-[#0B1E3F] gap-1"
data-testid="em-download-pdf"
>
<FileText className="w-4 h-4" />
{t("executiveMeetings.pdf.downloadPdf")}
</Button>
</div>
<p className="text-xs text-gray-500">
{t("executiveMeetings.pdf.downloadHint")}
</p>
</div>
<div className="bg-white border border-gray-200 rounded-md">
<div className="px-4 py-2 border-b border-gray-200 text-sm font-semibold text-[#0B1E3F]">
{t("executiveMeetings.pdf.archivesHeading")}
</div>
{archivesQuery.isLoading ? (
<div className="p-4 text-sm text-gray-500">{t("common.loading")}</div>
) : archives.length === 0 ? (
<div className="p-4 text-sm text-gray-500">
{t("executiveMeetings.pdf.noArchives")}
</div>
) : (
<ul className="divide-y divide-gray-100" data-testid="em-archives-list">
{archives.map((a) => {
const who =
a.generator
? lang === "ar"
? a.generator.displayNameAr || a.generator.username
: a.generator.displayNameEn || a.generator.username
: "—";
return (
<li
key={a.id}
className="px-4 py-2 text-sm flex items-center justify-between gap-3"
>
<div>
<div className="font-medium text-[#0B1E3F]">
v{a.version} {a.archiveDate}
</div>
<div className="text-xs text-gray-500">
{new Date(a.generatedAt).toLocaleString(
lang === "ar" ? "ar" : "en",
)}{" "}
· {who}
</div>
</div>
<div className="flex items-center gap-2">
{a.filePath?.startsWith("/objects/") ? (
<Button
size="sm"
variant="outline"
onClick={() => downloadArchive(a)}
data-testid={`em-archive-download-${a.id}`}
>
{t("executiveMeetings.pdf.downloadArchive")}
</Button>
) : (
<span
className="text-xs text-gray-400"
data-testid={`em-archive-legacy-${a.id}`}
>
{t("executiveMeetings.pdf.legacyArchive")}
</span>
)}
{typeof a.byteSize === "number" ? (
<span className="text-xs text-gray-400">
{formatBytesForDisplay(a.byteSize)}
</span>
) : null}
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
// ============ FONT SETTINGS ============
function FontSettingsSection({
font,
canEditGlobal,
t,
}: {
font: FontPrefs;
canEditGlobal: boolean;
t: (k: string) => string;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const [scope, setScope] = useState<"user" | "global">("user");
const [prefs, setPrefs] = useState<FontPrefs>(font);
const [saving, setSaving] = useState(false);
useEffect(() => {
setPrefs(font);
}, [font]);
async function save() {
setSaving(true);
try {
await apiJson(`/api/executive-meetings/font-settings`, {
method: "PATCH",
body: { scope, ...prefs },
});
toast({ title: t("executiveMeetings.fontSettingsPage.saved") });
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/font-settings"] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
} finally {
setSaving(false);
}
}
function reset() {
setPrefs(DEFAULT_FONT);
}
return (
<div className="space-y-4 max-w-2xl">
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.fontSettingsPage.heading")}
</h2>
<p className="text-sm text-gray-600">{t("executiveMeetings.fontSettingsPage.intro")}</p>
<div className="bg-white border border-gray-200 rounded-md p-4 space-y-3">
{canEditGlobal && (
<FormRow label={t("executiveMeetings.fontSettingsPage.scope")}>
<Select value={scope} onValueChange={(v) => setScope(v as "user" | "global")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">
{t("executiveMeetings.fontSettingsPage.scopeUser")}
</SelectItem>
<SelectItem value="global">
{t("executiveMeetings.fontSettingsPage.scopeGlobal")}
</SelectItem>
</SelectContent>
</Select>
</FormRow>
)}
<FormRow label={t("executiveMeetings.fontSettingsPage.fontFamily")}>
<Select
value={prefs.fontFamily}
onValueChange={(v) => setPrefs({ ...prefs, fontFamily: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
"system",
"Cairo",
"Tajawal",
"Noto Naskh Arabic",
"Amiri",
].map((f) => (
<SelectItem key={f} value={f}>
{f}
</SelectItem>
))}
</SelectContent>
</Select>
</FormRow>
<FormRow label={`${t("executiveMeetings.fontSettingsPage.fontSize")}: ${prefs.fontSize}px`}>
<input
type="range"
min={12}
max={22}
value={prefs.fontSize}
onChange={(e) => setPrefs({ ...prefs, fontSize: Number(e.target.value) })}
className="w-full"
/>
</FormRow>
<FormRow label={t("executiveMeetings.fontSettingsPage.fontWeight")}>
<Select
value={prefs.fontWeight}
onValueChange={(v) => setPrefs({ ...prefs, fontWeight: v as FontPrefs["fontWeight"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["regular", "bold"] as const).map((w) => (
<SelectItem key={w} value={w}>
{t(`executiveMeetings.fontSettingsPage.weight.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormRow>
<FormRow label={t("executiveMeetings.fontSettingsPage.alignment")}>
<Select
value={prefs.alignment}
onValueChange={(v) => setPrefs({ ...prefs, alignment: v as FontPrefs["alignment"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["start", "center"] as const).map((a) => (
<SelectItem key={a} value={a}>
{t(`executiveMeetings.fontSettingsPage.align.${a}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormRow>
<div className="border-t border-gray-200 pt-3">
<Label className="text-xs text-gray-700">
{t("executiveMeetings.fontSettingsPage.preview")}
</Label>
<div
className="mt-2 p-4 border border-gray-200 rounded bg-[#fafbff]"
style={buildFontStyle(prefs)}
>
{t("executiveMeetings.fontSettingsPage.previewText")}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={reset}>
{t("executiveMeetings.fontSettingsPage.reset")}
</Button>
<Button
onClick={save}
disabled={saving}
className="bg-[#0B1E3F]"
data-testid="em-font-save"
>
{t("executiveMeetings.fontSettingsPage.save")}
</Button>
</div>
</div>
</div>
);
}
// ============ SHARED ============
function NoPermission({ t }: { t: (k: string) => string }) {
return (
<div className="bg-white border border-gray-200 rounded-md p-10 sm:p-16 text-center">
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-[#0B1E3F]/10 flex items-center justify-center text-[#0B1E3F]">
<SettingsIcon className="w-6 h-6" />
</div>
<p className="text-sm text-gray-600 max-w-md mx-auto">
{t("executiveMeetings.common.noPermission")}
</p>
</div>
);
}