Files
TX/executive-meetings-page-base.tsx
T
riyadhafraa 57e8297464 Task #349: Executive Meetings PDF improvements
- Schema: add `font_color` (hex, default #000000) and `logo_object_path`
  to `executive_meeting_font_settings`. Pushed via drizzle-kit.

- PDF renderer:
  - Add `fontColor` to PdfFontPrefs (body cells only; header chrome
    and logo intentionally ignore it to preserve branding).
  - Add `rowColor` to PdfMeeting and a ROW_COLOR_FILL palette kept in
    lockstep with the on-screen swatches; deliberately stop painting
    the legacy `isHighlighted` overlay so the archived PDF reflects
    editorial state instead of the viewer's transient cursor.
  - Add optional `logo: Buffer`. Header now reserves a left-anchored
    logo box and centers the title in the remaining width; bad image
    bytes log + fall back to the no-logo layout instead of crashing.

- API route:
  - Extend fontSettingsSchema with strict #RRGGBB regex and
    /^/objects/<id>$/ regex for logoObjectPath.
  - resolveFontPrefsForUser now returns { font, logoObjectPath }.
  - loadLogoBytes downloads the brand asset via ObjectStorageService.
  - Logo only writable on the global-scope row.
  - PDF labels switched to "قائمة بأسماء حضور الاجتماعات" /
    "Meeting Attendance List" per the user's printed sample.

- Frontend (executive-meetings.tsx):
  - FontPrefs gains fontColor; FontSettingsResponse.global gains
    logoObjectPath; DEFAULT_FONT and effectiveFont updated.
  - buildFontStyle applies fontColor to on-screen rows.
  - FontSettingsSection: native color picker + hex text input;
    logo upload (PNG/JPEG) via @workspace/object-storage-web's
    useUpload, visible only at the global scope for admins.

- Locales: AR/EN keys for fontColor + logo.{label,upload,replace,
  remove,uploading,uploadFailed,globalOnly}.

- Tests: existing font-settings roundtrip extended with fontColor;
  new test rejects malformed fontColor and non-/objects logo paths.

Type-check clean for api-server and tx-os; new font-settings tests
pass. Other test failures in the suite pre-date this change.
2026-05-03 14:34:29 +00:00

5266 lines
177 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,
} 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";
type HighlightPrefs = { enabled: boolean; color: string };
const DEFAULT_HIGHLIGHT_PREFS: HighlightPrefs = {
enabled: true,
color: "#16a34a",
};
const HIGHLIGHT_COLOR_SWATCHES = [
"#16a34a",
"#2563eb",
"#dc2626",
"#ea580c",
"#7c3aed",
"#0B1E3F",
];
function parseTimeToMinutes(t: string | null): number | null {
if (!t) return null;
const m = /^(\d{2}):(\d{2})/.exec(t);
if (!m) return null;
const h = Number(m[1]);
const mm = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
return h * 60 + mm;
}
/**
* Returns the id of the meeting that is currently in progress on `dateIso`,
* or null. A meeting is "current" when the wall clock falls within
* [startTime, endTime) and the displayed date matches today.
*/
function computeCurrentMeetingId(
meetings: Meeting[],
dateIso: string,
nowMs: number,
): number | null {
const now = new Date(nowMs);
const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
if (todayIso !== dateIso) return null;
const minutesNow = now.getHours() * 60 + now.getMinutes();
for (const m of meetings) {
const s = parseTimeToMinutes(m.startTime);
const e = parseTimeToMinutes(m.endTime);
if (s === null || e === null) continue;
if (s <= minutesNow && minutesNow < e) return m.id;
}
return null;
}
function formatTime(t: string | null, lang: "ar" | "en"): string {
if (!t) return "";
// DB stores HH:mm:ss (24h). Build a Date anchored to a fixed day so
// Intl can format the time-of-day in the user's language with AM/PM
// (English) or ص/م (Arabic). Latin digits are enforced via the shared
// formatTime helper. Falls back to the raw HH:mm slice if parsing
// somehow fails (defensive — shouldn't happen for our DB shape).
const hhmm = t.slice(0, 5);
const parsed = new Date(`1970-01-01T${hhmm}:00`);
if (Number.isNaN(parsed.getTime())) return hhmm;
return i18nFormatTime(parsed, lang, {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function fontWeightToCss(w: FontPrefs["fontWeight"]): number {
return { regular: 400, bold: 700 }[w];
}
function fontFamilyToCss(name: string): string {
if (name === "system") return "";
if (/[\s-]/.test(name)) return `"${name}", system-ui, sans-serif`;
return `${name}, system-ui, sans-serif`;
}
function buildFontStyle(prefs: FontPrefs): CSSProperties {
return {
fontFamily: fontFamilyToCss(prefs.fontFamily) || undefined,
fontSize: prefs.fontSize,
fontWeight: fontWeightToCss(prefs.fontWeight),
textAlign: prefs.alignment,
};
}
function tWithFallback(
t: (key: string) => string,
key: string,
fallback: string,
): string {
const value = t(key);
return value === key ? fallback : value;
}
async function apiJson<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}
/>
)}
{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,
}: {
meetings: Meeting[];
date: string;
onDateChange: (d: string) => void;
isLoading: boolean;
isRtl: boolean;
t: (k: string) => string;
font: FontPrefs;
canMutate: boolean;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const tableStyle = buildFontStyle(font);
const [columns, setColumns] = useState<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]);
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);
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);
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],
);
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)");
const showResizeHandles = isXl && isFinePointer;
// dnd-kit: sortable column headers + sortable rows.
//
// - PointerSensor for desktop mouse: a 6px activation distance keeps a
// normal click on cell content from being mistaken for a drag (so the
// inline editors on title/attendee/time cells still open on click).
// - TouchSensor for iPad and other touch devices: PointerSensor alone
// does not work on touch because the browser claims the gesture as a
// vertical scroll before the finger has moved 6px, so the drag never
// activates. A long-press activation (delay+tolerance) lets the user
// press-and-hold the row's grip handle to start dragging while still
// leaving normal scrolling intact everywhere else on the row.
// - KeyboardSensor for accessibility / keyboard reordering.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 8 },
}),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const onColumnDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
setColumns((prev) => {
const ids = prev.map((c) => c.id);
const fromIdx = ids.indexOf(active.id as ColumnId);
const toIdx = ids.indexOf(over.id as ColumnId);
if (fromIdx < 0 || toIdx < 0) return prev;
const next = prev.slice();
const [moved] = next.splice(fromIdx, 1);
if (!moved) return prev;
next.splice(toIdx, 0, moved);
return next;
});
},
[],
);
const onRowDragEnd = useCallback(
(e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
void reorderRows(Number(active.id), Number(over.id));
},
[reorderRows],
);
return (
<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">
<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>
<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}
onResize={(delta) => setColumnWidth(c.id, c.width + delta)}
/>
))}
</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={canMutate}
onSaveTitle={(html) => saveTitle(m, html)}
onSaveAttendeeName={(idx, html) =>
saveAttendeeName(m, idx, html)
}
onSaveTimes={(start, end) => saveTimes(m, start, end)}
onSaveMerge={(merge) => saveMerge(m, merge)}
onDeleteMeeting={() => deleteMeeting(m)}
// Disable every row's delete button while any delete
// is in flight, since deleteMeeting short-circuits
// concurrent calls — keeps the UI honest about which
// clicks will do anything.
isDeleting={deletingMeetingId !== null}
isCurrent={
highlightPrefs.enabled && currentMeetingId === m.id
}
highlightColor={highlightPrefs.color}
reordering={reordering}
autoEditTitle={autoEditTitleId === m.id}
onAutoEditTitleConsumed={clearAutoEditTitleId}
pendingAttendee={pendingAttendee}
onStartAddAttendee={startAddAttendee}
onCommitAddAttendee={(type, html) =>
commitAddAttendee(m, type, html)
}
onCancelAddAttendee={cancelAddAttendee}
/>
))}
</SortableContext>
{canMutate && !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}
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"
>
<Plus className="w-4 h-4" />
<span>
{creatingRow
? t("common.loading")
: t("executiveMeetings.schedule.addRow")}
</span>
</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,
onResize,
}: {
col: ColumnSetting;
isLast: boolean;
isRtl: boolean;
t: (k: string) => string;
showResizeHandle: boolean;
onResize: (delta: number) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: col.id });
const align =
col.id === "number" || col.id === "time" ? "text-center" : "text-start";
const style: CSSProperties = {
width: col.width,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
};
return (
<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}`}
{...attributes}
{...listeners}
>
{t(`executiveMeetings.col.${col.id}`)}
{/* Resize handles only render on large screens AND fine (mouse)
pointers — see comment on 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>
</>
);
}
function RowColorPicker({
current,
onPick,
t,
}: {
current: string;
onPick: (key: string) => void;
t: (k: string) => string;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="absolute top-1 inline-end-1 p-1 rounded text-muted-foreground hover:text-[#0B1E3F] hover:bg-white/70 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
aria-label={t("executiveMeetings.rowColor.label")}
data-testid="em-row-color-trigger"
>
<Palette className="w-3.5 h-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="end">
<RowColorPickerSwatches current={current} onPick={onPick} t={t} />
</PopoverContent>
</Popover>
);
}
// Reusable row-level "delete entire meeting" trigger. Rendered as an
// absolutely-positioned overlay inside whichever cell is the row's
// current anchor (first visible cell on unmerged rows, the merged
// cell on merged rows) so it stays available regardless of which
// columns the user has hidden via the column customizer. The host
// <td> must carry `relative group` for the positioning + hover
// reveal to work, which every renderCell branch already does.
function DeleteRowButton({
meetingId,
isDeleting,
onDelete,
label,
}: {
meetingId: number;
isDeleting: boolean;
onDelete: () => void;
label: string;
}) {
return (
<button
type="button"
// Pinned to the cell's leading-top corner. The color picker (on
// # cell only) sits at top-end, the merge trigger sits at
// bottom-end, so the three row-level overlays don't collide.
// Hover-reveal on desktop, ~40% on touch (same pattern as the
// merge trigger and grip handle), focusable for keyboard users,
// hidden in print. Stops propagation so the click never bubbles
// into row drag or inline-edit affordances.
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
onPointerDown={(e) => e.stopPropagation()}
disabled={isDeleting}
className="absolute top-1 inline-start-1 p-1 rounded text-destructive/80 hover:text-destructive 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 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={label}
title={label}
data-testid={`em-delete-row-${meetingId}`}
>
<Trash2 className="w-3.5 h-3.5 [@media(hover:none)_and_(pointer:coarse)]:w-4 [@media(hover:none)_and_(pointer:coarse)]:h-4" />
</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,
}: {
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;
}) {
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);
const renderCell = (col: ColumnSetting, idx: number): ReactNode => {
// Anchor the merge-menu trigger to whichever cell is the first
// visible one on an unmerged row. Using "first visible cell"
// (instead of always the # cell) keeps the trigger reachable when
// editors hide the number column. The trigger is rendered as an
// absolutely-positioned overlay inside the cell, so every cell type
// needs `relative group` on its <td> for the overlay to find it.
const showMergeTrigger = canMutate && !isMerged && idx === firstUnmergedIdx;
const mergeOverlay = showMergeTrigger ? (
<MergeMenu
meetingId={meeting.id}
// Offer Unmerge whenever the row has a stored merge in the DB,
// even if we're in degraded (non-contiguous) rendering — that's
// the only way the user can clear the stored state.
hasStoredMerge={hasStoredMerge}
existingMergeText={meeting.mergeText ?? null}
t={t}
onChange={onSaveMerge}
/>
) : null;
// Delete trigger uses the same "first visible cell" anchoring as the
// merge trigger so the action stays available even when editors hide
// the # column via the column customizer. Only one button per row.
// The merged-cell branch (in renderCells below) renders its own copy.
const showDeleteTrigger = canMutate && !isMerged && idx === firstUnmergedIdx;
const deleteOverlay = showDeleteTrigger ? (
<DeleteRowButton
meetingId={meeting.id}
isDeleting={isDeleting}
onDelete={onDeleteMeeting}
label={t("executiveMeetings.schedule.deleteRow")}
/>
) : null;
switch (col.id) {
case "number":
return (
<td
key="number"
className={`relative group ${numberCellCls}`}
style={
tintBg
? {
...cellStyle(col),
backgroundColor: highlightColor,
color: "white",
}
: cellStyle(col)
}
>
<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>
<RowColorPicker current={rowColorKey} onPick={onPickRowColor} t={t} />
{deleteOverlay}
{mergeOverlay}
</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>
)}
{deleteOverlay}
{mergeOverlay}
</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}
/>
{deleteOverlay}
{mergeOverlay}
</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}
/>
{deleteOverlay}
{mergeOverlay}
</td>
);
}
};
// When a merge is active we render a single <td colSpan=N> with an
// EditableCell for `mergeText`, and skip the cells the merge covers
// after the first. The merged cell hosts its own MergeMenu so users
// can always unmerge or re-merge — even when the # column is hidden.
// Originals stay in the DB so unmerge restores them.
const renderCells = (): ReactNode[] => {
if (!isMerged) {
return visibleColumns.map((c, idx) => renderCell(c, idx));
}
const cells: ReactNode[] = [];
const span = visibleMergeIndices.length;
visibleColumns.forEach((col, idx) => {
if (mergeSkipSet?.has(idx)) return;
if (idx === mergeFirstIdx) {
// Sum widths of the spanned visible columns so layout in
// table-fixed mode reserves the right horizontal space.
const totalWidth = visibleMergeIndices.reduce(
(sum, i) => sum + (visibleColumns[i].width || 0),
0,
);
cells.push(
<td
key="merge"
colSpan={span}
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
style={{
width: totalWidth,
overflow: "hidden",
...(tintBg ? { backgroundColor: tintBg } : null),
}}
data-testid={`em-merge-cell-${meeting.id}`}
>
<EditableCell
value={meeting.mergeText ?? ""}
onSave={async (html) => {
await onSaveMerge({
mergeStartColumn: meeting.mergeStartColumn as ColumnId,
mergeEndColumn: meeting.mergeEndColumn as ColumnId,
mergeText: html,
});
}}
ariaLabel={t("executiveMeetings.merge.editLabel")}
dir={isRtl ? "rtl" : "ltr"}
className="font-medium leading-snug break-words"
disabled={!canMutate}
placeholder={t("executiveMeetings.merge.placeholder")}
testId={`em-merge-edit-${meeting.id}`}
/>
{canMutate && (
<DeleteRowButton
meetingId={meeting.id}
isDeleting={isDeleting}
onDelete={onDeleteMeeting}
label={t("executiveMeetings.schedule.deleteRow")}
/>
)}
{canMutate && (
<MergeMenu
meetingId={meeting.id}
isMerged
hasStoredMerge
existingMergeText={meeting.mergeText ?? null}
t={t}
onChange={onSaveMerge}
/>
)}
</td>,
);
} else {
cells.push(renderCell(col, idx));
}
});
return cells;
};
// Combine row background with highlight tint. When the row is the current
// meeting we apply a soft tint (color at low opacity) plus a coloured ring
// via box-shadow (rings on <tr> don't render across cells; box-shadow does).
const baseBg = rowBg || "white";
const trStyle: CSSProperties = {
backgroundColor: baseBg,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
boxShadow: isCurrent ? `inset 0 0 0 2px ${highlightColor}` : undefined,
};
return (
<tr
ref={setNodeRef}
className="group"
style={trStyle}
data-testid={`em-row-${meeting.id}`}
data-current-meeting={isCurrent ? "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})`;
}
// Small popover-driven control attached to a row (in the # cell when
// not merged, or to the merged cell itself) that lets editors pick a
// merge range or clear an existing one. Each option corresponds to a
// fixed [start..end] column range.
function MergeMenu({
meetingId,
// True iff the row is rendered as a merged cell *right now*. Used
// for testid wiring only.
isMerged = false,
// True iff the row has a stored merge in the DB (regardless of
// whether it's currently renderable as merged). Drives the
// "Unmerge" option so users can always clear stored merges, even
// when the visible columns aren't contiguous.
hasStoredMerge,
// Existing merge text from the DB, preserved when the user picks a
// new merge range so we never silently overwrite their content.
existingMergeText,
t,
onChange,
}: {
meetingId: number;
isMerged?: boolean;
hasStoredMerge: boolean;
existingMergeText: string | null;
t: (k: string) => string;
onChange: (
merge:
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
| null,
) => Promise<void>;
}) {
// Re-merging keeps the existing text so users don't accidentally lose
// what they had typed. New merges (no stored text) start empty.
const apply = async (start: ColumnId, end: ColumnId) => {
await onChange({
mergeStartColumn: start,
mergeEndColumn: end,
mergeText: existingMergeText ?? "",
});
};
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="absolute bottom-1 inline-end-1 p-1 rounded text-muted-foreground hover:text-[#0B1E3F] hover:bg-white/70 opacity-0 group-hover:opacity-100 focus:opacity-100 [@media(hover:none)_and_(pointer:coarse)]:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:p-1.5 transition-opacity print:hidden"
aria-label={t("executiveMeetings.merge.label")}
data-testid={`em-merge-trigger-${meetingId}`}
>
<Combine className="w-3.5 h-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="end">
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
data-testid={`em-merge-opt-meeting-attendees-${meetingId}`}
onClick={() => apply("meeting", "attendees")}
>
{t("executiveMeetings.merge.meetingAttendees")}
</button>
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
data-testid={`em-merge-opt-meeting-time-${meetingId}`}
onClick={() => apply("meeting", "time")}
>
{t("executiveMeetings.merge.meetingTime")}
</button>
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
data-testid={`em-merge-opt-all-${meetingId}`}
onClick={() => apply("number", "time")}
>
{t("executiveMeetings.merge.all")}
</button>
{hasStoredMerge && (
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent text-destructive"
data-testid={`em-merge-opt-unmerge-${meetingId}`}
onClick={() => onChange(null)}
>
{t("executiveMeetings.merge.unmerge")}
</button>
)}
</PopoverContent>
</Popover>
);
}
function TimeRangeCell({
meeting,
t,
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]);
const save = useCallback(async () => {
const newStart = start || null;
const newEnd = end || null;
if (newStart === (startSaved || null) && newEnd === (endSaved || null)) {
setEditing(false);
return;
}
if (newStart && newEnd && newStart > newEnd) {
// Surface the validation error and keep the user in edit mode so they
// can correct it without losing the values they just typed.
toast({
title: t("executiveMeetings.schedule.timeOrderError"),
variant: "destructive",
});
startInputRef.current?.focus();
return;
}
setEditing(false);
try {
await onSaveTimes(newStart, newEnd);
} catch {
// The parent `saveTimes` handler is responsible for surfacing the
// error toast; we only need to roll back the local draft state so
// the cell shows the previously-saved values.
setStart(startSaved);
setEnd(endSaved);
}
}, [start, end, startSaved, endSaved, onSaveTimes]);
const onFocusOut = (e: React.FocusEvent<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")}
className={
"font-mono text-[#0B1E3F] whitespace-nowrap leading-tight " +
(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}
className={editable ? "min-w-[3rem]" : "whitespace-nowrap"}
>
<span className="text-gray-500 me-1">{displayIdx + 1}-</span>
{editable ? (
<EditableCell
value={a.name}
onSave={(html) => onSaveAttendeeName!(i, html)}
ariaLabel={editAriaLabel}
dir="auto"
placeholder="…"
// Always-visible dashed underline gives each attendee name a
// discoverable click target even when names wrap onto multiple
// lines. The vertical padding + min-height widen the tap
// target so a near-miss tap on an iPad/touch device still
// lands on the editor. Hidden in edit mode (the editor draws
// its own border) and on print.
className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
testId={`em-edit-attendee-${i}`}
/>
) : (
<span dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }} />
)}
</li>
))}
{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"
>
{addLabel}
</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">
{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 });
}
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">
<h4 className="font-semibold text-sm text-[#0B1E3F]">
{t("executiveMeetings.manage.attendees.label")} ({state.attendees.length})
</h4>
<Button size="sm" variant="outline" onClick={addAttendee} className="gap-1">
<Plus className="w-4 h-4" />
{t("executiveMeetings.manage.attendees.add")}
</Button>
</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>
);
}