db6b835f75
Native window.confirm exposes the Repl hostname and ignores the app's
RTL/branding. The user (Arabic-speaking) flagged it as visually jarring
inside Executive Meetings.
Changes (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Add a small ConfirmProvider + useConfirm() backed by the existing
shadcn AlertDialog (`@/components/ui/alert-dialog`). The hook returns
Promise<boolean> so call sites stay one-liners:
if (!(await confirm({ message, destructive: true }))) return;
- Single-flight guard: if a second confirm() arrives while the first is
still pending, the previous Promise resolves to false (no orphaned
resolvers).
- Mount the provider once around the page (split the default export
into a thin wrapper + ExecutiveMeetingsPageInner so the inner tree
can call useConfirm()).
- Centered title + description, destructive-styled confirm button,
mobile-safe sizing (max-w-md, w-[calc(100%-2rem)], max-h-[90vh],
overflow-y-auto). Explicit `dir` on AlertDialogContent so the
portal-rendered dialog always inherits the page's RTL/LTR.
- Footer renders Action first then Cancel: in LTR Confirm sits on the
left and Cancel on the right; RTL flips automatically (Confirm right,
Cancel left). Mobile stacks Cancel on top, destructive at the bottom.
- Replace all 5 window.confirm sites:
1. Schedule single-row delete (deleteMeeting)
2. Schedule bulk delete
3. Manage bulk delete
4. Manage single delete (confirmDelete)
5. MeetingFormDialog "remove all attendees" (function turned async)
- Add `confirm` to the relevant useCallback deps (deleteMeeting,
bulk delete) and inject useConfirm() inside ScheduleSection,
ManageSection, MeetingFormDialog (all rendered inside the provider).
- New i18n keys: executiveMeetings.common.confirm and .confirmTitle in
both ar.json and en.json (used as default title / non-destructive
confirm label).
`pnpm -C artifacts/tx-os exec tsc --noEmit` clean. Pre-existing
api-server / test workflow failures are unrelated to this UI change
(also failing before the task started).
7733 lines
283 KiB
TypeScript
7733 lines
283 KiB
TypeScript
import {
|
||
createContext,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type CSSProperties,
|
||
type Dispatch,
|
||
type ReactNode,
|
||
type SetStateAction,
|
||
} from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useLocation } from "wouter";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
CalendarClock,
|
||
FileDown,
|
||
ListChecks,
|
||
Bell,
|
||
ScrollText,
|
||
FileText,
|
||
Settings as SettingsIcon,
|
||
Plus,
|
||
Pencil,
|
||
Trash2,
|
||
Check,
|
||
X,
|
||
ChevronUp,
|
||
ChevronDown,
|
||
Copy,
|
||
Eye,
|
||
EyeOff,
|
||
RotateCcw,
|
||
Palette,
|
||
Sliders,
|
||
Combine,
|
||
MoreVertical,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
} from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
TimePicker12h,
|
||
type TimePicker12hHandle,
|
||
} from "@/components/time-picker-12h";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/components/ui/alert-dialog";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
import { ToastAction } from "@/components/ui/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 {
|
||
ALERT_COLOR_PRESETS,
|
||
DEFAULT_ALERT_PREFS,
|
||
useAlertPrefs,
|
||
type AlertPrefs,
|
||
} from "@/lib/upcoming-alert-prefs";
|
||
|
||
type Attendee = {
|
||
id?: number;
|
||
meetingId?: number;
|
||
// For persons: sanitized rich-text HTML name (Tiptap output).
|
||
// For subheadings: the user's free-text section label.
|
||
name: string;
|
||
title: string | null;
|
||
attendanceType: "internal" | "virtual" | "external";
|
||
sortOrder: number;
|
||
// "person" — a normal attendee row (rendered in AttendeeFlow with a
|
||
// running "1-, 2-…" index that skips subheadings).
|
||
// "subheading" — a user-defined label that lives between persons inside
|
||
// the same attendance-type group. Excluded from numbering,
|
||
// attendee count, and virtual-platform-name extraction.
|
||
// Optional in the type so older snapshots and any in-flight code paths
|
||
// that don't set it explicitly default to "person" — same as the DB and
|
||
// Zod defaults.
|
||
kind?: "person" | "subheading";
|
||
// Client-only stable id used by the manage-dialog drag-and-drop. Never
|
||
// sent to the server (the save projection at the PATCH/POST call site
|
||
// explicitly enumerates wire fields and omits this). Persisted attendees
|
||
// can fall back to `id` for sortable identity but new rows have no `id`
|
||
// until the first save, so we always assign `_sid` when the dialog opens
|
||
// or a new row is added.
|
||
_sid?: string;
|
||
};
|
||
|
||
type Meeting = {
|
||
id: number;
|
||
dailyNumber: number;
|
||
titleAr: string;
|
||
titleEn: string;
|
||
meetingDate: string;
|
||
startTime: string | null;
|
||
endTime: string | null;
|
||
location: string | null;
|
||
meetingUrl: string | null;
|
||
platform: "none" | "webex" | "teams" | "zoom" | "other";
|
||
status: string;
|
||
isHighlighted: number;
|
||
notes: string | null;
|
||
attendees: Attendee[];
|
||
// Optional cell-merge overlay. When all three are non-null the
|
||
// schedule renders one merged cell spanning
|
||
// [mergeStartColumn..mergeEndColumn] showing mergeText (sanitized
|
||
// HTML) instead of the original column values. Originals are
|
||
// preserved on the row so clearing the merge restores them.
|
||
mergeStartColumn?: ColumnId | null;
|
||
mergeEndColumn?: ColumnId | null;
|
||
mergeText?: string | null;
|
||
// Shared row-highlight colour (#288). Stored on the meeting row so it
|
||
// is the same for every viewer. NULL/missing = "default" (no tint).
|
||
// Allowed values come from ROW_COLOR_OPTIONS below.
|
||
rowColor?: string | null;
|
||
};
|
||
|
||
type DayResponse = { date: string; meetings: Meeting[] };
|
||
|
||
type AuditEntry = {
|
||
id: number;
|
||
action: string;
|
||
entityType: string;
|
||
entityId: number | null;
|
||
oldValue: unknown;
|
||
newValue: unknown;
|
||
performedBy: number | null;
|
||
performedAt: string;
|
||
actor: {
|
||
username: string;
|
||
displayNameAr: string | null;
|
||
displayNameEn: string | null;
|
||
} | null;
|
||
};
|
||
|
||
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: "notifications", icon: Bell },
|
||
{ key: "audit", icon: ScrollText },
|
||
{ key: "pdf", icon: FileText },
|
||
{ key: "settings", icon: SettingsIcon },
|
||
] as const;
|
||
|
||
type SectionKey = (typeof SECTIONS)[number]["key"];
|
||
|
||
type MeCapabilities = {
|
||
userId: number;
|
||
roles: string[];
|
||
canRead: boolean;
|
||
canMutate: boolean;
|
||
canEditGlobalFontSettings: boolean;
|
||
canViewAudit: boolean;
|
||
};
|
||
|
||
type MeRoles = MeCapabilities;
|
||
|
||
function isSectionVisible(
|
||
key: SectionKey,
|
||
me: MeCapabilities | null | undefined,
|
||
): boolean {
|
||
if (!me) return key === "schedule"; // Pre-load: only show the read-only schedule.
|
||
switch (key) {
|
||
case "schedule":
|
||
case "pdf":
|
||
case "settings":
|
||
return me.canRead;
|
||
case "manage":
|
||
return me.canMutate;
|
||
case "notifications":
|
||
return me.canRead;
|
||
case "audit":
|
||
return me.canViewAudit;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const DEFAULT_FONT: FontPrefs = {
|
||
fontFamily: "system",
|
||
fontSize: 14,
|
||
fontWeight: "regular",
|
||
alignment: "start",
|
||
};
|
||
|
||
type ColumnId = "number" | "meeting" | "attendees" | "time";
|
||
|
||
type ColumnSetting = {
|
||
id: ColumnId;
|
||
visible: boolean;
|
||
width: number;
|
||
};
|
||
|
||
const DEFAULT_COLUMNS: ColumnSetting[] = [
|
||
{ id: "number", visible: true, width: 56 },
|
||
{ id: "meeting", visible: true, width: 320 },
|
||
{ id: "attendees", visible: true, width: 600 },
|
||
// Wide enough to fit "HH:MM – HH:MM" on one line, plus the two
|
||
// <input type="time"> controls when the cell enters edit mode.
|
||
{ id: "time", visible: true, width: 200 },
|
||
];
|
||
|
||
const MIN_COL_WIDTH = 56;
|
||
const MAX_COL_WIDTH = 1200;
|
||
|
||
const ROW_COLOR_OPTIONS: { key: string; bg: string; label: string }[] = [
|
||
{ key: "default", bg: "", label: "default" },
|
||
{ key: "red", bg: "#fee2e2", label: "red" },
|
||
{ key: "amber", bg: "#fef3c7", label: "amber" },
|
||
{ key: "green", bg: "#dcfce7", label: "green" },
|
||
{ key: "blue", bg: "#dbeafe", label: "blue" },
|
||
{ key: "violet", bg: "#ede9fe", label: "violet" },
|
||
{ key: "gray", bg: "#f3f4f6", label: "gray" },
|
||
];
|
||
|
||
const COLS_STORAGE_KEY = "em-schedule-cols-v1";
|
||
const ROW_COLORS_STORAGE_KEY = "em-schedule-row-colors-v1";
|
||
const HIGHLIGHT_STORAGE_KEY = "em-current-meeting-highlight-v1";
|
||
const EDIT_MODE_STORAGE_KEY = "em-schedule-edit-mode-v1";
|
||
|
||
type HighlightPrefs = { enabled: boolean; color: string };
|
||
const DEFAULT_HIGHLIGHT_PREFS: HighlightPrefs = {
|
||
enabled: true,
|
||
color: "#16a34a",
|
||
};
|
||
|
||
const HIGHLIGHT_COLOR_SWATCHES = [
|
||
"#16a34a",
|
||
"#2563eb",
|
||
"#dc2626",
|
||
"#ea580c",
|
||
"#7c3aed",
|
||
"#0B1E3F",
|
||
];
|
||
|
||
function parseTimeToMinutes(t: string | null): number | null {
|
||
if (!t) return null;
|
||
const m = /^(\d{2}):(\d{2})/.exec(t);
|
||
if (!m) return null;
|
||
const h = Number(m[1]);
|
||
const mm = Number(m[2]);
|
||
if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
|
||
return h * 60 + mm;
|
||
}
|
||
|
||
/**
|
||
* Returns the id of the meeting that is currently in progress on `dateIso`,
|
||
* or null. A meeting is "current" when the wall clock falls within
|
||
* [startTime, endTime) and the displayed date matches today.
|
||
*/
|
||
function computeCurrentMeetingId(
|
||
meetings: Meeting[],
|
||
dateIso: string,
|
||
nowMs: number,
|
||
): number | null {
|
||
const now = new Date(nowMs);
|
||
const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||
if (todayIso !== dateIso) return null;
|
||
const minutesNow = now.getHours() * 60 + now.getMinutes();
|
||
for (const m of meetings) {
|
||
const s = parseTimeToMinutes(m.startTime);
|
||
const e = parseTimeToMinutes(m.endTime);
|
||
if (s === null || e === null) continue;
|
||
if (s <= minutesNow && minutesNow < e) return m.id;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function formatTime(t: string | null, lang: "ar" | "en"): string {
|
||
if (!t) return "";
|
||
// DB stores HH:mm:ss (24h). Build a Date anchored to a fixed day so
|
||
// Intl can format the time-of-day in the user's language. We render
|
||
// in 12-hour format BUT without the AM/PM (English) or ص/م (Arabic)
|
||
// day-period suffix — admins know meetings sit in business hours and
|
||
// requested the cleaner compact form ("5:39 – 5:50"). To do this we
|
||
// use `formatToParts` and drop the `dayPeriod` segment, then trim
|
||
// any leading/trailing whitespace literals that were glueing it on
|
||
// (Intl typically inserts a regular space, but some locales use
|
||
// U+00A0 / U+202F — JS `String.prototype.trim` handles all of those).
|
||
// `hour: "numeric"` (vs "2-digit") drops the leading zero so
|
||
// single-digit hours render as "5:39" instead of "05:39". Latin
|
||
// digits are forced via the `-u-nu-latn` locale extension so Arabic
|
||
// doesn't bleed Arabic-Indic numerals into the table. Falls back
|
||
// to the raw HH:mm slice if parsing somehow fails (defensive —
|
||
// shouldn't happen for our DB shape).
|
||
const hhmm = t.slice(0, 5);
|
||
const parsed = new Date(`1970-01-01T${hhmm}:00`);
|
||
if (Number.isNaN(parsed.getTime())) return hhmm;
|
||
const locale = lang === "ar" ? "ar-u-nu-latn" : "en-US-u-nu-latn";
|
||
const parts = new Intl.DateTimeFormat(locale, {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
hour12: true,
|
||
numberingSystem: "latn",
|
||
}).formatToParts(parsed);
|
||
return parts
|
||
.filter((p) => p.type !== "dayPeriod")
|
||
.map((p) => p.value)
|
||
.join("")
|
||
.trim();
|
||
}
|
||
|
||
function todayIso(): string {
|
||
return new Date().toISOString().slice(0, 10);
|
||
}
|
||
|
||
function fontWeightToCss(w: FontPrefs["fontWeight"]): number {
|
||
return { regular: 400, bold: 700 }[w];
|
||
}
|
||
|
||
function fontFamilyToCss(name: string): string {
|
||
if (name === "system") return "";
|
||
if (/[\s-]/.test(name)) return `"${name}", system-ui, sans-serif`;
|
||
return `${name}, system-ui, sans-serif`;
|
||
}
|
||
|
||
function buildFontStyle(prefs: FontPrefs): CSSProperties {
|
||
return {
|
||
fontFamily: fontFamilyToCss(prefs.fontFamily) || undefined,
|
||
fontSize: prefs.fontSize,
|
||
fontWeight: fontWeightToCss(prefs.fontWeight),
|
||
textAlign: prefs.alignment,
|
||
};
|
||
}
|
||
|
||
// #326: Map known server-side validation messages to friendly,
|
||
// translated copy. The API strings come straight from the Zod
|
||
// `superRefine` rules in routes/executive-meetings.ts; matching by
|
||
// substring keeps the helper resilient to surrounding prefixes
|
||
// like the field path (e.g. "endTime: startTime must be <= endTime").
|
||
// Anything we don't recognize falls through to the raw message so
|
||
// unexpected server problems aren't silently hidden.
|
||
function translateSaveError(rawMsg: string, t: (k: string) => string): string {
|
||
if (rawMsg.includes("startTime must be <= endTime")) {
|
||
return t("executiveMeetings.manage.errors.endTimeBeforeStart");
|
||
}
|
||
return rawMsg;
|
||
}
|
||
|
||
// #329: Normalize a free-text string so the schedule's search box can match
|
||
// across HTML markup (titles + attendee names are sanitized Tiptap rich text)
|
||
// and across the most common Arabic spelling variants. Strips:
|
||
// • HTML tags + common entities ( / U+00A0)
|
||
// • Arabic diacritics (\u064B–\u065F) and tatweel (\u0640)
|
||
// • Folds alef variants (آ أ إ ٱ → ا), ya (ى → ي), ta marbouta (ة → ه),
|
||
// hamza on waw/ya (ؤ ئ → و / ي)
|
||
// Then lowercases for case-insensitive English matching. Empty/whitespace
|
||
// input becomes "" so the caller can short-circuit.
|
||
function normalizeForSearch(input: string | null | undefined): string {
|
||
if (!input) return "";
|
||
let s = String(input);
|
||
// Strip tags + common HTML entities first so we don't index markup.
|
||
s = s.replace(/<[^>]*>/g, " ").replace(/ /gi, " ").replace(/\u00a0/g, " ");
|
||
// Drop Arabic diacritics + tatweel.
|
||
s = s.replace(/[\u064B-\u065F\u0640]/g, "");
|
||
// Fold common Arabic letter variants.
|
||
s = s
|
||
.replace(/[\u0622\u0623\u0625\u0671]/g, "\u0627") // alef variants → ا
|
||
.replace(/\u0649/g, "\u064A") // ى → ي
|
||
.replace(/\u0629/g, "\u0647") // ة → ه
|
||
.replace(/\u0624/g, "\u0648") // ؤ → و
|
||
.replace(/\u0626/g, "\u064A"); // ئ → ي
|
||
return s.toLowerCase().replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function meetingMatchesSearch(m: Meeting, needle: string): boolean {
|
||
if (!needle) return true;
|
||
const haystacks: string[] = [
|
||
normalizeForSearch(m.titleAr),
|
||
normalizeForSearch(m.titleEn),
|
||
normalizeForSearch(m.location),
|
||
normalizeForSearch(m.notes),
|
||
normalizeForSearch(m.mergeText ?? ""),
|
||
];
|
||
for (const a of m.attendees) {
|
||
haystacks.push(normalizeForSearch(a.name));
|
||
haystacks.push(normalizeForSearch(a.title));
|
||
}
|
||
for (const h of haystacks) {
|
||
if (h && h.includes(needle)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function tWithFallback(
|
||
t: (key: string) => string,
|
||
key: string,
|
||
fallback: string,
|
||
): string {
|
||
const value = t(key);
|
||
return value === key ? fallback : value;
|
||
}
|
||
|
||
async function apiJson<T>(
|
||
url: string,
|
||
init?: Omit<RequestInit, "body"> & { body?: unknown },
|
||
): Promise<T> {
|
||
const { body: rawBody, headers: rawHeaders, ...rest } = init ?? {};
|
||
const opts: RequestInit = {
|
||
credentials: "include",
|
||
...rest,
|
||
headers: { "Content-Type": "application/json", ...(rawHeaders ?? {}) },
|
||
};
|
||
if (rawBody !== undefined) {
|
||
opts.body =
|
||
typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody);
|
||
}
|
||
const res = await fetch(url, opts);
|
||
if (res.status === 204) return undefined as T;
|
||
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
|
||
if (!res.ok) {
|
||
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;
|
||
}
|
||
|
||
// In-app replacement for `window.confirm`. The native browser dialog
|
||
// shows the Repl's hostname in its header and ignores the app's RTL/
|
||
// branding, which felt jarring inside the bilingual Executive Meetings
|
||
// page (#342). We expose an imperative `confirm({...})` that returns a
|
||
// Promise<boolean> so call sites read identically to the old
|
||
// `if (!window.confirm(msg)) return;` pattern — only the `await`
|
||
// changes. The dialog itself is a single shadcn `AlertDialog` mounted
|
||
// once at the page root via `ConfirmProvider`, so any nested component
|
||
// (Schedule, Manage, MeetingFormDialog) can call `useConfirm()` without
|
||
// prop drilling.
|
||
type ConfirmOptions = {
|
||
title?: string;
|
||
message: string;
|
||
confirmLabel?: string;
|
||
cancelLabel?: string;
|
||
destructive?: boolean;
|
||
};
|
||
|
||
const ConfirmContext = createContext<
|
||
((opts: ConfirmOptions) => Promise<boolean>) | null
|
||
>(null);
|
||
|
||
function useConfirm(): (opts: ConfirmOptions) => Promise<boolean> {
|
||
const ctx = useContext(ConfirmContext);
|
||
if (!ctx) {
|
||
throw new Error("useConfirm must be used inside <ConfirmProvider>");
|
||
}
|
||
return ctx;
|
||
}
|
||
|
||
function ConfirmProvider({ children }: { children: ReactNode }) {
|
||
const { t, i18n } = useTranslation();
|
||
const isRtl = i18n.language === "ar";
|
||
const [pending, setPending] = useState<{
|
||
options: ConfirmOptions;
|
||
resolve: (v: boolean) => void;
|
||
} | null>(null);
|
||
|
||
const confirm = useCallback((options: ConfirmOptions) => {
|
||
return new Promise<boolean>((resolve) => {
|
||
// Single-flight: if a previous confirm is still pending (e.g. a
|
||
// second handler fired before the user clicked anything on the
|
||
// first dialog), settle the older Promise as `false` so its
|
||
// caller doesn't hang forever, then replace it with the new one.
|
||
setPending((prev) => {
|
||
if (prev) prev.resolve(false);
|
||
return { options, resolve };
|
||
});
|
||
});
|
||
}, []);
|
||
|
||
// Resolves the open promise exactly once and tears down the dialog.
|
||
// Guards against double-resolve when the user clicks Cancel while
|
||
// Radix is also firing onOpenChange(false) for the same close.
|
||
const settle = useCallback(
|
||
(value: boolean) => {
|
||
setPending((cur) => {
|
||
if (cur) cur.resolve(value);
|
||
return null;
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const opts = pending?.options;
|
||
const title =
|
||
opts?.title ??
|
||
tWithFallback(t, "executiveMeetings.common.confirmTitle", "تأكيد");
|
||
const confirmLabel =
|
||
opts?.confirmLabel ??
|
||
(opts?.destructive
|
||
? tWithFallback(t, "executiveMeetings.common.delete", "حذف")
|
||
: tWithFallback(t, "executiveMeetings.common.confirm", "تأكيد"));
|
||
const cancelLabel =
|
||
opts?.cancelLabel ??
|
||
tWithFallback(t, "executiveMeetings.common.cancel", "إلغاء");
|
||
|
||
return (
|
||
<ConfirmContext.Provider value={confirm}>
|
||
{children}
|
||
<AlertDialog
|
||
open={pending !== null}
|
||
onOpenChange={(open) => {
|
||
if (!open) settle(false);
|
||
}}
|
||
>
|
||
<AlertDialogContent
|
||
data-testid="em-confirm-dialog"
|
||
dir={isRtl ? "rtl" : "ltr"}
|
||
className="max-w-md w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto"
|
||
>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle className="text-center">{title}</AlertDialogTitle>
|
||
<AlertDialogDescription className="text-center text-base text-foreground whitespace-pre-line break-words">
|
||
{opts?.message ?? ""}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
{/*
|
||
Render the confirm/destructive action FIRST in the JSX so
|
||
that the default `flex-row` (LTR) places it on the left and
|
||
Cancel on the right (English spec), while in RTL the visual
|
||
order flips automatically and Confirm sits on the right with
|
||
Cancel on the left (Arabic spec). On mobile the
|
||
`flex-col-reverse` from AlertDialogFooter naturally stacks
|
||
the destructive action at the bottom and Cancel on top.
|
||
*/}
|
||
<AlertDialogFooter className="sm:justify-center gap-2">
|
||
<AlertDialogAction
|
||
data-testid="em-confirm-ok"
|
||
onClick={() => settle(true)}
|
||
className={
|
||
opts?.destructive
|
||
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||
: undefined
|
||
}
|
||
>
|
||
{confirmLabel}
|
||
</AlertDialogAction>
|
||
<AlertDialogCancel
|
||
data-testid="em-confirm-cancel"
|
||
onClick={() => settle(false)}
|
||
>
|
||
{cancelLabel}
|
||
</AlertDialogCancel>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</ConfirmContext.Provider>
|
||
);
|
||
}
|
||
|
||
export default function ExecutiveMeetingsPage() {
|
||
return (
|
||
<ConfirmProvider>
|
||
<ExecutiveMeetingsPageInner />
|
||
</ConfirmProvider>
|
||
);
|
||
}
|
||
|
||
function ExecutiveMeetingsPageInner() {
|
||
const { t, i18n } = useTranslation();
|
||
const [, setLocation] = useLocation();
|
||
const isRtl = i18n.language === "ar";
|
||
const lang: "ar" | "en" = isRtl ? "ar" : "en";
|
||
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
||
|
||
const [section, setSection] = useState<SectionKey>("schedule");
|
||
const [date, setDate] = useState<string>(todayIso());
|
||
|
||
// Lifted from ScheduleSection so the unified Settings tab can edit
|
||
// the same column visibility / highlight state the schedule consumes.
|
||
const [columns, setColumns] = useState<ColumnSetting[]>(() =>
|
||
normalizeColumns(readJsonFromStorage(COLS_STORAGE_KEY, DEFAULT_COLUMNS)),
|
||
);
|
||
const [highlightPrefs, setHighlightPrefs] = useState<HighlightPrefs>(() =>
|
||
readJsonFromStorage<HighlightPrefs>(
|
||
HIGHLIGHT_STORAGE_KEY,
|
||
DEFAULT_HIGHLIGHT_PREFS,
|
||
),
|
||
);
|
||
useEffect(() => {
|
||
writeJsonToStorage(COLS_STORAGE_KEY, columns);
|
||
}, [columns]);
|
||
useEffect(() => {
|
||
writeJsonToStorage(HIGHLIGHT_STORAGE_KEY, highlightPrefs);
|
||
}, [highlightPrefs]);
|
||
|
||
const { data, isLoading } = useQuery<DayResponse>({
|
||
queryKey: ["/api/executive-meetings", date],
|
||
queryFn: async () => {
|
||
const res = await fetch(`/api/executive-meetings?date=${date}`, {
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json();
|
||
},
|
||
});
|
||
|
||
const { data: me } = useQuery<MeRoles>({
|
||
queryKey: ["/api/executive-meetings/me"],
|
||
queryFn: () => apiJson<MeRoles>("/api/executive-meetings/me"),
|
||
});
|
||
|
||
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 ?? [];
|
||
|
||
// #267: Measure the header so the in-section sticky rows (page title +
|
||
// table thead) can sit flush below it on every breakpoint. The header
|
||
// wraps on narrow widths, so a static Tailwind `top-N` would either
|
||
// gap or overlap. We expose the live height as `--em-header-h` on the
|
||
// page root and let descendant sticky elements read it via `top:
|
||
// var(--em-header-h)`.
|
||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||
const headerRef = useRef<HTMLElement | null>(null);
|
||
useEffect(() => {
|
||
const header = headerRef.current;
|
||
const root = rootRef.current;
|
||
if (!header || !root) return;
|
||
const update = () => {
|
||
root.style.setProperty("--em-header-h", `${header.offsetHeight}px`);
|
||
};
|
||
update();
|
||
const ro = new ResizeObserver(update);
|
||
ro.observe(header);
|
||
window.addEventListener("resize", update);
|
||
return () => {
|
||
ro.disconnect();
|
||
window.removeEventListener("resize", update);
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<div
|
||
ref={rootRef}
|
||
data-em-root
|
||
className="min-h-screen bg-[#f4f6fb] text-foreground"
|
||
dir={isRtl ? "rtl" : "ltr"}
|
||
style={{ ["--em-header-h" as string]: "0px", ["--em-heading-h" as string]: "0px" }}
|
||
>
|
||
<header
|
||
ref={headerRef}
|
||
data-testid="em-page-header"
|
||
className="sticky top-0 z-40 bg-white border-b border-gray-200 shadow-sm print:static print:shadow-none print:hidden"
|
||
>
|
||
<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.title")}
|
||
</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>
|
||
</div>
|
||
</div>
|
||
|
||
<nav className="border-t border-gray-100 bg-white overflow-x-auto">
|
||
<div className="max-w-screen-2xl mx-auto px-2 sm:px-4 flex gap-1 sm:gap-2 min-w-max">
|
||
{SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
|
||
({ key, icon: Icon }) => {
|
||
const active = section === key;
|
||
return (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() => setSection(key)}
|
||
className={
|
||
"flex items-center gap-1.5 px-3 sm:px-4 py-2.5 text-xs sm:text-sm whitespace-nowrap border-b-2 transition-colors " +
|
||
(active
|
||
? "border-[#0B1E3F] text-[#0B1E3F] font-semibold"
|
||
: "border-transparent text-muted-foreground hover:text-foreground")
|
||
}
|
||
data-testid={`em-nav-${key}`}
|
||
>
|
||
<Icon className="w-4 h-4" />
|
||
{t(`executiveMeetings.nav.${key}`)}
|
||
</button>
|
||
);
|
||
},
|
||
)}
|
||
</div>
|
||
</nav>
|
||
</header>
|
||
|
||
<main className="max-w-screen-2xl mx-auto px-3 sm:px-6 py-4 sm:py-6">
|
||
{section === "schedule" && (
|
||
<ScheduleSection
|
||
meetings={meetings}
|
||
date={date}
|
||
onDateChange={setDate}
|
||
isLoading={isLoading}
|
||
isRtl={isRtl}
|
||
t={t}
|
||
font={effectiveFont}
|
||
canMutate={!!me?.canMutate}
|
||
userId={me?.userId ?? null}
|
||
columns={columns}
|
||
onColumnsChange={setColumns}
|
||
highlightPrefs={highlightPrefs}
|
||
/>
|
||
)}
|
||
{section === "manage" && me && (
|
||
<ManageSection date={date} onDateChange={setDate} canMutate={me.canMutate} 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 === "settings" && me && (
|
||
<SettingsSection
|
||
font={effectiveFont}
|
||
canEditGlobalFont={me.canEditGlobalFontSettings}
|
||
columns={columns}
|
||
onColumnsChange={setColumns}
|
||
highlightPrefs={highlightPrefs}
|
||
onHighlightPrefsChange={setHighlightPrefs}
|
||
t={t}
|
||
/>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============ SCHEDULE ============
|
||
|
||
function ScheduleSection({
|
||
meetings,
|
||
date,
|
||
onDateChange,
|
||
isLoading,
|
||
isRtl,
|
||
t,
|
||
font,
|
||
canMutate,
|
||
userId,
|
||
columns,
|
||
onColumnsChange,
|
||
highlightPrefs,
|
||
}: {
|
||
meetings: Meeting[];
|
||
date: string;
|
||
onDateChange: (d: string) => void;
|
||
isLoading: boolean;
|
||
isRtl: boolean;
|
||
t: (k: string) => string;
|
||
font: FontPrefs;
|
||
canMutate: boolean;
|
||
userId: number | null;
|
||
columns: ColumnSetting[];
|
||
onColumnsChange: Dispatch<SetStateAction<ColumnSetting[]>>;
|
||
highlightPrefs: HighlightPrefs;
|
||
}) {
|
||
const qc = useQueryClient();
|
||
const { toast } = useToast();
|
||
const confirm = useConfirm();
|
||
const tableStyle = buildFontStyle(font);
|
||
const setColumns = onColumnsChange;
|
||
// #288: Row highlight colours are now stored on the meeting row in the
|
||
// DB and shared across every viewer. Derive a quick lookup from the
|
||
// current `meetings` prop so the rest of this component (which still
|
||
// calls `rowColors[m.id] ?? "default"`) keeps working unchanged.
|
||
// Mutations go through `setRowColor` below, which PATCHes the meeting
|
||
// and then refetches via the day-changed query invalidation — no
|
||
// separate client-side cache to keep in sync.
|
||
const rowColors = useMemo<Record<number, string>>(() => {
|
||
const out: Record<number, string> = {};
|
||
for (const m of meetings) {
|
||
if (m.rowColor) out[m.id] = m.rowColor;
|
||
}
|
||
return out;
|
||
}, [meetings]);
|
||
|
||
// Global "Edit / View" toggle for the schedule. The default is view mode
|
||
// (no edit affordances). When a user with edit permission flips it on,
|
||
// every per-cell editor and per-row action (merge, color swatch, drag
|
||
// handle, add/delete row, inline edit, column drag, column resize) is
|
||
// re-enabled. We persist the user's last choice in localStorage so the
|
||
// toggle survives reloads + date changes, but we *also* always start in
|
||
// view mode for users without permission so they never see a stale "on"
|
||
// state from a previous session where they had access. Hydrating the
|
||
// localStorage value happens inside an effect to keep SSR-safe and to
|
||
// avoid reading from a non-mutator's namespace.
|
||
//
|
||
// The storage key is namespaced by the current user's id so a shared
|
||
// browser profile can't leak one editor's last toggle state into the
|
||
// next account that signs in. When userId is null (no /me yet) we
|
||
// fall back to view mode without touching storage.
|
||
const editModeStorageKey = useMemo(
|
||
() => (userId != null ? `${EDIT_MODE_STORAGE_KEY}:${userId}` : null),
|
||
[userId],
|
||
);
|
||
const [editMode, setEditModeState] = useState<boolean>(false);
|
||
useEffect(() => {
|
||
if (!canMutate || !editModeStorageKey) {
|
||
setEditModeState(false);
|
||
return;
|
||
}
|
||
try {
|
||
const raw = window.localStorage.getItem(editModeStorageKey);
|
||
setEditModeState(raw === "true");
|
||
} catch {
|
||
// localStorage may be unavailable (private mode); fall through to
|
||
// the default false.
|
||
}
|
||
}, [canMutate, editModeStorageKey]);
|
||
const setEditMode = useCallback(
|
||
(next: boolean) => {
|
||
if (!canMutate || !editModeStorageKey) return;
|
||
setEditModeState(next);
|
||
try {
|
||
window.localStorage.setItem(editModeStorageKey, next ? "true" : "false");
|
||
} catch {
|
||
// Persistence is best-effort; the in-memory state still flips.
|
||
}
|
||
},
|
||
[canMutate, editModeStorageKey],
|
||
);
|
||
// Anything the user can mutate must AND with editMode. We pass this
|
||
// derived flag down to every row + cell so they don't each have to
|
||
// know about the toggle. The actual `canMutate` permission stays in
|
||
// scope for the toggle button visibility check.
|
||
const effectiveCanMutate = canMutate && editMode;
|
||
|
||
const [nowTick, setNowTick] = useState(() => Date.now());
|
||
useEffect(() => {
|
||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||
const msToNextMinute = 60_000 - (Date.now() % 60_000);
|
||
const timeoutId = setTimeout(() => {
|
||
setNowTick(Date.now());
|
||
intervalId = setInterval(() => setNowTick(Date.now()), 60_000);
|
||
}, msToNextMinute);
|
||
return () => {
|
||
clearTimeout(timeoutId);
|
||
if (intervalId) clearInterval(intervalId);
|
||
};
|
||
}, []);
|
||
|
||
const currentMeetingId = useMemo(
|
||
() => computeCurrentMeetingId(meetings, date, nowTick),
|
||
[meetings, date, nowTick],
|
||
);
|
||
|
||
// Optimistic local override so we can render the new row order before the
|
||
// server round-trip finishes. Cleared on each new meetings prop.
|
||
const [optimisticOrder, setOptimisticOrder] = useState<number[] | null>(null);
|
||
const [reordering, setReordering] = useState(false);
|
||
const reorderingRef = useRef(false);
|
||
useEffect(() => {
|
||
setOptimisticOrder(null);
|
||
}, [meetings, date]);
|
||
|
||
const orderedMeetings = useMemo(() => {
|
||
// #273: cancelled meetings disappear from the daily schedule view
|
||
// (they remain queryable via archive / API for audit trails).
|
||
const visible = meetings.filter((m) => m.status !== "cancelled");
|
||
if (!optimisticOrder) return visible;
|
||
const byId = new Map(visible.map((m) => [m.id, m]));
|
||
const out: Meeting[] = [];
|
||
for (const id of optimisticOrder) {
|
||
const m = byId.get(id);
|
||
if (m) out.push(m);
|
||
}
|
||
// Append any visible meetings not in the optimistic order (defensive).
|
||
for (const m of visible) {
|
||
if (!optimisticOrder.includes(m.id)) out.push(m);
|
||
}
|
||
return out;
|
||
}, [meetings, optimisticOrder]);
|
||
|
||
// #329: in-day search. Free-text needle is normalized once (Arabic
|
||
// diacritics + letter variants folded, lowercased, HTML stripped) and
|
||
// matched against title, attendee names/titles, location, notes, and
|
||
// any merge text on the row. Empty needle → pass-through.
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
// Reset whenever the active day changes — a search is "scoped to the
|
||
// currently visible date" per the spec, so jumping days clears it.
|
||
useEffect(() => {
|
||
setSearchQuery("");
|
||
}, [date]);
|
||
const normalizedSearch = useMemo(
|
||
() => normalizeForSearch(searchQuery),
|
||
[searchQuery],
|
||
);
|
||
const filteredMeetings = useMemo(() => {
|
||
if (!normalizedSearch) return orderedMeetings;
|
||
return orderedMeetings.filter((m) =>
|
||
meetingMatchesSearch(m, normalizedSearch),
|
||
);
|
||
}, [orderedMeetings, normalizedSearch]);
|
||
|
||
// #329: while printing, ignore the active search so PDF/print output
|
||
// is always the full day's schedule. We track an `isPrinting` flag
|
||
// via the standard beforeprint/afterprint events (with a `print` media
|
||
// query listener as a fallback for browsers that fire only the latter)
|
||
// and feed `displayedMeetings` to the table render path. The search
|
||
// input itself is also `print:hidden`, so the PDF doesn't show a
|
||
// half-typed query either.
|
||
const [isPrinting, setIsPrinting] = useState(false);
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
const onBefore = () => setIsPrinting(true);
|
||
const onAfter = () => setIsPrinting(false);
|
||
window.addEventListener("beforeprint", onBefore);
|
||
window.addEventListener("afterprint", onAfter);
|
||
const mq = window.matchMedia("print");
|
||
const onMq = (e: MediaQueryListEvent) => setIsPrinting(e.matches);
|
||
mq.addEventListener?.("change", onMq);
|
||
return () => {
|
||
window.removeEventListener("beforeprint", onBefore);
|
||
window.removeEventListener("afterprint", onAfter);
|
||
mq.removeEventListener?.("change", onMq);
|
||
};
|
||
}, []);
|
||
const displayedMeetings = isPrinting ? orderedMeetings : filteredMeetings;
|
||
|
||
const refreshDay = useCallback(() => {
|
||
void qc.invalidateQueries({
|
||
queryKey: ["/api/executive-meetings", date],
|
||
});
|
||
}, [qc, date]);
|
||
|
||
// #317: optimistic-cache helper shared by every inline-edit save
|
||
// handler on the schedule. Snapshots the current DayResponse, applies
|
||
// `patch` to the matching meeting, and returns a rollback function the
|
||
// caller invokes from its `catch` branch on PATCH failure. This is
|
||
// what makes title / attendee / merge / row-color edits repaint the
|
||
// cell within a single React commit instead of waiting for the GET
|
||
// refetch — the same pattern saveTimes already uses for the schedule
|
||
// time editor (task #316).
|
||
const applyMeetingPatch = useCallback(
|
||
(meetingId: number, patch: (m: Meeting) => Meeting) => {
|
||
const queryKey = ["/api/executive-meetings", date] as const;
|
||
const previous = qc.getQueryData<DayResponse>(queryKey);
|
||
qc.setQueryData<DayResponse>(queryKey, (prev) =>
|
||
prev
|
||
? {
|
||
...prev,
|
||
meetings: prev.meetings.map((m) =>
|
||
m.id === meetingId ? patch(m) : m,
|
||
),
|
||
}
|
||
: prev,
|
||
);
|
||
return () => {
|
||
if (previous !== undefined) {
|
||
qc.setQueryData<DayResponse>(queryKey, previous);
|
||
}
|
||
};
|
||
},
|
||
[qc, date],
|
||
);
|
||
|
||
const saveTitle = useCallback(
|
||
async (meeting: Meeting, html: string) => {
|
||
// #202: route by visible UI language (i18n.language → isRtl), NOT
|
||
// by user.preferredLanguage. An ar-pref user who switches the UI
|
||
// to en and edits the cell must update titleEn.
|
||
const body = isRtl ? { titleAr: html } : { titleEn: html };
|
||
// #317: optimistic local repaint so the cell shows the new title
|
||
// in the same React commit that closes the editor. Rolls back on
|
||
// PATCH failure so a network/server error snaps the cell back to
|
||
// the previously-saved value while the toast surfaces the error.
|
||
const rollback = applyMeetingPatch(meeting.id, (m) =>
|
||
isRtl ? { ...m, titleAr: html } : { ...m, titleEn: html },
|
||
);
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${meeting.id}`, {
|
||
method: "PATCH",
|
||
body,
|
||
});
|
||
refreshDay();
|
||
} catch (err) {
|
||
rollback();
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
toast({
|
||
title: t("common.error"),
|
||
description: msg,
|
||
variant: "destructive",
|
||
});
|
||
throw err;
|
||
}
|
||
},
|
||
[applyMeetingPatch, isRtl, refreshDay, toast, t],
|
||
);
|
||
|
||
const saveAttendeeName = useCallback(
|
||
async (meeting: Meeting, attendeeIdx: number, html: string) => {
|
||
// Detect "truly empty" content: Tiptap empty paragraphs, , or
|
||
// pure whitespace. When the attendee's name is cleared we treat the
|
||
// edit as a delete-row gesture and remove the attendee instead of
|
||
// saving an empty record. The same gesture deletes a custom
|
||
// subheading row (clear the label text → row disappears on blur).
|
||
const isEmpty =
|
||
html
|
||
.replace(/<[^>]*>/g, "")
|
||
.replace(/ /g, " ")
|
||
.replace(/\u00a0/g, " ")
|
||
.trim() === "";
|
||
const next = isEmpty
|
||
? meeting.attendees
|
||
.filter((_, i) => i !== attendeeIdx)
|
||
// Re-pack sortOrder so the server stores a contiguous run; the
|
||
// PUT endpoint takes the array as the new source of truth and
|
||
// reassigns ids on insert.
|
||
.map((a, i) => ({ ...a, sortOrder: i }))
|
||
: meeting.attendees.map((a, i) =>
|
||
i === attendeeIdx ? { ...a, name: html } : a,
|
||
);
|
||
// #317: optimistic repaint of the attendees cell. Mirrors the
|
||
// wire-shape we PUT below so the cell collapses to the new state
|
||
// (renamed row, or removed row when the user cleared the text)
|
||
// immediately, without waiting on the server round-trip.
|
||
const rollback = applyMeetingPatch(meeting.id, (m) => ({
|
||
...m,
|
||
attendees: next.map((a) => ({
|
||
...a,
|
||
kind: a.kind ?? "person",
|
||
})) as Attendee[],
|
||
}));
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${meeting.id}/attendees`, {
|
||
method: "PUT",
|
||
body: {
|
||
attendees: next.map((a) => ({
|
||
name: a.name,
|
||
title: a.title,
|
||
attendanceType: a.attendanceType,
|
||
sortOrder: a.sortOrder,
|
||
// Round-trip the row kind so an inline edit of a person row
|
||
// can't silently demote a sibling subheading back to default.
|
||
kind: a.kind ?? "person",
|
||
})),
|
||
},
|
||
});
|
||
refreshDay();
|
||
} catch (err) {
|
||
rollback();
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
toast({
|
||
title: t("common.error"),
|
||
description: msg,
|
||
variant: "destructive",
|
||
});
|
||
throw err;
|
||
}
|
||
},
|
||
[applyMeetingPatch, refreshDay, toast, t],
|
||
);
|
||
|
||
// -------------------------------------------------------------------
|
||
// "+ Add attendee" — optimistic ghost row
|
||
//
|
||
// Clicking the inline "+" button in the attendees cell does NOT hit
|
||
// the server immediately (the API requires a non-empty name). Instead
|
||
// we render a synthetic in-edit EditableCell next to the existing
|
||
// attendees. When the user saves a non-empty name we PUT the full
|
||
// array with the new attendee appended; if they leave the cell empty
|
||
// (blur or Escape) we just discard the pending state.
|
||
//
|
||
// We allow at most one pending attendee at a time across the whole
|
||
// page — the AttendeesCell hides "+" buttons elsewhere while a ghost
|
||
// row is open so the user can't accidentally orphan their typing.
|
||
// The same ghost is reused for "+ subheading" (kind = "subheading")
|
||
// so the one-at-a-time rule covers both gestures uniformly.
|
||
type PendingAttendee = {
|
||
meetingId: number;
|
||
attendanceType: Attendee["attendanceType"];
|
||
kind: "person" | "subheading";
|
||
// Bumped each time the user clicks "+" so React remounts the ghost
|
||
// EditableCell (giving it a fresh editor and focus).
|
||
key: number;
|
||
// When set, insert the new row at this list position (0-based,
|
||
// inserts BEFORE that index — i.e. `splice(insertAtIndex, 0, …)`).
|
||
// When undefined, append to the end of the cell (legacy behavior
|
||
// for the trailing "+" / "+ عنوان فرعي" buttons).
|
||
insertAtIndex?: number;
|
||
};
|
||
const [pendingAttendee, setPendingAttendee] =
|
||
useState<PendingAttendee | null>(null);
|
||
|
||
// If the user toggles edit mode off (or otherwise loses edit
|
||
// permission) while the ghost "+ Add attendee" row is open, drop the
|
||
// pending state so the synthetic row unmounts immediately. Without
|
||
// this the ghost row would keep its open editor in an otherwise
|
||
// read-only schedule.
|
||
useEffect(() => {
|
||
if (!effectiveCanMutate && pendingAttendee !== null) {
|
||
setPendingAttendee(null);
|
||
}
|
||
}, [effectiveCanMutate, pendingAttendee]);
|
||
|
||
const startAddAttendee = useCallback(
|
||
(
|
||
meetingId: number,
|
||
attendanceType: Attendee["attendanceType"],
|
||
kind: "person" | "subheading" = "person",
|
||
insertAtIndex?: number,
|
||
) => {
|
||
// Only one ghost row may be open at a time across the whole page.
|
||
// We refuse a new start while another is pending — the user must
|
||
// first commit (blur with text) or cancel (Escape / blur empty).
|
||
// The "+" buttons are already hidden in this state, but a stale
|
||
// click that beats the re-render is still possible.
|
||
setPendingAttendee((prev) =>
|
||
prev
|
||
? prev
|
||
: {
|
||
meetingId,
|
||
attendanceType,
|
||
kind,
|
||
key: Date.now(),
|
||
insertAtIndex,
|
||
},
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const cancelAddAttendee = useCallback(() => {
|
||
setPendingAttendee(null);
|
||
}, []);
|
||
|
||
const commitAddAttendee = useCallback(
|
||
async (
|
||
meeting: Meeting,
|
||
attendanceType: Attendee["attendanceType"],
|
||
html: string,
|
||
kind: "person" | "subheading" = "person",
|
||
insertAtIndex?: number,
|
||
) => {
|
||
// Always clear pending — whether we end up persisting or not.
|
||
setPendingAttendee(null);
|
||
const isEmpty =
|
||
html
|
||
.replace(/<[^>]*>/g, "")
|
||
.replace(/ /g, " ")
|
||
.replace(/\u00a0/g, " ")
|
||
.trim() === "";
|
||
if (isEmpty) return;
|
||
// Build the next array. When `insertAtIndex` is set we splice the
|
||
// new row INTO the existing list at that position and renumber every
|
||
// sortOrder from 0 so the ordering matches array order. Otherwise
|
||
// we keep the legacy "append with sort = max+1" behavior to leave
|
||
// pre-existing sort gaps intact for the trailing "+" button.
|
||
const existing = meeting.attendees.map((a) => ({
|
||
name: a.name,
|
||
title: a.title,
|
||
attendanceType: a.attendanceType,
|
||
sortOrder: a.sortOrder,
|
||
kind: a.kind ?? "person",
|
||
}));
|
||
const newRow = {
|
||
name: html,
|
||
title: null as string | null,
|
||
attendanceType,
|
||
sortOrder: 0, // overwritten below
|
||
kind,
|
||
};
|
||
let next: typeof existing;
|
||
if (
|
||
insertAtIndex !== undefined &&
|
||
insertAtIndex >= 0 &&
|
||
insertAtIndex <= existing.length
|
||
) {
|
||
const draft = existing.slice();
|
||
draft.splice(insertAtIndex, 0, newRow);
|
||
next = draft.map((a, idx) => ({ ...a, sortOrder: idx }));
|
||
} else {
|
||
const maxSort = existing.reduce(
|
||
(m, a) => Math.max(m, a.sortOrder ?? 0),
|
||
-1,
|
||
);
|
||
next = [...existing, { ...newRow, sortOrder: maxSort + 1 }];
|
||
}
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${meeting.id}/attendees`, {
|
||
method: "PUT",
|
||
body: { attendees: next },
|
||
});
|
||
refreshDay();
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
toast({
|
||
title: t("common.error"),
|
||
description: msg,
|
||
variant: "destructive",
|
||
});
|
||
}
|
||
},
|
||
[refreshDay, toast, t],
|
||
);
|
||
|
||
const saveTimes = useCallback(
|
||
async (
|
||
meeting: Meeting,
|
||
startTime: string | null,
|
||
endTime: string | null,
|
||
) => {
|
||
// Optimistically patch the cached meetings list so the read-only
|
||
// schedule row repaints with the new times in the same React
|
||
// commit that closes the inline editor — no flicker from
|
||
// empty/old → new while the GET refetch is in flight. We snapshot
|
||
// the previous payload first so the catch branch below can roll
|
||
// back if the PATCH fails (network error, server validation,
|
||
// anything that doesn't end with a fresh GET landing).
|
||
//
|
||
// Shape note: server returns `startTime` / `endTime` as
|
||
// "HH:MM:SS" strings. The wire body we POST is "HH:MM" (no
|
||
// seconds), so we re-append `:00` when seeding the cache so
|
||
// downstream readers (cascade-shift logic, conflict warnings,
|
||
// TimeRangeCell's own startSaved.slice(0,5)) see exactly the
|
||
// same shape they'll get from the next refetch.
|
||
const queryKey = ["/api/executive-meetings", date] as const;
|
||
const previous = qc.getQueryData<DayResponse>(queryKey);
|
||
qc.setQueryData<DayResponse>(queryKey, (prev) =>
|
||
prev
|
||
? {
|
||
...prev,
|
||
meetings: prev.meetings.map((m) =>
|
||
m.id === meeting.id
|
||
? {
|
||
...m,
|
||
startTime: startTime ? `${startTime}:00` : null,
|
||
endTime: endTime ? `${endTime}:00` : null,
|
||
}
|
||
: m,
|
||
),
|
||
}
|
||
: prev,
|
||
);
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${meeting.id}`, {
|
||
method: "PATCH",
|
||
body: { startTime, endTime },
|
||
});
|
||
refreshDay();
|
||
} catch (err) {
|
||
// Roll back the optimistic cache so the row snaps back to the
|
||
// previously-saved times on any failure (network, 4xx, 5xx).
|
||
// The TimeRangeCell catch branch separately re-opens the
|
||
// editor with the user's draft so they can retry.
|
||
if (previous !== undefined) {
|
||
qc.setQueryData<DayResponse>(queryKey, previous);
|
||
}
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
// The server enforces start <= end and returns a 400 like
|
||
// "endTime: startTime must be <= endTime". Surface the same
|
||
// friendly localized message the client-side guard uses, and
|
||
// throw a tagged error so TimeRangeCell can re-open edit mode
|
||
// with the user's draft values intact instead of resetting.
|
||
if (msg.includes("startTime must be <= endTime")) {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.timeOrderError"),
|
||
variant: "destructive",
|
||
});
|
||
const e = new Error("time-order");
|
||
e.name = "TimeOrderError";
|
||
throw e;
|
||
}
|
||
toast({
|
||
title: t("common.error"),
|
||
description: msg,
|
||
variant: "destructive",
|
||
});
|
||
throw err;
|
||
}
|
||
},
|
||
[qc, date, refreshDay, toast, t],
|
||
);
|
||
|
||
// PATCH the cell-merge overlay on a meeting. `merge === null` clears
|
||
// the overlay and restores the original cells; otherwise it sets a
|
||
// new range + free text that the row will display in place of the
|
||
// spanned cells.
|
||
const saveMerge = useCallback(
|
||
async (
|
||
meeting: Meeting,
|
||
merge:
|
||
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
|
||
| null,
|
||
) => {
|
||
// #317: optimistic repaint of the merge overlay so the cell shows
|
||
// the new merged span (or restores the original cells when
|
||
// `merge === null`) in the same React commit that closes the
|
||
// editor. Rolls back on PATCH failure.
|
||
const rollback = applyMeetingPatch(meeting.id, (m) =>
|
||
merge
|
||
? {
|
||
...m,
|
||
mergeStartColumn: merge.mergeStartColumn,
|
||
mergeEndColumn: merge.mergeEndColumn,
|
||
mergeText: merge.mergeText,
|
||
}
|
||
: {
|
||
...m,
|
||
mergeStartColumn: null,
|
||
mergeEndColumn: null,
|
||
mergeText: null,
|
||
},
|
||
);
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${meeting.id}`, {
|
||
method: "PATCH",
|
||
body: { merge },
|
||
});
|
||
refreshDay();
|
||
} catch (err) {
|
||
rollback();
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
toast({
|
||
title: t("common.error"),
|
||
description: msg,
|
||
variant: "destructive",
|
||
});
|
||
throw err;
|
||
}
|
||
},
|
||
[applyMeetingPatch, refreshDay, toast, t],
|
||
);
|
||
|
||
// Inline "delete entire meeting" action exposed on each schedule row.
|
||
// Uses the same DELETE endpoint as the Manage section, but lives on
|
||
// the schedule itself so editors can remove a row without context-
|
||
// switching. Confirmation surfaces the meeting title so users know
|
||
// exactly what will be removed (titles can contain rich-text HTML so
|
||
// we strip tags before showing the prompt).
|
||
const [deletingMeetingId, setDeletingMeetingId] = useState<number | null>(
|
||
null,
|
||
);
|
||
const deleteMeeting = useCallback(
|
||
async (meeting: Meeting) => {
|
||
if (deletingMeetingId != null) return;
|
||
// Pick the localized display title with the same fallback the row
|
||
// uses, then strip HTML so the native confirm dialog shows plain
|
||
// text (titles are Tiptap rich text in the DB).
|
||
const rawTitle = isRtl
|
||
? meeting.titleAr
|
||
: meeting.titleEn || meeting.titleAr;
|
||
const plainTitle = rawTitle
|
||
.replace(/<[^>]*>/g, "")
|
||
.replace(/ /g, " ")
|
||
.replace(/\u00a0/g, " ")
|
||
.trim();
|
||
const message = t("executiveMeetings.schedule.deleteRowConfirm").replace(
|
||
"{{title}}",
|
||
plainTitle,
|
||
);
|
||
if (!(await confirm({ message, destructive: true }))) return;
|
||
setDeletingMeetingId(meeting.id);
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${meeting.id}`, {
|
||
method: "DELETE",
|
||
});
|
||
toast({ title: t("executiveMeetings.schedule.deleted") });
|
||
refreshDay();
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
toast({
|
||
title: t("common.error"),
|
||
description: msg,
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setDeletingMeetingId(null);
|
||
}
|
||
},
|
||
[confirm, deletingMeetingId, isRtl, refreshDay, t, toast],
|
||
);
|
||
|
||
// ─── Multi-select + bulk delete (edit mode only) ────────────────────
|
||
// Lifted here (alongside the existing single-row deleteMeeting) so a
|
||
// single source of truth for "which rows are selected" is available
|
||
// to both the floating toolbar and every MeetingRow's checkbox. We
|
||
// keep ids in a Set for O(1) membership lookups during render.
|
||
const [selectedMeetingIds, setSelectedMeetingIds] = useState<Set<number>>(
|
||
() => new Set(),
|
||
);
|
||
// Drop the selection whenever the schedule context changes — switching
|
||
// dates or turning edit mode off should not leave a stale selection
|
||
// hanging around (it would resurface the moment the user re-enters
|
||
// edit mode and look like a bug).
|
||
useEffect(() => {
|
||
setSelectedMeetingIds(new Set());
|
||
}, [date, effectiveCanMutate]);
|
||
// Also clear the selection on every successful day refresh
|
||
// (refetch of the meetings query). React Query hands a brand-new
|
||
// `meetings` array reference on every refetch — including realtime
|
||
// invalidations and post-mutation refreshes triggered by
|
||
// refreshDay() — so detecting a reference change here means the
|
||
// selection cannot survive past a refresh even when the refreshed
|
||
// payload happens to contain the same ids. We skip the very first
|
||
// value (mount load) via a ref so an initially-empty selection
|
||
// isn't reset for nothing.
|
||
const lastMeetingsRef = useRef<Meeting[] | null>(null);
|
||
useEffect(() => {
|
||
if (lastMeetingsRef.current === null) {
|
||
lastMeetingsRef.current = meetings;
|
||
return;
|
||
}
|
||
if (lastMeetingsRef.current !== meetings) {
|
||
lastMeetingsRef.current = meetings;
|
||
setSelectedMeetingIds(new Set());
|
||
}
|
||
}, [meetings]);
|
||
// Defensive intersection: if the meetings list shrinks (e.g. after a
|
||
// bulk delete or someone else removed a row in real time), drop any
|
||
// ids that no longer exist so we don't try to act on ghosts.
|
||
useEffect(() => {
|
||
setSelectedMeetingIds((prev) => {
|
||
if (prev.size === 0) return prev;
|
||
const present = new Set(meetings.map((m) => m.id));
|
||
const next = new Set<number>();
|
||
let changed = false;
|
||
for (const id of prev) {
|
||
if (present.has(id)) next.add(id);
|
||
else changed = true;
|
||
}
|
||
return changed ? next : prev;
|
||
});
|
||
}, [meetings]);
|
||
const toggleMeetingSelected = useCallback(
|
||
(id: number, on: boolean) => {
|
||
setSelectedMeetingIds((prev) => {
|
||
if (on === prev.has(id)) return prev;
|
||
const next = new Set(prev);
|
||
if (on) next.add(id);
|
||
else next.delete(id);
|
||
return next;
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
const setAllMeetingsSelected = useCallback(
|
||
(on: boolean) => {
|
||
// #329: "select all" operates on the currently visible (filtered)
|
||
// rows so users can't accidentally bulk-act on rows they hid via
|
||
// the search box. Turning it off always clears the entire selection
|
||
// (including any rows that may be hidden by an active search).
|
||
setSelectedMeetingIds((prev) => {
|
||
if (!on) return new Set();
|
||
const next = new Set(prev);
|
||
for (const m of filteredMeetings) next.add(m.id);
|
||
return next;
|
||
});
|
||
},
|
||
[filteredMeetings],
|
||
);
|
||
// Tri-state hint for the "select all" checkbox in the toolbar.
|
||
// "all" → fully checked, "some" → indeterminate, "none" → unchecked.
|
||
// Computed against the visible (filtered) row set so the header
|
||
// checkbox reflects what the user can actually see.
|
||
const allSelectedState: "all" | "some" | "none" = useMemo(() => {
|
||
const total = filteredMeetings.length;
|
||
if (total === 0) return "none";
|
||
let count = 0;
|
||
for (const m of filteredMeetings) if (selectedMeetingIds.has(m.id)) count++;
|
||
if (count === 0) return "none";
|
||
if (count === total) return "all";
|
||
return "some";
|
||
}, [filteredMeetings, selectedMeetingIds]);
|
||
// #329: count of selected rows that are *currently visible*. Used by
|
||
// the bulk toolbar so the displayed count and the action it triggers
|
||
// (delete) refer to the same set, even when an active search hides
|
||
// some previously-selected rows.
|
||
const visibleSelectedCount = useMemo(() => {
|
||
let n = 0;
|
||
for (const m of displayedMeetings) if (selectedMeetingIds.has(m.id)) n++;
|
||
return n;
|
||
}, [displayedMeetings, selectedMeetingIds]);
|
||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||
// #330: bulk-duplicate-to-date dialog state. `targetDate` defaults to
|
||
// the currently viewed `date` so the dialog opens on a sensible
|
||
// value; busy flag disables the confirm button while the per-row
|
||
// POSTs are in flight. The id list at action time is the visible
|
||
// selected set (intersection with displayedMeetings) for the same
|
||
// safety reason as bulk-delete.
|
||
const [bulkDuplicateOpen, setBulkDuplicateOpen] = useState(false);
|
||
const [bulkDuplicateDate, setBulkDuplicateDate] = useState<string>(date);
|
||
const [bulkDuplicating, setBulkDuplicating] = useState(false);
|
||
const openBulkDuplicate = useCallback(() => {
|
||
setBulkDuplicateDate(date);
|
||
setBulkDuplicateOpen(true);
|
||
}, [date]);
|
||
const performBulkDuplicate = useCallback(async () => {
|
||
if (bulkDuplicating) return;
|
||
if (!bulkDuplicateDate) return;
|
||
const visibleIds = new Set(displayedMeetings.map((m) => m.id));
|
||
const ids = Array.from(selectedMeetingIds).filter((id) =>
|
||
visibleIds.has(id),
|
||
);
|
||
if (ids.length === 0) {
|
||
setBulkDuplicateOpen(false);
|
||
return;
|
||
}
|
||
setBulkDuplicating(true);
|
||
try {
|
||
// Mirror the bulk-delete pattern: allSettled so one failure
|
||
// doesn't abort the rest, then aggregate into a single toast.
|
||
const results = await Promise.allSettled(
|
||
ids.map((id) =>
|
||
apiJson(`/api/executive-meetings/${id}/duplicate`, {
|
||
method: "POST",
|
||
body: { targetDate: bulkDuplicateDate },
|
||
}),
|
||
),
|
||
);
|
||
const ok = results.filter((r) => r.status === "fulfilled").length;
|
||
const failed = results.length - ok;
|
||
if (failed === 0) {
|
||
toast({
|
||
title: t(
|
||
"executiveMeetings.schedule.bulkDuplicateResultAll",
|
||
).replace("{{n}}", String(ok)),
|
||
});
|
||
} else if (ok === 0) {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.bulkDuplicateNone"),
|
||
variant: "destructive",
|
||
});
|
||
} else {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.bulkDuplicatePartial")
|
||
.replace("{{ok}}", String(ok))
|
||
.replace("{{total}}", String(results.length))
|
||
.replace("{{failed}}", String(failed)),
|
||
variant: "destructive",
|
||
});
|
||
}
|
||
setSelectedMeetingIds(new Set());
|
||
setBulkDuplicateOpen(false);
|
||
// If the new rows landed on the day we're viewing, refresh in
|
||
// place; otherwise jump to the target date so the user can see
|
||
// the duplicates immediately (matches per-row duplicate UX).
|
||
if (bulkDuplicateDate === date) {
|
||
refreshDay();
|
||
} else {
|
||
onDateChange(bulkDuplicateDate);
|
||
}
|
||
} finally {
|
||
setBulkDuplicating(false);
|
||
}
|
||
}, [
|
||
bulkDuplicating,
|
||
bulkDuplicateDate,
|
||
displayedMeetings,
|
||
selectedMeetingIds,
|
||
date,
|
||
onDateChange,
|
||
refreshDay,
|
||
toast,
|
||
t,
|
||
]);
|
||
// #199: undo via recreate. dailyNumber is omitted so a stolen slot
|
||
// doesn't 409 the restore — the row gets the next available number.
|
||
const restoreMeetingFromSnapshot = useCallback(
|
||
async (snap: Meeting) => {
|
||
const created = (await apiJson("/api/executive-meetings", {
|
||
method: "POST",
|
||
body: {
|
||
titleAr: snap.titleAr,
|
||
titleEn: snap.titleEn,
|
||
meetingDate: snap.meetingDate,
|
||
startTime: snap.startTime ?? undefined,
|
||
endTime: snap.endTime ?? undefined,
|
||
location: snap.location ?? undefined,
|
||
meetingUrl: snap.meetingUrl ?? undefined,
|
||
platform: snap.platform,
|
||
status: snap.status,
|
||
isHighlighted: snap.isHighlighted ? 1 : 0,
|
||
notes: snap.notes ?? undefined,
|
||
attendees: (snap.attendees ?? []).map((a, i) => ({
|
||
name: a.name,
|
||
title: a.title ?? null,
|
||
attendanceType: a.attendanceType,
|
||
sortOrder: a.sortOrder ?? i,
|
||
kind: a.kind ?? "person",
|
||
})),
|
||
},
|
||
})) as Meeting;
|
||
if (snap.mergeStartColumn && snap.mergeEndColumn && snap.mergeText) {
|
||
await apiJson(`/api/executive-meetings/${created.id}`, {
|
||
method: "PATCH",
|
||
body: {
|
||
merge: {
|
||
mergeStartColumn: snap.mergeStartColumn,
|
||
mergeEndColumn: snap.mergeEndColumn,
|
||
mergeText: snap.mergeText,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
const deleteSelectedMeetings = useCallback(async () => {
|
||
if (bulkDeleting) return;
|
||
// #329: only act on rows that are currently visible. The selection
|
||
// set may legitimately contain ids that an active search filter is
|
||
// hiding (we don't auto-prune so that clearing the search restores
|
||
// the user's selection). Intersecting at action time prevents an
|
||
// accidental destructive op against rows the user can't even see.
|
||
const visibleIds = new Set(filteredMeetings.map((m) => m.id));
|
||
const ids = Array.from(selectedMeetingIds).filter((id) =>
|
||
visibleIds.has(id),
|
||
);
|
||
if (ids.length === 0) return;
|
||
const message = t(
|
||
"executiveMeetings.schedule.bulkDeleteConfirm",
|
||
).replace("{{n}}", String(ids.length));
|
||
if (!(await confirm({ message, destructive: true }))) return;
|
||
// Snapshot before deletion so undo can reconstruct the rows.
|
||
const idsSet = new Set(ids);
|
||
const snapshots = meetings.filter((m) => idsSet.has(m.id));
|
||
setBulkDeleting(true);
|
||
try {
|
||
// Deliberately allSettled, not all: a single failed DELETE shouldn't
|
||
// skip the others. We aggregate the outcome into one toast so the
|
||
// user sees a single, actionable result instead of N toasts.
|
||
const results = await Promise.allSettled(
|
||
ids.map((id) =>
|
||
apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" }),
|
||
),
|
||
);
|
||
const ok = results.filter((r) => r.status === "fulfilled").length;
|
||
const failed = results.length - ok;
|
||
const okIds = ids.filter((_, idx) => results[idx].status === "fulfilled");
|
||
const okSnapshots = snapshots.filter((s) => okIds.includes(s.id));
|
||
// #199: guard against double-click — restore is non-idempotent.
|
||
let undoFired = false;
|
||
const undo = async () => {
|
||
if (undoFired) return;
|
||
undoFired = true;
|
||
handle.dismiss();
|
||
const undoResults = await Promise.allSettled(
|
||
okSnapshots.map((s) => restoreMeetingFromSnapshot(s)),
|
||
);
|
||
const undoOk = undoResults.filter(
|
||
(r) => r.status === "fulfilled",
|
||
).length;
|
||
const undoFailed = undoResults.length - undoOk;
|
||
if (undoFailed === 0) {
|
||
toast({
|
||
title: t(
|
||
"executiveMeetings.schedule.bulkDeleteUndone",
|
||
).replace("{{n}}", String(undoOk)),
|
||
});
|
||
} else if (undoOk === 0) {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.bulkDeleteUndoFailed"),
|
||
variant: "destructive",
|
||
});
|
||
} else {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.bulkDeleteUndoPartial")
|
||
.replace("{{ok}}", String(undoOk))
|
||
.replace("{{total}}", String(undoResults.length)),
|
||
variant: "destructive",
|
||
});
|
||
}
|
||
refreshDay();
|
||
};
|
||
const undoLabel = t("executiveMeetings.schedule.bulkDeleteUndo");
|
||
const undoAction =
|
||
okSnapshots.length > 0 ? (
|
||
<ToastAction
|
||
altText={undoLabel}
|
||
onClick={() => {
|
||
void undo();
|
||
}}
|
||
data-testid="em-bulk-delete-undo"
|
||
>
|
||
{undoLabel}
|
||
</ToastAction>
|
||
) : undefined;
|
||
let handle: ReturnType<typeof toast>;
|
||
if (failed === 0) {
|
||
handle = toast({
|
||
title: t("executiveMeetings.schedule.bulkDeleteResultAll").replace(
|
||
"{{n}}",
|
||
String(ok),
|
||
),
|
||
action: undoAction,
|
||
});
|
||
} else if (ok === 0) {
|
||
handle = toast({
|
||
title: t("executiveMeetings.schedule.bulkDeleteResultNone"),
|
||
variant: "destructive",
|
||
});
|
||
} else {
|
||
handle = toast({
|
||
title: t("executiveMeetings.schedule.bulkDeletePartial")
|
||
.replace("{{ok}}", String(ok))
|
||
.replace("{{total}}", String(results.length))
|
||
.replace("{{failed}}", String(failed)),
|
||
variant: "destructive",
|
||
action: undoAction,
|
||
});
|
||
}
|
||
setSelectedMeetingIds(new Set());
|
||
refreshDay();
|
||
} finally {
|
||
setBulkDeleting(false);
|
||
}
|
||
}, [
|
||
bulkDeleting,
|
||
confirm,
|
||
selectedMeetingIds,
|
||
filteredMeetings,
|
||
meetings,
|
||
t,
|
||
toast,
|
||
refreshDay,
|
||
restoreMeetingFromSnapshot,
|
||
]);
|
||
|
||
const [creatingRow, setCreatingRow] = useState(false);
|
||
// When the user clicks "Add row" we capture the new meeting id here so
|
||
// that, once the refreshed list contains it, we can scroll the row into
|
||
// view and auto-open the title cell in inline-edit mode.
|
||
const [autoEditTitleId, setAutoEditTitleId] = useState<number | null>(null);
|
||
const createRow = useCallback(async () => {
|
||
if (creatingRow) return;
|
||
setCreatingRow(true);
|
||
try {
|
||
const created = (await apiJson(`/api/executive-meetings`, {
|
||
method: "POST",
|
||
body: {
|
||
titleAr: t("executiveMeetings.schedule.newMeetingDefaultAr"),
|
||
titleEn: t("executiveMeetings.schedule.newMeetingDefaultEn"),
|
||
meetingDate: date,
|
||
},
|
||
})) as Meeting | undefined;
|
||
if (created?.id) setAutoEditTitleId(created.id);
|
||
refreshDay();
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
toast({
|
||
title: t("common.error"),
|
||
description: msg,
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setCreatingRow(false);
|
||
}
|
||
}, [creatingRow, date, refreshDay, toast, t]);
|
||
|
||
// Once the auto-edit target meeting is in the list, scroll it into view.
|
||
// The MeetingRow itself receives `startInEditMode` so its EditableCell
|
||
// opens immediately and clears `autoEditTitleId` via `onStartedEditing`.
|
||
// We also schedule a fallback clear so the state never goes stale if the
|
||
// title cell never mounts (e.g. column hidden, row filtered out).
|
||
useEffect(() => {
|
||
if (autoEditTitleId == null) return;
|
||
if (!meetings.some((m) => m.id === autoEditTitleId)) return;
|
||
const rafId = window.requestAnimationFrame(() => {
|
||
const row = document.querySelector(
|
||
`[data-testid="em-row-${autoEditTitleId}"]`,
|
||
);
|
||
if (row && "scrollIntoView" in row) {
|
||
(row as HTMLElement).scrollIntoView({
|
||
block: "center",
|
||
behavior: "smooth",
|
||
});
|
||
}
|
||
});
|
||
const fallback = window.setTimeout(() => {
|
||
setAutoEditTitleId(null);
|
||
}, 1500);
|
||
return () => {
|
||
window.cancelAnimationFrame(rafId);
|
||
window.clearTimeout(fallback);
|
||
};
|
||
}, [autoEditTitleId, meetings]);
|
||
const clearAutoEditTitleId = useCallback(() => {
|
||
setAutoEditTitleId(null);
|
||
}, []);
|
||
|
||
const reorderRows = useCallback(
|
||
async (fromId: number, toId: number) => {
|
||
if (fromId === toId) return;
|
||
if (reorderingRef.current) return;
|
||
// Use the VISIBLE list — the same one dnd-kit's SortableContext
|
||
// operates on. Building orderedIds from raw `meetings` (which
|
||
// includes hidden cancelled rows) corrupted both the index math
|
||
// and the slot-swap on the server, so dragging a meeting from
|
||
// top to bottom landed it in a non-chronological position.
|
||
const ids = orderedMeetings.map((m) => m.id);
|
||
const fromIdx = ids.indexOf(fromId);
|
||
const toIdx = ids.indexOf(toId);
|
||
if (fromIdx < 0 || toIdx < 0) return;
|
||
const newOrder = ids.slice();
|
||
newOrder.splice(fromIdx, 1);
|
||
newOrder.splice(toIdx, 0, fromId);
|
||
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);
|
||
}
|
||
},
|
||
[orderedMeetings, date, refreshDay, toast, t],
|
||
);
|
||
|
||
// #265: columns persistence is owned by ExecutiveMeetingsPage now that
|
||
// both Schedule and Settings tabs can mutate the array. Keeping the
|
||
// write effect here would silently drop edits made from the Settings
|
||
// tab while ScheduleSection is unmounted.
|
||
|
||
// #288: row colours moved to the meeting record on the server, so the
|
||
// old `writeJsonToStorage(ROW_COLORS_STORAGE_KEY, rowColors)` effect is
|
||
// gone. The client-side state is now derived from the `meetings` prop
|
||
// (see the `rowColors` memo above).
|
||
|
||
// #288: Best-effort migration of legacy per-device colours into the
|
||
// shared server-side field. Runs every time the loaded day's meetings
|
||
// change, so colours saved on a date the user hasn't visited yet
|
||
// survive in localStorage and get migrated when they navigate there.
|
||
// Rules:
|
||
// - drops invalid keys / unknown colours immediately (those were
|
||
// never going to migrate anywhere),
|
||
// - skips ids whose server row already has a colour (don't overwrite
|
||
// someone else's choice),
|
||
// - PATCHes ids present in the currently-loaded day,
|
||
// - keeps ids whose meeting we haven't seen yet AND ids whose PATCH
|
||
// failed so the next effect run / next visit can retry,
|
||
// - removes the localStorage key entirely once nothing is left.
|
||
// Concurrency: a Set of in-flight ids prevents the same id from being
|
||
// PATCHed twice if the effect re-fires while a request is pending.
|
||
const migrationInFlightRef = useRef<Set<number>>(new Set());
|
||
useEffect(() => {
|
||
if (!canMutate) return;
|
||
if (typeof window === "undefined") return;
|
||
let raw: string | null = null;
|
||
try {
|
||
raw = window.localStorage.getItem(ROW_COLORS_STORAGE_KEY);
|
||
} catch {
|
||
return;
|
||
}
|
||
if (!raw) return;
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(raw);
|
||
} catch {
|
||
try {
|
||
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return;
|
||
}
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
try {
|
||
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return;
|
||
}
|
||
const allowedKeys = new Set(
|
||
ROW_COLOR_OPTIONS.filter((o) => o.key !== "default").map((o) => o.key),
|
||
);
|
||
const meetingById = new Map(meetings.map((m) => [m.id, m]));
|
||
const remaining: Record<string, string> = {};
|
||
const toMigrate: Array<{ id: number; color: string }> = [];
|
||
for (const [idStr, color] of Object.entries(
|
||
parsed as Record<string, unknown>,
|
||
)) {
|
||
const id = Number(idStr);
|
||
// Drop entries that could never apply: invalid id or unknown
|
||
// colour key. Keeping them would just keep the localStorage key
|
||
// alive forever.
|
||
if (!Number.isInteger(id) || id <= 0) continue;
|
||
if (typeof color !== "string" || !allowedKeys.has(color)) continue;
|
||
const m = meetingById.get(id);
|
||
if (!m) {
|
||
// Not in this day's payload — could be a future day the user
|
||
// hasn't visited yet. Preserve so a later effect run picks it up.
|
||
remaining[idStr] = color;
|
||
continue;
|
||
}
|
||
if (m.rowColor) continue; // server already has a colour, drop ours
|
||
if (migrationInFlightRef.current.has(id)) {
|
||
remaining[idStr] = color; // already PATCHing — wait for next tick
|
||
continue;
|
||
}
|
||
toMigrate.push({ id, color });
|
||
}
|
||
if (toMigrate.length === 0) {
|
||
// Either everything left over is for unseen days, or there's
|
||
// nothing left at all.
|
||
try {
|
||
if (Object.keys(remaining).length === 0) {
|
||
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
|
||
} else {
|
||
// Rewrite to compact away the dropped (invalid / already-set)
|
||
// entries so we don't keep iterating them forever.
|
||
window.localStorage.setItem(
|
||
ROW_COLORS_STORAGE_KEY,
|
||
JSON.stringify(remaining),
|
||
);
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return;
|
||
}
|
||
for (const c of toMigrate) migrationInFlightRef.current.add(c.id);
|
||
void (async () => {
|
||
const results = await Promise.allSettled(
|
||
toMigrate.map((c) =>
|
||
apiJson(`/api/executive-meetings/${c.id}`, {
|
||
method: "PATCH",
|
||
body: { rowColor: c.color },
|
||
}),
|
||
),
|
||
);
|
||
// Failed PATCHes get folded back into `remaining` so we can
|
||
// retry next time. Successful ones are dropped.
|
||
const finalRemaining: Record<string, string> = { ...remaining };
|
||
results.forEach((res, i) => {
|
||
const c = toMigrate[i];
|
||
migrationInFlightRef.current.delete(c.id);
|
||
if (res.status === "rejected") {
|
||
finalRemaining[String(c.id)] = c.color;
|
||
}
|
||
});
|
||
try {
|
||
if (Object.keys(finalRemaining).length === 0) {
|
||
window.localStorage.removeItem(ROW_COLORS_STORAGE_KEY);
|
||
} else {
|
||
window.localStorage.setItem(
|
||
ROW_COLORS_STORAGE_KEY,
|
||
JSON.stringify(finalRemaining),
|
||
);
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
void qc.invalidateQueries({
|
||
queryKey: ["/api/executive-meetings", date],
|
||
});
|
||
})();
|
||
}, [canMutate, meetings, qc, date]);
|
||
|
||
const visibleColumns = columns.filter((c) => c.visible);
|
||
|
||
const setColumnWidth = useCallback((id: ColumnId, width: number) => {
|
||
setColumns((prev) =>
|
||
prev.map((c) =>
|
||
c.id === id
|
||
? {
|
||
...c,
|
||
width: Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, width)),
|
||
}
|
||
: c,
|
||
),
|
||
);
|
||
}, []);
|
||
|
||
// #288: persist a row colour by PATCHing the meeting. The server
|
||
// validates the key against the ROW_COLOR_KEYS whitelist, writes an
|
||
// audit log entry, and emits a realtime day-changed event so other
|
||
// open Tx OS tabs/devices refetch and pick up the new colour. The
|
||
// local list refreshes through the same react-query invalidation
|
||
// (`executive-meetings:day` keyed by `date`) used by every other
|
||
// mutation on this page. We do an optimistic toast-based rollback if
|
||
// the request fails so the user always knows the colour didn't stick.
|
||
const setRowColor = useCallback(
|
||
async (meetingId: number, colorKey: string) => {
|
||
const wireValue = colorKey === "default" ? null : colorKey;
|
||
// #317: optimistic repaint so the row tint changes the moment the
|
||
// user picks a swatch, without waiting on the PATCH round-trip.
|
||
// Rolls back on failure so the colour snaps to the previous value.
|
||
const rollback = applyMeetingPatch(meetingId, (m) => ({
|
||
...m,
|
||
rowColor: wireValue,
|
||
}));
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${meetingId}`, {
|
||
method: "PATCH",
|
||
body: { rowColor: wireValue },
|
||
});
|
||
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
|
||
} catch (err) {
|
||
rollback();
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
toast({
|
||
title: t("common.error"),
|
||
description: msg,
|
||
variant: "destructive",
|
||
});
|
||
}
|
||
},
|
||
[applyMeetingPatch, qc, date, toast, t],
|
||
);
|
||
|
||
// Responsive breakpoints:
|
||
// < md (<768px) → stacked card layout (phones)
|
||
// md..<xl → fluid table, browser shrinks columns to fit (tablets)
|
||
// ≥ xl (≥1280px) → fixed-width table with resize handles (desktop)
|
||
// print → fixed-width table at all viewports (preserves the
|
||
// historical print layout from any screen size).
|
||
// Whether the configured pixel widths are *enforced* is controlled purely
|
||
// by CSS: `table-auto xl:table-fixed print:table-fixed`. We always emit the
|
||
// colgroup and per-cell widths so they're available whenever the table is
|
||
// in fixed-layout mode (xl screens AND any print). In `table-auto` mode,
|
||
// the browser treats those widths as hints and shrinks columns to fit.
|
||
// Resize handles only appear on large screens AND fine pointers (mouse).
|
||
// Touch devices — including iPad Pro at >=1280 CSS px — get no handles
|
||
// since drag-resizing with a finger fights touch scrolling.
|
||
const isXl = useMediaQuery("(min-width: 1280px)");
|
||
const isFinePointer = useMediaQuery("(pointer: fine)");
|
||
// Resize handles only render when the user is in edit mode AND on a
|
||
// large screen with a fine pointer. View mode hides them entirely so
|
||
// the table feels frozen until the editor opts in.
|
||
const showResizeHandles = isXl && isFinePointer && effectiveCanMutate;
|
||
|
||
// dnd-kit: sortable column headers + sortable rows.
|
||
//
|
||
// - 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],
|
||
);
|
||
|
||
// #267: Same dynamic-measurement trick as the page header — the title
|
||
// row wraps on narrow widths (heading + edit toggle + date picker), so
|
||
// we publish its live height so the table thead can sit flush below.
|
||
const headingRef = useRef<HTMLDivElement | null>(null);
|
||
useEffect(() => {
|
||
const heading = headingRef.current;
|
||
if (!heading) return;
|
||
const root = heading.closest<HTMLElement>("[data-em-root]");
|
||
if (!root) return;
|
||
const update = () => {
|
||
root.style.setProperty("--em-heading-h", `${heading.offsetHeight}px`);
|
||
};
|
||
update();
|
||
const ro = new ResizeObserver(update);
|
||
ro.observe(heading);
|
||
window.addEventListener("resize", update);
|
||
return () => {
|
||
ro.disconnect();
|
||
window.removeEventListener("resize", update);
|
||
// Reset so other sections (which don't render this heading) don't
|
||
// inherit a stale offset for any sticky descendants.
|
||
root.style.setProperty("--em-heading-h", "0px");
|
||
};
|
||
}, []);
|
||
|
||
// #267: Floating sticky thead. The actual table sits inside an
|
||
// overflow-x-auto wrapper (so wide tables can scroll horizontally on
|
||
// narrow screens), which means a CSS-only `position: sticky` on the
|
||
// thead is trapped inside that scrolling container and won't stick
|
||
// to the viewport. Workaround: render a separate sticky overlay
|
||
// above the wrapper that mirrors the actual table's column widths +
|
||
// horizontal scroll. The actual <thead> is hidden in screen mode
|
||
// (kept for print so paginated output still has column labels).
|
||
const tableWrapperRef = useRef<HTMLDivElement | null>(null);
|
||
const [floatingScrollLeft, setFloatingScrollLeft] = useState(0);
|
||
// Live, measured column widths in current visual order. Filled by
|
||
// the ResizeObserver below from the real thead's <th> cells. Falls
|
||
// back to the stored `columns[i].width` when measurement isn't
|
||
// available yet (first paint, or the table isn't yet in the DOM).
|
||
const [floatingColWidths, setFloatingColWidths] = useState<number[]>([]);
|
||
const [floatingTableWidth, setFloatingTableWidth] = useState<number>(0);
|
||
|
||
useEffect(() => {
|
||
const wrapper = tableWrapperRef.current;
|
||
if (!wrapper) return;
|
||
const onScroll = () => setFloatingScrollLeft(wrapper.scrollLeft);
|
||
wrapper.addEventListener("scroll", onScroll, { passive: true });
|
||
onScroll();
|
||
return () => wrapper.removeEventListener("scroll", onScroll);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const wrapper = tableWrapperRef.current;
|
||
if (!wrapper) return;
|
||
const measure = () => {
|
||
const table = wrapper.querySelector("table");
|
||
if (!table) return;
|
||
// The actual <thead> is display:none in screen mode (only the
|
||
// floating thead is visible), so we measure column widths from
|
||
// the first regular tbody row whose td-count matches the
|
||
// visible-column count. Subheading / placeholder / colspan
|
||
// rows are skipped.
|
||
const rows = Array.from(table.querySelectorAll<HTMLTableRowElement>("tbody > tr"));
|
||
const sample = rows.find((r) => r.children.length === visibleColumns.length);
|
||
if (sample) {
|
||
const widths = Array.from(sample.children).map(
|
||
(td) => (td as HTMLElement).getBoundingClientRect().width,
|
||
);
|
||
setFloatingColWidths(widths);
|
||
}
|
||
setFloatingTableWidth(table.getBoundingClientRect().width);
|
||
};
|
||
measure();
|
||
const ro = new ResizeObserver(measure);
|
||
ro.observe(wrapper);
|
||
const table = wrapper.querySelector("table");
|
||
if (table) ro.observe(table);
|
||
window.addEventListener("resize", measure);
|
||
return () => {
|
||
ro.disconnect();
|
||
window.removeEventListener("resize", measure);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [orderedMeetings.length, visibleColumns.length]);
|
||
|
||
return (
|
||
<div className="space-y-4 print:space-y-2">
|
||
<div
|
||
ref={headingRef}
|
||
data-testid="em-schedule-heading-bar"
|
||
className="sticky z-30 bg-[#f4f6fb] -mx-3 sm:-mx-6 px-3 sm:px-6 py-2 shadow-[0_4px_6px_-4px_rgba(11,30,63,0.15)] flex items-center justify-between gap-3 flex-wrap print:hidden print:!static print:!shadow-none print:!mx-0 print:!px-0"
|
||
style={{ top: "var(--em-header-h, 0px)" }}
|
||
>
|
||
<h2
|
||
className="text-xl sm:text-2xl font-bold text-[#0B1E3F]"
|
||
data-testid="em-schedule-heading"
|
||
>
|
||
{t("executiveMeetings.scheduleHeading")}
|
||
</h2>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{canMutate && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditMode(!editMode)}
|
||
aria-pressed={editMode}
|
||
aria-label={
|
||
editMode
|
||
? t("executiveMeetings.schedule.saveToggleAria")
|
||
: t("executiveMeetings.schedule.editToggleAria")
|
||
}
|
||
title={
|
||
editMode
|
||
? t("executiveMeetings.schedule.saveToggleOn")
|
||
: t("executiveMeetings.schedule.saveToggleOff")
|
||
}
|
||
data-testid="em-edit-mode-toggle"
|
||
className={
|
||
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[#0B1E3F]/40 " +
|
||
(editMode
|
||
? "bg-[#0B1E3F] text-white border-[#0B1E3F] hover:bg-[#0B1E3F]/90"
|
||
: "bg-white text-[#0B1E3F] border-gray-300 hover:bg-gray-50")
|
||
}
|
||
>
|
||
{editMode ? (
|
||
<Check className="w-3.5 h-3.5" />
|
||
) : (
|
||
<Pencil className="w-3.5 h-3.5" />
|
||
)}
|
||
<span>
|
||
{editMode
|
||
? t("executiveMeetings.schedule.saveToggle")
|
||
: t("executiveMeetings.schedule.editToggle")}
|
||
</span>
|
||
</button>
|
||
)}
|
||
{/* #329: in-day search box. Sits next to the date picker so
|
||
the user finds a meeting in the currently visible day
|
||
without having to scroll. Hidden in print so PDFs still
|
||
show the full schedule. */}
|
||
<div className="relative print:hidden">
|
||
<Input
|
||
type="search"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder={t("executiveMeetings.schedule.searchPlaceholder")}
|
||
aria-label={t("executiveMeetings.schedule.searchAria")}
|
||
data-testid="em-schedule-search"
|
||
className="h-8 w-44 sm:w-56 text-sm bg-white"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchQuery("")}
|
||
aria-label={t("executiveMeetings.schedule.searchClear")}
|
||
title={t("executiveMeetings.schedule.searchClear")}
|
||
data-testid="em-schedule-search-clear"
|
||
className={
|
||
"absolute top-1/2 -translate-y-1/2 text-muted-foreground hover:text-[#0B1E3F] " +
|
||
(isRtl ? "left-1.5" : "right-1.5")
|
||
}
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<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.title")}</div>
|
||
<div className="text-sm">{date}</div>
|
||
</div>
|
||
|
||
{/* Floating bulk-action toolbar. Per spec it appears ONLY when
|
||
≥1 row is selected (and in edit mode). The tri-state
|
||
"select all" checkbox lives in the table header (see
|
||
SortableHeader's selectAllOverlay below), so this bar
|
||
contains only the count + the destructive actions.
|
||
Hidden in print so it doesn't show up in PDF exports. */}
|
||
{effectiveCanMutate && visibleSelectedCount > 0 && (
|
||
<div
|
||
className="flex items-center gap-3 flex-wrap rounded-md border border-[#0B1E3F]/30 bg-[#0B1E3F]/5 px-3 py-2 mb-2 print:hidden"
|
||
data-testid="em-bulk-toolbar"
|
||
>
|
||
<span
|
||
className="text-sm text-[#0B1E3F]/80 font-medium"
|
||
data-testid="em-bulk-selection-count"
|
||
>
|
||
{t("executiveMeetings.schedule.bulkSelectionCount")
|
||
.replace("{{n}}", String(visibleSelectedCount))
|
||
.replace("{{total}}", String(displayedMeetings.length))}
|
||
</span>
|
||
<div className="flex items-center gap-2 ms-auto">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setSelectedMeetingIds(new Set())}
|
||
disabled={bulkDeleting}
|
||
data-testid="em-bulk-clear-selection"
|
||
>
|
||
{t("executiveMeetings.schedule.bulkClearSelection")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={openBulkDuplicate}
|
||
disabled={bulkDeleting || bulkDuplicating}
|
||
className="gap-1"
|
||
data-testid="em-bulk-duplicate-selected"
|
||
>
|
||
<Copy className="w-4 h-4" />
|
||
{t("executiveMeetings.schedule.bulkDuplicateSelected")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
onClick={deleteSelectedMeetings}
|
||
disabled={bulkDeleting || bulkDuplicating}
|
||
className="gap-1"
|
||
data-testid="em-bulk-delete-selected"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
{t("executiveMeetings.schedule.bulkDeleteSelected")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* #267: Floating sticky thead — overlays the column header at
|
||
the top of the viewport when the schedule scrolls, with a
|
||
translateX kept in sync with the data table's horizontal
|
||
scrollLeft so the columns stay aligned. Hidden in print
|
||
mode; the actual <thead> below carries the labels for
|
||
paginated output. */}
|
||
<div
|
||
data-testid="em-sticky-thead"
|
||
className="sticky z-[6] overflow-hidden bg-white border-x-2 border-t-2 border-[#0B1E3F] rounded-t-md shadow-[0_4px_6px_-4px_rgba(11,30,63,0.15)] print:hidden"
|
||
style={{
|
||
top: "calc(var(--em-header-h, 0px) + var(--em-heading-h, 0px))",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
// `translateX(-scrollLeft)` works in both LTR and RTL:
|
||
// RTL browsers report scrollLeft as negative when content
|
||
// is scrolled left, so negating it yields a positive
|
||
// (rightward) translate that matches the table's
|
||
// direction of motion.
|
||
transform: `translateX(${-floatingScrollLeft}px)`,
|
||
width: floatingTableWidth || undefined,
|
||
}}
|
||
>
|
||
<table
|
||
className="border-collapse text-sm md:text-[15px] table-fixed"
|
||
dir={isRtl ? "rtl" : "ltr"}
|
||
style={tableStyle}
|
||
>
|
||
<colgroup>
|
||
{visibleColumns.map((c, i) => (
|
||
<col
|
||
key={c.id}
|
||
style={{ width: floatingColWidths[i] ?? c.width }}
|
||
/>
|
||
))}
|
||
</colgroup>
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={onColumnDragEnd}
|
||
>
|
||
<thead>
|
||
<tr className="bg-[#0B1E3F] text-white">
|
||
<SortableContext
|
||
items={visibleColumns.map((c) => c.id)}
|
||
strategy={horizontalListSortingStrategy}
|
||
>
|
||
{visibleColumns.map((c, idx) => (
|
||
<SortableHeader
|
||
key={c.id}
|
||
col={{
|
||
...c,
|
||
width: floatingColWidths[idx] ?? c.width,
|
||
}}
|
||
isLast={idx === visibleColumns.length - 1}
|
||
isRtl={isRtl}
|
||
t={t}
|
||
showResizeHandle={showResizeHandles}
|
||
dragEnabled={effectiveCanMutate}
|
||
onResize={(delta) =>
|
||
setColumnWidth(c.id, c.width + delta)
|
||
}
|
||
selectAllOverlay={
|
||
idx === 0 &&
|
||
effectiveCanMutate &&
|
||
displayedMeetings.length > 0 ? (
|
||
<div
|
||
className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
>
|
||
<Checkbox
|
||
checked={
|
||
allSelectedState === "all"
|
||
? true
|
||
: allSelectedState === "some"
|
||
? "indeterminate"
|
||
: false
|
||
}
|
||
onCheckedChange={(v) =>
|
||
setAllMeetingsSelected(v === true)
|
||
}
|
||
aria-label={t(
|
||
"executiveMeetings.schedule.bulkSelectAll",
|
||
)}
|
||
data-testid="em-bulk-select-all"
|
||
className="h-4 w-4 bg-white shadow-sm"
|
||
/>
|
||
</div>
|
||
) : undefined
|
||
}
|
||
/>
|
||
))}
|
||
</SortableContext>
|
||
</tr>
|
||
</thead>
|
||
</DndContext>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
ref={tableWrapperRef}
|
||
className="bg-white border-2 border-[#0B1E3F] rounded-md overflow-x-auto shadow-sm print:shadow-none print:border-black print:overflow-hidden"
|
||
id="executive-schedule-printable"
|
||
>
|
||
<table
|
||
className="border-collapse text-sm md:text-[15px] w-full table-auto xl:table-fixed print:table-fixed"
|
||
dir={isRtl ? "rtl" : "ltr"}
|
||
style={tableStyle}
|
||
>
|
||
{/* Always emit colgroup + th widths so they're available for print
|
||
and for ≥xl screens. CSS classes on the <table> decide whether
|
||
they're enforced (table-fixed) or treated as hints (table-auto). */}
|
||
<colgroup>
|
||
{visibleColumns.map((c) => (
|
||
<col key={c.id} style={{ width: c.width }} />
|
||
))}
|
||
</colgroup>
|
||
{/* #267: hidden in screen mode (the floating sticky thead
|
||
above is the visible/interactive one); kept available
|
||
in print mode so paginated output still shows column
|
||
labels. Plain <th>s — no testids, no Sortable, no
|
||
checkbox — to avoid duplicating those with the floating
|
||
thead (which would break getByTestId in tests). */}
|
||
<thead className="hidden print:table-header-group">
|
||
<tr className="bg-[#0B1E3F] text-white">
|
||
{visibleColumns.map((c) => {
|
||
// #320: every column header is centered so the schedule's
|
||
// print thead stays in sync with the screen thead below.
|
||
const align = "text-center";
|
||
return (
|
||
<th
|
||
key={c.id}
|
||
className={`border border-[#0B1E3F] px-2 py-2 ${align} font-semibold`}
|
||
style={{ width: c.width }}
|
||
>
|
||
{t(`executiveMeetings.col.${c.id}`)}
|
||
</th>
|
||
);
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={onRowDragEnd}
|
||
>
|
||
<tbody>
|
||
{isLoading && (
|
||
<tr>
|
||
<td
|
||
colSpan={visibleColumns.length}
|
||
className="py-10 text-center text-muted-foreground"
|
||
>
|
||
{t("common.loading")}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{!isLoading && orderedMeetings.length === 0 && (
|
||
<tr>
|
||
<td
|
||
colSpan={visibleColumns.length}
|
||
className="py-10 text-center text-muted-foreground"
|
||
>
|
||
{t("executiveMeetings.noMeetings")}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{!isLoading &&
|
||
orderedMeetings.length > 0 &&
|
||
displayedMeetings.length === 0 && (
|
||
<tr data-testid="em-search-no-matches">
|
||
<td
|
||
colSpan={visibleColumns.length}
|
||
className="py-10 text-center text-muted-foreground"
|
||
>
|
||
{t("executiveMeetings.schedule.searchNoMatches")}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
<SortableContext
|
||
items={displayedMeetings.map((m) => m.id)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
{displayedMeetings.map((m, idx) => (
|
||
<MeetingRow
|
||
key={m.id}
|
||
meeting={m}
|
||
displayNumber={idx + 1}
|
||
isRtl={isRtl}
|
||
t={t}
|
||
visibleColumns={visibleColumns}
|
||
rowColorKey={rowColors[m.id] ?? "default"}
|
||
onPickRowColor={(key) => setRowColor(m.id, key)}
|
||
canMutate={effectiveCanMutate}
|
||
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, kind, insertAtIndex) =>
|
||
commitAddAttendee(m, type, html, kind, insertAtIndex)
|
||
}
|
||
onCancelAddAttendee={cancelAddAttendee}
|
||
bulkSelectable={effectiveCanMutate}
|
||
bulkSelected={selectedMeetingIds.has(m.id)}
|
||
onBulkSelectChange={(v) => toggleMeetingSelected(m.id, v)}
|
||
/>
|
||
))}
|
||
</SortableContext>
|
||
{effectiveCanMutate && !isLoading && (
|
||
<tr
|
||
className="print:hidden"
|
||
data-testid="em-add-row-tr"
|
||
>
|
||
<td
|
||
colSpan={visibleColumns.length}
|
||
className="border border-gray-300 p-0"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={createRow}
|
||
disabled={creatingRow}
|
||
aria-label={t("executiveMeetings.schedule.addRow")}
|
||
aria-busy={creatingRow}
|
||
className="w-full flex items-center justify-center gap-2 py-2.5 text-sm text-[#0B1E3F] hover:bg-blue-50/60 focus:bg-blue-50/60 focus:outline-none transition-colors disabled:opacity-60"
|
||
data-testid="em-add-row-button"
|
||
>
|
||
{creatingRow ? (
|
||
<span>{t("common.loading")}</span>
|
||
) : (
|
||
<Plus className="w-4 h-4" aria-hidden="true" />
|
||
)}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</DndContext>
|
||
</table>
|
||
</div>
|
||
|
||
{/* #330: Bulk-duplicate-to-date dialog. Mirrors the per-row
|
||
duplicate dialog in ManageSection (single date picker, same
|
||
POST endpoint per id) but operates on the visible-selected
|
||
subset and aggregates the result into one toast. */}
|
||
<Dialog
|
||
open={bulkDuplicateOpen}
|
||
onOpenChange={(o) => {
|
||
if (bulkDuplicating) return;
|
||
setBulkDuplicateOpen(o);
|
||
}}
|
||
>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{t("executiveMeetings.schedule.bulkDuplicateTitle").replace(
|
||
"{{n}}",
|
||
String(visibleSelectedCount),
|
||
)}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
|
||
<Input
|
||
type="date"
|
||
value={bulkDuplicateDate}
|
||
onChange={(e) => setBulkDuplicateDate(e.target.value)}
|
||
disabled={bulkDuplicating}
|
||
data-testid="em-bulk-duplicate-date"
|
||
/>
|
||
</FormRow>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setBulkDuplicateOpen(false)}
|
||
disabled={bulkDuplicating}
|
||
>
|
||
{t("executiveMeetings.common.cancel")}
|
||
</Button>
|
||
<Button
|
||
onClick={() => void performBulkDuplicate()}
|
||
disabled={bulkDuplicating || !bulkDuplicateDate}
|
||
className="bg-[#0B1E3F]"
|
||
data-testid="em-bulk-duplicate-confirm"
|
||
>
|
||
{bulkDuplicating
|
||
? t("executiveMeetings.schedule.bulkDuplicating")
|
||
: t("executiveMeetings.schedule.bulkDuplicateConfirm")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ResizeHandle({
|
||
isRtl,
|
||
onResize,
|
||
}: {
|
||
isRtl: boolean;
|
||
onResize: (delta: number) => void;
|
||
}) {
|
||
const startXRef = useRef<number | null>(null);
|
||
|
||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
(e.target as HTMLDivElement).setPointerCapture(e.pointerId);
|
||
startXRef.current = e.clientX;
|
||
};
|
||
|
||
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||
if (startXRef.current === null) return;
|
||
const raw = e.clientX - startXRef.current;
|
||
// In RTL the trailing edge is on the LEFT, so dragging left makes the
|
||
// column wider. Negate the delta in that case.
|
||
const delta = isRtl ? -raw : raw;
|
||
if (Math.abs(delta) >= 1) {
|
||
startXRef.current = e.clientX;
|
||
onResize(delta);
|
||
}
|
||
};
|
||
|
||
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||
startXRef.current = null;
|
||
try {
|
||
(e.target as HTMLDivElement).releasePointerCapture(e.pointerId);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
};
|
||
|
||
// Place handle on the trailing (logical end) edge so it works the same in
|
||
// both LTR and RTL.
|
||
const sideClass = isRtl ? "left-0" : "right-0";
|
||
|
||
return (
|
||
<div
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
className={`absolute top-0 ${sideClass} h-full w-1.5 cursor-col-resize hover:bg-white/40 active:bg-white/60`}
|
||
onPointerDown={onPointerDown}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp}
|
||
onPointerCancel={onPointerUp}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ColumnsCustomizerPanel({
|
||
columns,
|
||
onChange,
|
||
t,
|
||
highlightPrefs,
|
||
onHighlightPrefsChange,
|
||
}: {
|
||
columns: ColumnSetting[];
|
||
onChange: (next: ColumnSetting[]) => void;
|
||
t: (k: string) => string;
|
||
highlightPrefs: HighlightPrefs;
|
||
onHighlightPrefsChange: (next: HighlightPrefs) => void;
|
||
}) {
|
||
const toggle = (id: ColumnId, value: boolean) => {
|
||
// Always keep at least one column visible.
|
||
const visibleCount = columns.filter((c) => c.visible).length;
|
||
if (!value && visibleCount <= 1) return;
|
||
onChange(columns.map((c) => (c.id === id ? { ...c, visible: value } : c)));
|
||
};
|
||
|
||
const reset = () => onChange(DEFAULT_COLUMNS.map((c) => ({ ...c })));
|
||
|
||
return (
|
||
<div data-testid="em-customize-columns-panel">
|
||
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
||
{t("executiveMeetings.customize.hint")}
|
||
</p>
|
||
<ul className="space-y-1.5">
|
||
{columns.map((c) => (
|
||
<li
|
||
key={c.id}
|
||
className="flex items-center gap-2 rounded border border-gray-100 bg-gray-50 px-2 py-1.5"
|
||
data-testid={`em-customize-row-${c.id}`}
|
||
>
|
||
<Checkbox
|
||
id={`em-col-${c.id}`}
|
||
checked={c.visible}
|
||
onCheckedChange={(v) => toggle(c.id, !!v)}
|
||
data-testid={`em-customize-toggle-${c.id}`}
|
||
/>
|
||
<label
|
||
htmlFor={`em-col-${c.id}`}
|
||
className="flex-1 text-sm text-[#0B1E3F] cursor-pointer truncate"
|
||
>
|
||
{t(`executiveMeetings.col.${c.id}`)}
|
||
</label>
|
||
{!c.visible ? (
|
||
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
|
||
) : (
|
||
<Eye className="w-3.5 h-3.5 text-[#0B1E3F]" />
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<div className="mt-3 flex items-center justify-between">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="gap-1.5 text-xs"
|
||
onClick={reset}
|
||
data-testid="em-customize-reset"
|
||
>
|
||
<RotateCcw className="w-3.5 h-3.5" />
|
||
{t("executiveMeetings.customize.reset")}
|
||
</Button>
|
||
<span className="text-[11px] text-muted-foreground">
|
||
{t("executiveMeetings.customize.dragHint")}
|
||
</span>
|
||
</div>
|
||
<div className="mt-4 pt-3 border-t border-gray-200">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<label
|
||
htmlFor="em-highlight-toggle"
|
||
className="text-sm font-semibold text-[#0B1E3F] cursor-pointer"
|
||
>
|
||
{t("executiveMeetings.highlight.title")}
|
||
</label>
|
||
<Switch
|
||
id="em-highlight-toggle"
|
||
checked={highlightPrefs.enabled}
|
||
onCheckedChange={(v) =>
|
||
onHighlightPrefsChange({ ...highlightPrefs, enabled: !!v })
|
||
}
|
||
data-testid="em-highlight-toggle"
|
||
/>
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground mb-2 leading-relaxed">
|
||
{t("executiveMeetings.highlight.hint")}
|
||
</p>
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
{HIGHLIGHT_COLOR_SWATCHES.map((c) => {
|
||
const selected = highlightPrefs.color === c;
|
||
return (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
onClick={() =>
|
||
onHighlightPrefsChange({ ...highlightPrefs, color: c })
|
||
}
|
||
className={
|
||
"w-6 h-6 rounded-full border-2 transition-transform " +
|
||
(selected
|
||
? "border-[#0B1E3F] scale-110"
|
||
: "border-gray-300 hover:scale-110")
|
||
}
|
||
style={{ backgroundColor: c }}
|
||
aria-label={`highlight color ${c}`}
|
||
data-testid={`em-highlight-color-${c}`}
|
||
disabled={!highlightPrefs.enabled}
|
||
/>
|
||
);
|
||
})}
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
onHighlightPrefsChange({ ...DEFAULT_HIGHLIGHT_PREFS })
|
||
}
|
||
className="ml-1 text-[11px] text-[#0B1E3F] underline hover:no-underline disabled:opacity-50"
|
||
data-testid="em-highlight-reset"
|
||
>
|
||
{t("executiveMeetings.highlight.reset")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Sortable column header for the schedule table. The whole <th> is the
|
||
* drag handle so it stays grabbable in any RTL/LTR mode and on touch. The
|
||
* resize handle on the trailing edge stops drag propagation so resizing
|
||
* still works.
|
||
*/
|
||
function SortableHeader({
|
||
col,
|
||
isLast,
|
||
isRtl,
|
||
t,
|
||
showResizeHandle,
|
||
dragEnabled,
|
||
onResize,
|
||
selectAllOverlay,
|
||
}: {
|
||
col: ColumnSetting;
|
||
isLast: boolean;
|
||
isRtl: boolean;
|
||
t: (k: string) => string;
|
||
showResizeHandle: boolean;
|
||
dragEnabled: boolean;
|
||
onResize: (delta: number) => void;
|
||
// Optional tri-state "select all" checkbox rendered as an absolute
|
||
// overlay anchored to the start-corner of this header. The parent
|
||
// passes it only for the first visible column so the checkbox lives
|
||
// in <thead> per the spec, while still gracefully surviving the
|
||
// case where the # column has been hidden via the column customizer.
|
||
selectAllOverlay?: ReactNode;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id: col.id, disabled: !dragEnabled });
|
||
// #320: every schedule column header is centered (was previously
|
||
// start-aligned for everything except # and time). The select-all
|
||
// checkbox in the # column is rendered as an absolute overlay
|
||
// anchored to the start corner, so it doesn't collide with the
|
||
// centered label text.
|
||
const align = "text-center";
|
||
const style: CSSProperties = {
|
||
width: col.width,
|
||
transform: CSS.Transform.toString(transform),
|
||
transition,
|
||
opacity: isDragging ? 0.5 : 1,
|
||
// Only advertise grab cursor when drag is actually enabled — view
|
||
// mode renders the header as static text.
|
||
cursor: dragEnabled ? "grab" : undefined,
|
||
};
|
||
return (
|
||
<th
|
||
ref={setNodeRef}
|
||
className={`relative border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none`}
|
||
style={style}
|
||
data-testid={`em-col-header-${col.id}`}
|
||
{...(dragEnabled ? attributes : {})}
|
||
{...(dragEnabled ? listeners : {})}
|
||
>
|
||
{t(`executiveMeetings.col.${col.id}`)}
|
||
{selectAllOverlay}
|
||
{/* Resize handles only render on large screens AND fine (mouse)
|
||
pointers AND when the user is in edit mode. See showResizeHandles. */}
|
||
{!isLast && showResizeHandle && (
|
||
<ResizeHandle isRtl={isRtl} onResize={onResize} />
|
||
)}
|
||
</th>
|
||
);
|
||
}
|
||
|
||
function RowColorPickerSwatches({
|
||
current,
|
||
onPick,
|
||
t,
|
||
}: {
|
||
current: string;
|
||
onPick: (key: string) => void;
|
||
t: (k: string) => string;
|
||
}) {
|
||
return (
|
||
<>
|
||
<div className="text-[11px] text-muted-foreground mb-2">
|
||
{t("executiveMeetings.rowColor.label")}
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
{ROW_COLOR_OPTIONS.map((opt) => {
|
||
const selected = current === opt.key;
|
||
const isDefault = opt.key === "default";
|
||
return (
|
||
<button
|
||
key={opt.key}
|
||
type="button"
|
||
onClick={() => onPick(opt.key)}
|
||
className={
|
||
"w-6 h-6 rounded-full border-2 transition-transform " +
|
||
(selected
|
||
? "border-[#0B1E3F] scale-110"
|
||
: "border-gray-300 hover:scale-110")
|
||
}
|
||
style={{
|
||
background: isDefault
|
||
? "repeating-linear-gradient(45deg,#fff,#fff 4px,#e5e7eb 4px,#e5e7eb 8px)"
|
||
: opt.bg,
|
||
}}
|
||
aria-label={opt.label}
|
||
data-testid={`em-row-color-${opt.key}`}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Consolidated row-level actions menu. Replaces the previous three
|
||
// separate overlay triggers (Trash, Palette, Combine icons) that all
|
||
// floated inside the same narrow cell and visually collided on small
|
||
// screens / iPad. Now there is exactly one kebab button per row, and
|
||
// it opens a small popover that exposes:
|
||
// 1. Delete row — direct action, fires onDelete()
|
||
// 2. Row color — expands inline to RowColorPickerSwatches
|
||
// 3. Merge cells — expands inline to the merge options list
|
||
// Both expansion views show a "Back" link so the user can return to
|
||
// the main menu without closing the popover.
|
||
//
|
||
// Anchor / positioning rules match the old triggers exactly:
|
||
// - Hover-reveal on desktop, low-opacity-always on touch
|
||
// - Hidden in print
|
||
// - Uses `inline-end-1` so RTL layouts flip the button to the
|
||
// correct corner.
|
||
//
|
||
// The host <td> must carry `relative group` for the positioning +
|
||
// hover reveal to work — every renderCell branch already does.
|
||
//
|
||
// Test ids are preserved on the corresponding menu items so existing
|
||
// Playwright assertions about presence/absence keep working:
|
||
// em-delete-row-{id}, em-row-color-trigger, em-merge-trigger-{id}.
|
||
function RowActionsMenu({
|
||
meetingId,
|
||
isDeleting,
|
||
onDelete,
|
||
rowColorKey,
|
||
onPickRowColor,
|
||
hasStoredMerge,
|
||
existingMergeText,
|
||
mergeColumnLabels,
|
||
onChangeMerge,
|
||
t,
|
||
}: {
|
||
meetingId: number;
|
||
isDeleting: boolean;
|
||
onDelete: () => void;
|
||
rowColorKey: string;
|
||
onPickRowColor: (key: string) => void;
|
||
hasStoredMerge: boolean;
|
||
existingMergeText: string | null;
|
||
// Localized labels of every column currently spanned by the row's
|
||
// stored merge (canonical order). Used by the main-menu Merge cells
|
||
// item to render an inline "Merged: …" badge so users can see the
|
||
// active range without drilling into the merge sub-view. Null when
|
||
// the row has no stored merge.
|
||
mergeColumnLabels: string[] | null;
|
||
onChangeMerge: (
|
||
merge:
|
||
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
|
||
| null,
|
||
) => Promise<void>;
|
||
// Widened from `(k: string) => string` so the main menu can pass
|
||
// interpolation opts (e.g. {{cols}}) for the merge badge label.
|
||
t: (k: string, opts?: Record<string, unknown>) => string;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const [view, setView] = useState<"main" | "color" | "merge">("main");
|
||
|
||
// Always reset to the main menu whenever the popover closes, so the
|
||
// next open lands the user back on Delete/Color/Merge instead of a
|
||
// half-finished sub-view.
|
||
useEffect(() => {
|
||
if (!open) setView("main");
|
||
}, [open]);
|
||
|
||
const applyMerge = async (start: ColumnId, end: ColumnId) => {
|
||
setOpen(false);
|
||
await onChangeMerge({
|
||
mergeStartColumn: start,
|
||
mergeEndColumn: end,
|
||
mergeText: existingMergeText ?? "",
|
||
});
|
||
};
|
||
|
||
const itemCls =
|
||
"w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed";
|
||
|
||
return (
|
||
<Popover open={open} onOpenChange={setOpen}>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
type="button"
|
||
// NOTE: do NOT add onPointerDown stopPropagation here — Radix
|
||
// Popover uses pointerdown to open and our handler would
|
||
// swallow it. The host cell has no drag listeners (only the
|
||
// grip button does), so nothing else needs blocking.
|
||
aria-label={t("executiveMeetings.rowActions.label")}
|
||
title={t("executiveMeetings.rowActions.label")}
|
||
data-testid={`em-row-actions-${meetingId}`}
|
||
className="absolute top-1 inline-end-1 p-1 rounded text-muted-foreground hover:text-[#0B1E3F] hover:bg-white/80 opacity-0 group-hover:opacity-100 focus:opacity-100 [@media(hover:none)_and_(pointer:coarse)]:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:p-1.5 transition-opacity print:hidden"
|
||
>
|
||
<MoreVertical className="w-3.5 h-3.5 [@media(hover:none)_and_(pointer:coarse)]:w-4 [@media(hover:none)_and_(pointer:coarse)]:h-4" />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-56 p-1" align="end">
|
||
{view === "main" && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className={`${itemCls} text-destructive`}
|
||
disabled={isDeleting}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setOpen(false);
|
||
onDelete();
|
||
}}
|
||
data-testid={`em-delete-row-${meetingId}`}
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
|
||
{t("executiveMeetings.schedule.deleteRow")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={itemCls}
|
||
onClick={() => setView("color")}
|
||
data-testid="em-row-color-trigger"
|
||
>
|
||
<Palette className="w-3.5 h-3.5" aria-hidden="true" />
|
||
<span>{t("executiveMeetings.rowColor.label")}</span>
|
||
{/* Inline preview of the row's currently-saved colour so
|
||
users can tell at a glance which colour is set without
|
||
having to expand the swatch sub-view. The "default"
|
||
state shows a hatched pattern (matching the swatch
|
||
picker) to read clearly as "no colour". Decorative —
|
||
the sub-view is the actual control, so this stays
|
||
aria-hidden. */}
|
||
{(() => {
|
||
const opt = ROW_COLOR_OPTIONS.find(
|
||
(o) => o.key === rowColorKey,
|
||
);
|
||
const isDefault = !opt || opt.key === "default";
|
||
return (
|
||
<span
|
||
aria-hidden="true"
|
||
data-testid={`em-row-color-indicator-${meetingId}`}
|
||
data-color-key={opt?.key ?? "default"}
|
||
className="ms-auto w-3 h-3 rounded-full border border-gray-300 shrink-0"
|
||
style={{
|
||
background: isDefault
|
||
? "repeating-linear-gradient(45deg,#fff,#fff 3px,#e5e7eb 3px,#e5e7eb 6px)"
|
||
: opt!.bg,
|
||
}}
|
||
/>
|
||
);
|
||
})()}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={itemCls}
|
||
onClick={() => setView("merge")}
|
||
data-testid={`em-merge-trigger-${meetingId}`}
|
||
>
|
||
<Combine className="w-3.5 h-3.5" aria-hidden="true" />
|
||
<span>{t("executiveMeetings.merge.label")}</span>
|
||
{/* Inline indicator of the row's active merge range, so
|
||
users see which columns are merged without drilling
|
||
into the merge sub-view. Only shown when a merge is
|
||
stored in the DB; truncated visually but readable in
|
||
full via the title tooltip on hover. */}
|
||
{hasStoredMerge && mergeColumnLabels && mergeColumnLabels.length > 0 && (
|
||
<span
|
||
data-testid={`em-merge-indicator-${meetingId}`}
|
||
title={t("executiveMeetings.merge.activeBadge", {
|
||
cols: mergeColumnLabels.join(" + "),
|
||
})}
|
||
className="ms-auto px-1.5 py-0.5 text-[10px] leading-none rounded bg-[#0B1E3F]/10 text-[#0B1E3F] truncate max-w-[120px] shrink-0"
|
||
>
|
||
{t("executiveMeetings.merge.activeBadge", {
|
||
cols: mergeColumnLabels.join(" + "),
|
||
})}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</>
|
||
)}
|
||
{view === "color" && (
|
||
<>
|
||
<BackToMainButton onClick={() => setView("main")} t={t} />
|
||
<div className="px-1 pb-1">
|
||
<RowColorPickerSwatches
|
||
current={rowColorKey}
|
||
onPick={(key) => {
|
||
onPickRowColor(key);
|
||
setOpen(false);
|
||
}}
|
||
t={t}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
{view === "merge" && (
|
||
<>
|
||
<BackToMainButton onClick={() => setView("main")} t={t} />
|
||
<button
|
||
type="button"
|
||
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
|
||
data-testid={`em-merge-opt-meeting-attendees-${meetingId}`}
|
||
onClick={() => applyMerge("meeting", "attendees")}
|
||
>
|
||
{t("executiveMeetings.merge.meetingAttendees")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
|
||
data-testid={`em-merge-opt-meeting-time-${meetingId}`}
|
||
onClick={() => applyMerge("meeting", "time")}
|
||
>
|
||
{t("executiveMeetings.merge.meetingTime")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
|
||
data-testid={`em-merge-opt-all-${meetingId}`}
|
||
onClick={() => applyMerge("number", "time")}
|
||
>
|
||
{t("executiveMeetings.merge.all")}
|
||
</button>
|
||
{hasStoredMerge && (
|
||
<button
|
||
type="button"
|
||
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent text-destructive"
|
||
data-testid={`em-merge-opt-unmerge-${meetingId}`}
|
||
onClick={() => {
|
||
setOpen(false);
|
||
onChangeMerge(null);
|
||
}}
|
||
>
|
||
{t("executiveMeetings.merge.unmerge")}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
// Small "Back" affordance shown at the top of the row-actions submenu
|
||
// views (Row color, Merge cells). RTL-aware: uses ChevronRight in
|
||
// Arabic so the arrow points back toward the main menu visually.
|
||
function BackToMainButton({
|
||
onClick,
|
||
t,
|
||
}: {
|
||
onClick: () => void;
|
||
t: (k: string) => string;
|
||
}) {
|
||
const isRtl =
|
||
typeof document !== "undefined" && document.documentElement.dir === "rtl";
|
||
const ArrowIcon = isRtl ? ChevronRight : ChevronLeft;
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="w-full text-start px-2 py-1.5 text-xs rounded hover:bg-accent text-muted-foreground flex items-center gap-1 mb-0.5"
|
||
onClick={onClick}
|
||
data-testid="em-row-actions-back"
|
||
>
|
||
<ArrowIcon className="w-3.5 h-3.5" aria-hidden="true" />
|
||
{t("executiveMeetings.rowActions.back")}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function MeetingRow({
|
||
meeting,
|
||
isRtl,
|
||
t,
|
||
visibleColumns,
|
||
rowColorKey,
|
||
onPickRowColor,
|
||
canMutate,
|
||
onSaveTitle,
|
||
onSaveAttendeeName,
|
||
onSaveTimes,
|
||
onSaveMerge,
|
||
onDeleteMeeting,
|
||
isDeleting,
|
||
isCurrent,
|
||
highlightColor,
|
||
reordering,
|
||
autoEditTitle = false,
|
||
onAutoEditTitleConsumed,
|
||
pendingAttendee,
|
||
onStartAddAttendee,
|
||
onCommitAddAttendee,
|
||
onCancelAddAttendee,
|
||
bulkSelectable,
|
||
bulkSelected,
|
||
onBulkSelectChange,
|
||
displayNumber,
|
||
}: {
|
||
meeting: Meeting;
|
||
displayNumber?: number;
|
||
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"];
|
||
kind: "person" | "subheading";
|
||
key: number;
|
||
insertAtIndex?: number;
|
||
} | null;
|
||
onStartAddAttendee?: (
|
||
meetingId: number,
|
||
attendanceType: Attendee["attendanceType"],
|
||
kind?: "person" | "subheading",
|
||
insertAtIndex?: number,
|
||
) => void;
|
||
onCommitAddAttendee?: (
|
||
attendanceType: Attendee["attendanceType"],
|
||
html: string,
|
||
kind?: "person" | "subheading",
|
||
insertAtIndex?: number,
|
||
) => Promise<void>;
|
||
onCancelAddAttendee?: () => void;
|
||
bulkSelectable: boolean;
|
||
bulkSelected: boolean;
|
||
onBulkSelectChange: (next: boolean) => void;
|
||
}) {
|
||
const title = isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr;
|
||
const isCancelled = meeting.status === "cancelled";
|
||
const highlight = meeting.isHighlighted === 1;
|
||
|
||
const rowColor = ROW_COLOR_OPTIONS.find((o) => o.key === rowColorKey);
|
||
const rowBg = rowColor?.bg || "";
|
||
|
||
// 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;
|
||
// Localized labels for the canonical columns spanned by the row's
|
||
// stored merge — passed into RowActionsMenu so the main-menu Merge
|
||
// cells item can render an inline "Merged: …" preview. Computed
|
||
// against CANONICAL_MERGE_ORDER (not visibleColumns) so the badge
|
||
// still describes the full saved range even when one of the
|
||
// boundary columns is currently hidden.
|
||
const mergeColumnLabels: string[] | null = hasStoredMerge
|
||
? CANONICAL_MERGE_ORDER.slice(canonStart, canonEnd + 1).map((id) =>
|
||
t(`executiveMeetings.col.${id}`),
|
||
)
|
||
: null;
|
||
const mergeFirstIdx = isMerged ? visibleMergeIndices[0] : -1;
|
||
const mergeSkipSet = isMerged ? new Set(visibleMergeIndices.slice(1)) : null;
|
||
// First visible cell that's NOT part of a merge. The merge trigger
|
||
// for an unmerged row is anchored here so editors can still open the
|
||
// merge menu even when the # column is hidden. We look for the
|
||
// first index in visibleColumns whose canonical id exists (which is
|
||
// every schedule column), so this collapses to `0` in practice but
|
||
// would skip non-mergeable columns if they're added later.
|
||
const firstUnmergedIdx = isMerged ? -1 : visibleColumns.length > 0 ? 0 : -1;
|
||
|
||
// Light tint for the current meeting on the editable cells (meeting,
|
||
// attendees, time). The number cell keeps its solid white background
|
||
// and original text colour so it stays legible — only the rest of the
|
||
// row gets a soft wash of highlightColor. We keep the original text
|
||
// colour everywhere so the row remains readable.
|
||
const tintBg =
|
||
isCurrent && !(highlight || isCancelled)
|
||
? hexToRgba(highlightColor, 0.13)
|
||
: null;
|
||
const tintedCellStyle = (col: ColumnSetting): CSSProperties =>
|
||
tintBg ? { ...cellStyle(col), backgroundColor: tintBg } : cellStyle(col);
|
||
|
||
// Per-row bulk-select checkbox, lifted out of `renderCell` so the
|
||
// merged-cell branch can re-use the same JSX. Anchored to the
|
||
// top-start corner of whichever cell hosts it (the first visible
|
||
// non-merged cell for unmerged rows, or the merged cell itself for
|
||
// merged rows). Anchoring as an absolute overlay (rather than
|
||
// inline inside the # cell) keeps the checkbox available even when
|
||
// the editor has hidden the # column AND leaves the # cell's grip
|
||
// handle sitting in its original position so dnd-kit's
|
||
// drag-to-reorder isn't disturbed by neighbouring controls.
|
||
const bulkSelectOverlay = bulkSelectable ? (
|
||
<div
|
||
className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<Checkbox
|
||
checked={bulkSelected}
|
||
onCheckedChange={(v) => onBulkSelectChange(v === true)}
|
||
aria-label={t("executiveMeetings.schedule.bulkSelectRow")}
|
||
data-testid={`em-row-select-${meeting.id}`}
|
||
className="h-4 w-4 bg-white shadow-sm"
|
||
/>
|
||
</div>
|
||
) : null;
|
||
|
||
const renderCell = (col: ColumnSetting, idx: number): ReactNode => {
|
||
// Anchor the consolidated row-actions menu (delete + row color +
|
||
// merge) to whichever cell is the first visible one on an unmerged
|
||
// row. Using "first visible cell" (instead of always the # cell)
|
||
// keeps the trigger reachable when editors hide the number column
|
||
// via the column customizer. The button is rendered as an
|
||
// absolutely-positioned overlay inside the cell, so every cell type
|
||
// needs `relative group` on its <td> for it to find them.
|
||
const showActionsMenu = canMutate && !isMerged && idx === firstUnmergedIdx;
|
||
const actionsOverlay = showActionsMenu ? (
|
||
<RowActionsMenu
|
||
meetingId={meeting.id}
|
||
isDeleting={isDeleting}
|
||
onDelete={onDeleteMeeting}
|
||
rowColorKey={rowColorKey}
|
||
onPickRowColor={onPickRowColor}
|
||
// Offer Unmerge whenever the row has a stored merge in the DB,
|
||
// even if we're in degraded (non-contiguous) rendering — that's
|
||
// the only way the user can clear the stored state.
|
||
hasStoredMerge={hasStoredMerge}
|
||
existingMergeText={meeting.mergeText ?? null}
|
||
mergeColumnLabels={mergeColumnLabels}
|
||
onChangeMerge={onSaveMerge}
|
||
t={t}
|
||
/>
|
||
) : null;
|
||
// The bulk-select overlay only attaches to the first visible
|
||
// non-merged cell (so it appears once per row, never twice). For
|
||
// merged rows the overlay is rendered separately on the merged
|
||
// <td> (see `renderCells` below) so it remains reachable even
|
||
// when the merge swallows what would otherwise be the first
|
||
// visible cell.
|
||
const cellBulkOverlay =
|
||
!isMerged && idx === firstUnmergedIdx ? bulkSelectOverlay : null;
|
||
switch (col.id) {
|
||
case "number":
|
||
// Priority for the # cell background:
|
||
// 1. highlight || isCancelled → solid red badge (className wins)
|
||
// 2. tintBg (current meeting wash) → solid highlightColor with
|
||
// white text so the number stays legible.
|
||
// 3. rowBg (user-picked row color) → match the rest of the row
|
||
// so the chosen color wraps the whole row including the
|
||
// number cell. The palette is all light tints, so the
|
||
// existing dark `text-[#0B1E3F]` from numberCellCls stays
|
||
// readable.
|
||
// 4. otherwise → fall back to the white background already in
|
||
// numberCellCls.
|
||
// We omit any inline backgroundColor in case (1) so the
|
||
// bg-red-600 utility class isn't overridden by an inline style.
|
||
const numberCellInlineStyle: CSSProperties = (() => {
|
||
if (highlight || isCancelled) return cellStyle(col);
|
||
if (tintBg) {
|
||
return {
|
||
...cellStyle(col),
|
||
backgroundColor: highlightColor,
|
||
color: "white",
|
||
};
|
||
}
|
||
if (rowBg) {
|
||
return { ...cellStyle(col), backgroundColor: rowBg };
|
||
}
|
||
return cellStyle(col);
|
||
})();
|
||
return (
|
||
<td
|
||
key="number"
|
||
className={`relative group ${numberCellCls}`}
|
||
style={numberCellInlineStyle}
|
||
>
|
||
<div className="flex items-center justify-center gap-1">
|
||
{canMutate && (
|
||
<button
|
||
type="button"
|
||
// The grip handle is hover-only on devices that have a
|
||
// real hover (desktop mouse), but always visible at ~40%
|
||
// opacity on touch devices that have no hover state — so
|
||
// iPad users can actually find what to press to reorder
|
||
// a row. The tap target is also enlarged on touch with a
|
||
// small padding so the long-press lands reliably.
|
||
// touch-action:none stays on the handle itself so the
|
||
// browser hands touch events to dnd-kit instead of
|
||
// claiming them as a vertical scroll; the rest of the
|
||
// row keeps default touch-action so page scrolling works
|
||
// normally outside of the handle.
|
||
className="opacity-0 group-hover:opacity-70 hover:opacity-100 [@media(hover:none)_and_(pointer:coarse)]:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:group-hover:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:p-1.5 cursor-grab active:cursor-grabbing touch-none print:hidden"
|
||
aria-label={t("executiveMeetings.dragRow")}
|
||
data-testid={`em-row-grip-${meeting.id}`}
|
||
{...listeners}
|
||
{...attributes}
|
||
>
|
||
<GripVertical className="w-3.5 h-3.5 [@media(hover:none)_and_(pointer:coarse)]:w-4 [@media(hover:none)_and_(pointer:coarse)]:h-4" />
|
||
</button>
|
||
)}
|
||
<span>{displayNumber ?? meeting.dailyNumber}</span>
|
||
</div>
|
||
{cellBulkOverlay}
|
||
{actionsOverlay}
|
||
</td>
|
||
);
|
||
case "meeting":
|
||
return (
|
||
<td
|
||
key="meeting"
|
||
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
|
||
style={tintedCellStyle(col)}
|
||
>
|
||
<EditableCell
|
||
value={title}
|
||
onSave={onSaveTitle}
|
||
ariaLabel={t("executiveMeetings.editMeetingTitle")}
|
||
dir={isRtl ? "rtl" : "ltr"}
|
||
className="font-medium leading-snug break-words"
|
||
disabled={!canMutate}
|
||
testId={`em-edit-title-${meeting.id}`}
|
||
startInEditMode={autoEditTitle}
|
||
onStartedEditing={onAutoEditTitleConsumed}
|
||
/>
|
||
{meeting.location && (
|
||
<div className="text-[11px] text-muted-foreground mt-0.5 break-words">
|
||
{meeting.location}
|
||
</div>
|
||
)}
|
||
{cellBulkOverlay}
|
||
{actionsOverlay}
|
||
</td>
|
||
);
|
||
case "attendees":
|
||
return (
|
||
<td
|
||
key="attendees"
|
||
className="border border-gray-300 px-3 py-3 align-middle relative group"
|
||
style={tintedCellStyle(col)}
|
||
>
|
||
<AttendeesCell
|
||
meeting={meeting}
|
||
t={t}
|
||
canMutate={canMutate}
|
||
onSaveAttendeeName={onSaveAttendeeName}
|
||
pendingAttendee={pendingAttendee ?? null}
|
||
onStartAddAttendee={onStartAddAttendee}
|
||
onCommitAddAttendee={onCommitAddAttendee}
|
||
onCancelAddAttendee={onCancelAddAttendee}
|
||
/>
|
||
{cellBulkOverlay}
|
||
{actionsOverlay}
|
||
</td>
|
||
);
|
||
case "time":
|
||
return (
|
||
<td
|
||
key="time"
|
||
className="border border-gray-300 px-3 py-3 align-middle text-center relative group"
|
||
style={tintedCellStyle(col)}
|
||
>
|
||
<TimeRangeCell
|
||
meeting={meeting}
|
||
t={t}
|
||
lang={isRtl ? "ar" : "en"}
|
||
canMutate={canMutate}
|
||
onSaveTimes={onSaveTimes}
|
||
/>
|
||
{cellBulkOverlay}
|
||
{actionsOverlay}
|
||
</td>
|
||
);
|
||
}
|
||
};
|
||
|
||
// When a merge is active we render a single <td colSpan=N> with an
|
||
// EditableCell for `mergeText`, and skip the cells the merge covers
|
||
// after the first. The merged cell hosts its own RowActionsMenu so users
|
||
// can always unmerge or re-merge — even when the # column is hidden.
|
||
// Originals stay in the DB so unmerge restores them.
|
||
const renderCells = (): ReactNode[] => {
|
||
if (!isMerged) {
|
||
return visibleColumns.map((c, idx) => renderCell(c, idx));
|
||
}
|
||
const cells: ReactNode[] = [];
|
||
const span = visibleMergeIndices.length;
|
||
visibleColumns.forEach((col, idx) => {
|
||
if (mergeSkipSet?.has(idx)) return;
|
||
if (idx === mergeFirstIdx) {
|
||
// Sum widths of the spanned visible columns so layout in
|
||
// table-fixed mode reserves the right horizontal space.
|
||
const totalWidth = visibleMergeIndices.reduce(
|
||
(sum, i) => sum + (visibleColumns[i].width || 0),
|
||
0,
|
||
);
|
||
cells.push(
|
||
<td
|
||
key="merge"
|
||
colSpan={span}
|
||
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
|
||
style={{
|
||
width: totalWidth,
|
||
overflow: "hidden",
|
||
...(tintBg ? { backgroundColor: tintBg } : null),
|
||
}}
|
||
data-testid={`em-merge-cell-${meeting.id}`}
|
||
>
|
||
<EditableCell
|
||
value={meeting.mergeText ?? ""}
|
||
onSave={async (html) => {
|
||
await onSaveMerge({
|
||
mergeStartColumn: meeting.mergeStartColumn as ColumnId,
|
||
mergeEndColumn: meeting.mergeEndColumn as ColumnId,
|
||
mergeText: html,
|
||
});
|
||
}}
|
||
ariaLabel={t("executiveMeetings.merge.editLabel")}
|
||
dir={isRtl ? "rtl" : "ltr"}
|
||
className="font-medium leading-snug break-words"
|
||
disabled={!canMutate}
|
||
placeholder={t("executiveMeetings.merge.placeholder")}
|
||
testId={`em-merge-edit-${meeting.id}`}
|
||
/>
|
||
{bulkSelectOverlay}
|
||
{canMutate && (
|
||
<RowActionsMenu
|
||
meetingId={meeting.id}
|
||
isDeleting={isDeleting}
|
||
onDelete={onDeleteMeeting}
|
||
rowColorKey={rowColorKey}
|
||
onPickRowColor={onPickRowColor}
|
||
hasStoredMerge
|
||
existingMergeText={meeting.mergeText ?? null}
|
||
mergeColumnLabels={mergeColumnLabels}
|
||
onChangeMerge={onSaveMerge}
|
||
t={t}
|
||
/>
|
||
)}
|
||
</td>,
|
||
);
|
||
} else {
|
||
cells.push(renderCell(col, idx));
|
||
}
|
||
});
|
||
return cells;
|
||
};
|
||
|
||
// Combine row background with highlight tint. When the row is the current
|
||
// meeting we apply a soft tint (color at low opacity) plus a coloured ring
|
||
// via box-shadow (rings on <tr> don't render across cells; box-shadow does).
|
||
const baseBg = rowBg || "white";
|
||
// The "selected for bulk delete" ring stacks ON TOP of the existing
|
||
// current-meeting ring when both apply. Inset shadows compose from
|
||
// left to right (top of stack first), so we list the selection ring
|
||
// first so it's the visible outer outline. Color is the same brand
|
||
// navy used elsewhere in the page so it reads as a deliberate state.
|
||
const selectionShadow = bulkSelected ? "inset 0 0 0 2px #0B1E3F" : null;
|
||
const currentShadow = isCurrent
|
||
? `inset 0 0 0 2px ${highlightColor}`
|
||
: null;
|
||
const composedShadow = [selectionShadow, currentShadow]
|
||
.filter(Boolean)
|
||
.join(", ");
|
||
const trStyle: CSSProperties = {
|
||
backgroundColor: baseBg,
|
||
transform: CSS.Transform.toString(transform),
|
||
transition,
|
||
opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
|
||
boxShadow: composedShadow || undefined,
|
||
};
|
||
|
||
return (
|
||
<tr
|
||
ref={setNodeRef}
|
||
className="group"
|
||
style={trStyle}
|
||
data-testid={`em-row-${meeting.id}`}
|
||
data-current-meeting={isCurrent ? "true" : undefined}
|
||
data-bulk-selected={bulkSelected ? "true" : undefined}
|
||
>
|
||
{renderCells()}
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
// Canonical schedule column order for cell-merge ranges. MUST stay in
|
||
// sync with the API's MERGE_COLUMNS array in
|
||
// artifacts/api-server/src/routes/executive-meetings.ts. We use this
|
||
// (instead of `visibleColumns`) when computing whether a stored merge
|
||
// overlaps the currently visible columns, so that hiding one boundary
|
||
// of the range still lets the merged cell render across whichever
|
||
// columns inside the range remain visible.
|
||
const CANONICAL_MERGE_ORDER: ColumnId[] = [
|
||
"number",
|
||
"meeting",
|
||
"attendees",
|
||
"time",
|
||
];
|
||
function canonicalMergeIndex(id: ColumnId | string | null | undefined): number {
|
||
if (id == null) return -1;
|
||
return CANONICAL_MERGE_ORDER.indexOf(id as ColumnId);
|
||
}
|
||
|
||
// Convert a #RRGGBB or #RGB hex color into an rgba() string with the
|
||
// given alpha. Used to derive the soft tint applied to the current
|
||
// meeting row from the user's highlight color preference. Falls back
|
||
// to the input string unchanged if it isn't a recognised hex.
|
||
function hexToRgba(hex: string, alpha: number): string {
|
||
const m3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(hex);
|
||
const m6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||
let r = 0;
|
||
let g = 0;
|
||
let b = 0;
|
||
if (m6) {
|
||
r = parseInt(m6[1], 16);
|
||
g = parseInt(m6[2], 16);
|
||
b = parseInt(m6[3], 16);
|
||
} else if (m3) {
|
||
r = parseInt(m3[1] + m3[1], 16);
|
||
g = parseInt(m3[2] + m3[2], 16);
|
||
b = parseInt(m3[3] + m3[3], 16);
|
||
} else {
|
||
return hex;
|
||
}
|
||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||
}
|
||
|
||
function TimeRangeCell({
|
||
meeting,
|
||
t,
|
||
lang,
|
||
canMutate,
|
||
onSaveTimes,
|
||
}: {
|
||
meeting: Meeting;
|
||
t: (k: string) => string;
|
||
lang: "ar" | "en";
|
||
canMutate: boolean;
|
||
onSaveTimes: (
|
||
startTime: string | null,
|
||
endTime: string | null,
|
||
) => Promise<void>;
|
||
}) {
|
||
const { toast } = useToast();
|
||
const startSaved = meeting.startTime ? meeting.startTime.slice(0, 5) : "";
|
||
const endSaved = meeting.endTime ? meeting.endTime.slice(0, 5) : "";
|
||
|
||
const [editing, setEditing] = useState(false);
|
||
const [start, setStart] = useState(startSaved);
|
||
const [end, setEnd] = useState(endSaved);
|
||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
// Imperative handles to the start/end pickers. The inline editor
|
||
// reads the live draft (typed text + AM/PM toggle) via commit()
|
||
// when saving, instead of relying on React state — same
|
||
// belt-and-braces pattern task #308 used to dodge "Enter pressed
|
||
// before React's batched onChange flush" races.
|
||
const startPickerRef = useRef<TimePicker12hHandle | null>(null);
|
||
const endPickerRef = useRef<TimePicker12hHandle | null>(null);
|
||
|
||
// Track the last `startSaved`/`endSaved` we've observed so the sync
|
||
// effect below only fires when the *saved props themselves change*,
|
||
// not on every `editing` flip. This is the fix for task #316's
|
||
// snap-back race: `save()` writes the optimistic times, then
|
||
// `setEditing(false)` triggered a re-run of the prior
|
||
// `[editing, startSaved, endSaved]` effect that overwrote the
|
||
// optimistic draft with the still-stale `startSaved` (the GET
|
||
// refetch hadn't landed yet), making the next click on the cell
|
||
// re-open the picker with empty fields. Comparing against a ref
|
||
// ensures we ONLY re-seed when the upstream meetings cache (or a
|
||
// date change) actually delivers new values.
|
||
const lastSyncedRef = useRef({ start: startSaved, end: endSaved });
|
||
// `editing` flips false synchronously when save() starts, but the
|
||
// PATCH (and any setQueryData rollback the parent fires from its
|
||
// catch branch) can shuttle `startSaved`/`endSaved` underneath us
|
||
// BEFORE our own catch flips `editing` back to true. Without this
|
||
// ref, the sync effect below would see `editing=false` plus a new
|
||
// `startSaved` (e.g. the rolled-back original) and overwrite the
|
||
// user's draft just before we re-open the editor — exactly the
|
||
// snap-back the test catches. Mute the sync effect for the entire
|
||
// save round-trip and release once we know the final outcome.
|
||
const savingRef = useRef(false);
|
||
|
||
// If the saved times change while we're not editing (e.g. another tab
|
||
// updated them, or the user picked a different date), sync local state.
|
||
useEffect(() => {
|
||
if (editing) return; // never fight the user mid-edit
|
||
if (savingRef.current) return; // save round-trip owns the values
|
||
if (
|
||
lastSyncedRef.current.start === startSaved &&
|
||
lastSyncedRef.current.end === endSaved
|
||
) {
|
||
return; // saved values are unchanged; do not snap-back the draft
|
||
}
|
||
lastSyncedRef.current = { start: startSaved, end: endSaved };
|
||
setStart(startSaved);
|
||
setEnd(endSaved);
|
||
}, [editing, startSaved, endSaved]);
|
||
|
||
useEffect(() => {
|
||
if (editing) {
|
||
// The picker auto-focuses its text input via the autoFocus prop
|
||
// on mount; keep an explicit focus() call here too so re-entry
|
||
// edge cases (e.g. clicking the cell again while React is mid-
|
||
// commit) still land focus on the start input.
|
||
startPickerRef.current?.focus();
|
||
startPickerRef.current?.select();
|
||
}
|
||
}, [editing]);
|
||
|
||
const enterEdit = useCallback(() => {
|
||
if (!canMutate) return;
|
||
setEditing(true);
|
||
}, [canMutate]);
|
||
|
||
const cancel = useCallback(() => {
|
||
setStart(startSaved);
|
||
setEnd(endSaved);
|
||
setEditing(false);
|
||
}, [startSaved, endSaved]);
|
||
|
||
// If the global Edit toggle flips off (or the user loses permission)
|
||
// while the time inputs are open, drop the unsaved draft and exit
|
||
// edit mode immediately so the cell snaps back to its read-only
|
||
// display instead of stranding stale time inputs in view mode.
|
||
useEffect(() => {
|
||
if (!canMutate && editing) {
|
||
setStart(startSaved);
|
||
setEnd(endSaved);
|
||
setEditing(false);
|
||
}
|
||
}, [canMutate, editing, startSaved, endSaved]);
|
||
|
||
const save = useCallback(async () => {
|
||
// Read the live drafts from each picker's commit() handle. This
|
||
// returns the canonical "HH:mm" 24h value derived from the typed
|
||
// text PLUS the AM/PM toggle — empty input is `value: null,
|
||
// error: null`, an unparseable typed value is `error: "invalid"`,
|
||
// and a 1–12 hour without an AM/PM pick is `error: "ambiguous"`
|
||
// (the core fix for the "1:15 silently saved as AM" bug).
|
||
const startResult = startPickerRef.current?.commit() ?? {
|
||
value: null,
|
||
error: null as null,
|
||
};
|
||
const endResult = endPickerRef.current?.commit() ?? {
|
||
value: null,
|
||
error: null as null,
|
||
};
|
||
|
||
// Surface the picker's validation errors as toasts and keep the
|
||
// editor open with focus on the offending input — never silently
|
||
// discard the user's typing or guess the period for them.
|
||
if (startResult.error === "invalid") {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.timeParseError"),
|
||
variant: "destructive",
|
||
});
|
||
startPickerRef.current?.focus();
|
||
startPickerRef.current?.select();
|
||
return;
|
||
}
|
||
if (startResult.error === "ambiguous") {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.timeAmbiguousError"),
|
||
variant: "destructive",
|
||
});
|
||
startPickerRef.current?.focus();
|
||
return;
|
||
}
|
||
if (endResult.error === "invalid") {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.timeParseError"),
|
||
variant: "destructive",
|
||
});
|
||
endPickerRef.current?.focus();
|
||
endPickerRef.current?.select();
|
||
return;
|
||
}
|
||
if (endResult.error === "ambiguous") {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.timeAmbiguousError"),
|
||
variant: "destructive",
|
||
});
|
||
endPickerRef.current?.focus();
|
||
return;
|
||
}
|
||
|
||
const newStart = startResult.value;
|
||
const newEnd = endResult.value;
|
||
|
||
if (newStart === (startSaved || null) && newEnd === (endSaved || null)) {
|
||
setEditing(false);
|
||
return;
|
||
}
|
||
if (newStart && newEnd && newStart > newEnd) {
|
||
// Surface the validation error and keep the user in edit mode so they
|
||
// can correct it without losing the values they just typed.
|
||
toast({
|
||
title: t("executiveMeetings.schedule.timeOrderError"),
|
||
variant: "destructive",
|
||
});
|
||
startPickerRef.current?.focus();
|
||
return;
|
||
}
|
||
// Snap the controlled state to the canonical "HH:MM" so the
|
||
// collapsed display (and any subsequent re-open of the editor)
|
||
// shows the same value the server now holds, even if the user
|
||
// typed a non-canonical shape like "9:30" or "0930".
|
||
setStart(newStart ?? "");
|
||
setEnd(newEnd ?? "");
|
||
// Mark the optimistic values as the "last synced" baseline so the
|
||
// sync effect above doesn't immediately undo them when `editing`
|
||
// flips false on the next line. The eventual GET refetch will
|
||
// deliver the same canonical values and the ref equality check
|
||
// will short-circuit.
|
||
lastSyncedRef.current = { start: newStart ?? "", end: newEnd ?? "" };
|
||
// Close the editor BEFORE awaiting the PATCH round-trip. Combined
|
||
// with the parent `saveTimes`'s optimistic cache write, this
|
||
// collapses the cell to its read-only display showing the new
|
||
// times in the very next React commit — no waiting on the
|
||
// network. On failure the catch branch re-opens the editor with
|
||
// the user's draft intact (see below).
|
||
savingRef.current = true;
|
||
setEditing(false);
|
||
try {
|
||
await onSaveTimes(newStart, newEnd);
|
||
savingRef.current = false;
|
||
} catch (err) {
|
||
savingRef.current = false;
|
||
// The parent `saveTimes` handler is responsible for surfacing the
|
||
// error toast AND for rolling back the optimistic cache write.
|
||
// For the server-side time-order validation error we re-open the
|
||
// editor with the user's draft values intact so they can fix
|
||
// the typo without re-entering both times, then refocus the
|
||
// start input. For any other failure we drop the draft, snap
|
||
// back to the previously-saved values, and re-open the editor
|
||
// so the user knows the save did not stick and can retry.
|
||
if (err instanceof Error && err.name === "TimeOrderError") {
|
||
setEditing(true);
|
||
startPickerRef.current?.focus();
|
||
return;
|
||
}
|
||
// Preserve the user's draft (`start` / `end` keep the values
|
||
// we optimistically wrote above) so they can fix and retry
|
||
// without retyping. The PARENT `saveTimes` is responsible for
|
||
// rolling back the optimistic cache write so the underlying
|
||
// `meeting.startTime` / `meeting.endTime` props snap back to
|
||
// the previously-saved values; the sync effect above will not
|
||
// overwrite our draft because `editing` is `true` again.
|
||
setEditing(true);
|
||
}
|
||
}, [startSaved, endSaved, onSaveTimes, toast, t]);
|
||
|
||
const onFocusOut = (e: React.FocusEvent<HTMLDivElement>) => {
|
||
if (!editing) return;
|
||
const next = e.relatedTarget as Node | null;
|
||
if (next && wrapperRef.current?.contains(next)) return;
|
||
if (blurTimerRef.current) clearTimeout(blurTimerRef.current);
|
||
blurTimerRef.current = setTimeout(() => {
|
||
void save();
|
||
}, 150);
|
||
};
|
||
|
||
const onFocusIn = () => {
|
||
if (blurTimerRef.current) {
|
||
clearTimeout(blurTimerRef.current);
|
||
blurTimerRef.current = null;
|
||
}
|
||
};
|
||
|
||
if (!editing) {
|
||
const hasAny = !!(meeting.startTime || meeting.endTime);
|
||
const display = hasAny
|
||
? `${formatTime(meeting.startTime, lang)} – ${formatTime(meeting.endTime, lang)}`
|
||
: "—";
|
||
return (
|
||
<div
|
||
role={canMutate ? "button" : undefined}
|
||
tabIndex={canMutate ? 0 : -1}
|
||
aria-label={t("executiveMeetings.col.time")}
|
||
// Force LTR so the time range always reads "10:00 – 11:00"
|
||
// (start on the left, end on the right) regardless of the
|
||
// surrounding RTL Arabic layout.
|
||
dir="ltr"
|
||
className={
|
||
"font-mono text-[#0B1E3F] whitespace-nowrap leading-tight inline-block " +
|
||
(canMutate
|
||
? "cursor-text hover:bg-blue-50/50 focus:bg-blue-50/50 focus:outline-none rounded transition-colors px-1"
|
||
: "")
|
||
}
|
||
onClick={enterEdit}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
enterEdit();
|
||
}
|
||
}}
|
||
data-testid={`em-time-${meeting.id}`}
|
||
>
|
||
{display}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Stable per-meeting input ids so the <label htmlFor=...> hookup is
|
||
// unique across meeting rows in the schedule. Without this, screen
|
||
// readers and click-to-focus on the label would jump to the first
|
||
// matching id elsewhere in the document.
|
||
const startInputId = `em-time-start-input-${meeting.id}`;
|
||
const endInputId = `em-time-end-input-${meeting.id}`;
|
||
|
||
return (
|
||
<div
|
||
ref={wrapperRef}
|
||
onFocus={onFocusIn}
|
||
onBlur={onFocusOut}
|
||
// Lock the editor to LTR so start-on-the-left / end-on-the-right
|
||
// is preserved regardless of the surrounding RTL Arabic page
|
||
// layout. Without this, the two time inputs, the dash, and the
|
||
// save/cancel buttons get visually mirrored, making it impossible
|
||
// for the user to tell which input is which.
|
||
dir="ltr"
|
||
// `flex-wrap` lets the start picker, the en-dash, and the end
|
||
// picker fall onto a second line when the cell is narrower than
|
||
// their combined width — this is what makes the editor usable
|
||
// on a 375px iPhone viewport, where the prior single-row
|
||
// `inline-flex` layout pushed the end picker out of view.
|
||
className="flex flex-wrap items-end justify-center gap-x-1.5 gap-y-1 font-mono"
|
||
data-testid={`em-time-${meeting.id}`}
|
||
data-editing="true"
|
||
>
|
||
<div className="flex flex-col items-center">
|
||
<label
|
||
htmlFor={startInputId}
|
||
className="text-[11px] font-medium leading-none text-muted-foreground mb-1 select-none"
|
||
>
|
||
{t("executiveMeetings.schedule.timeStartShort")}
|
||
</label>
|
||
{/* The 12-hour picker keeps the legacy `em-time-start-${id}`
|
||
testid on its hour input so the existing keyboard-editing
|
||
e2e suite still resolves it. The new minute input and
|
||
AM/PM toggle expose their own per-meeting testids
|
||
(`em-time-start-${id}-minute`,
|
||
`em-time-start-period-am-${id}` / `-pm-${id}`) for tests
|
||
that need to drive them explicitly. */}
|
||
<TimePicker12h
|
||
ref={startPickerRef}
|
||
inputId={startInputId}
|
||
value={start}
|
||
onChange={setStart}
|
||
onCommit={() => void save()}
|
||
onCancel={cancel}
|
||
ariaLabel={t("executiveMeetings.schedule.timeStart")}
|
||
groupLabel={t("executiveMeetings.timeEditor.periodGroupStart")}
|
||
amLabel={t("executiveMeetings.timeEditor.am")}
|
||
pmLabel={t("executiveMeetings.timeEditor.pm")}
|
||
inputTestId={`em-time-start-${meeting.id}`}
|
||
minuteTestId={`em-time-start-minute-${meeting.id}`}
|
||
amTestId={`em-time-start-period-am-${meeting.id}`}
|
||
pmTestId={`em-time-start-period-pm-${meeting.id}`}
|
||
autoFocus
|
||
size="inline"
|
||
/>
|
||
</div>
|
||
<span className="text-muted-foreground pb-2 self-center hidden sm:inline">–</span>
|
||
<div className="flex flex-col items-center">
|
||
<label
|
||
htmlFor={endInputId}
|
||
className="text-[11px] font-medium leading-none text-muted-foreground mb-1 select-none"
|
||
>
|
||
{t("executiveMeetings.schedule.timeEndShort")}
|
||
</label>
|
||
<TimePicker12h
|
||
ref={endPickerRef}
|
||
inputId={endInputId}
|
||
value={end}
|
||
onChange={setEnd}
|
||
onCommit={() => void save()}
|
||
onCancel={cancel}
|
||
ariaLabel={t("executiveMeetings.schedule.timeEnd")}
|
||
groupLabel={t("executiveMeetings.timeEditor.periodGroupEnd")}
|
||
amLabel={t("executiveMeetings.timeEditor.am")}
|
||
pmLabel={t("executiveMeetings.timeEditor.pm")}
|
||
inputTestId={`em-time-end-${meeting.id}`}
|
||
minuteTestId={`em-time-end-minute-${meeting.id}`}
|
||
amTestId={`em-time-end-period-am-${meeting.id}`}
|
||
pmTestId={`em-time-end-period-pm-${meeting.id}`}
|
||
size="inline"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => void save()}
|
||
aria-label={t("common.save")}
|
||
className="ms-0.5 h-8 w-8 inline-flex items-center justify-center rounded text-green-700 hover:bg-green-50 focus:bg-green-50 focus:outline-none self-end"
|
||
data-testid={`em-time-save-${meeting.id}`}
|
||
>
|
||
<Check className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={cancel}
|
||
aria-label={t("common.cancel")}
|
||
className="h-8 w-8 inline-flex items-center justify-center rounded text-red-700 hover:bg-red-50 focus:bg-red-50 focus:outline-none self-end"
|
||
data-testid={`em-time-cancel-${meeting.id}`}
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AttendeesCell({
|
||
meeting,
|
||
t,
|
||
canMutate = false,
|
||
onSaveAttendeeName,
|
||
pendingAttendee = null,
|
||
onStartAddAttendee,
|
||
onCommitAddAttendee,
|
||
onCancelAddAttendee,
|
||
}: {
|
||
meeting: Meeting;
|
||
t: (k: string) => string;
|
||
canMutate?: boolean;
|
||
onSaveAttendeeName?: (idx: number, html: string) => Promise<void>;
|
||
pendingAttendee?: {
|
||
meetingId: number;
|
||
attendanceType: Attendee["attendanceType"];
|
||
kind: "person" | "subheading";
|
||
key: number;
|
||
insertAtIndex?: number;
|
||
} | null;
|
||
onStartAddAttendee?: (
|
||
meetingId: number,
|
||
attendanceType: Attendee["attendanceType"],
|
||
kind?: "person" | "subheading",
|
||
insertAtIndex?: number,
|
||
) => void;
|
||
onCommitAddAttendee?: (
|
||
attendanceType: Attendee["attendanceType"],
|
||
html: string,
|
||
kind?: "person" | "subheading",
|
||
insertAtIndex?: number,
|
||
) => Promise<void>;
|
||
onCancelAddAttendee?: () => void;
|
||
}) {
|
||
// Build (attendee, originalIndex) tuples so attendees can be sliced into
|
||
// virtual/internal/external groups while still referring back to their
|
||
// index in `meeting.attendees` (needed by the PUT save endpoint). Each
|
||
// tuple keeps both person and subheading rows; AttendeeFlow is in charge
|
||
// of skipping subheadings when computing the running "1-, 2-…" index.
|
||
const tagged = meeting.attendees.map((a, i) => ({ a, i }));
|
||
const virtual = tagged.filter(({ a }) => a.attendanceType === "virtual");
|
||
const internal = tagged.filter(({ a }) => a.attendanceType === "internal");
|
||
const external = tagged.filter(({ a }) => a.attendanceType === "external");
|
||
// A subheading belongs to exactly one attendance-type group, so we
|
||
// gate the split layout on group existence (any row, person or
|
||
// subheading) rather than on person count. Otherwise a group that
|
||
// currently contains only subheadings (e.g. after the user deletes
|
||
// all of its persons) would silently fold its rows into a sibling
|
||
// group and lose the user's intended placement.
|
||
const hasSplit =
|
||
virtual.length > 0 && (internal.length > 0 || external.length > 0);
|
||
|
||
const platformLabel: Record<Meeting["platform"], string> = {
|
||
none: "",
|
||
webex: "Webex",
|
||
teams: "Teams",
|
||
zoom: "Zoom",
|
||
other: t("executiveMeetings.virtual"),
|
||
};
|
||
|
||
// pendingAttendee is GLOBAL — it's the single ghost row open anywhere on
|
||
// the page. So we must distinguish:
|
||
// * "is the ghost in THIS row, in THIS group?" — controls whether to
|
||
// render the ghost EditableCell here.
|
||
// * "is a ghost open ANYWHERE on the page?" — controls whether to hide
|
||
// the "+" buttons in this row, so the user can't orphan their typing
|
||
// by clicking "+" in a different row while one is already open.
|
||
const isPendingForThisMeeting =
|
||
!!pendingAttendee && pendingAttendee.meetingId === meeting.id;
|
||
// True when the ghost is open in THIS meeting + group, regardless of
|
||
// kind. Used by the parent layout to decide whether to render the
|
||
// group at all (a brand-new "+ subheading" inside a so-far-empty
|
||
// virtual group still needs the virtual section to mount).
|
||
const isPendingForType = (type: Attendee["attendanceType"]) =>
|
||
isPendingForThisMeeting && pendingAttendee!.attendanceType === type;
|
||
const hasAnyPending = !!pendingAttendee;
|
||
|
||
const startAdd = onStartAddAttendee
|
||
? (
|
||
type: Attendee["attendanceType"],
|
||
kind: "person" | "subheading" = "person",
|
||
insertAtIndex?: number,
|
||
) => onStartAddAttendee(meeting.id, type, kind, insertAtIndex)
|
||
: undefined;
|
||
|
||
const commitAdd = onCommitAddAttendee;
|
||
const cancelAdd = onCancelAddAttendee;
|
||
|
||
const flowProps = {
|
||
canMutate,
|
||
onSaveAttendeeName,
|
||
pendingKind: (pendingAttendee?.kind ?? "person") as "person" | "subheading",
|
||
isPending: false,
|
||
pendingKey: pendingAttendee?.key ?? 0,
|
||
// Tells AttendeeFlow where in the full meeting.attendees array the
|
||
// pending ghost belongs. Used both to render the ghost at the right
|
||
// slot and to forward the value through to onCommitAdd so the splice
|
||
// in commitAddAttendee lands in the same place.
|
||
pendingInsertAtIndex: pendingAttendee?.insertAtIndex,
|
||
onStartAdd: hasAnyPending ? undefined : startAdd,
|
||
chainStartAdd: startAdd,
|
||
onCommitAdd: commitAdd,
|
||
onCancelAdd: cancelAdd,
|
||
addLabel: t("executiveMeetings.schedule.addAttendee"),
|
||
addSubheadingLabel: t("executiveMeetings.schedule.addSubheading"),
|
||
editAriaLabel: t("executiveMeetings.schedule.editAttendeeAria"),
|
||
newAriaLabel: t("executiveMeetings.schedule.newAttendeeAria"),
|
||
editSubheadingAriaLabel: t(
|
||
"executiveMeetings.schedule.editSubheadingAria",
|
||
),
|
||
newSubheadingAriaLabel: t(
|
||
"executiveMeetings.schedule.newSubheadingAria",
|
||
),
|
||
meetingId: meeting.id,
|
||
};
|
||
|
||
if (hasSplit) {
|
||
// Split-mode cells render ONE cell-level "+ عنوان فرعي" chip below
|
||
// all groups (see Task #234), so each per-group AttendeeFlow must
|
||
// suppress its own trailing subheading chip. Per-group "+" person
|
||
// chips and the after-section chips are unaffected.
|
||
return (
|
||
<div className="space-y-2">
|
||
{(virtual.length > 0 || isPendingForType("virtual")) && (
|
||
<AttendeeGroup
|
||
label={
|
||
meeting.platform !== "none"
|
||
? `${t("executiveMeetings.virtualAttendance")} — ${platformLabel[meeting.platform]}`
|
||
: t("executiveMeetings.virtualAttendance")
|
||
}
|
||
items={virtual}
|
||
addType="virtual"
|
||
{...flowProps}
|
||
isPending={isPendingForType("virtual")}
|
||
suppressTrailingSubheadingChip
|
||
/>
|
||
)}
|
||
{(internal.length > 0 || isPendingForType("internal")) && (
|
||
<AttendeeGroup
|
||
label={t("executiveMeetings.internalAttendance")}
|
||
items={internal}
|
||
addType="internal"
|
||
{...flowProps}
|
||
isPending={isPendingForType("internal")}
|
||
suppressTrailingSubheadingChip
|
||
/>
|
||
)}
|
||
{(external.length > 0 || isPendingForType("external")) && (
|
||
<AttendeeGroup
|
||
label={t("executiveMeetings.externalAttendance")}
|
||
items={external}
|
||
addType="external"
|
||
{...flowProps}
|
||
isPending={isPendingForType("external")}
|
||
suppressTrailingSubheadingChip
|
||
/>
|
||
)}
|
||
{/* Add chips for groups that don't yet exist for this meeting. */}
|
||
{canMutate && !hasAnyPending && startAdd && (
|
||
<div className="flex flex-wrap justify-center gap-1.5 print:hidden text-xs">
|
||
{virtual.length === 0 && (
|
||
<AddAttendeeChip
|
||
onClick={() => startAdd("virtual")}
|
||
label={t("executiveMeetings.schedule.addVirtualAttendee")}
|
||
testId={`em-add-attendee-virtual-${meeting.id}`}
|
||
/>
|
||
)}
|
||
{internal.length === 0 && (
|
||
<AddAttendeeChip
|
||
onClick={() => startAdd("internal")}
|
||
label={t("executiveMeetings.schedule.addInternalAttendee")}
|
||
testId={`em-add-attendee-internal-${meeting.id}`}
|
||
/>
|
||
)}
|
||
{external.length === 0 && (
|
||
<AddAttendeeChip
|
||
onClick={() => startAdd("external")}
|
||
label={t("executiveMeetings.schedule.addExternalAttendee")}
|
||
testId={`em-add-attendee-external-${meeting.id}`}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Cell-level "+ عنوان فرعي" chip (Task #234). One per cell,
|
||
not one per group. Clicking it routes the new subheading to
|
||
the LAST visible group in render order (virtual → internal
|
||
→ external). The chip mirrors the per-group hide rules:
|
||
only renders when the user can edit, no add ghost is
|
||
already in flight, and a startAdd handler exists. */}
|
||
{canMutate && !hasAnyPending && startAdd && (
|
||
<div className="flex justify-center print:hidden">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const lastVisibleAddType: Attendee["attendanceType"] =
|
||
external.length > 0
|
||
? "external"
|
||
: internal.length > 0
|
||
? "internal"
|
||
: "virtual";
|
||
startAdd(lastVisibleAddType, "subheading");
|
||
}}
|
||
data-testid={`em-add-subheading-cell-${meeting.id}`}
|
||
aria-label={t("executiveMeetings.schedule.addSubheading")}
|
||
className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
|
||
>
|
||
{t("executiveMeetings.schedule.addSubheading")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Single-group cases: meeting.platform-only virtual list, all-internal,
|
||
// all-external, or empty. Pick a sensible default add type so the user
|
||
// can add a first attendee without reaching for the request modal.
|
||
const singleGroupType: Attendee["attendanceType"] =
|
||
meeting.platform !== "none" && virtual.length > 0
|
||
? "virtual"
|
||
: tagged[0]?.a.attendanceType ?? "internal";
|
||
|
||
if (meeting.platform !== "none" && virtual.length > 0) {
|
||
return (
|
||
<AttendeeGroup
|
||
label={`${t("executiveMeetings.virtualAttendance")} — ${platformLabel[meeting.platform]}`}
|
||
items={virtual}
|
||
addType="virtual"
|
||
{...flowProps}
|
||
isPending={isPendingForType("virtual")}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// Empty cell or single-type cell — render flow without a header.
|
||
return (
|
||
<AttendeeFlow
|
||
items={tagged}
|
||
addType={singleGroupType}
|
||
{...flowProps}
|
||
isPending={isPendingForType(singleGroupType)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
type AttendeeWithIndex = { a: Attendee; i: number };
|
||
|
||
function AddAttendeeChip({
|
||
onClick,
|
||
label,
|
||
testId,
|
||
}: {
|
||
onClick: () => void;
|
||
label: string;
|
||
testId: string;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
data-testid={testId}
|
||
className="inline-flex items-center rounded-full border border-dashed border-blue-400 px-2.5 py-1 min-h-[1.5rem] text-xs text-blue-700 hover:bg-blue-50 hover:border-blue-500 transition-colors"
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
type AttendeeFlowSharedProps = {
|
||
canMutate?: boolean;
|
||
onSaveAttendeeName?: (idx: number, html: string) => Promise<void>;
|
||
addType: Attendee["attendanceType"];
|
||
isPending: boolean;
|
||
// Which kind of ghost is being added — drives where in the flex flow
|
||
// the pending EditableCell is rendered (inline person at the end of the
|
||
// numbered list, or a full-width subheading line).
|
||
pendingKind: "person" | "subheading";
|
||
pendingKey: number;
|
||
// Position in meeting.attendees where the pending ghost should land.
|
||
// When undefined the ghost falls back to the legacy "trailing" slot
|
||
// (rendered after the last item in the group). When set, the ghost is
|
||
// rendered inline before the first item whose original index is >= the
|
||
// value, so the user sees the input materialize right where they
|
||
// clicked an after-section "+ عنوان فرعي" chip.
|
||
pendingInsertAtIndex?: number;
|
||
onStartAdd?: (
|
||
type: Attendee["attendanceType"],
|
||
kind?: "person" | "subheading",
|
||
insertAtIndex?: number,
|
||
) => void;
|
||
/**
|
||
* Same shape as `onStartAdd`, but always defined regardless of whether
|
||
* a pending row is currently open. Used by the Tab quick-add flow so
|
||
* the user can chain new attendees one after another even while a
|
||
* pending row is in flight (the parent's `hasAnyPending` guard would
|
||
* otherwise hide `onStartAdd` and break the chain).
|
||
*/
|
||
chainStartAdd?: (
|
||
type: Attendee["attendanceType"],
|
||
kind?: "person" | "subheading",
|
||
insertAtIndex?: number,
|
||
) => void;
|
||
onCommitAdd?: (
|
||
type: Attendee["attendanceType"],
|
||
html: string,
|
||
kind?: "person" | "subheading",
|
||
insertAtIndex?: number,
|
||
) => Promise<void>;
|
||
onCancelAdd?: () => void;
|
||
addLabel: string;
|
||
addSubheadingLabel: string;
|
||
editAriaLabel: string;
|
||
newAriaLabel: string;
|
||
editSubheadingAriaLabel: string;
|
||
newSubheadingAriaLabel: string;
|
||
meetingId: number;
|
||
// When true, the trailing "+ عنوان فرعي" <li> at the very end of the
|
||
// flow is NOT rendered. Used by split-mode cells (≥2 attendance
|
||
// groups visible) so the cell shows ONE cell-level "+ عنوان فرعي"
|
||
// chip below all groups, instead of one per group. The trailing
|
||
// "+" person <li> is unaffected.
|
||
suppressTrailingSubheadingChip?: boolean;
|
||
};
|
||
|
||
function AttendeeGroup({
|
||
label,
|
||
items,
|
||
...flowProps
|
||
}: { label: string; items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) {
|
||
return (
|
||
<div>
|
||
{/* The group label is purely informational — make it pointer-events:none
|
||
so a near-miss tap on the label still falls through to whatever is
|
||
underneath (e.g. an attendee name or the cell background). The
|
||
subtle bottom border anchors the label to the list of attendees
|
||
beneath it so the user reads it as a sub-section header rather
|
||
than a stray line of text. */}
|
||
<div
|
||
data-testid="em-attendee-group-header"
|
||
className="text-sm font-bold text-[#0B1E3F] mt-0.5 mb-1 pb-0.5 text-center pointer-events-none select-none border-b border-[#0B1E3F]/15 print:border-b print:border-gray-400"
|
||
>
|
||
{label}
|
||
</div>
|
||
<AttendeeFlow items={items} {...flowProps} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AttendeeFlow({
|
||
items,
|
||
canMutate = false,
|
||
onSaveAttendeeName,
|
||
addType,
|
||
isPending,
|
||
pendingKind,
|
||
pendingKey,
|
||
pendingInsertAtIndex,
|
||
onStartAdd,
|
||
chainStartAdd,
|
||
onCommitAdd,
|
||
onCancelAdd,
|
||
addLabel,
|
||
addSubheadingLabel,
|
||
editAriaLabel,
|
||
newAriaLabel,
|
||
editSubheadingAriaLabel,
|
||
newSubheadingAriaLabel,
|
||
suppressTrailingSubheadingChip = false,
|
||
}: { items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) {
|
||
const editable = canMutate && !!onSaveAttendeeName;
|
||
const showAdd = canMutate && !!onStartAdd && !isPending;
|
||
// When Shift+Tab fires inside a person row we ask the previous
|
||
// person row to enter edit mode by setting `focusedAttendeeIdx` to
|
||
// its meeting-attendees index `i`. The target row's EditableCell is
|
||
// re-rendered with `startInEditMode={focusedAttendeeIdx === i}`,
|
||
// which trips the existing startInEditMode useEffect and opens it.
|
||
// We clear the value back to null via onStartedEditing so click
|
||
// interactions on other rows remain free of any stale "force edit"
|
||
// pull. null means "no row is being force-focused right now".
|
||
const [focusedAttendeeIdx, setFocusedAttendeeIdx] = useState<number | null>(
|
||
null,
|
||
);
|
||
// True when the in-flight ghost is targeted at a specific slot — set
|
||
// by clicking an after-section "+ عنوان فرعي" chip. The same flag
|
||
// drives inline rendering for the person ghost (still wired through
|
||
// the manage dialog flow). When unset we fall back to the legacy
|
||
// trailing render so the trailing "+" / "+ subheading" buttons keep
|
||
// working.
|
||
const hasInlineInsert = isPending && pendingInsertAtIndex !== undefined;
|
||
|
||
// Each subheading starts a fresh numbered section. Within a section,
|
||
// persons are numbered 1..N starting over after every subheading.
|
||
// Numbering rules:
|
||
// - If the cell has NO subheading: keep the legacy "don't number a
|
||
// single name" rule (only show numbers when 2+ persons exist).
|
||
// - If the cell HAS any subheading: always show numbers, even for
|
||
// solo persons inside a section, so the grouping reads clearly.
|
||
// Pre-compute per-list-position metadata so the render loop is O(n).
|
||
const hasAnySubheading = items.some(
|
||
({ a }) => (a.kind ?? "person") === "subheading",
|
||
);
|
||
const sectionMeta = items.map(() => ({
|
||
sectionPersonCount: 0,
|
||
idxInSection: 0,
|
||
}));
|
||
{
|
||
let secStart = 0;
|
||
let secCount = 0;
|
||
const finalize = (endExclusive: number) => {
|
||
for (let k = secStart; k < endExclusive; k++) {
|
||
sectionMeta[k].sectionPersonCount = secCount;
|
||
}
|
||
};
|
||
items.forEach(({ a }, idx) => {
|
||
if ((a.kind ?? "person") === "subheading") {
|
||
finalize(idx);
|
||
secStart = idx + 1;
|
||
secCount = 0;
|
||
} else {
|
||
sectionMeta[idx].idxInSection = secCount;
|
||
secCount += 1;
|
||
}
|
||
});
|
||
finalize(items.length);
|
||
}
|
||
// Person count of the trailing (last) section — drives whether the
|
||
// pending "+ name" ghost row shows a leading number.
|
||
let lastSectionPersonCount = 0;
|
||
for (let k = items.length - 1; k >= 0; k--) {
|
||
if ((items[k].a.kind ?? "person") === "subheading") break;
|
||
lastSectionPersonCount += 1;
|
||
}
|
||
|
||
if (items.length === 0 && !showAdd && !isPending) {
|
||
return <span className="text-xs text-gray-500">—</span>;
|
||
}
|
||
|
||
// Locate the listIdx where the inline pending ghost should appear, when
|
||
// an inline insert is in flight. The ghost lands BEFORE the first item
|
||
// whose original index in meeting.attendees is >= pendingInsertAtIndex.
|
||
// If no such item exists (e.g. insert after the last item), the ghost
|
||
// is rendered after the loop. Computed once up front so the JSX is
|
||
// straightforward.
|
||
let inlineGhostTargetListIdx: number | null = null;
|
||
if (hasInlineInsert) {
|
||
for (let k = 0; k < items.length; k++) {
|
||
if (items[k].i >= pendingInsertAtIndex!) {
|
||
inlineGhostTargetListIdx = k;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Walk backward from `startListIdx - 1` until we find a person row;
|
||
// stop (return null) if we hit a subheading first or run off the top
|
||
// of the list. Used by Shift+Tab to decide which row should enter
|
||
// edit mode, while staying inside the current section.
|
||
const findPrevPersonInSection = (startListIdx: number): number | null => {
|
||
for (let j = startListIdx - 1; j >= 0; j--) {
|
||
const kind = items[j].a.kind ?? "person";
|
||
if (kind === "subheading") return null;
|
||
return items[j].i;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// The pending subheading ghost <li>. Used for inline rendering when
|
||
// the user clicks an after-section "+ عنوان فرعي" chip — the ghost
|
||
// materializes at the requested slot rather than the trailing
|
||
// fallback so the user sees their click land where expected.
|
||
const renderPendingSubheadingLi = (key: string) => {
|
||
if (
|
||
!(canMutate && isPending && pendingKind === "subheading" && onCommitAdd)
|
||
) {
|
||
return null;
|
||
}
|
||
return (
|
||
<li
|
||
key={key}
|
||
className="basis-full text-center font-semibold text-[#0B1E3F]/90 mt-1.5 mb-0.5"
|
||
data-testid={`em-add-subheading-pending-${addType}`}
|
||
>
|
||
<EditableCell
|
||
key={`pending-sub-${pendingKey}`}
|
||
value=""
|
||
onSave={(html) =>
|
||
onCommitAdd(addType, html, "subheading", pendingInsertAtIndex)
|
||
}
|
||
ariaLabel={newSubheadingAriaLabel}
|
||
dir="auto"
|
||
placeholder="…"
|
||
className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 border-b border-dashed border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
|
||
testId={`em-add-subheading-pending-input-${addType}`}
|
||
startInEditMode
|
||
forceSaveOnBlur
|
||
onCancel={onCancelAdd}
|
||
/>
|
||
</li>
|
||
);
|
||
};
|
||
|
||
// The pending person ghost <li>. Defined once so we can render it
|
||
// either inline (at the slot the user clicked) or as a trailing row
|
||
// (legacy "+" button at the end of the cell). It always shows the next
|
||
// section index when the cell has subheadings or the surrounding
|
||
// section already has people; otherwise it stays unnumbered just like
|
||
// a sole attendee in a no-subheading cell.
|
||
const renderPendingPersonLi = (key: string) => {
|
||
if (!(canMutate && isPending && pendingKind === "person" && onCommitAdd)) {
|
||
return null;
|
||
}
|
||
// When inserting inline after a subheading at array index K (so
|
||
// insertAtIndex = K + 1) we want the ghost to be numbered "1-" so the
|
||
// user sees a fresh section starting. Compute the section-local index
|
||
// for the inline slot from the per-item meta we already built.
|
||
let ghostDisplayNumber: number | null = null;
|
||
if (hasInlineInsert && inlineGhostTargetListIdx !== null) {
|
||
const target = inlineGhostTargetListIdx;
|
||
// The slot's section-local index = sectionMeta[target].idxInSection
|
||
// (0-based) for the would-be inserted person. That row pushes the
|
||
// current target row down, so the ghost takes the target's slot.
|
||
ghostDisplayNumber = sectionMeta[target].idxInSection + 1;
|
||
} else if (hasInlineInsert && inlineGhostTargetListIdx === null) {
|
||
// Insert at end (after the last item). Number = persons in last
|
||
// section + 1, mirroring the trailing case.
|
||
ghostDisplayNumber = lastSectionPersonCount + 1;
|
||
} else {
|
||
// Legacy trailing case.
|
||
ghostDisplayNumber =
|
||
lastSectionPersonCount > 0 || hasAnySubheading
|
||
? lastSectionPersonCount + 1
|
||
: null;
|
||
}
|
||
return (
|
||
<li
|
||
key={key}
|
||
className="min-w-[6rem]"
|
||
data-testid={`em-add-attendee-pending-${addType}`}
|
||
>
|
||
{ghostDisplayNumber !== null && (
|
||
<span className="text-gray-500 me-1">{ghostDisplayNumber}-</span>
|
||
)}
|
||
<EditableCell
|
||
key={`pending-${pendingKey}`}
|
||
value=""
|
||
onSave={(html) =>
|
||
onCommitAdd(addType, html, "person", pendingInsertAtIndex)
|
||
}
|
||
ariaLabel={newAriaLabel}
|
||
dir="auto"
|
||
placeholder="…"
|
||
className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 border-b border-dashed border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
|
||
testId={`em-add-attendee-pending-input-${addType}`}
|
||
startInEditMode
|
||
forceSaveOnBlur
|
||
onCancel={onCancelAdd}
|
||
// Tab inside the pending row → commit it, then immediately
|
||
// open another pending row right after it. We use
|
||
// `chainStartAdd` (not `onStartAdd`) because the parent
|
||
// gates `onStartAdd` away while a pending row exists; the
|
||
// chain prop bypasses that guard. When `pendingInsertAtIndex`
|
||
// is undefined (legacy trailing slot) we leave it undefined
|
||
// so the next pending also opens at the trailing slot.
|
||
onTabNext={
|
||
chainStartAdd
|
||
? () =>
|
||
chainStartAdd(
|
||
addType,
|
||
"person",
|
||
pendingInsertAtIndex !== undefined
|
||
? pendingInsertAtIndex + 1
|
||
: undefined,
|
||
)
|
||
: undefined
|
||
}
|
||
// Shift+Tab → commit the pending row (onCancelAdd handles the
|
||
// empty-blur path; non-empty values commit via onSave) and
|
||
// focus the previous person in the same section. The pending
|
||
// ghost lives at listIdx = inlineGhostTargetListIdx (when
|
||
// inline) or after the very last item (when trailing).
|
||
onTabPrev={() => {
|
||
const startListIdx =
|
||
inlineGhostTargetListIdx !== null
|
||
? inlineGhostTargetListIdx
|
||
: items.length;
|
||
const prevI = findPrevPersonInSection(startListIdx);
|
||
if (prevI !== null) setFocusedAttendeeIdx(prevI);
|
||
}}
|
||
/>
|
||
</li>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<ul className="flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug">
|
||
{items.flatMap(({ a, i }, listIdx) => {
|
||
const isSub = (a.kind ?? "person") === "subheading";
|
||
const nodes: ReactNode[] = [];
|
||
if (inlineGhostTargetListIdx === listIdx) {
|
||
if (pendingKind === "subheading") {
|
||
const ghost = renderPendingSubheadingLi(
|
||
`pending-inline-sub-${pendingKey}`,
|
||
);
|
||
if (ghost) nodes.push(ghost);
|
||
} else {
|
||
const ghost = renderPendingPersonLi(
|
||
`pending-inline-${pendingKey}`,
|
||
);
|
||
if (ghost) nodes.push(ghost);
|
||
}
|
||
}
|
||
if (isSub) {
|
||
// Subheading row: full-width line break inside the flex-wrap so
|
||
// it visually separates the persons before/after. No leading
|
||
// index, no inline-flex — pure label.
|
||
nodes.push(
|
||
<li
|
||
key={a.id ?? `sub-${i}`}
|
||
data-testid={`em-attendee-subheading-${i}`}
|
||
data-attendee-kind="subheading"
|
||
className="basis-full text-center font-semibold text-[#0B1E3F]/90 mt-1.5 mb-0.5 leading-snug"
|
||
>
|
||
{editable ? (
|
||
<EditableCell
|
||
value={a.name}
|
||
onSave={(html) => onSaveAttendeeName!(i, html)}
|
||
ariaLabel={editSubheadingAriaLabel}
|
||
dir="auto"
|
||
placeholder="…"
|
||
className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
|
||
testId={`em-edit-subheading-${i}`}
|
||
/>
|
||
) : (
|
||
<span
|
||
data-testid={`em-subheading-name-${i}`}
|
||
className="[&_p]:inline [&_p]:m-0"
|
||
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
|
||
/>
|
||
)}
|
||
</li>,
|
||
);
|
||
// Empty-section boundary: this subheading starts a section
|
||
// that has zero persons (the next item is ANOTHER subheading,
|
||
// i.e. the section it leads is empty). The user still needs a
|
||
// way to insert a NEW subheading between this section and the
|
||
// next one — so render the after-section chip right after the
|
||
// subheading itself. The trailing-section case (no next item)
|
||
// is intentionally skipped: the trailing "+ عنوان فرعي"
|
||
// button at the cell's end already covers that slot.
|
||
const nextItemForSub = items[listIdx + 1];
|
||
const nextSubIsSubheading =
|
||
nextItemForSub &&
|
||
(nextItemForSub.a.kind ?? "person") === "subheading";
|
||
if (showAdd && nextSubIsSubheading) {
|
||
nodes.push(
|
||
<li
|
||
key={`add-sub-after-${i}`}
|
||
className="basis-full flex justify-center print:hidden"
|
||
data-testid={`em-add-subheading-after-row-${i}`}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
onStartAdd!(addType, "subheading", i + 1)
|
||
}
|
||
data-testid={`em-add-subheading-after-${i}`}
|
||
aria-label={addSubheadingLabel}
|
||
className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
|
||
>
|
||
{addSubheadingLabel}
|
||
</button>
|
||
</li>,
|
||
);
|
||
}
|
||
return nodes;
|
||
}
|
||
// Person row — section-local 0-based index pre-computed above.
|
||
const meta = sectionMeta[listIdx];
|
||
const myDisplayIdx = meta.idxInSection;
|
||
nodes.push(
|
||
<li
|
||
key={a.id ?? i}
|
||
data-testid={`em-attendee-row-${i}`}
|
||
// `inline-flex items-baseline` keeps the index prefix and the
|
||
// name as flex siblings on the same line. The `[&_p]` utilities
|
||
// on the inner wrappers below flatten tiptap's block `<p>` (with
|
||
// its default 1em margins) so the rendered name stays inline.
|
||
className={
|
||
"inline-flex items-baseline whitespace-nowrap" +
|
||
(editable ? " min-w-[3rem]" : "")
|
||
}
|
||
>
|
||
{(meta.sectionPersonCount > 1 || hasAnySubheading) && (
|
||
<span
|
||
data-testid={`em-attendee-index-${i}`}
|
||
className="text-gray-500 me-1"
|
||
>
|
||
{myDisplayIdx + 1}-
|
||
</span>
|
||
)}
|
||
{editable ? (
|
||
<EditableCell
|
||
value={a.name}
|
||
onSave={(html) => onSaveAttendeeName!(i, html)}
|
||
ariaLabel={editAriaLabel}
|
||
dir="auto"
|
||
placeholder="…"
|
||
// `[&>span_p]:inline [&>span_p]:m-0` only matches the view-mode
|
||
// shell (`div > span > p`); the active editor's
|
||
// `div > div > EditorContent` does NOT match, so Enter still
|
||
// creates a real new paragraph inside the editor.
|
||
className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
|
||
testId={`em-edit-attendee-${i}`}
|
||
// When Shift+Tab on a sibling row asks us to take focus
|
||
// we flip startInEditMode true; the cell's existing
|
||
// useEffect enters edit mode, then onStartedEditing
|
||
// clears the flag so future clicks elsewhere are not
|
||
// hijacked back to this row.
|
||
startInEditMode={focusedAttendeeIdx === i}
|
||
onStartedEditing={() => setFocusedAttendeeIdx(null)}
|
||
// Tab → commit current name then open a pending person
|
||
// row immediately after this one in the same cell, so
|
||
// the user can keep typing names without mousing.
|
||
// We use `chainStartAdd` so the chain is reachable even
|
||
// when the parent has just moved into a pending state.
|
||
onTabNext={
|
||
canMutate && chainStartAdd
|
||
? () => chainStartAdd(addType, "person", i + 1)
|
||
: undefined
|
||
}
|
||
// Shift+Tab → commit then jump back to the previous
|
||
// person row in the same section (no-op on section's
|
||
// first row — saveEdit still runs).
|
||
onTabPrev={() => {
|
||
const prevI = findPrevPersonInSection(listIdx);
|
||
if (prevI !== null) setFocusedAttendeeIdx(prevI);
|
||
}}
|
||
/>
|
||
) : (
|
||
<span
|
||
data-testid={`em-attendee-name-${i}`}
|
||
className="[&_p]:inline [&_p]:m-0"
|
||
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
|
||
/>
|
||
)}
|
||
</li>,
|
||
);
|
||
const nextItemForPerson = items[listIdx + 1];
|
||
const nextIsSubheading =
|
||
nextItemForPerson &&
|
||
(nextItemForPerson.a.kind ?? "person") === "subheading";
|
||
// The inter-person "+ person here" chip used to live here
|
||
// (originally added in #222). It was removed in #299: with one
|
||
// chip rendered between every pair of person rows the cell
|
||
// looked noisy and repetitive, and the trailing "+" button at
|
||
// the end of the list already covers the add-person flow.
|
||
// The "+ subheading" chip just before a subheading row is
|
||
// preserved below.
|
||
if (showAdd && nextIsSubheading) {
|
||
nodes.push(
|
||
<li
|
||
key={`add-sub-after-${i}`}
|
||
className="basis-full flex justify-center print:hidden"
|
||
data-testid={`em-add-subheading-after-row-${i}`}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
onStartAdd!(addType, "subheading", i + 1)
|
||
}
|
||
data-testid={`em-add-subheading-after-${i}`}
|
||
aria-label={addSubheadingLabel}
|
||
className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
|
||
>
|
||
{addSubheadingLabel}
|
||
</button>
|
||
</li>,
|
||
);
|
||
}
|
||
return nodes;
|
||
})}
|
||
{/* Inline ghost pinned to the trailing slot (insert-at-end case).
|
||
Either kind may land here: a person from manage flows, or a
|
||
subheading from the trailing "+ عنوان فرعي" button when the
|
||
insertAtIndex equals items.length. */}
|
||
{hasInlineInsert &&
|
||
inlineGhostTargetListIdx === null &&
|
||
pendingKind === "person" &&
|
||
renderPendingPersonLi(`pending-inline-trailing-${pendingKey}`)}
|
||
{hasInlineInsert &&
|
||
inlineGhostTargetListIdx === null &&
|
||
pendingKind === "subheading" &&
|
||
renderPendingSubheadingLi(`pending-inline-trailing-sub-${pendingKey}`)}
|
||
{/* Legacy trailing ghost (no inline insert in flight) — used for
|
||
the global trailing "+" / "+ subheading" buttons. */}
|
||
{!hasInlineInsert &&
|
||
renderPendingPersonLi(`pending-trailing-${pendingKey}`)}
|
||
{!hasInlineInsert &&
|
||
renderPendingSubheadingLi(`pending-trailing-sub-${pendingKey}`)}
|
||
{showAdd && (
|
||
<>
|
||
<li className="print:hidden">
|
||
<button
|
||
type="button"
|
||
onClick={() => onStartAdd!(addType, "person")}
|
||
data-testid={`em-add-attendee-${addType}`}
|
||
aria-label={addLabel}
|
||
className="text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded px-2 py-1 min-h-[1.5rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
|
||
>
|
||
+
|
||
</button>
|
||
</li>
|
||
{!suppressTrailingSubheadingChip && (
|
||
<li className="print:hidden">
|
||
<button
|
||
type="button"
|
||
onClick={() => onStartAdd!(addType, "subheading")}
|
||
data-testid={`em-add-subheading-${addType}`}
|
||
aria-label={addSubheadingLabel}
|
||
className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
|
||
>
|
||
{addSubheadingLabel}
|
||
</button>
|
||
</li>
|
||
)}
|
||
</>
|
||
)}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
// ============ MANAGE ============
|
||
|
||
type MeetingFormState = {
|
||
id: number | null;
|
||
titleAr: string;
|
||
titleEn: string;
|
||
meetingDate: string;
|
||
dailyNumber: string;
|
||
startTime: string;
|
||
endTime: string;
|
||
location: string;
|
||
meetingUrl: string;
|
||
platform: Meeting["platform"];
|
||
status: string;
|
||
isHighlighted: boolean;
|
||
notes: string;
|
||
attendees: Attendee[];
|
||
};
|
||
|
||
// Generate a stable client-only attendee id for the manage-dialog DnD.
|
||
// Combines an incrementing counter with a random suffix so we don't
|
||
// collide even when several openEdit() calls fire in the same tick (e.g.
|
||
// after a duplicate-and-edit gesture). The id never leaves the browser.
|
||
let __attendeeSidCounter = 0;
|
||
function genAttendeeSid(): string {
|
||
__attendeeSidCounter += 1;
|
||
return `sid-${__attendeeSidCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
function emptyMeetingForm(date: string): MeetingFormState {
|
||
return {
|
||
id: null,
|
||
titleAr: "",
|
||
titleEn: "",
|
||
meetingDate: date,
|
||
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 confirm = useConfirm();
|
||
const [editing, setEditing] = useState<MeetingFormState | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||
const [duplicating, setDuplicating] = useState<{
|
||
id: number;
|
||
targetDate: string;
|
||
} | null>(null);
|
||
const [duplicatingBusy, setDuplicatingBusy] = useState(false);
|
||
|
||
const { data, isLoading } = useQuery<DayResponse>({
|
||
queryKey: ["/api/executive-meetings", date],
|
||
queryFn: () => apiJson<DayResponse>(`/api/executive-meetings?date=${date}`),
|
||
});
|
||
const meetings = data?.meetings ?? [];
|
||
|
||
// #332: in-day search on the Manage tab. Mirrors the Schedule tab
|
||
// (see ~L945) — same `normalizeForSearch`/`meetingMatchesSearch`
|
||
// helpers, same "reset on date change" behavior, same scoping of
|
||
// bulk actions to the visible (filtered) row set.
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
useEffect(() => {
|
||
setSearchQuery("");
|
||
}, [date]);
|
||
const normalizedSearch = useMemo(
|
||
() => normalizeForSearch(searchQuery),
|
||
[searchQuery],
|
||
);
|
||
const filteredMeetings = useMemo(() => {
|
||
if (!normalizedSearch) return meetings;
|
||
return meetings.filter((m) => meetingMatchesSearch(m, normalizedSearch));
|
||
}, [meetings, normalizedSearch]);
|
||
|
||
// #332: bulk-selection state for Manage. The selection set may
|
||
// legitimately contain ids hidden by an active search filter (we
|
||
// don't auto-prune so clearing the search restores the user's
|
||
// selection); every action intersects with `filteredMeetings` at
|
||
// call time for safety.
|
||
const [selectedMeetingIds, setSelectedMeetingIds] = useState<Set<number>>(
|
||
() => new Set(),
|
||
);
|
||
const toggleMeetingSelected = useCallback((id: number, on: boolean) => {
|
||
setSelectedMeetingIds((prev) => {
|
||
const next = new Set(prev);
|
||
if (on) next.add(id);
|
||
else next.delete(id);
|
||
return next;
|
||
});
|
||
}, []);
|
||
const toggleSelectAllVisible = useCallback(
|
||
(on: boolean) => {
|
||
setSelectedMeetingIds((prev) => {
|
||
if (!on) return new Set();
|
||
const next = new Set(prev);
|
||
for (const m of filteredMeetings) next.add(m.id);
|
||
return next;
|
||
});
|
||
},
|
||
[filteredMeetings],
|
||
);
|
||
const allSelectedState: "all" | "some" | "none" = useMemo(() => {
|
||
const total = filteredMeetings.length;
|
||
if (total === 0) return "none";
|
||
let count = 0;
|
||
for (const m of filteredMeetings) if (selectedMeetingIds.has(m.id)) count++;
|
||
if (count === 0) return "none";
|
||
if (count === total) return "all";
|
||
return "some";
|
||
}, [filteredMeetings, selectedMeetingIds]);
|
||
const visibleSelectedCount = useMemo(() => {
|
||
let n = 0;
|
||
for (const m of filteredMeetings) if (selectedMeetingIds.has(m.id)) n++;
|
||
return n;
|
||
}, [filteredMeetings, selectedMeetingIds]);
|
||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||
const [bulkDuplicateOpen, setBulkDuplicateOpen] = useState(false);
|
||
const [bulkDuplicateDate, setBulkDuplicateDate] = useState<string>(date);
|
||
const [bulkDuplicating, setBulkDuplicating] = useState(false);
|
||
const openBulkDuplicate = useCallback(() => {
|
||
setBulkDuplicateDate(date);
|
||
setBulkDuplicateOpen(true);
|
||
}, [date]);
|
||
const performBulkDuplicate = useCallback(async () => {
|
||
if (bulkDuplicating) return;
|
||
if (!bulkDuplicateDate) return;
|
||
const visibleIds = new Set(filteredMeetings.map((m) => m.id));
|
||
const ids = Array.from(selectedMeetingIds).filter((id) =>
|
||
visibleIds.has(id),
|
||
);
|
||
if (ids.length === 0) {
|
||
setBulkDuplicateOpen(false);
|
||
return;
|
||
}
|
||
setBulkDuplicating(true);
|
||
try {
|
||
const results = await Promise.allSettled(
|
||
ids.map((id) =>
|
||
apiJson(`/api/executive-meetings/${id}/duplicate`, {
|
||
method: "POST",
|
||
body: { targetDate: bulkDuplicateDate },
|
||
}),
|
||
),
|
||
);
|
||
const ok = results.filter((r) => r.status === "fulfilled").length;
|
||
const failed = results.length - ok;
|
||
if (failed === 0) {
|
||
toast({
|
||
title: t(
|
||
"executiveMeetings.schedule.bulkDuplicateResultAll",
|
||
).replace("{{n}}", String(ok)),
|
||
});
|
||
} else if (ok === 0) {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.bulkDuplicateNone"),
|
||
variant: "destructive",
|
||
});
|
||
} else {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.bulkDuplicatePartial")
|
||
.replace("{{ok}}", String(ok))
|
||
.replace("{{total}}", String(results.length))
|
||
.replace("{{failed}}", String(failed)),
|
||
variant: "destructive",
|
||
});
|
||
}
|
||
setSelectedMeetingIds(new Set());
|
||
setBulkDuplicateOpen(false);
|
||
if (bulkDuplicateDate === date) {
|
||
await qc.invalidateQueries({
|
||
queryKey: ["/api/executive-meetings", date],
|
||
});
|
||
} else {
|
||
onDateChange(bulkDuplicateDate);
|
||
}
|
||
} finally {
|
||
setBulkDuplicating(false);
|
||
}
|
||
}, [
|
||
bulkDuplicating,
|
||
bulkDuplicateDate,
|
||
filteredMeetings,
|
||
selectedMeetingIds,
|
||
date,
|
||
onDateChange,
|
||
qc,
|
||
toast,
|
||
t,
|
||
]);
|
||
const deleteSelectedMeetings = useCallback(async () => {
|
||
if (bulkDeleting) return;
|
||
const visibleIds = new Set(filteredMeetings.map((m) => m.id));
|
||
const ids = Array.from(selectedMeetingIds).filter((id) =>
|
||
visibleIds.has(id),
|
||
);
|
||
if (ids.length === 0) return;
|
||
const message = t(
|
||
"executiveMeetings.schedule.bulkDeleteConfirm",
|
||
).replace("{{n}}", String(ids.length));
|
||
if (!(await confirm({ message, destructive: true }))) return;
|
||
setBulkDeleting(true);
|
||
try {
|
||
const results = await Promise.allSettled(
|
||
ids.map((id) =>
|
||
apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" }),
|
||
),
|
||
);
|
||
const ok = results.filter((r) => r.status === "fulfilled").length;
|
||
const failed = results.length - ok;
|
||
if (failed === 0) {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.bulkDeleteResultAll").replace(
|
||
"{{n}}",
|
||
String(ok),
|
||
),
|
||
});
|
||
} else if (ok === 0) {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.bulkDeleteResultNone"),
|
||
variant: "destructive",
|
||
});
|
||
} else {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.bulkDeletePartial")
|
||
.replace("{{ok}}", String(ok))
|
||
.replace("{{total}}", String(results.length))
|
||
.replace("{{failed}}", String(failed)),
|
||
variant: "destructive",
|
||
});
|
||
}
|
||
setSelectedMeetingIds(new Set());
|
||
await qc.invalidateQueries({
|
||
queryKey: ["/api/executive-meetings", date],
|
||
});
|
||
} finally {
|
||
setBulkDeleting(false);
|
||
}
|
||
}, [
|
||
bulkDeleting,
|
||
selectedMeetingIds,
|
||
filteredMeetings,
|
||
qc,
|
||
date,
|
||
toast,
|
||
t,
|
||
]);
|
||
|
||
function openCreate() {
|
||
setEditing(emptyMeetingForm(date));
|
||
}
|
||
function openDuplicate(m: Meeting) {
|
||
setDuplicating({ id: m.id, targetDate: date });
|
||
}
|
||
async function performDuplicate() {
|
||
if (!duplicating) return;
|
||
setDuplicatingBusy(true);
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${duplicating.id}/duplicate`, {
|
||
method: "POST",
|
||
body: { targetDate: duplicating.targetDate },
|
||
});
|
||
toast({ title: t("executiveMeetings.manage.duplicated") });
|
||
const newDate = duplicating.targetDate;
|
||
setDuplicating(null);
|
||
if (newDate === date) {
|
||
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
|
||
} else {
|
||
onDateChange(newDate);
|
||
}
|
||
} catch (err) {
|
||
toast({
|
||
title: t("executiveMeetings.manage.duplicateFailed"),
|
||
description: err instanceof Error ? err.message : String(err),
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setDuplicatingBusy(false);
|
||
}
|
||
}
|
||
function openEdit(m: Meeting) {
|
||
setEditing({
|
||
id: m.id,
|
||
titleAr: 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 ?? "",
|
||
// Stamp every attendee with a stable client-side id used by the
|
||
// dnd-kit SortableContext in the manage dialog. Persisted attendees
|
||
// also have a server `id` we could reuse, but new rows added inside
|
||
// the dialog don't have one until after save, so we always assign
|
||
// `_sid` here for uniformity. The save projection enumerates wire
|
||
// fields and naturally drops `_sid`.
|
||
attendees: m.attendees.map((a) => ({ ...a, _sid: genAttendeeSid() })),
|
||
});
|
||
}
|
||
|
||
async function save(timeOverrides?: { startTime: string; endTime: string }) {
|
||
if (!editing) return;
|
||
if (!editing.titleAr.trim()) {
|
||
toast({
|
||
title: t("executiveMeetings.manage.errors.titleRequired"),
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
// #322: the English-title input was removed from the dialog. Fall
|
||
// back to the Arabic title when the form's English value is blank
|
||
// so the API's `titleEn` (min length 1) requirement is satisfied
|
||
// for new meetings, while preserving any pre-existing English
|
||
// title on edits (the dialog hides the field but openEdit still
|
||
// loads it into MeetingFormState).
|
||
const titleEn = editing.titleEn.trim() || editing.titleAr.trim();
|
||
// Prefer the dialog-supplied canonical values from the time
|
||
// picker's commit() result over the controlled `editing.*`
|
||
// state — the picker's onChange suppresses ambiguous/invalid
|
||
// drafts so `editing.startTime` / `editing.endTime` may still
|
||
// hold the previous saved value when the user typed but never
|
||
// resolved the AM/PM. Task #315: prevent silent fallback.
|
||
const startTime = timeOverrides
|
||
? timeOverrides.startTime || null
|
||
: editing.startTime || null;
|
||
const endTime = timeOverrides
|
||
? timeOverrides.endTime || null
|
||
: editing.endTime || null;
|
||
setSaving(true);
|
||
const body: Record<string, unknown> = {
|
||
titleAr: editing.titleAr.trim(),
|
||
titleEn,
|
||
meetingDate: editing.meetingDate,
|
||
startTime,
|
||
endTime,
|
||
location: editing.location.trim() || null,
|
||
meetingUrl: editing.meetingUrl.trim() || null,
|
||
platform: editing.platform,
|
||
status: editing.status,
|
||
isHighlighted: editing.isHighlighted ? 1 : 0,
|
||
notes: editing.notes.trim() || null,
|
||
attendees: editing.attendees.map((a, idx) => ({
|
||
name: a.name,
|
||
title: a.title,
|
||
attendanceType: a.attendanceType,
|
||
sortOrder: idx,
|
||
// Round-trip the row kind so subheadings created in the inline
|
||
// schedule survive a save through the Manage dialog.
|
||
kind: a.kind ?? "person",
|
||
})),
|
||
};
|
||
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: translateSaveError(msg, t),
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function confirmDelete(id: number) {
|
||
if (
|
||
!(await confirm({
|
||
message: t("executiveMeetings.manage.deleteConfirm"),
|
||
destructive: true,
|
||
}))
|
||
)
|
||
return;
|
||
setDeletingId(id);
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" });
|
||
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
toast({ title: msg, variant: "destructive" });
|
||
} finally {
|
||
setDeletingId(null);
|
||
}
|
||
}
|
||
|
||
// #334: publish the live height of the Manage heading bar as a CSS
|
||
// var so the sticky bulk-action toolbar below can sit flush under
|
||
// it without overlap. Mirrors the Schedule tab's pattern at L2155
|
||
// for `--em-heading-h`. We use a distinct var name
|
||
// (`--em-manage-heading-h`) so the two tabs don't collide when the
|
||
// user switches between them. Cleared on unmount so the Schedule
|
||
// tab doesn't inherit a stale offset.
|
||
const headingRef = useRef<HTMLDivElement | null>(null);
|
||
useEffect(() => {
|
||
const heading = headingRef.current;
|
||
if (!heading) return;
|
||
const root = heading.closest<HTMLElement>("[data-em-root]");
|
||
if (!root) return;
|
||
const update = () => {
|
||
root.style.setProperty(
|
||
"--em-manage-heading-h",
|
||
`${heading.offsetHeight}px`,
|
||
);
|
||
};
|
||
update();
|
||
const ro = new ResizeObserver(update);
|
||
ro.observe(heading);
|
||
window.addEventListener("resize", update);
|
||
return () => {
|
||
ro.disconnect();
|
||
window.removeEventListener("resize", update);
|
||
root.style.setProperty("--em-manage-heading-h", "0px");
|
||
};
|
||
}, []);
|
||
|
||
if (!canMutate) {
|
||
return <NoPermission t={t} />;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* #321: sticky toolbar — date + Add Meeting stay pinned just
|
||
under the app's top header while the meeting list scrolls,
|
||
mirroring `em-schedule-heading-bar` on the Schedule tab. */}
|
||
<div
|
||
ref={headingRef}
|
||
data-testid="em-manage-heading-bar"
|
||
className="sticky z-30 bg-[#f4f6fb] -mx-3 sm:-mx-6 px-3 sm:px-6 py-2 shadow-[0_4px_6px_-4px_rgba(11,30,63,0.15)] flex items-center justify-between gap-3 flex-wrap"
|
||
style={{ top: "var(--em-header-h, 0px)" }}
|
||
>
|
||
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
|
||
{t("executiveMeetings.manage.heading")}
|
||
</h2>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{/* #332: in-day search box for the Manage tab. Reuses the
|
||
same locale strings as the Schedule tab so wording stays
|
||
identical across tabs. */}
|
||
<div className="relative">
|
||
<Input
|
||
type="search"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder={t("executiveMeetings.schedule.searchPlaceholder")}
|
||
aria-label={t("executiveMeetings.schedule.searchAria")}
|
||
data-testid="em-manage-search"
|
||
className="h-8 w-44 sm:w-56 text-sm bg-white"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchQuery("")}
|
||
aria-label={t("executiveMeetings.schedule.searchClear")}
|
||
title={t("executiveMeetings.schedule.searchClear")}
|
||
data-testid="em-manage-search-clear"
|
||
className={
|
||
"absolute top-1/2 -translate-y-1/2 text-muted-foreground hover:text-[#0B1E3F] " +
|
||
(isRtl ? "left-1.5" : "right-1.5")
|
||
}
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<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>
|
||
|
||
{/* #332: floating bulk-action toolbar — appears only when ≥1
|
||
visible row is selected. Mirrors the Schedule tab's toolbar
|
||
(count + clear + duplicate-to-date + delete).
|
||
#334: sticky just under `em-manage-heading-bar` so the
|
||
actions stay reachable while the user scrolls through a
|
||
long day. Same horizontal bleed + `#f4f6fb` page-bg as the
|
||
heading bar so the two visually stack as one header unit;
|
||
`print:hidden` matches the Schedule counterpart. */}
|
||
{visibleSelectedCount > 0 && (
|
||
<div
|
||
className="sticky z-20 !mt-0 -mx-3 sm:-mx-6 px-3 sm:px-6 py-2 flex items-center gap-3 flex-wrap border-b border-[#0B1E3F]/15 bg-[#f4f6fb] shadow-[0_4px_6px_-4px_rgba(11,30,63,0.15)] print:hidden"
|
||
style={{
|
||
top: "calc(var(--em-header-h, 0px) + var(--em-manage-heading-h, 0px))",
|
||
}}
|
||
data-testid="em-manage-bulk-toolbar"
|
||
>
|
||
<span
|
||
className="text-sm text-[#0B1E3F]/80 font-medium"
|
||
data-testid="em-manage-bulk-selection-count"
|
||
>
|
||
{t("executiveMeetings.schedule.bulkSelectionCount")
|
||
.replace("{{n}}", String(visibleSelectedCount))
|
||
.replace("{{total}}", String(filteredMeetings.length))}
|
||
</span>
|
||
<div className="flex items-center gap-2 ms-auto">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setSelectedMeetingIds(new Set())}
|
||
disabled={bulkDeleting || bulkDuplicating}
|
||
data-testid="em-manage-bulk-clear-selection"
|
||
>
|
||
{t("executiveMeetings.schedule.bulkClearSelection")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={openBulkDuplicate}
|
||
disabled={bulkDeleting || bulkDuplicating}
|
||
className="gap-1"
|
||
data-testid="em-manage-bulk-duplicate"
|
||
>
|
||
<Copy className="w-4 h-4" />
|
||
{t("executiveMeetings.schedule.bulkDuplicateSelected")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
onClick={deleteSelectedMeetings}
|
||
disabled={bulkDeleting || bulkDuplicating}
|
||
className="gap-1"
|
||
data-testid="em-manage-bulk-delete"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
{t("executiveMeetings.schedule.bulkDeleteSelected")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-white border border-gray-200 rounded-md overflow-hidden">
|
||
<table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
|
||
<thead className="bg-gray-50 text-[#0B1E3F]">
|
||
<tr>
|
||
<th className="px-3 py-2 text-start w-10">
|
||
{/* #332: tri-state "select all visible" header
|
||
checkbox. Operates on filteredMeetings so an
|
||
active search scopes the action. */}
|
||
<Checkbox
|
||
checked={
|
||
allSelectedState === "all"
|
||
? true
|
||
: allSelectedState === "some"
|
||
? "indeterminate"
|
||
: false
|
||
}
|
||
onCheckedChange={(v) =>
|
||
toggleSelectAllVisible(v === true)
|
||
}
|
||
disabled={filteredMeetings.length === 0}
|
||
aria-label={t(
|
||
"executiveMeetings.schedule.bulkClearSelection",
|
||
)}
|
||
data-testid="em-manage-select-all"
|
||
/>
|
||
</th>
|
||
<th className="px-3 py-2 text-start w-12">#</th>
|
||
<th className="px-3 py-2 text-start">{t("executiveMeetings.col.meeting")}</th>
|
||
<th className="px-3 py-2 text-center w-28">{t("executiveMeetings.col.time")}</th>
|
||
<th className="px-3 py-2 text-center w-32 border-s border-gray-200">{t("executiveMeetings.common.actions")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{isLoading && (
|
||
<tr>
|
||
<td colSpan={4} className="py-8 text-center text-gray-500">
|
||
{t("common.loading")}
|
||
</td>
|
||
{/* #337: keep the time/actions divider visible even in
|
||
loading/empty rows so the column boundary stays
|
||
consistent with the data rows. */}
|
||
<td className="border-s border-gray-200" aria-hidden="true" />
|
||
</tr>
|
||
)}
|
||
{!isLoading && filteredMeetings.length === 0 && (
|
||
<tr>
|
||
<td colSpan={4} className="py-8 text-center text-gray-500">
|
||
{t("executiveMeetings.manage.noMeetings")}
|
||
</td>
|
||
<td className="border-s border-gray-200" aria-hidden="true" />
|
||
</tr>
|
||
)}
|
||
{filteredMeetings.map((m) => (
|
||
<tr key={m.id} className="border-t border-gray-100">
|
||
<td className="px-3 py-2 align-middle">
|
||
<Checkbox
|
||
checked={selectedMeetingIds.has(m.id)}
|
||
onCheckedChange={(v) =>
|
||
toggleMeetingSelected(m.id, v === true)
|
||
}
|
||
aria-label={t(
|
||
"executiveMeetings.schedule.bulkClearSelection",
|
||
)}
|
||
data-testid={`em-manage-row-select-${m.id}`}
|
||
/>
|
||
</td>
|
||
<td className="px-3 py-2 align-middle">{m.dailyNumber}</td>
|
||
<td className="px-3 py-2 align-middle">
|
||
{/* #321: titles are stored as sanitized rich-text
|
||
HTML (Tiptap output). Render via safeHtml so the
|
||
user sees formatted text instead of literal
|
||
<p>/<span style="…"> tokens. */}
|
||
<div
|
||
className="font-medium text-[#0B1E3F]"
|
||
dangerouslySetInnerHTML={{
|
||
__html: safeHtml(
|
||
isRtl ? m.titleAr : m.titleEn || m.titleAr,
|
||
),
|
||
}}
|
||
/>
|
||
{/* #336: just the attendee count — the raw
|
||
`m.platform` token (e.g. "none") was removed
|
||
because it surfaced an English enum value next
|
||
to Arabic content with no value to the user.
|
||
The platform is still shown contextually inside
|
||
the meeting card via the "Virtual attendance —
|
||
{platformLabel}" group header in AttendeeFlow. */}
|
||
<div className="text-xs text-gray-500">
|
||
{
|
||
m.attendees.filter(
|
||
(a) => (a.kind ?? "person") === "person",
|
||
).length
|
||
}
|
||
</div>
|
||
</td>
|
||
<td className="px-3 py-2 align-middle font-mono text-xs whitespace-nowrap text-center" dir="ltr">
|
||
{formatTime(m.startTime, isRtl ? "ar" : "en")} — {formatTime(m.endTime, isRtl ? "ar" : "en")}
|
||
</td>
|
||
<td className="px-3 py-2 align-middle text-center border-s border-gray-200">
|
||
<div className="inline-flex gap-1">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => openEdit(m)}
|
||
data-testid={`em-edit-${m.id}`}
|
||
>
|
||
<Pencil className="w-4 h-4" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => openDuplicate(m)}
|
||
title={t("executiveMeetings.manage.duplicateToDate")}
|
||
data-testid={`em-duplicate-${m.id}`}
|
||
>
|
||
<Copy className="w-4 h-4" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => confirmDelete(m.id)}
|
||
disabled={deletingId === m.id}
|
||
data-testid={`em-delete-${m.id}`}
|
||
>
|
||
<Trash2 className="w-4 h-4 text-red-600" />
|
||
</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{editing && (
|
||
<MeetingFormDialog
|
||
state={editing}
|
||
onChange={setEditing}
|
||
// Pass the committed (canonical) start/end through so the
|
||
// dialog's Enter/click-Save path validates the time pickers
|
||
// synchronously (per task #315: typing "1:15" without an
|
||
// AM/PM pick must surface as an error, NOT silently fall
|
||
// back to the previous saved value or AM). The values
|
||
// arriving here are already canonical "HH:mm" or "" — we
|
||
// hand them straight into the save body so a stale React
|
||
// state batch from the picker's onChange can't lose them.
|
||
onSave={(committedStart, committedEnd) =>
|
||
save({ startTime: committedStart, endTime: committedEnd })
|
||
}
|
||
onClose={() => setEditing(null)}
|
||
saving={saving}
|
||
t={t}
|
||
/>
|
||
)}
|
||
|
||
<Dialog
|
||
open={duplicating !== null}
|
||
onOpenChange={(o) => !o && setDuplicating(null)}
|
||
>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{t("executiveMeetings.manage.duplicateToDate")}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
|
||
<Input
|
||
type="date"
|
||
value={duplicating?.targetDate ?? ""}
|
||
onChange={(e) =>
|
||
setDuplicating(
|
||
duplicating
|
||
? { ...duplicating, targetDate: e.target.value }
|
||
: null,
|
||
)
|
||
}
|
||
data-testid="em-duplicate-date"
|
||
/>
|
||
</FormRow>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setDuplicating(null)}
|
||
disabled={duplicatingBusy}
|
||
>
|
||
{t("executiveMeetings.common.cancel")}
|
||
</Button>
|
||
<Button
|
||
onClick={performDuplicate}
|
||
disabled={duplicatingBusy || !duplicating?.targetDate}
|
||
className="bg-[#0B1E3F]"
|
||
data-testid="em-duplicate-confirm"
|
||
>
|
||
{t("executiveMeetings.manage.duplicate")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* #332: bulk-duplicate-to-date dialog. Mirrors the Schedule
|
||
tab's dialog (~L2648) — single date picker, same POST per
|
||
id, aggregated toast. */}
|
||
<Dialog
|
||
open={bulkDuplicateOpen}
|
||
onOpenChange={(o) => {
|
||
if (bulkDuplicating) return;
|
||
setBulkDuplicateOpen(o);
|
||
}}
|
||
>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{t("executiveMeetings.schedule.bulkDuplicateTitle").replace(
|
||
"{{n}}",
|
||
String(visibleSelectedCount),
|
||
)}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
|
||
<Input
|
||
type="date"
|
||
value={bulkDuplicateDate}
|
||
onChange={(e) => setBulkDuplicateDate(e.target.value)}
|
||
disabled={bulkDuplicating}
|
||
data-testid="em-manage-bulk-duplicate-date"
|
||
/>
|
||
</FormRow>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setBulkDuplicateOpen(false)}
|
||
disabled={bulkDuplicating}
|
||
>
|
||
{t("executiveMeetings.common.cancel")}
|
||
</Button>
|
||
<Button
|
||
onClick={() => void performBulkDuplicate()}
|
||
disabled={bulkDuplicating || !bulkDuplicateDate}
|
||
className="bg-[#0B1E3F]"
|
||
data-testid="em-manage-bulk-duplicate-confirm"
|
||
>
|
||
{bulkDuplicating
|
||
? t("executiveMeetings.schedule.bulkDuplicating")
|
||
: t("executiveMeetings.schedule.bulkDuplicateConfirm")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// One row inside the manage-dialog attendee list. Uses dnd-kit's
|
||
// useSortable so the row can be picked up by its dedicated drag handle
|
||
// (the GripVertical button on the side). The visible up/down chevrons,
|
||
// inputs and delete button continue to fire their callbacks normally —
|
||
// the drag handle is the only element that initiates a drag.
|
||
function SortableAttendeeRow({
|
||
attendee,
|
||
index,
|
||
last,
|
||
t,
|
||
onMoveUp,
|
||
onMoveDown,
|
||
onChangeName,
|
||
onRemove,
|
||
}: {
|
||
attendee: Attendee;
|
||
index: number;
|
||
last: boolean;
|
||
t: (k: string) => string;
|
||
onMoveUp: () => void;
|
||
onMoveDown: () => void;
|
||
onChangeName: (v: string) => void;
|
||
onRemove: () => void;
|
||
}) {
|
||
const sid = attendee._sid ?? `__missing-sid-${index}`;
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id: sid });
|
||
const style: CSSProperties = {
|
||
transform: CSS.Transform.toString(transform),
|
||
transition,
|
||
// Subtle lift while the row is being dragged so the user sees the
|
||
// active item clearly above the others.
|
||
zIndex: isDragging ? 10 : undefined,
|
||
opacity: isDragging ? 0.85 : undefined,
|
||
};
|
||
const isSub = (attendee.kind ?? "person") === "subheading";
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={style}
|
||
className={
|
||
"flex items-center gap-2" +
|
||
(isSub ? " bg-blue-50/60 rounded px-2 py-1" : "")
|
||
}
|
||
data-testid={`em-attendee-row-${index}`}
|
||
data-attendee-kind={attendee.kind ?? "person"}
|
||
data-attendee-sid={sid}
|
||
>
|
||
{/* Drag handle — the ONLY element wired to dnd-kit listeners, so
|
||
the row's inputs and buttons remain interactive. */}
|
||
<button
|
||
type="button"
|
||
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-700 px-1 touch-none"
|
||
aria-label={t("executiveMeetings.manage.attendees.dragHandle")}
|
||
data-testid={`em-attendee-drag-${index}`}
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<GripVertical className="w-4 h-4" />
|
||
</button>
|
||
<div className="flex flex-col">
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-5 w-5"
|
||
onClick={onMoveUp}
|
||
disabled={index === 0}
|
||
aria-label={t("executiveMeetings.manage.attendees.moveUp")}
|
||
data-testid={`em-attendee-up-${index}`}
|
||
>
|
||
<ChevronUp className="w-3 h-3" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-5 w-5"
|
||
onClick={onMoveDown}
|
||
disabled={last}
|
||
aria-label={t("executiveMeetings.manage.attendees.moveDown")}
|
||
data-testid={`em-attendee-down-${index}`}
|
||
>
|
||
<ChevronDown className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
{isSub && (
|
||
<span
|
||
className="inline-flex items-center text-[10px] font-semibold uppercase tracking-wide text-blue-700 bg-blue-100 rounded-full px-2 py-0.5"
|
||
data-testid={`em-attendee-kind-badge-${index}`}
|
||
>
|
||
{t("executiveMeetings.manage.attendees.subheadingBadge")}
|
||
</span>
|
||
)}
|
||
{/* #322: name is the only attendee field exposed in the dialog.
|
||
The parenthesized title and the internal/virtual/external
|
||
select were removed; existing rows keep their stored values
|
||
and new rows default to title=null + attendanceType="internal"
|
||
(see addAttendee). */}
|
||
<Input
|
||
className="flex-1"
|
||
placeholder={
|
||
isSub
|
||
? t("executiveMeetings.manage.attendees.subheadingName")
|
||
: t("executiveMeetings.manage.attendees.name")
|
||
}
|
||
value={attendee.name}
|
||
onChange={(e) => onChangeName(e.target.value)}
|
||
/>
|
||
<Button size="icon" variant="ghost" onClick={onRemove}>
|
||
<Trash2 className="w-4 h-4 text-red-600" />
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MeetingFormDialog({
|
||
state,
|
||
onChange,
|
||
onSave,
|
||
onClose,
|
||
saving,
|
||
t,
|
||
}: {
|
||
state: MeetingFormState;
|
||
onChange: (s: MeetingFormState) => void;
|
||
// Receives the canonical "HH:mm" 24h start/end values that the
|
||
// time pickers commit() resolved. The dialog blocks Save when
|
||
// either picker reports `invalid` or `ambiguous`, so the parent
|
||
// never has to second-guess: if onSave fires, the times have
|
||
// been validated.
|
||
onSave: (committedStart: string, committedEnd: string) => void;
|
||
onClose: () => void;
|
||
saving: boolean;
|
||
t: (k: string) => string;
|
||
}) {
|
||
const { toast } = useToast();
|
||
const confirm = useConfirm();
|
||
// Imperative handles to the start/end pickers so the Save click
|
||
// can read the live draft (typed text + AM/PM toggle) and surface
|
||
// ambiguous/invalid input as a toast rather than silently saving
|
||
// a stale value. Same belt-and-braces pattern the inline schedule
|
||
// editor uses (TimeRangeCell).
|
||
const startPickerRef = useRef<TimePicker12hHandle | null>(null);
|
||
const endPickerRef = useRef<TimePicker12hHandle | null>(null);
|
||
|
||
function handleSaveClick() {
|
||
const startResult = startPickerRef.current?.commit() ?? {
|
||
value: null,
|
||
error: null as null,
|
||
};
|
||
const endResult = endPickerRef.current?.commit() ?? {
|
||
value: null,
|
||
error: null as null,
|
||
};
|
||
if (startResult.error === "invalid") {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.timeParseError"),
|
||
variant: "destructive",
|
||
});
|
||
startPickerRef.current?.focus();
|
||
startPickerRef.current?.select();
|
||
return;
|
||
}
|
||
if (startResult.error === "ambiguous") {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.timeAmbiguousError"),
|
||
variant: "destructive",
|
||
});
|
||
startPickerRef.current?.focus();
|
||
return;
|
||
}
|
||
if (endResult.error === "invalid") {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.timeParseError"),
|
||
variant: "destructive",
|
||
});
|
||
endPickerRef.current?.focus();
|
||
endPickerRef.current?.select();
|
||
return;
|
||
}
|
||
if (endResult.error === "ambiguous") {
|
||
toast({
|
||
title: t("executiveMeetings.schedule.timeAmbiguousError"),
|
||
variant: "destructive",
|
||
});
|
||
endPickerRef.current?.focus();
|
||
return;
|
||
}
|
||
onSave(startResult.value ?? "", endResult.value ?? "");
|
||
}
|
||
function update<K extends keyof MeetingFormState>(k: K, v: MeetingFormState[K]) {
|
||
onChange({ ...state, [k]: v });
|
||
}
|
||
function addAttendee() {
|
||
onChange({
|
||
...state,
|
||
attendees: [
|
||
...state.attendees,
|
||
{
|
||
name: "",
|
||
title: null,
|
||
attendanceType: "internal",
|
||
sortOrder: state.attendees.length,
|
||
kind: "person",
|
||
_sid: genAttendeeSid(),
|
||
},
|
||
],
|
||
});
|
||
}
|
||
function addSubheading() {
|
||
onChange({
|
||
...state,
|
||
attendees: [
|
||
...state.attendees,
|
||
{
|
||
name: "",
|
||
// Subheadings have no separate title field — they are a single
|
||
// free-text label. Keep title null on the row to match what the
|
||
// server stores.
|
||
title: null,
|
||
// Default new subheadings into "internal" — the user can switch
|
||
// the section via the same Select that persons use.
|
||
attendanceType: "internal",
|
||
sortOrder: state.attendees.length,
|
||
kind: "subheading",
|
||
_sid: genAttendeeSid(),
|
||
},
|
||
],
|
||
});
|
||
}
|
||
function removeAttendee(i: number) {
|
||
const arr = state.attendees.slice();
|
||
arr.splice(i, 1);
|
||
onChange({ ...state, attendees: arr });
|
||
}
|
||
// Bulk-clear: empties the in-memory attendee list. Persists only when
|
||
// the user clicks the dialog's Save button (just like the per-row
|
||
// remove). Confirmation surfaces the count so users don't nuke a long
|
||
// list by accident.
|
||
async function removeAllAttendees() {
|
||
const count = state.attendees.length;
|
||
if (count === 0) return;
|
||
const message = t(
|
||
"executiveMeetings.manage.attendees.removeAllConfirm",
|
||
).replace("{{count}}", String(count));
|
||
if (!(await confirm({ message, destructive: true }))) return;
|
||
onChange({ ...state, attendees: [] });
|
||
}
|
||
function setAttendee(i: number, patch: Partial<Attendee>) {
|
||
const arr = state.attendees.slice();
|
||
arr[i] = { ...arr[i], ...patch } as Attendee;
|
||
onChange({ ...state, attendees: arr });
|
||
}
|
||
function moveAttendee(i: number, dir: -1 | 1) {
|
||
const j = i + dir;
|
||
if (j < 0 || j >= state.attendees.length) return;
|
||
const arr = state.attendees.slice();
|
||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||
onChange({ ...state, attendees: arr });
|
||
}
|
||
// Drag-and-drop reorder driven by dnd-kit. Items are keyed by their
|
||
// client `_sid` so the SortableContext stays stable across renames,
|
||
// attendance-type changes, etc. We resolve the over/active sids to
|
||
// their current array positions inside the handler so consecutive
|
||
// drags compose correctly.
|
||
function reorderAttendeesByDrag(activeSid: string, overSid: string) {
|
||
if (activeSid === overSid) return;
|
||
const arr = state.attendees;
|
||
const from = arr.findIndex((a) => a._sid === activeSid);
|
||
const to = arr.findIndex((a) => a._sid === overSid);
|
||
if (from < 0 || to < 0) return;
|
||
const next = arr.slice();
|
||
const [moved] = next.splice(from, 1);
|
||
next.splice(to, 0, moved);
|
||
onChange({ ...state, attendees: next });
|
||
}
|
||
// Pointer + keyboard sensors with a small activation distance so that
|
||
// clicking the inputs / buttons inside a row doesn't start a drag.
|
||
const dragSensors = useSensors(
|
||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
}),
|
||
);
|
||
// Memoize the id list so SortableContext only re-resolves when the
|
||
// attendee order actually changes.
|
||
const sortableIds = useMemo(
|
||
() =>
|
||
state.attendees.map(
|
||
(a, i) => a._sid ?? `__missing-sid-${i}`,
|
||
),
|
||
[state.attendees],
|
||
);
|
||
|
||
return (
|
||
<Dialog open={true} onOpenChange={(o) => !o && onClose()}>
|
||
{/* #324: rebuild DialogContent as a flex column so the header,
|
||
a single scrollable middle, and the footer stack predictably
|
||
on mobile. Without this the whole sheet scrolled and the
|
||
Save/Cancel buttons slid below the visible 90vh viewport on
|
||
phones. Reduced mobile padding/gaps; desktop unchanged. */}
|
||
<DialogContent className="max-w-3xl max-h-[90vh] p-4 sm:p-6 flex flex-col gap-3 sm:gap-4 overflow-hidden">
|
||
<DialogHeader className="shrink-0">
|
||
<DialogTitle>
|
||
{state.id == null
|
||
? t("executiveMeetings.manage.addMeeting")
|
||
: t("executiveMeetings.manage.editMeeting")}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="flex-1 min-h-0 overflow-y-auto -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-3 sm:space-y-4">
|
||
{/* #322 polish (grid): title spans full width. Date + daily
|
||
number always share one row (both are short). Time pickers
|
||
are full-width on mobile so the AM/PM controls don't get
|
||
cramped, but share a row on tablet/desktop. */}
|
||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||
<FormRow
|
||
label={t("executiveMeetings.manage.field.titleAr")}
|
||
full
|
||
>
|
||
<Input
|
||
value={state.titleAr}
|
||
onChange={(e) => update("titleAr", e.target.value)}
|
||
data-testid="em-form-titleAr"
|
||
/>
|
||
</FormRow>
|
||
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
|
||
<Input
|
||
type="date"
|
||
value={state.meetingDate}
|
||
onChange={(e) => update("meetingDate", e.target.value)}
|
||
/>
|
||
</FormRow>
|
||
<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")}
|
||
mobileFull
|
||
>
|
||
<TimePicker12h
|
||
ref={startPickerRef}
|
||
value={state.startTime}
|
||
onChange={(v) => update("startTime", v)}
|
||
ariaLabel={t("executiveMeetings.manage.field.startTime")}
|
||
groupLabel={t("executiveMeetings.timeEditor.periodGroupStart")}
|
||
amLabel={t("executiveMeetings.timeEditor.am")}
|
||
pmLabel={t("executiveMeetings.timeEditor.pm")}
|
||
size="form"
|
||
inputTestId="em-form-startTime"
|
||
minuteTestId="em-form-startTime-minute"
|
||
amTestId="em-form-startTime-am"
|
||
pmTestId="em-form-startTime-pm"
|
||
/>
|
||
</FormRow>
|
||
<FormRow
|
||
label={t("executiveMeetings.manage.field.endTime")}
|
||
mobileFull
|
||
>
|
||
<TimePicker12h
|
||
ref={endPickerRef}
|
||
value={state.endTime}
|
||
onChange={(v) => update("endTime", v)}
|
||
ariaLabel={t("executiveMeetings.manage.field.endTime")}
|
||
groupLabel={t("executiveMeetings.timeEditor.periodGroupEnd")}
|
||
amLabel={t("executiveMeetings.timeEditor.am")}
|
||
pmLabel={t("executiveMeetings.timeEditor.pm")}
|
||
size="form"
|
||
inputTestId="em-form-endTime"
|
||
minuteTestId="em-form-endTime-minute"
|
||
amTestId="em-form-endTime-am"
|
||
pmTestId="em-form-endTime-pm"
|
||
/>
|
||
</FormRow>
|
||
{/* #322: location, meetingUrl, platform, status,
|
||
isHighlighted, and notes inputs were removed from the
|
||
dialog. Their values still round-trip through
|
||
MeetingFormState and the save body so existing data is
|
||
preserved for meetings being edited. */}
|
||
</div>
|
||
|
||
<div className="border-t border-gray-200 pt-3">
|
||
<div className="flex items-center justify-between mb-2 gap-2 flex-wrap">
|
||
<h4 className="font-semibold text-sm text-[#0B1E3F]">
|
||
{t("executiveMeetings.manage.attendees.label")} (
|
||
{
|
||
// Persons only — subheadings are labels, not attendees.
|
||
state.attendees.filter(
|
||
(a) => (a.kind ?? "person") === "person",
|
||
).length
|
||
}
|
||
)
|
||
</h4>
|
||
<div className="flex items-center gap-2">
|
||
{state.attendees.length > 0 && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={removeAllAttendees}
|
||
className="gap-1 border-red-300 text-red-700 hover:bg-red-50 hover:text-red-800"
|
||
data-testid="em-attendee-remove-all"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
{t("executiveMeetings.manage.attendees.removeAll")}
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={addSubheading}
|
||
className="gap-1"
|
||
data-testid="em-attendee-add-subheading"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
{t("executiveMeetings.manage.attendees.addSubheading")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={addAttendee}
|
||
className="gap-1"
|
||
data-testid="em-attendee-add"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
{t("executiveMeetings.manage.attendees.add")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{/* Drag-and-drop reordering wraps the whole list. The
|
||
up/down arrow buttons inside each row continue to work
|
||
in parallel — DnD is an additive convenience, not a
|
||
replacement, so keyboard-only flows keep functioning
|
||
even if the SortableContext fails to mount. */}
|
||
<DndContext
|
||
sensors={dragSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={(e: DragEndEvent) => {
|
||
const { active, over } = e;
|
||
if (!over) return;
|
||
reorderAttendeesByDrag(
|
||
String(active.id),
|
||
String(over.id),
|
||
);
|
||
}}
|
||
>
|
||
<SortableContext
|
||
items={sortableIds}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
{state.attendees.map((a, i) => (
|
||
<SortableAttendeeRow
|
||
key={a._sid ?? `__missing-sid-${i}`}
|
||
attendee={a}
|
||
index={i}
|
||
last={i === state.attendees.length - 1}
|
||
t={t}
|
||
onMoveUp={() => moveAttendee(i, -1)}
|
||
onMoveDown={() => moveAttendee(i, 1)}
|
||
onChangeName={(v) => setAttendee(i, { name: v })}
|
||
onRemove={() => removeAttendee(i)}
|
||
/>
|
||
))}
|
||
</SortableContext>
|
||
</DndContext>
|
||
{state.attendees.length === 0 && (
|
||
<div className="text-xs text-gray-500">—</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* #324: footer sits outside the scroll area so it stays
|
||
pinned to the bottom of the dialog even after many
|
||
attendees are added. */}
|
||
<DialogFooter className="shrink-0">
|
||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||
{t("executiveMeetings.common.cancel")}
|
||
</Button>
|
||
<Button
|
||
onClick={handleSaveClick}
|
||
disabled={saving}
|
||
className="bg-[#0B1E3F]"
|
||
data-testid="em-form-save"
|
||
>
|
||
{saving ? t("common.loading") : t("executiveMeetings.common.save")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
function FormRow({
|
||
label,
|
||
children,
|
||
full,
|
||
mobileFull,
|
||
hint,
|
||
}: {
|
||
label: string;
|
||
children: React.ReactNode;
|
||
/** Always span both columns of a 2-col grid. Safe in single-column
|
||
* grids too (col-span-2 just clamps to the available width). */
|
||
full?: boolean;
|
||
/** Span both columns on mobile but a single column from sm+. Used
|
||
* by #324 so the time pickers don't get squeezed on phones. */
|
||
mobileFull?: boolean;
|
||
hint?: string;
|
||
}) {
|
||
const span = full
|
||
? "col-span-2"
|
||
: mobileFull
|
||
? "col-span-2 sm:col-span-1"
|
||
: "";
|
||
return (
|
||
<div className={span}>
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// ============ 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>
|
||
|
||
<NotificationPrefsCard isRtl={isRtl} t={t} />
|
||
|
||
<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>
|
||
);
|
||
}
|
||
|
||
type NotificationPref = {
|
||
notificationType: string;
|
||
inApp: boolean;
|
||
email: boolean;
|
||
};
|
||
|
||
type NotificationPrefsResponse = {
|
||
types: string[];
|
||
prefs: NotificationPref[];
|
||
};
|
||
|
||
/**
|
||
* Per-user notification preferences card. Toggles in-app vs email
|
||
* delivery for each event type the Executive Meetings module fans out.
|
||
*
|
||
* Rendering rules:
|
||
* - One row per event type returned by the server (server is the
|
||
* source of truth for "which event types exist").
|
||
* - Both channels default ON; an explicit off toggle persists a
|
||
* mute-row server-side so the fan-out helpers skip the user.
|
||
* - Saves are batched: we accumulate edits in local state and flush
|
||
* them together via "Save preferences".
|
||
*/
|
||
function NotificationPrefsCard({
|
||
isRtl,
|
||
t,
|
||
}: {
|
||
isRtl: boolean;
|
||
t: (k: string) => string;
|
||
}) {
|
||
const qc = useQueryClient();
|
||
const { toast } = useToast();
|
||
const { data, isLoading } = useQuery<NotificationPrefsResponse>({
|
||
queryKey: ["/api/executive-meetings/notification-prefs"],
|
||
queryFn: () =>
|
||
apiJson<NotificationPrefsResponse>(
|
||
"/api/executive-meetings/notification-prefs",
|
||
),
|
||
});
|
||
const [draft, setDraft] = useState<Map<string, NotificationPref> | null>(
|
||
null,
|
||
);
|
||
// When the server payload arrives (or refetches), seed local edit state.
|
||
// We only seed when local state is null so that an in-flight edit isn't
|
||
// clobbered by a background refetch.
|
||
useEffect(() => {
|
||
if (!data || draft) return;
|
||
const m = new Map<string, NotificationPref>();
|
||
for (const p of data.prefs) m.set(p.notificationType, { ...p });
|
||
setDraft(m);
|
||
}, [data, draft]);
|
||
|
||
const dirty = useMemo(() => {
|
||
if (!data || !draft) return false;
|
||
for (const p of data.prefs) {
|
||
const cur = draft.get(p.notificationType);
|
||
if (!cur) continue;
|
||
if (cur.inApp !== p.inApp || cur.email !== p.email) return true;
|
||
}
|
||
return false;
|
||
}, [data, draft]);
|
||
|
||
function update(type: string, channel: "inApp" | "email", v: boolean) {
|
||
setDraft((prev) => {
|
||
if (!prev) return prev;
|
||
const next = new Map(prev);
|
||
const cur = next.get(type);
|
||
if (!cur) return prev;
|
||
next.set(type, { ...cur, [channel]: v });
|
||
return next;
|
||
});
|
||
}
|
||
|
||
async function save() {
|
||
if (!draft) return;
|
||
const prefs = Array.from(draft.values());
|
||
try {
|
||
await apiJson("/api/executive-meetings/notification-prefs", {
|
||
method: "PUT",
|
||
body: { prefs },
|
||
});
|
||
await qc.invalidateQueries({
|
||
queryKey: ["/api/executive-meetings/notification-prefs"],
|
||
});
|
||
// Clear local state so the next render reseeds from the freshly
|
||
// refetched payload — keeps client and server in lockstep.
|
||
setDraft(null);
|
||
toast({ title: t("executiveMeetings.notificationsPage.prefs.saved") });
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
toast({ title: msg, variant: "destructive" });
|
||
}
|
||
}
|
||
|
||
function reset() {
|
||
if (!data) return;
|
||
const m = new Map<string, NotificationPref>();
|
||
for (const p of data.prefs) m.set(p.notificationType, { ...p });
|
||
setDraft(m);
|
||
}
|
||
|
||
// #236: wipe every server-side pref row for the current user. The GET
|
||
// handler then synthesizes the defaults (in_app=true, email=true) for
|
||
// every event type, so a single round-trip restores the factory state
|
||
// without the client having to PUT a full payload of `true` toggles.
|
||
async function restoreDefaults() {
|
||
try {
|
||
await apiJson("/api/executive-meetings/notification-prefs", {
|
||
method: "DELETE",
|
||
});
|
||
// #236: invalidate FIRST so the refetch lands before we drop the
|
||
// local draft. If we cleared draft first, the reseed effect at
|
||
// line ~6284 would fire against the still-cached pre-delete
|
||
// payload, repopulating draft from stale data; the later refetch
|
||
// would then no-op because draft is non-null again.
|
||
await qc.invalidateQueries({
|
||
queryKey: ["/api/executive-meetings/notification-prefs"],
|
||
});
|
||
setDraft(null);
|
||
toast({ title: t("executiveMeetings.notificationsPage.prefs.restored") });
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
toast({ title: msg, variant: "destructive" });
|
||
}
|
||
}
|
||
|
||
const types = data?.types ?? [];
|
||
|
||
return (
|
||
<div
|
||
className="bg-white border border-gray-200 rounded-md p-4"
|
||
dir={isRtl ? "rtl" : "ltr"}
|
||
data-testid="em-notification-prefs"
|
||
>
|
||
<div className="flex items-center justify-between gap-3 mb-1">
|
||
<h3 className="text-sm sm:text-base font-semibold text-[#0B1E3F]">
|
||
{t("executiveMeetings.notificationsPage.prefs.heading")}
|
||
</h3>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
|
||
{t("executiveMeetings.notificationsPage.prefs.intro")}
|
||
</p>
|
||
{isLoading || !draft ? (
|
||
<div className="py-4 text-sm text-gray-500">{t("common.loading")}</div>
|
||
) : (
|
||
<>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="text-xs text-gray-500">
|
||
<tr>
|
||
<th className="px-2 py-1 text-start font-medium">
|
||
{t("executiveMeetings.notificationsPage.prefs.col.event")}
|
||
</th>
|
||
<th className="px-2 py-1 text-center font-medium w-24">
|
||
{t("executiveMeetings.notificationsPage.prefs.col.inApp")}
|
||
</th>
|
||
<th className="px-2 py-1 text-center font-medium w-24">
|
||
{t("executiveMeetings.notificationsPage.prefs.col.email")}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{types.map((type) => {
|
||
const cur = draft.get(type);
|
||
if (!cur) return null;
|
||
return (
|
||
<tr key={type} className="border-t border-gray-100">
|
||
<td className="px-2 py-2 text-[#0B1E3F]">
|
||
{tWithFallback(
|
||
t,
|
||
`executiveMeetings.notificationsPage.type.${type}`,
|
||
type,
|
||
)}
|
||
</td>
|
||
<td className="px-2 py-2 text-center">
|
||
<Switch
|
||
checked={cur.inApp}
|
||
onCheckedChange={(v) => update(type, "inApp", !!v)}
|
||
aria-label={t(
|
||
"executiveMeetings.notificationsPage.prefs.col.inApp",
|
||
)}
|
||
data-testid={`em-pref-inapp-${type}`}
|
||
/>
|
||
</td>
|
||
<td className="px-2 py-2 text-center">
|
||
<Switch
|
||
checked={cur.email}
|
||
onCheckedChange={(v) => update(type, "email", !!v)}
|
||
aria-label={t(
|
||
"executiveMeetings.notificationsPage.prefs.col.email",
|
||
)}
|
||
data-testid={`em-pref-email-${type}`}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="flex items-center gap-2 mt-3">
|
||
<Button
|
||
size="sm"
|
||
onClick={save}
|
||
disabled={!dirty}
|
||
className="bg-[#0B1E3F]"
|
||
data-testid="em-pref-save"
|
||
>
|
||
{t("executiveMeetings.notificationsPage.prefs.save")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={reset}
|
||
disabled={!dirty}
|
||
data-testid="em-pref-reset"
|
||
>
|
||
{t("executiveMeetings.notificationsPage.prefs.reset")}
|
||
</Button>
|
||
{/* #236: restore-defaults wipes server rows even when the
|
||
local draft is clean, so it must NOT be gated on `dirty`. */}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={restoreDefaults}
|
||
data-testid="em-pref-restore-defaults"
|
||
>
|
||
{t("executiveMeetings.notificationsPage.prefs.restoreDefaults")}
|
||
</Button>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
// ============ SETTINGS ============
|
||
// #265: Unified settings tab — wraps font preferences plus the column /
|
||
// highlight customizer that used to live in a popover above the schedule.
|
||
|
||
function SettingsSection({
|
||
font,
|
||
canEditGlobalFont,
|
||
columns,
|
||
onColumnsChange,
|
||
highlightPrefs,
|
||
onHighlightPrefsChange,
|
||
t,
|
||
}: {
|
||
font: FontPrefs;
|
||
canEditGlobalFont: boolean;
|
||
columns: ColumnSetting[];
|
||
onColumnsChange: (next: ColumnSetting[]) => void;
|
||
highlightPrefs: HighlightPrefs;
|
||
onHighlightPrefsChange: (next: HighlightPrefs) => void;
|
||
t: (k: string) => string;
|
||
}) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
|
||
<h2 className="text-base font-semibold text-[#0B1E3F] mb-3 flex items-center gap-2">
|
||
<Sliders className="w-4 h-4" />
|
||
{t("executiveMeetings.customize.title")}
|
||
</h2>
|
||
<ColumnsCustomizerPanel
|
||
columns={columns}
|
||
onChange={onColumnsChange}
|
||
t={t}
|
||
highlightPrefs={highlightPrefs}
|
||
onHighlightPrefsChange={onHighlightPrefsChange}
|
||
/>
|
||
</section>
|
||
<section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
|
||
<AlertPrefsCard t={t} />
|
||
</section>
|
||
<section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
|
||
<FontSettingsSection
|
||
font={font}
|
||
canEditGlobal={canEditGlobalFont}
|
||
t={t}
|
||
/>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============ ALERT POPUP PREFS ============
|
||
//
|
||
// Lets each user toggle the floating "upcoming meeting" alert on/off
|
||
// and pick a color preset for it. Prefs live entirely client-side
|
||
// (localStorage) and the floating alert in App.tsx subscribes via the
|
||
// shared `useAlertPrefs` hook so changes apply instantly without a
|
||
// page reload. See `lib/upcoming-alert-prefs.ts` for the contract.
|
||
|
||
function AlertPrefsCard({ t }: { t: (k: string) => string }) {
|
||
const [prefs, setPrefs] = useAlertPrefs();
|
||
|
||
const isPresetActive = (
|
||
p: (typeof ALERT_COLOR_PRESETS)[number],
|
||
): boolean =>
|
||
p.bg.toLowerCase() === prefs.bg.toLowerCase() &&
|
||
p.accent.toLowerCase() === prefs.accent.toLowerCase() &&
|
||
p.fg.toLowerCase() === prefs.fg.toLowerCase();
|
||
|
||
const applyPreset = (p: (typeof ALERT_COLOR_PRESETS)[number]) => {
|
||
const next: AlertPrefs = {
|
||
enabled: prefs.enabled,
|
||
bg: p.bg,
|
||
accent: p.accent,
|
||
fg: p.fg,
|
||
};
|
||
setPrefs(next);
|
||
};
|
||
|
||
const reset = () => setPrefs({ ...DEFAULT_ALERT_PREFS });
|
||
|
||
return (
|
||
<div className="space-y-4 max-w-2xl" data-testid="em-alert-prefs">
|
||
<div>
|
||
<h2 className="text-base font-semibold text-[#0B1E3F] flex items-center gap-2">
|
||
<Bell className="w-4 h-4" />
|
||
{t("executiveMeetings.alertPrefs.title")}
|
||
</h2>
|
||
<p className="text-xs text-gray-500 mt-1 leading-relaxed">
|
||
{t("executiveMeetings.alertPrefs.hint")}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-3 border-t border-gray-200 pt-3">
|
||
<label
|
||
htmlFor="em-alert-enabled-toggle"
|
||
className="text-sm font-medium text-[#0B1E3F] cursor-pointer"
|
||
>
|
||
{t("executiveMeetings.alertPrefs.enabledLabel")}
|
||
</label>
|
||
<Switch
|
||
id="em-alert-enabled-toggle"
|
||
checked={prefs.enabled}
|
||
onCheckedChange={(v) => setPrefs({ ...prefs, enabled: !!v })}
|
||
data-testid="em-alert-enabled-toggle"
|
||
/>
|
||
</div>
|
||
|
||
<div className="border-t border-gray-200 pt-3">
|
||
<div className="text-sm font-medium text-[#0B1E3F] mb-2">
|
||
{t("executiveMeetings.alertPrefs.colorTitle")}
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{ALERT_COLOR_PRESETS.map((p) => {
|
||
const active = isPresetActive(p);
|
||
return (
|
||
<button
|
||
key={p.key}
|
||
type="button"
|
||
onClick={() => applyPreset(p)}
|
||
disabled={!prefs.enabled}
|
||
className={
|
||
"relative h-8 w-14 rounded-md border-2 transition-transform " +
|
||
(active
|
||
? "border-[#0B1E3F] scale-105"
|
||
: "border-gray-300 hover:scale-105") +
|
||
" disabled:opacity-50 disabled:cursor-not-allowed"
|
||
}
|
||
style={{ backgroundColor: p.bg }}
|
||
aria-label={t(`executiveMeetings.alertPrefs.preset.${p.key}`)}
|
||
aria-pressed={active}
|
||
data-testid={`em-alert-preset-${p.key}`}
|
||
title={t(`executiveMeetings.alertPrefs.preset.${p.key}`)}
|
||
>
|
||
<span
|
||
className="absolute inset-y-1 right-1 w-3 rounded-sm"
|
||
style={{ backgroundColor: p.accent }}
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
);
|
||
})}
|
||
<button
|
||
type="button"
|
||
onClick={reset}
|
||
className="ml-1 text-[11px] text-[#0B1E3F] underline hover:no-underline"
|
||
data-testid="em-alert-prefs-reset"
|
||
>
|
||
{t("executiveMeetings.alertPrefs.reset")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============ FONT SETTINGS ============
|
||
|
||
function FontSettingsSection({
|
||
font,
|
||
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>
|
||
{/*
|
||
Only families that ship locally via `custom-fonts.css`
|
||
(DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic,
|
||
Helvetica Neue, Majalla) plus the special "system"
|
||
option (= use whatever the site's CSS default is, which
|
||
is currently DIN Next LT Arabic). The previous list
|
||
included Cairo / Noto Naskh Arabic / Amiri but those
|
||
were never registered with @font-face, so picking them
|
||
produced no visible change. Keep this list in sync with
|
||
the FONT_FAMILIES enum on the API server, otherwise the
|
||
PATCH font-settings request will be rejected by Zod.
|
||
*/}
|
||
{[
|
||
"system",
|
||
"DIN Next LT Arabic",
|
||
"Tajawal",
|
||
"Helvetica Neue LT Arabic",
|
||
"Helvetica Neue",
|
||
"Majalla",
|
||
].map((f) => (
|
||
<SelectItem key={f} value={f}>
|
||
{f}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</FormRow>
|
||
<FormRow label={`${t("executiveMeetings.fontSettingsPage.fontSize")}: ${prefs.fontSize}px`}>
|
||
<input
|
||
type="range"
|
||
min={12}
|
||
max={22}
|
||
value={prefs.fontSize}
|
||
onChange={(e) => setPrefs({ ...prefs, fontSize: Number(e.target.value) })}
|
||
className="w-full"
|
||
/>
|
||
</FormRow>
|
||
<FormRow label={t("executiveMeetings.fontSettingsPage.fontWeight")}>
|
||
<Select
|
||
value={prefs.fontWeight}
|
||
onValueChange={(v) => setPrefs({ ...prefs, fontWeight: v as FontPrefs["fontWeight"] })}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{(["regular", "bold"] as const).map((w) => (
|
||
<SelectItem key={w} value={w}>
|
||
{t(`executiveMeetings.fontSettingsPage.weight.${w}`)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</FormRow>
|
||
<FormRow label={t("executiveMeetings.fontSettingsPage.alignment")}>
|
||
<Select
|
||
value={prefs.alignment}
|
||
onValueChange={(v) => setPrefs({ ...prefs, alignment: v as FontPrefs["alignment"] })}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{(["start", "center"] as const).map((a) => (
|
||
<SelectItem key={a} value={a}>
|
||
{t(`executiveMeetings.fontSettingsPage.align.${a}`)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</FormRow>
|
||
|
||
<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>
|
||
);
|
||
}
|