57e8297464
- 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.
5266 lines
177 KiB
TypeScript
5266 lines
177 KiB
TypeScript
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, , or
|
||
// pure whitespace. When the attendee's name is cleared we treat the
|
||
// edit as a delete-row gesture and remove the attendee instead of
|
||
// saving an empty record.
|
||
const isEmpty =
|
||
html
|
||
.replace(/<[^>]*>/g, "")
|
||
.replace(/ /g, " ")
|
||
.replace(/\u00a0/g, " ")
|
||
.trim() === "";
|
||
const next = isEmpty
|
||
? meeting.attendees
|
||
.filter((_, i) => i !== attendeeIdx)
|
||
// Re-pack sortOrder so the server stores a contiguous run; the
|
||
// PUT endpoint takes the array as the new source of truth and
|
||
// reassigns ids on insert.
|
||
.map((a, i) => ({ ...a, sortOrder: i }))
|
||
: meeting.attendees.map((a, i) =>
|
||
i === attendeeIdx ? { ...a, name: html } : a,
|
||
);
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${meeting.id}/attendees`, {
|
||
method: "PUT",
|
||
body: {
|
||
attendees: next.map((a) => ({
|
||
name: a.name,
|
||
title: a.title,
|
||
attendanceType: a.attendanceType,
|
||
sortOrder: a.sortOrder,
|
||
})),
|
||
},
|
||
});
|
||
refreshDay();
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
toast({
|
||
title: t("common.error"),
|
||
description: msg,
|
||
variant: "destructive",
|
||
});
|
||
throw err;
|
||
}
|
||
},
|
||
[refreshDay, toast, t],
|
||
);
|
||
|
||
// -------------------------------------------------------------------
|
||
// "+ Add attendee" — optimistic ghost row
|
||
//
|
||
// Clicking the inline "+" button in the attendees cell does NOT hit
|
||
// the server immediately (the API requires a non-empty name). Instead
|
||
// we render a synthetic in-edit EditableCell next to the existing
|
||
// attendees. When the user saves a non-empty name we PUT the full
|
||
// array with the new attendee appended; if they leave the cell empty
|
||
// (blur or Escape) we just discard the pending state.
|
||
//
|
||
// We allow at most one pending attendee at a time across the whole
|
||
// page — the AttendeesCell hides "+" buttons elsewhere while a ghost
|
||
// row is open so the user can't accidentally orphan their typing.
|
||
type PendingAttendee = {
|
||
meetingId: number;
|
||
attendanceType: Attendee["attendanceType"];
|
||
// Bumped each time the user clicks "+" so React remounts the ghost
|
||
// EditableCell (giving it a fresh editor and focus).
|
||
key: number;
|
||
};
|
||
const [pendingAttendee, setPendingAttendee] =
|
||
useState<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(/ /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(/ /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>
|
||
);
|
||
}
|