7a2ae8434d
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: fa18e5d4-a810-4bd5-8cde-2a60d64d9e3f Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu Replit-Helium-Checkpoint-Created: true
2090 lines
76 KiB
TypeScript
2090 lines
76 KiB
TypeScript
// #273: Floating, draggable 5-minute pre-meeting alert.
|
||
// Mounted globally from App.tsx so it stays visible while the user
|
||
// navigates around tx-os. Polls /api/executive-meetings for today and
|
||
// the per-user alert state every 30 s, and listens to React Query
|
||
// invalidations from the executive_meetings_changed socket event.
|
||
|
||
import {
|
||
Fragment,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type PointerEvent as ReactPointerEvent,
|
||
type ReactNode,
|
||
} from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
import { useAuth } from "@/contexts/AuthContext";
|
||
import {
|
||
Calendar,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Clock,
|
||
ExternalLink,
|
||
GripVertical,
|
||
Users,
|
||
X,
|
||
} from "lucide-react";
|
||
import { hexToRgba, useAlertPrefs } from "@/lib/upcoming-alert-prefs";
|
||
import { formatTime as formatTimeLocale } from "@/lib/i18n-format";
|
||
import { notificationPlayer } from "@/lib/notification-sounds";
|
||
// #486: ApiError + apiJson moved to a shared module so the new
|
||
// schedule row quick-actions popover can reuse the PostponeDialog
|
||
// (exported below) and inspect the same structured 409 stale_meeting
|
||
// payload without duplicating the fetch wrapper.
|
||
import { ApiError, apiJson } from "@/lib/api-json";
|
||
|
||
const POSITION_KEY = "txos.upcomingMeetingAlert.position";
|
||
const ALERT_LEAD_MINUTES = 5;
|
||
const POLL_MS = 30_000;
|
||
|
||
// Subset of attendee fields we need for the details panel. The server
|
||
// returns more (sortOrder, attendanceType, etc.) but the popup only
|
||
// shows name + type grouping.
|
||
type Attendee = {
|
||
id?: number;
|
||
name: string;
|
||
attendanceType: "internal" | "external" | "virtual" | string;
|
||
kind?: "person" | "subheading";
|
||
};
|
||
|
||
type Meeting = {
|
||
id: number;
|
||
dailyNumber: number;
|
||
titleAr: string;
|
||
titleEn: string;
|
||
meetingDate: string;
|
||
startTime: string | null;
|
||
endTime: string | null;
|
||
location: string | null;
|
||
meetingUrl: string | null;
|
||
status: string;
|
||
notes: string | null;
|
||
platform: "none" | "webex" | "teams" | "zoom" | "other";
|
||
attendees: Attendee[];
|
||
// #283: optimistic-locking token used by the postpone flow to detect
|
||
// when another user mutated this meeting after it was loaded. Server
|
||
// sets it on every update; client echoes it back as expectedUpdatedAt.
|
||
updatedAt: string;
|
||
};
|
||
type DayResponse = { date: string; meetings: Meeting[] };
|
||
type AlertState = {
|
||
meetingId: number;
|
||
dismissed: boolean;
|
||
acknowledged: boolean;
|
||
};
|
||
type Capabilities = {
|
||
canRead: boolean;
|
||
canMutate: boolean;
|
||
};
|
||
|
||
function parseTimeToMinutes(t: string | null | undefined): number | null {
|
||
if (!t) return null;
|
||
const m = /^(\d{1,2}):(\d{2})/.exec(t);
|
||
if (!m) return null;
|
||
const h = Number(m[1]);
|
||
const mm = Number(m[2]);
|
||
if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
|
||
return h * 60 + mm;
|
||
}
|
||
function todayLocalDate(): string {
|
||
const d = new Date();
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
function nowLocalMinutes(): number {
|
||
const d = new Date();
|
||
return d.getHours() * 60 + d.getMinutes();
|
||
}
|
||
// #307: Render scheduled HH:mm strings as a localized 12-hour clock
|
||
// (e.g. "2:30 PM" / "2:30 م"). The stored value is always a wall-clock
|
||
// "HH:mm[:ss]" so we anchor it on today's date just to feed Intl.
|
||
function formatTime(t: string | null, lang: string): string {
|
||
if (!t) return "";
|
||
const m = /^(\d{1,2}):(\d{2})/.exec(t);
|
||
if (!m) return t;
|
||
const d = new Date();
|
||
d.setHours(Number(m[1]), Number(m[2]), 0, 0);
|
||
return formatTimeLocale(d, lang, {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
hour12: true,
|
||
});
|
||
}
|
||
|
||
// #283/#486: ApiError + apiJson are imported above from
|
||
// `@/lib/api-json` so this file and the schedule row quick-actions
|
||
// popover share one fetch wrapper and one error class.
|
||
|
||
type DragPosition = { x: number; y: number };
|
||
function loadPosition(): DragPosition | null {
|
||
try {
|
||
const raw = localStorage.getItem(POSITION_KEY);
|
||
if (!raw) return null;
|
||
const obj = JSON.parse(raw) as DragPosition;
|
||
if (typeof obj.x !== "number" || typeof obj.y !== "number") return null;
|
||
return obj;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
function savePosition(pos: DragPosition) {
|
||
try {
|
||
localStorage.setItem(POSITION_KEY, JSON.stringify(pos));
|
||
} catch {
|
||
/* ignore storage errors */
|
||
}
|
||
}
|
||
function defaultPosition(): DragPosition {
|
||
// Top-right corner with 16px margin; gracefully degrades on small screens.
|
||
if (typeof window === "undefined") return { x: 16, y: 16 };
|
||
const w = Math.min(380, window.innerWidth - 32);
|
||
return { x: Math.max(16, window.innerWidth - w - 16), y: 80 };
|
||
}
|
||
|
||
export function UpcomingMeetingAlert() {
|
||
const { user, isLoading: authLoading } = useAuth();
|
||
const { t, i18n } = useTranslation();
|
||
const isRtl = i18n.language === "ar";
|
||
const { toast } = useToast();
|
||
const queryClient = useQueryClient();
|
||
const [now, setNow] = useState<number>(() => nowLocalMinutes());
|
||
const [postponeOpen, setPostponeOpen] = useState(false);
|
||
const [savingAction, setSavingAction] = useState<string | null>(null);
|
||
// Per-user theme + enable toggle for this floating popup. Lives in
|
||
// localStorage and is shared with the settings card via a custom
|
||
// event — see `lib/upcoming-alert-prefs.ts`.
|
||
const [alertPrefs] = useAlertPrefs();
|
||
// Inline-expand state for the new "Details" toggle that replaced the
|
||
// old "Open meetings" button. Resets whenever the eligible meeting
|
||
// changes so a new alert doesn't pop open already-expanded.
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
// Tick the clock every minute so the countdown stays current. Also
|
||
// tick when the tab regains focus so users coming back to it don't
|
||
// see a stale 5-minutes-ago timestamp.
|
||
useEffect(() => {
|
||
const interval = setInterval(() => setNow(nowLocalMinutes()), 30_000);
|
||
const onFocus = () => setNow(nowLocalMinutes());
|
||
window.addEventListener("focus", onFocus);
|
||
return () => {
|
||
clearInterval(interval);
|
||
window.removeEventListener("focus", onFocus);
|
||
};
|
||
}, []);
|
||
|
||
const today = todayLocalDate();
|
||
|
||
const { data: caps } = useQuery<Capabilities>({
|
||
queryKey: ["/api/executive-meetings/me", user?.id ?? null],
|
||
enabled: !authLoading && !!user,
|
||
queryFn: async () => {
|
||
const res = await fetch("/api/executive-meetings/me", {
|
||
credentials: "include",
|
||
});
|
||
if (res.status === 401 || res.status === 403) {
|
||
return { canRead: false, canMutate: false };
|
||
}
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const json = (await res.json()) as Capabilities;
|
||
return { canRead: json.canRead, canMutate: json.canMutate };
|
||
},
|
||
staleTime: 5 * 60_000,
|
||
});
|
||
|
||
const enabled = !!user && !!caps?.canRead;
|
||
|
||
const { data: dayData } = useQuery<DayResponse>({
|
||
queryKey: ["/api/executive-meetings", today],
|
||
enabled,
|
||
queryFn: async () => {
|
||
const res = await fetch(`/api/executive-meetings?date=${today}`, {
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json();
|
||
},
|
||
refetchInterval: POLL_MS,
|
||
refetchOnWindowFocus: true,
|
||
});
|
||
|
||
const { data: alertStateData, refetch: refetchAlertState } = useQuery<{
|
||
states: AlertState[];
|
||
}>({
|
||
queryKey: ["/api/executive-meetings/alert-state", today, user?.id ?? null],
|
||
enabled,
|
||
queryFn: async () => {
|
||
const res = await fetch(
|
||
`/api/executive-meetings/alert-state?date=${today}`,
|
||
{ credentials: "include" },
|
||
);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json();
|
||
},
|
||
refetchInterval: POLL_MS,
|
||
});
|
||
|
||
const stateById = useMemo(() => {
|
||
const map = new Map<number, AlertState>();
|
||
for (const s of alertStateData?.states ?? []) map.set(s.meetingId, s);
|
||
return map;
|
||
}, [alertStateData]);
|
||
|
||
// #480: id → dailyNumber lookup for today's meetings, used by the
|
||
// cascade-affected-meetings table inside the postpone/reschedule
|
||
// dialog so each row can show the user-facing meeting number.
|
||
const meetingNumbersById = useMemo(() => {
|
||
const map = new Map<number, number>();
|
||
for (const m of dayData?.meetings ?? []) map.set(m.id, m.dailyNumber);
|
||
return map;
|
||
}, [dayData]);
|
||
|
||
// Pick the *next upcoming* meeting that:
|
||
// - is on today,
|
||
// - has a known startTime,
|
||
// - is not cancelled or completed,
|
||
// - is strictly in the future and starts within the next 5 minutes
|
||
// (0 < remainingMinutes <= ALERT_LEAD_MINUTES),
|
||
// - hasn't been acknowledged or dismissed by this user.
|
||
// The alert hides as soon as the meeting actually starts (delta <= 0).
|
||
const eligible = useMemo(() => {
|
||
const meetings = dayData?.meetings ?? [];
|
||
const candidates = meetings
|
||
.map((m) => {
|
||
const s = parseTimeToMinutes(m.startTime);
|
||
if (s == null) return null;
|
||
if (m.status === "cancelled" || m.status === "completed") return null;
|
||
const state = stateById.get(m.id);
|
||
if (state?.dismissed || state?.acknowledged) return null;
|
||
const delta = s - now; // positive = in the future
|
||
if (delta <= 0) return null;
|
||
if (delta > ALERT_LEAD_MINUTES) return null;
|
||
return { meeting: m, delta };
|
||
})
|
||
.filter(
|
||
(
|
||
x,
|
||
): x is {
|
||
meeting: Meeting;
|
||
delta: number;
|
||
} => !!x,
|
||
);
|
||
candidates.sort((a, b) => a.delta - b.delta);
|
||
return candidates[0] ?? null;
|
||
}, [dayData, stateById, now]);
|
||
|
||
// Record "shown" the first time we surface a given meeting so admins
|
||
// can audit who actually saw the alert. We use a ref to dedupe.
|
||
const shownRef = useRef<Set<number>>(new Set());
|
||
useEffect(() => {
|
||
if (!eligible || !enabled) return;
|
||
const id = eligible.meeting.id;
|
||
const state = stateById.get(id);
|
||
if (state) return; // already has a row -> already counted as shown
|
||
if (shownRef.current.has(id)) return;
|
||
shownRef.current.add(id);
|
||
void apiJson(`/api/executive-meetings/${id}/alert-state`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ action: "shown" }),
|
||
})
|
||
.then(() => refetchAlertState())
|
||
.catch(() => {
|
||
shownRef.current.delete(id);
|
||
});
|
||
}, [eligible, enabled, stateById, refetchAlertState]);
|
||
|
||
// ----- Drag handling -----
|
||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||
const [pos, setPos] = useState<DragPosition>(
|
||
() => loadPosition() ?? defaultPosition(),
|
||
);
|
||
|
||
// #273: keep the floating panel inside the viewport whenever the
|
||
// window resizes (e.g. mobile orientation change, or the user
|
||
// dragging the browser window narrower than where they had pinned
|
||
// the alert). Runs once on mount to clamp any stale localStorage
|
||
// position from a wider viewport, then on every resize.
|
||
useEffect(() => {
|
||
const clamp = () => {
|
||
const w = window.innerWidth;
|
||
const h = window.innerHeight;
|
||
const el = panelRef.current;
|
||
const elW = el?.offsetWidth ?? 360;
|
||
const elH = el?.offsetHeight ?? 160;
|
||
setPos((p) => {
|
||
const x = Math.max(8, Math.min(Math.max(8, w - elW - 8), p.x));
|
||
const y = Math.max(8, Math.min(Math.max(8, h - elH - 8), p.y));
|
||
if (x === p.x && y === p.y) return p;
|
||
const next = { x, y };
|
||
savePosition(next);
|
||
return next;
|
||
});
|
||
};
|
||
clamp();
|
||
window.addEventListener("resize", clamp);
|
||
window.addEventListener("orientationchange", clamp);
|
||
return () => {
|
||
window.removeEventListener("resize", clamp);
|
||
window.removeEventListener("orientationchange", clamp);
|
||
};
|
||
}, []);
|
||
|
||
const dragState = useRef<{
|
||
pointerId: number;
|
||
offsetX: number;
|
||
offsetY: number;
|
||
} | null>(null);
|
||
const onDragStart = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||
const target = e.currentTarget;
|
||
target.setPointerCapture(e.pointerId);
|
||
const rect = target.parentElement?.getBoundingClientRect();
|
||
dragState.current = {
|
||
pointerId: e.pointerId,
|
||
offsetX: e.clientX - (rect?.left ?? 0),
|
||
offsetY: e.clientY - (rect?.top ?? 0),
|
||
};
|
||
};
|
||
const onDragMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||
const st = dragState.current;
|
||
if (!st || st.pointerId !== e.pointerId) return;
|
||
const w = window.innerWidth;
|
||
const h = window.innerHeight;
|
||
// Clamp against the *full panel* dimensions (panelRef), not the
|
||
// drag-handle parent, otherwise the user can drag the alert so far
|
||
// down/right that the body and action buttons spill off-screen
|
||
// until the next resize-clamp pass corrects it.
|
||
const panel = panelRef.current;
|
||
const elW = panel?.offsetWidth ?? 360;
|
||
const elH = panel?.offsetHeight ?? 160;
|
||
const x = Math.max(8, Math.min(Math.max(8, w - elW - 8), e.clientX - st.offsetX));
|
||
const y = Math.max(8, Math.min(Math.max(8, h - elH - 8), e.clientY - st.offsetY));
|
||
setPos({ x, y });
|
||
};
|
||
const onDragEnd = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||
const st = dragState.current;
|
||
if (!st || st.pointerId !== e.pointerId) return;
|
||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||
dragState.current = null;
|
||
setPos((p) => {
|
||
savePosition(p);
|
||
return p;
|
||
});
|
||
};
|
||
|
||
const handleDone = useCallback(async () => {
|
||
if (!eligible) return;
|
||
const id = eligible.meeting.id;
|
||
setSavingAction("done");
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${id}/alert-state`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ action: "acknowledged" }),
|
||
});
|
||
await refetchAlertState();
|
||
} catch (err) {
|
||
toast({
|
||
title: t("executiveMeetings.alert.saveFailed"),
|
||
description: err instanceof Error ? err.message : String(err),
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setSavingAction(null);
|
||
}
|
||
}, [eligible, refetchAlertState, t, toast]);
|
||
|
||
const handleDismiss = useCallback(async () => {
|
||
if (!eligible) return;
|
||
const id = eligible.meeting.id;
|
||
setSavingAction("dismiss");
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${id}/alert-state`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ action: "dismissed" }),
|
||
});
|
||
await refetchAlertState();
|
||
toast({ title: t("executiveMeetings.alert.dismissToast") });
|
||
} catch (err) {
|
||
toast({
|
||
title: t("executiveMeetings.alert.saveFailed"),
|
||
description: err instanceof Error ? err.message : String(err),
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setSavingAction(null);
|
||
}
|
||
}, [eligible, refetchAlertState, t, toast]);
|
||
|
||
// Whenever the eligible meeting changes (a new alert pops up after the
|
||
// user dismisses or finishes the previous one) collapse the details
|
||
// section so the next alert opens compact again. Tracked by id only so
|
||
// unrelated re-renders (countdown tick, query refetch with same data)
|
||
// don't reset user expansion mid-view.
|
||
const eligibleId = eligible?.meeting.id ?? null;
|
||
useEffect(() => {
|
||
setExpanded(false);
|
||
}, [eligibleId]);
|
||
|
||
// scale-in + fade entrance that mirrors the incoming-note
|
||
// popup, so the alert feels like the same family of "important
|
||
// floating thing arrived". Toggled false → true on the next paint of
|
||
// each new eligible meeting so the CSS transition has a starting
|
||
// state to interpolate from.
|
||
const [enterAnim, setEnterAnim] = useState(false);
|
||
useEffect(() => {
|
||
if (!eligibleId) return;
|
||
setEnterAnim(false);
|
||
const handle = requestAnimationFrame(() => setEnterAnim(true));
|
||
return () => cancelAnimationFrame(handle);
|
||
}, [eligibleId]);
|
||
|
||
// Play the meeting reminder sound exactly once per *new* eligible
|
||
// meeting (deduped by id) so polling refetches don't replay the chime
|
||
// every 30s. Plays whether the tab is foreground or background — the
|
||
// user wants to hear the reminder either way. Honors mute and the
|
||
// per-channel meeting toggle.
|
||
const playedRef = useRef<Set<number>>(new Set());
|
||
useEffect(() => {
|
||
if (!eligibleId || !user) return;
|
||
if (playedRef.current.has(eligibleId)) return;
|
||
playedRef.current.add(eligibleId);
|
||
if (user.notificationsMuted) return;
|
||
if (!user.notifyMeetingsEnabled) return;
|
||
notificationPlayer.play(user.notificationSoundMeeting, {
|
||
vibrate: user.vibrationEnabledMeeting,
|
||
});
|
||
}, [eligibleId, user]);
|
||
|
||
if (!enabled || !eligible) return null;
|
||
// User toggled the popup off in settings — render nothing. Detection
|
||
// happens after `enabled/eligible` so the upstream "shown" recording
|
||
// in the effect above still fires for audit purposes only when the
|
||
// user actually wanted to see alerts.
|
||
if (!alertPrefs.enabled) return null;
|
||
|
||
const meeting = eligible.meeting;
|
||
const title = isRtl
|
||
? meeting.titleAr || meeting.titleEn
|
||
: meeting.titleEn || meeting.titleAr;
|
||
const minutesAway = Math.max(0, eligible.delta);
|
||
const headline =
|
||
minutesAway === 0
|
||
? t("executiveMeetings.alert.startsNow")
|
||
: t("executiveMeetings.alert.minutesAway", { count: minutesAway });
|
||
|
||
// Theme tokens derived from the user's saved preset. The accent is
|
||
// used for the divider lines and the close-X hover; the bg/fg are
|
||
// applied directly. We compute soft variants once per render so the
|
||
// popup feels cohesive without us having to ship a CSS variable.
|
||
const dividerColor = hexToRgba(alertPrefs.accent, 0.45);
|
||
const closeHoverBg = hexToRgba(alertPrefs.accent, 0.25);
|
||
const detailsBg = hexToRgba(alertPrefs.accent, 0.12);
|
||
// #344: `accentText` was removed because the only consumer (the
|
||
// "starts in N minutes" headline) now uses a fixed red blink class.
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
ref={panelRef}
|
||
role="dialog"
|
||
aria-live="polite"
|
||
aria-label={t("executiveMeetings.alert.title")}
|
||
data-testid="upcoming-meeting-alert"
|
||
dir={isRtl ? "rtl" : "ltr"}
|
||
// #282: While the postpone modal is open, hide the floating
|
||
// alert panel so the modal (and its overlay) are not covered by
|
||
// the alert's `z-[60]` layer. We collapse the panel with
|
||
// `hidden` (display:none) instead of bumping z-index — that way
|
||
// the user can't accidentally drag/click the alert through the
|
||
// modal, and we leave the shared dialog primitive untouched so
|
||
// other dialogs/toasts are unaffected. The panel reappears
|
||
// automatically when the modal closes (if the alert is still
|
||
// valid for the current time window).
|
||
// #478: `pointer-events-auto` keeps the alert's buttons clickable
|
||
// even while a Radix `Dialog` is open elsewhere in the app (e.g.
|
||
// the notes thread dialog). Radix dialogs ship with
|
||
// `react-remove-scroll`, which sets `pointer-events: none` on
|
||
// the document body — anything portaled outside the dialog
|
||
// (including this fixed alert at `z-[60]`) inherits that and
|
||
// silently swallows clicks. Forcing pointer-events back to auto
|
||
// here lets the user act on the reminder without having to
|
||
// close the unrelated dialog first.
|
||
className={`pointer-events-auto fixed z-[60] w-[min(380px,calc(100vw-32px))] rounded-lg shadow-xl ${
|
||
postponeOpen ? "hidden" : ""
|
||
} ${enterAnim ? "scale-100 opacity-100" : "scale-95 opacity-0"}`}
|
||
aria-hidden={postponeOpen ? true : undefined}
|
||
style={{
|
||
left: pos.x,
|
||
top: pos.y,
|
||
backgroundColor: alertPrefs.bg,
|
||
color: alertPrefs.fg,
|
||
// #438: ring-4 frame in the user's accent matching the
|
||
// incoming-note popup. Implemented via boxShadow so the
|
||
// color tracks `alertPrefs.accent` (Tailwind ring-* requires
|
||
// a static class). The first shadow is the colored ring; the
|
||
// second preserves the existing shadow-xl drop.
|
||
boxShadow:
|
||
`0 0 0 4px ${hexToRgba(alertPrefs.accent, 0.7)},` +
|
||
` 0 20px 25px -5px rgba(0,0,0,0.1),` +
|
||
` 0 8px 10px -6px rgba(0,0,0,0.1)`,
|
||
transition:
|
||
"transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1)," +
|
||
" opacity 200ms ease-out",
|
||
transformOrigin: "center",
|
||
}}
|
||
>
|
||
<div
|
||
className="flex items-center justify-between gap-2 border-b px-3 py-2"
|
||
style={{ borderColor: dividerColor }}
|
||
>
|
||
<div
|
||
className="flex flex-1 cursor-grab items-center gap-2 select-none active:cursor-grabbing"
|
||
onPointerDown={onDragStart}
|
||
onPointerMove={onDragMove}
|
||
onPointerUp={onDragEnd}
|
||
onPointerCancel={onDragEnd}
|
||
data-testid="alert-drag-handle"
|
||
aria-label={t("executiveMeetings.alert.dragHandle")}
|
||
// #441: On touch devices (iPad/Safari) the browser's
|
||
// default touch-action treats a finger drag on the handle
|
||
// as a candidate scroll gesture and swallows the
|
||
// pointermove events before our handler can run, causing
|
||
// the alert to stutter or refuse to follow the finger.
|
||
// `touch-action: none` tells the browser we're handling
|
||
// the gesture ourselves so it delivers every move.
|
||
style={{ touchAction: "none" }}
|
||
>
|
||
{/* #438: pulsing accent halo around the drag handle to
|
||
match the incoming-note popup's avatar ping. Inline
|
||
background uses the user's accent so it tracks any
|
||
preset. */}
|
||
<span
|
||
className="relative inline-flex h-4 w-4 shrink-0 items-center justify-center"
|
||
// #441: the ping halo overlays the GripVertical icon, so
|
||
// without `pointer-events: none` it can intercept the
|
||
// finger touch on iPad and prevent the drag from
|
||
// starting on the handle below it.
|
||
style={{ pointerEvents: "none" }}
|
||
>
|
||
<span
|
||
className="absolute inset-0 rounded-full animate-ping"
|
||
style={{ backgroundColor: hexToRgba(alertPrefs.accent, 0.6) }}
|
||
aria-hidden="true"
|
||
/>
|
||
<GripVertical
|
||
className="relative h-4 w-4 opacity-80"
|
||
aria-hidden="true"
|
||
/>
|
||
</span>
|
||
<span className="text-sm font-semibold">
|
||
{t("executiveMeetings.alert.title")}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleDismiss}
|
||
disabled={savingAction !== null}
|
||
aria-label={t("executiveMeetings.alert.dismiss")}
|
||
data-testid="alert-close"
|
||
className="rounded p-1 transition-colors"
|
||
// Hover background derived from the user's accent so it
|
||
// works for any preset (light/dark, warm/cool).
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.backgroundColor = closeHoverBg;
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.backgroundColor = "transparent";
|
||
}}
|
||
>
|
||
<X className="h-4 w-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
<div className="px-3 py-3">
|
||
<div
|
||
className="text-sm font-medium"
|
||
data-testid="alert-meeting-title"
|
||
// titles may contain sanitized inline rich-text from the
|
||
// schedule editor; show as plain text only here.
|
||
>
|
||
{title.replace(/<[^>]+>/g, "").trim() || title}
|
||
</div>
|
||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||
<span
|
||
className="em-blink font-semibold text-red-600"
|
||
data-testid="alert-countdown"
|
||
>
|
||
{headline}
|
||
</span>
|
||
{meeting.startTime ? (
|
||
<span
|
||
className="inline-flex items-center gap-1 opacity-80"
|
||
data-testid="alert-time-window"
|
||
// The time window is always rendered LTR (start on the
|
||
// left, end on the right) even inside the Arabic UI —
|
||
// requested explicitly by the user (#344). The clock
|
||
// icon precedes the digits in source order; with
|
||
// dir="ltr" that puts it visually on the left.
|
||
dir="ltr"
|
||
>
|
||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||
{meeting.endTime
|
||
? `${formatTime(meeting.startTime, i18n.language)} – ${formatTime(meeting.endTime, i18n.language)}`
|
||
: t("executiveMeetings.alert.atTime", {
|
||
time: formatTime(meeting.startTime, i18n.language),
|
||
})}
|
||
</span>
|
||
) : null}
|
||
{meeting.location ? (
|
||
<span className="inline-flex items-center gap-1 opacity-80">
|
||
<Calendar className="h-3 w-3" aria-hidden="true" />
|
||
{meeting.location}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{expanded ? (
|
||
<DetailsPanel
|
||
meeting={meeting}
|
||
isRtl={isRtl}
|
||
t={t}
|
||
lang={i18n.language}
|
||
dividerColor={dividerColor}
|
||
sectionBg={detailsBg}
|
||
/>
|
||
) : null}
|
||
<div
|
||
className="flex items-center gap-2 border-t px-3 py-2"
|
||
style={{ borderColor: dividerColor }}
|
||
>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={handleDone}
|
||
disabled={savingAction !== null}
|
||
data-testid="alert-done"
|
||
>
|
||
{t("executiveMeetings.alert.done")}
|
||
</Button>
|
||
{caps?.canMutate ? (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setPostponeOpen(true)}
|
||
disabled={savingAction !== null}
|
||
data-testid="alert-postpone"
|
||
>
|
||
{t("executiveMeetings.alert.postpone")}
|
||
</Button>
|
||
) : null}
|
||
{/*
|
||
#344: The labelled "Dismiss alert" button was removed at
|
||
the user's request — the X close button in the header
|
||
(which still calls handleDismiss) is the single way to
|
||
hide the popup, keeping the action bar focused on
|
||
"Done" / "Postpone" / "Details".
|
||
*/}
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
className="ms-auto"
|
||
onClick={() => setExpanded((v) => !v)}
|
||
disabled={savingAction !== null}
|
||
data-testid="alert-details-toggle"
|
||
aria-expanded={expanded}
|
||
aria-controls="alert-details-panel"
|
||
>
|
||
{expanded ? (
|
||
<>
|
||
<ChevronUp className="me-1 h-3.5 w-3.5" aria-hidden="true" />
|
||
{t("executiveMeetings.alert.detailsClose")}
|
||
</>
|
||
) : (
|
||
<>
|
||
<ChevronDown
|
||
className="me-1 h-3.5 w-3.5"
|
||
aria-hidden="true"
|
||
/>
|
||
{t("executiveMeetings.alert.details")}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{caps?.canMutate ? (
|
||
<PostponeDialog
|
||
open={postponeOpen}
|
||
onOpenChange={setPostponeOpen}
|
||
meeting={meeting}
|
||
isRtl={isRtl}
|
||
meetingNumbersById={meetingNumbersById}
|
||
accent={alertPrefs.accent}
|
||
onSaved={async () => {
|
||
setPostponeOpen(false);
|
||
// Invalidate today and the alert-state cache so the alert
|
||
// disappears (or re-appears) immediately based on the new
|
||
// status / time window.
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({
|
||
queryKey: ["/api/executive-meetings"],
|
||
}),
|
||
refetchAlertState(),
|
||
]);
|
||
}}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---------- Postpone sub-modal ----------
|
||
|
||
// #302: cascade-prompt shapes used by both the postpone-minutes and
|
||
// reschedule flows. Hoisted to module scope so the small
|
||
// `CascadePromptBlock` helper component can be defined alongside the
|
||
// dialog without re-declaring them inside the function body.
|
||
type CascadeFollower = {
|
||
id: number;
|
||
titleAr: string;
|
||
titleEn: string;
|
||
oldStart: string;
|
||
oldEnd: string;
|
||
newStart: string;
|
||
newEnd: string;
|
||
};
|
||
type CascadeBlocked = {
|
||
id: number;
|
||
titleAr: string;
|
||
titleEn: string;
|
||
projectedStart: string;
|
||
projectedEnd: string;
|
||
};
|
||
type CascadePrompt =
|
||
| {
|
||
kind: "postpone";
|
||
delta: number;
|
||
loading: boolean;
|
||
followers: CascadeFollower[];
|
||
blockedBy: CascadeBlocked | null;
|
||
}
|
||
| {
|
||
kind: "reschedule";
|
||
delta: number;
|
||
// Snapshot of the form values at the moment of the click so the
|
||
// submit uses the same draft the preview was computed against,
|
||
// even if the user later changes the inputs underneath.
|
||
draft: {
|
||
meetingDate: string;
|
||
startTime: string;
|
||
endTime: string;
|
||
note: string;
|
||
};
|
||
loading: boolean;
|
||
followers: CascadeFollower[];
|
||
blockedBy: CascadeBlocked | null;
|
||
};
|
||
|
||
function CascadePromptBlock({
|
||
prompt,
|
||
isRtl,
|
||
busy,
|
||
meetingNumbersById,
|
||
accent,
|
||
lang,
|
||
onShiftFollowing,
|
||
onKeepTimes,
|
||
onBack,
|
||
}: {
|
||
prompt: CascadePrompt;
|
||
isRtl: boolean;
|
||
busy: boolean;
|
||
// #480: maps meeting id → dailyNumber so the affected-meetings table
|
||
// can show the user-facing meeting number alongside title/times.
|
||
// Built by the parent from already-loaded dayData.meetings; missing
|
||
// ids fall back to "—" rather than blocking the prompt.
|
||
meetingNumbersById: Map<number, number>;
|
||
// #481: accent hex from the user's alert color theme, used to derive
|
||
// the prompt's background + border via hexToRgba so the cascade block
|
||
// matches the rest of the alert instead of hard-coded amber.
|
||
accent: string;
|
||
// #481: active i18n language so times render localized 12-hour with
|
||
// ص/م (AR) or AM/PM (EN).
|
||
lang: string;
|
||
onShiftFollowing: () => void;
|
||
onKeepTimes: () => void;
|
||
onBack: () => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const promptBg = hexToRgba(accent, 0.12);
|
||
const promptBorder = hexToRgba(accent, 0.45);
|
||
// While the preview is in flight, we still render the block (so the
|
||
// user sees something happen after they click "Yes") but with empty
|
||
// content + a loading hint. The buttons are disabled until either
|
||
// followers or blockedBy land.
|
||
if (prompt.loading) {
|
||
return (
|
||
<div
|
||
className="flex flex-col gap-2 rounded border p-3"
|
||
style={{ backgroundColor: promptBg, borderColor: promptBorder }}
|
||
data-testid="cascade-prompt-loading"
|
||
>
|
||
<p className="text-sm font-medium">
|
||
{t("executiveMeetings.alert.cascadeLoading")}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const titleOf = (m: { titleAr: string; titleEn: string }) => {
|
||
const raw = isRtl
|
||
? m.titleAr || m.titleEn
|
||
: m.titleEn || m.titleAr;
|
||
return raw.replace(/<[^>]+>/g, "").trim() || raw;
|
||
};
|
||
|
||
if (prompt.blockedBy) {
|
||
// Midnight rejection: server (or preview) refused because shifting
|
||
// a follower would push it past 24:00. Offer "Keep their times"
|
||
// (apply the primary only) and "Back" (return to chips/form). The
|
||
// offending meeting is named verbatim so the user knows which one
|
||
// is the blocker without re-opening the schedule.
|
||
return (
|
||
<div
|
||
className="flex flex-col gap-2 rounded border border-rose-300 bg-rose-50 p-3 dark:border-rose-700 dark:bg-rose-950"
|
||
data-testid="cascade-prompt-blocked"
|
||
>
|
||
<p className="text-sm font-medium">
|
||
{t("executiveMeetings.alert.cascadeMidnightError", {
|
||
title: titleOf(prompt.blockedBy),
|
||
projectedStart: formatTime(prompt.blockedBy.projectedStart, lang),
|
||
})}
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={onKeepTimes}
|
||
disabled={busy}
|
||
data-testid="cascade-keep-times"
|
||
>
|
||
{t("executiveMeetings.alert.cascadeKeep")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={onBack}
|
||
disabled={busy}
|
||
data-testid="cascade-back"
|
||
>
|
||
{t("executiveMeetings.alert.back")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="flex flex-col gap-2 rounded border p-3"
|
||
style={{ backgroundColor: promptBg, borderColor: promptBorder }}
|
||
data-testid="cascade-prompt"
|
||
>
|
||
<p className="text-sm font-medium">
|
||
{t("executiveMeetings.alert.cascadePromptHeader", {
|
||
count: prompt.followers.length,
|
||
minutes: prompt.delta,
|
||
minutesText: t("executiveMeetings.alert.cascadeMinutesUnit", {
|
||
count: prompt.delta,
|
||
}),
|
||
})}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("executiveMeetings.alert.cascadeListTitle")}
|
||
</p>
|
||
{/* #481: 3-column table (Meeting #, Title, From → To) so the user
|
||
can scan affected meetings by their meeting-of-the-day number
|
||
alongside the title and the projected time shift. */}
|
||
<div className="max-h-32 overflow-y-auto overflow-x-hidden rounded bg-white/60 px-2 py-1 dark:bg-black/30">
|
||
<table
|
||
className="w-full table-fixed border-collapse text-xs"
|
||
data-testid="cascade-followers-list"
|
||
>
|
||
{/* #485: explicit colgroup so the meeting-# header doesn't
|
||
wrap to two lines, the title column truncates with an
|
||
ellipsis instead of pushing the times off-screen, and
|
||
the From → To column always fits on one line. */}
|
||
<colgroup>
|
||
{/* w-20 leaves room for the Arabic header "رقم الاجتماع"
|
||
with whitespace-nowrap; w-12 was too tight at common
|
||
font sizes. */}
|
||
<col className="w-20" />
|
||
<col />
|
||
<col className="w-40" />
|
||
</colgroup>
|
||
<thead className="text-muted-foreground">
|
||
<tr>
|
||
<th scope="col" className="px-1 py-1 text-start font-medium tabular-nums whitespace-nowrap">
|
||
{t("executiveMeetings.alert.cascadeColMeetingNumber")}
|
||
</th>
|
||
<th scope="col" className="px-1 py-1 text-start font-medium">
|
||
{t("executiveMeetings.alert.cascadeColTitle")}
|
||
</th>
|
||
<th scope="col" className="px-1 py-1 text-end font-medium tabular-nums whitespace-nowrap">
|
||
{t("executiveMeetings.alert.cascadeColTimes")}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{prompt.followers.map((f) => {
|
||
const dn = meetingNumbersById.get(f.id);
|
||
const title = titleOf(f);
|
||
return (
|
||
<tr
|
||
key={f.id}
|
||
className="border-t border-black/5 dark:border-white/10"
|
||
data-testid={`cascade-follower-${f.id}`}
|
||
>
|
||
<td className="px-1 py-0.5 tabular-nums">
|
||
{dn != null ? dn : "—"}
|
||
</td>
|
||
<td className="px-1 py-0.5 truncate" title={title}>
|
||
{title}
|
||
</td>
|
||
<td className="px-1 py-0.5 text-end tabular-nums whitespace-nowrap">
|
||
{formatTime(f.oldStart, lang)} {isRtl ? "←" : "→"} {formatTime(f.newStart, lang)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={onShiftFollowing}
|
||
disabled={busy}
|
||
data-testid="cascade-shift-following"
|
||
>
|
||
{t("executiveMeetings.alert.cascadeShift", {
|
||
count: prompt.followers.length,
|
||
})}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={onKeepTimes}
|
||
disabled={busy}
|
||
data-testid="cascade-keep-times"
|
||
>
|
||
{t("executiveMeetings.alert.cascadeKeep")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={onBack}
|
||
disabled={busy}
|
||
data-testid="cascade-back"
|
||
>
|
||
{t("executiveMeetings.alert.back")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type PostponeDialogProps = {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
meeting: Meeting;
|
||
isRtl: boolean;
|
||
// #480: id → dailyNumber lookup for the cascade-affected-meetings
|
||
// table. Provided by the parent so we don't refetch today's meetings
|
||
// inside the dialog.
|
||
meetingNumbersById: Map<number, number>;
|
||
// #481: accent hex from the parent's alert color theme; threaded
|
||
// into CascadePromptBlock so the cascade box matches the alert's
|
||
// chosen palette instead of hard-coded amber.
|
||
accent: string;
|
||
onSaved: () => void | Promise<void>;
|
||
};
|
||
|
||
type PostponeTab = "minutes" | "reschedule" | "cancel";
|
||
|
||
// #486: Exported so the Executive Meetings schedule page can mount the
|
||
// same dialog from its row quick-actions popover. The Meeting type
|
||
// (also exported as PostponeDialogMeeting) is the structural minimum
|
||
// the dialog reads from — any caller passing a row with these fields
|
||
// satisfies it.
|
||
export type PostponeDialogMeeting = Meeting;
|
||
export { PostponeDialog };
|
||
function PostponeDialog({
|
||
open,
|
||
onOpenChange,
|
||
meeting,
|
||
isRtl,
|
||
meetingNumbersById,
|
||
accent,
|
||
onSaved,
|
||
}: PostponeDialogProps) {
|
||
const { t, i18n } = useTranslation();
|
||
const { toast } = useToast();
|
||
const [minutes, setMinutes] = useState<string>("10");
|
||
const [resDate, setResDate] = useState<string>(meeting.meetingDate);
|
||
const [resStart, setResStart] = useState<string>(
|
||
meeting.startTime ? meeting.startTime.slice(0, 5) : "",
|
||
);
|
||
const [resEnd, setResEnd] = useState<string>(
|
||
meeting.endTime ? meeting.endTime.slice(0, 5) : "",
|
||
);
|
||
const [resNote, setResNote] = useState<string>("");
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [confirmCancel, setConfirmCancel] = useState<boolean>(false);
|
||
// #275: tabs replace the three stacked sections — only the active
|
||
// workflow is rendered, keeping the dialog compact (~520px tall).
|
||
const [activeTab, setActiveTab] = useState<PostponeTab>("minutes");
|
||
// #275: pendingMinutes holds the chip/manual value the user picked
|
||
// before the API call fires. While it's non-null, the dialog shows
|
||
// an "Are you sure? — Confirm / Back" prompt instead of executing.
|
||
const [pendingMinutes, setPendingMinutes] = useState<number | null>(null);
|
||
// #283: When the server returns 409 stale_meeting (someone else
|
||
// postponed/rescheduled this meeting between load and click), we
|
||
// capture the conflict payload here and render an explicit
|
||
// "Already postponed by X — apply N more minutes anyway?" prompt
|
||
// instead of silently double-shifting the meeting. `latestUpdatedAt`
|
||
// is the freshest server-observed token; the apply-anyway retry
|
||
// resubmits with it so a *third* concurrent mutation between
|
||
// conflict-display and click triggers another 409 instead of a
|
||
// silent stack.
|
||
const [staleConflict, setStaleConflict] = useState<{
|
||
minutes: number;
|
||
actorName: string | null;
|
||
currentStart: string | null;
|
||
currentEnd: string | null;
|
||
latestUpdatedAt: string;
|
||
} | null>(null);
|
||
// #302: cascade-prompt state. Once the user has confirmed the
|
||
// delta (or filled in the reschedule form and clicked Apply), we
|
||
// run cascade-preview. If there are no later active meetings we
|
||
// auto-fall-through to the single-meeting submit. Otherwise we
|
||
// render a "Shift following meetings (N) / Keep their times / Back"
|
||
// prompt. `blockedBy` is set when either the preview *or* the
|
||
// actual submit returns 400 cascade_crosses_midnight, in which
|
||
// case the prompt collapses to "Back / Keep their times".
|
||
const [cascadePrompt, setCascadePrompt] = useState<CascadePrompt | null>(
|
||
null,
|
||
);
|
||
|
||
// Reset local form state whenever the dialog opens against a new meeting
|
||
// so stale values don't leak between alerts.
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setMinutes("10");
|
||
setResDate(meeting.meetingDate);
|
||
setResStart(meeting.startTime ? meeting.startTime.slice(0, 5) : "");
|
||
setResEnd(meeting.endTime ? meeting.endTime.slice(0, 5) : "");
|
||
setResNote("");
|
||
setBusy(null);
|
||
setConfirmCancel(false);
|
||
setActiveTab("minutes");
|
||
setPendingMinutes(null);
|
||
setStaleConflict(null);
|
||
setCascadePrompt(null);
|
||
}, [open, meeting.id, meeting.meetingDate, meeting.startTime, meeting.endTime]);
|
||
|
||
const title = isRtl
|
||
? meeting.titleAr || meeting.titleEn
|
||
: meeting.titleEn || meeting.titleAr;
|
||
const titleText = title.replace(/<[^>]+>/g, "").trim() || title;
|
||
|
||
const handleResult = (
|
||
res: { conflicts?: boolean } | undefined,
|
||
successKey: string,
|
||
) => {
|
||
toast({ title: t(successKey) });
|
||
if (res?.conflicts) {
|
||
// The mutation already succeeded — the conflict notice is purely
|
||
// informational ("you may now have overlapping meetings"), so use
|
||
// the default (non-destructive) toast variant rather than the red
|
||
// "destructive" style that implies the action failed.
|
||
toast({
|
||
title: t("executiveMeetings.alert.conflictWarning"),
|
||
});
|
||
}
|
||
};
|
||
const handleErr = (err: unknown) => {
|
||
toast({
|
||
title: t("executiveMeetings.alert.saveFailed"),
|
||
description: err instanceof Error ? err.message : String(err),
|
||
variant: "destructive",
|
||
});
|
||
};
|
||
|
||
// #273: shared mutation used by both the manual "Apply" button and
|
||
// each minute chip click so the chips can shift start/end immediately.
|
||
// #283: every call sends `expectedUpdatedAt`. The first attempt uses
|
||
// the token from the loaded meeting; the "apply anyway" retry passes
|
||
// the freshly-seen token from the conflict payload so a third
|
||
// concurrent mutation between conflict-display and click will trigger
|
||
// another 409 instead of silently stacking.
|
||
const postponeBy = async (
|
||
delta: number,
|
||
expectedUpdatedAt?: string,
|
||
// #302: cascade later same-day meetings by the same delta.
|
||
cascadeFollowing: boolean = false,
|
||
) => {
|
||
if (!Number.isFinite(delta) || delta <= 0) return;
|
||
const token = expectedUpdatedAt ?? meeting.updatedAt;
|
||
if (!token) {
|
||
// Defensive: should never happen — meeting payload always includes
|
||
// updatedAt. If it ever is missing, fail loudly rather than send
|
||
// a request the server will reject as malformed.
|
||
toast({
|
||
title: t("executiveMeetings.alert.saveFailed"),
|
||
description: "Missing updatedAt token",
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
setBusy("minutes");
|
||
try {
|
||
const res = await apiJson<{ conflicts?: boolean; cascadedIds?: number[] }>(
|
||
`/api/executive-meetings/${meeting.id}/postpone-minutes`,
|
||
{
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
minutes: Math.floor(delta),
|
||
expectedUpdatedAt: token,
|
||
cascadeFollowing,
|
||
}),
|
||
},
|
||
);
|
||
handleResult(res, "executiveMeetings.alert.saved");
|
||
setStaleConflict(null);
|
||
setCascadePrompt(null);
|
||
await onSaved();
|
||
} catch (err) {
|
||
// #302: cascade_crosses_midnight → re-render the cascade prompt
|
||
// in the blocked state so the user can choose "Keep their times"
|
||
// (apply primary only) or back out. Don't toast — the prompt
|
||
// itself surfaces the offending meeting.
|
||
if (
|
||
err instanceof ApiError &&
|
||
err.code === "cascade_crosses_midnight"
|
||
) {
|
||
const blockedBy = (err.body as { blockedBy?: CascadeBlocked })
|
||
.blockedBy;
|
||
if (blockedBy) {
|
||
setCascadePrompt({
|
||
kind: "postpone",
|
||
delta: Math.floor(delta),
|
||
loading: false,
|
||
followers: [],
|
||
blockedBy,
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
// #283: 409 stale_meeting → don't error-toast; capture the
|
||
// conflict so the dialog can show the "already postponed by X"
|
||
// prompt with the option to apply N minutes anyway. We carry
|
||
// `lastModifiedAt` forward so the retry uses the freshly-seen
|
||
// token, not the original one.
|
||
if (err instanceof ApiError && err.code === "stale_meeting") {
|
||
const conflict = (err.body as {
|
||
conflict?: {
|
||
currentStartTime: string | null;
|
||
currentEndTime: string | null;
|
||
lastModifiedAt: string;
|
||
lastActor: {
|
||
displayNameAr: string | null;
|
||
displayNameEn: string | null;
|
||
username: string | null;
|
||
} | null;
|
||
};
|
||
}).conflict;
|
||
if (!conflict) {
|
||
handleErr(err);
|
||
return;
|
||
}
|
||
const actor = conflict.lastActor ?? null;
|
||
const actorName = actor
|
||
? (isRtl
|
||
? actor.displayNameAr || actor.displayNameEn || actor.username
|
||
: actor.displayNameEn || actor.displayNameAr || actor.username)
|
||
: null;
|
||
setStaleConflict({
|
||
minutes: Math.floor(delta),
|
||
actorName: actorName ?? null,
|
||
currentStart: conflict.currentStartTime ?? null,
|
||
currentEnd: conflict.currentEndTime ?? null,
|
||
latestUpdatedAt: conflict.lastModifiedAt,
|
||
});
|
||
// Clear the "are you sure?" prompt — we're replacing it with
|
||
// the conflict-aware prompt rendered below.
|
||
setPendingMinutes(null);
|
||
} else {
|
||
handleErr(err);
|
||
}
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
// #275: route both chip clicks and the manual "Apply" through the
|
||
// pending-confirm gate so the user always sees an explicit
|
||
// "Are you sure?" before the meeting actually shifts.
|
||
const requestPostpone = (n: number) => {
|
||
if (!Number.isFinite(n) || n <= 0) return;
|
||
setPendingMinutes(Math.floor(n));
|
||
};
|
||
const onPostponeMinutes = () => requestPostpone(Number(minutes));
|
||
|
||
// #302: shared helper — POST cascade-preview, then either
|
||
// auto-fall-through to the single-meeting submit (no followers) or
|
||
// surface the cascade prompt. Used by both the postpone-minutes
|
||
// confirm step and the reschedule Apply step.
|
||
const runCascadePreview = async (args:
|
||
| { kind: "postpone"; delta: number }
|
||
| {
|
||
kind: "reschedule";
|
||
delta: number;
|
||
draft: { meetingDate: string; startTime: string; endTime: string; note: string };
|
||
}
|
||
) => {
|
||
// #302: in-flight guard. If a preview (or its auto-fall-through
|
||
// submit) is already running, ignore the new trigger so we can't
|
||
// duplicate the resulting postpone/reschedule mutation. Both the
|
||
// confirm button and Apply button gate on `cascadePrompt` and
|
||
// `busy` to disable themselves, but a fast double-click could
|
||
// still slip through React's render — this is the belt-and-braces
|
||
// server-call guard.
|
||
if (cascadePrompt?.loading || busy) return;
|
||
setCascadePrompt({
|
||
...args,
|
||
loading: true,
|
||
followers: [],
|
||
blockedBy: null,
|
||
} as CascadePrompt);
|
||
try {
|
||
const res = await apiJson<{
|
||
followers: CascadeFollower[];
|
||
blockedBy: CascadeBlocked | null;
|
||
}>(`/api/executive-meetings/${meeting.id}/cascade-preview`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ deltaMinutes: args.delta }),
|
||
});
|
||
// No followers AND not blocked → silently fall through to the
|
||
// single-meeting submit. The user never sees a cascade prompt
|
||
// when there's nothing to cascade.
|
||
if (res.followers.length === 0 && !res.blockedBy) {
|
||
setCascadePrompt(null);
|
||
if (args.kind === "postpone") {
|
||
await postponeBy(args.delta, undefined, false);
|
||
} else {
|
||
await rescheduleSubmit(args.draft, false);
|
||
}
|
||
return;
|
||
}
|
||
setCascadePrompt({
|
||
...args,
|
||
loading: false,
|
||
followers: res.followers,
|
||
blockedBy: res.blockedBy,
|
||
} as CascadePrompt);
|
||
} catch (err) {
|
||
// Preview failures are non-recoverable from the UI — fall back
|
||
// to a toast and clear the prompt so the user can retry.
|
||
setCascadePrompt(null);
|
||
handleErr(err);
|
||
}
|
||
};
|
||
|
||
// #302: extracted reschedule submit so the cascade-prompt path can
|
||
// resubmit with the snapshotted draft + an explicit cascade flag.
|
||
// Original `onReschedule` (kept below) now defers to the cascade
|
||
// preview when same-day forward-shift is detected.
|
||
const rescheduleSubmit = async (
|
||
draft: { meetingDate: string; startTime: string; endTime: string; note: string },
|
||
cascadeFollowing: boolean,
|
||
) => {
|
||
if (!draft.meetingDate || !draft.startTime || !draft.endTime) return;
|
||
setBusy("reschedule");
|
||
try {
|
||
const res = await apiJson<{ conflicts?: boolean; cascadedIds?: number[] }>(
|
||
`/api/executive-meetings/${meeting.id}/reschedule`,
|
||
{
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
meetingDate: draft.meetingDate,
|
||
startTime: `${draft.startTime}:00`,
|
||
endTime: `${draft.endTime}:00`,
|
||
note: draft.note || undefined,
|
||
cascadeFollowing,
|
||
}),
|
||
},
|
||
);
|
||
handleResult(res, "executiveMeetings.alert.saved");
|
||
setCascadePrompt(null);
|
||
await onSaved();
|
||
} catch (err) {
|
||
// #302: same blocked-rendering treatment as postpone.
|
||
if (
|
||
err instanceof ApiError &&
|
||
err.code === "cascade_crosses_midnight"
|
||
) {
|
||
const blockedBy = (err.body as { blockedBy?: CascadeBlocked })
|
||
.blockedBy;
|
||
if (blockedBy) {
|
||
// Compute delta for display continuity with the prompt.
|
||
const oldStartMin = meeting.startTime
|
||
? Number(meeting.startTime.slice(0, 2)) * 60 +
|
||
Number(meeting.startTime.slice(3, 5))
|
||
: null;
|
||
const newStartMin =
|
||
Number(draft.startTime.slice(0, 2)) * 60 +
|
||
Number(draft.startTime.slice(3, 5));
|
||
const delta =
|
||
oldStartMin != null ? newStartMin - oldStartMin : 0;
|
||
setCascadePrompt({
|
||
kind: "reschedule",
|
||
delta,
|
||
draft,
|
||
loading: false,
|
||
followers: [],
|
||
blockedBy,
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
handleErr(err);
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const onReschedule = async () => {
|
||
if (!resDate || !resStart || !resEnd) return;
|
||
const draft = {
|
||
meetingDate: resDate,
|
||
startTime: resStart,
|
||
endTime: resEnd,
|
||
note: resNote,
|
||
};
|
||
// #302: only run the cascade-preview gate when (a) the meeting
|
||
// stays on the same date AND (b) the new start is later than the
|
||
// old one — otherwise the cascade flag would be a no-op server-
|
||
// side and the prompt would be a confusing no-op too.
|
||
const oldStart = meeting.startTime ? meeting.startTime.slice(0, 5) : "";
|
||
const sameDate = draft.meetingDate === meeting.meetingDate;
|
||
const oldStartMin =
|
||
oldStart.length >= 5
|
||
? Number(oldStart.slice(0, 2)) * 60 + Number(oldStart.slice(3, 5))
|
||
: null;
|
||
const newStartMin =
|
||
Number(draft.startTime.slice(0, 2)) * 60 +
|
||
Number(draft.startTime.slice(3, 5));
|
||
const delta = oldStartMin != null ? newStartMin - oldStartMin : 0;
|
||
if (sameDate && oldStartMin != null && delta > 0) {
|
||
await runCascadePreview({ kind: "reschedule", delta, draft });
|
||
return;
|
||
}
|
||
await rescheduleSubmit(draft, false);
|
||
};
|
||
const onCancelMeeting = async () => {
|
||
setBusy("cancel");
|
||
try {
|
||
await apiJson(`/api/executive-meetings/${meeting.id}/cancel`, {
|
||
method: "POST",
|
||
body: JSON.stringify({}),
|
||
});
|
||
toast({ title: t("executiveMeetings.alert.saved") });
|
||
await onSaved();
|
||
} catch (err) {
|
||
handleErr(err);
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const noTimeWindow = !meeting.startTime || !meeting.endTime;
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent
|
||
dir={isRtl ? "rtl" : "ltr"}
|
||
data-testid="postpone-dialog"
|
||
className="sm:max-w-2xl"
|
||
>
|
||
<DialogHeader className="space-y-1">
|
||
<DialogTitle className="text-base">
|
||
{t("executiveMeetings.alert.postponeTitle")}
|
||
</DialogTitle>
|
||
<DialogDescription className="truncate text-xs">
|
||
{t("executiveMeetings.alert.postponeIntro", { title: titleText })}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{/* #275: segmented tab strip with full ARIA tab semantics —
|
||
tablist + linked tabpanels, roving tabindex, and arrow-key
|
||
navigation that respects RTL (Left/Right swap directions). */}
|
||
{(() => {
|
||
const tabs = [
|
||
{ key: "minutes" as const, labelKey: "tabPostpone" },
|
||
{ key: "reschedule" as const, labelKey: "tabReschedule" },
|
||
{ key: "cancel" as const, labelKey: "tabCancel" },
|
||
];
|
||
const switchTab = (next: PostponeTab) => {
|
||
setActiveTab(next);
|
||
setPendingMinutes(null);
|
||
setConfirmCancel(false);
|
||
// Move focus to the newly active tab so screen-reader and
|
||
// keyboard users land where the panel is now anchored.
|
||
window.requestAnimationFrame(() => {
|
||
document
|
||
.querySelector<HTMLButtonElement>(
|
||
`[data-testid="postpone-tab-${next}"]`,
|
||
)
|
||
?.focus();
|
||
});
|
||
};
|
||
const onTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||
const idx = tabs.findIndex((tab) => tab.key === activeTab);
|
||
if (idx < 0) return;
|
||
// In RTL, ArrowLeft/ArrowRight visually swap meaning.
|
||
const forward = isRtl ? "ArrowLeft" : "ArrowRight";
|
||
const back = isRtl ? "ArrowRight" : "ArrowLeft";
|
||
let nextIdx: number | null = null;
|
||
if (e.key === forward) nextIdx = (idx + 1) % tabs.length;
|
||
else if (e.key === back)
|
||
nextIdx = (idx - 1 + tabs.length) % tabs.length;
|
||
else if (e.key === "Home") nextIdx = 0;
|
||
else if (e.key === "End") nextIdx = tabs.length - 1;
|
||
if (nextIdx == null) return;
|
||
e.preventDefault();
|
||
switchTab(tabs[nextIdx].key);
|
||
};
|
||
return (
|
||
<div
|
||
role="tablist"
|
||
aria-label={t("executiveMeetings.alert.postponeTitle")}
|
||
aria-orientation="horizontal"
|
||
onKeyDown={onTabKeyDown}
|
||
className="grid grid-cols-3 rounded-md border bg-muted p-0.5 text-xs"
|
||
>
|
||
{tabs.map((tab) => {
|
||
const selected = activeTab === tab.key;
|
||
return (
|
||
<button
|
||
key={tab.key}
|
||
type="button"
|
||
role="tab"
|
||
id={`postpone-tab-${tab.key}`}
|
||
aria-controls={`postpone-panel-${tab.key}`}
|
||
aria-selected={selected}
|
||
// Roving tabindex: only the active tab is in the tab
|
||
// sequence; arrow keys move between the rest.
|
||
tabIndex={selected ? 0 : -1}
|
||
disabled={busy !== null}
|
||
onClick={() => switchTab(tab.key)}
|
||
data-testid={`postpone-tab-${tab.key}`}
|
||
className={`rounded px-2 py-1.5 font-medium transition-colors ${
|
||
selected
|
||
? "bg-background text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
{t(`executiveMeetings.alert.${tab.labelKey}`)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{activeTab === "minutes" ? (
|
||
<div
|
||
role="tabpanel"
|
||
id="postpone-panel-minutes"
|
||
aria-labelledby="postpone-tab-minutes"
|
||
tabIndex={0}
|
||
className="space-y-2 focus:outline-none"
|
||
data-testid="postpone-minutes-panel"
|
||
>
|
||
{noTimeWindow ? (
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("executiveMeetings.alert.noTimeWindow")}
|
||
</p>
|
||
) : null}
|
||
{cascadePrompt && cascadePrompt.kind === "postpone" ? (
|
||
<CascadePromptBlock
|
||
prompt={cascadePrompt}
|
||
isRtl={isRtl}
|
||
busy={busy !== null}
|
||
meetingNumbersById={meetingNumbersById}
|
||
accent={accent}
|
||
lang={i18n.language}
|
||
onShiftFollowing={() =>
|
||
void postponeBy(cascadePrompt.delta, undefined, true)
|
||
}
|
||
onKeepTimes={() =>
|
||
void postponeBy(cascadePrompt.delta, undefined, false)
|
||
}
|
||
onBack={() => setCascadePrompt(null)}
|
||
/>
|
||
) : staleConflict ? (
|
||
<div
|
||
className="flex flex-col gap-2 rounded border border-rose-300 bg-rose-50 p-3 dark:border-rose-700 dark:bg-rose-950"
|
||
data-testid="postpone-stale-block"
|
||
>
|
||
<p className="text-sm font-medium">
|
||
{staleConflict.actorName
|
||
? t("executiveMeetings.alert.staleMeetingByUser", {
|
||
name: staleConflict.actorName,
|
||
})
|
||
: t("executiveMeetings.alert.staleMeetingTitle")}
|
||
</p>
|
||
{staleConflict.currentStart ? (
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("executiveMeetings.alert.staleMeetingCurrentTime", {
|
||
start: formatTime(staleConflict.currentStart, i18n.language),
|
||
end: staleConflict.currentEnd
|
||
? formatTime(staleConflict.currentEnd, i18n.language)
|
||
: "—",
|
||
})}
|
||
</p>
|
||
) : null}
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="destructive"
|
||
onClick={() => {
|
||
const n = staleConflict.minutes;
|
||
const fresh = staleConflict.latestUpdatedAt;
|
||
setStaleConflict(null);
|
||
// #283: pass the freshly-seen token; if the
|
||
// meeting was changed *again* between conflict
|
||
// display and click, the server will return 409
|
||
// a second time so the user re-confirms against
|
||
// the newer state.
|
||
void postponeBy(n, fresh);
|
||
}}
|
||
disabled={busy !== null}
|
||
data-testid="postpone-stale-apply-anyway"
|
||
>
|
||
{t("executiveMeetings.alert.staleMeetingApplyAnyway", {
|
||
count: staleConflict.minutes,
|
||
})}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setStaleConflict(null)}
|
||
disabled={busy !== null}
|
||
data-testid="postpone-stale-cancel"
|
||
>
|
||
{t("executiveMeetings.alert.back")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : pendingMinutes == null ? (
|
||
<>
|
||
<div className="flex flex-wrap items-center gap-1">
|
||
{[5, 10, 15, 30, 45, 60].map((n) => (
|
||
<Button
|
||
key={n}
|
||
type="button"
|
||
size="sm"
|
||
variant={Number(minutes) === n ? "default" : "outline"}
|
||
disabled={noTimeWindow || busy !== null}
|
||
// #275: chip click selects the value and arms the
|
||
// confirm step — the actual API call happens only
|
||
// after the user clicks "Yes, postpone N min".
|
||
onClick={() => {
|
||
setMinutes(String(n));
|
||
requestPostpone(n);
|
||
}}
|
||
data-testid={`postpone-chip-${n}`}
|
||
className="h-7 px-2 text-xs"
|
||
>
|
||
{n}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={1440}
|
||
value={minutes}
|
||
onChange={(e) => setMinutes(e.target.value)}
|
||
placeholder={t(
|
||
"executiveMeetings.alert.postponeMinutesPlaceholder",
|
||
)}
|
||
disabled={noTimeWindow || busy !== null}
|
||
data-testid="postpone-minutes-input"
|
||
className="w-24"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={onPostponeMinutes}
|
||
disabled={
|
||
noTimeWindow ||
|
||
busy !== null ||
|
||
!Number.isFinite(Number(minutes)) ||
|
||
Number(minutes) <= 0
|
||
}
|
||
data-testid="postpone-minutes-apply"
|
||
>
|
||
{t("executiveMeetings.alert.postponeMinutesApply", {
|
||
count: Number(minutes) || 0,
|
||
})}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div
|
||
className="flex flex-col gap-2 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950"
|
||
data-testid="postpone-confirm-block"
|
||
>
|
||
<p className="text-sm font-medium">
|
||
{t("executiveMeetings.alert.postponeConfirmPrompt", {
|
||
count: pendingMinutes,
|
||
})}
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={() => {
|
||
const n = pendingMinutes;
|
||
setPendingMinutes(null);
|
||
// #302: gate the actual submit on the cascade
|
||
// preview so we can show "also shift the next
|
||
// meetings?" when it applies. The helper falls
|
||
// through to plain `postponeBy` automatically
|
||
// when there are no followers.
|
||
void runCascadePreview({ kind: "postpone", delta: n });
|
||
}}
|
||
disabled={busy !== null}
|
||
data-testid="postpone-confirm-yes"
|
||
>
|
||
{t("executiveMeetings.alert.postponeConfirmYes", {
|
||
count: pendingMinutes,
|
||
})}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setPendingMinutes(null)}
|
||
disabled={busy !== null}
|
||
data-testid="postpone-confirm-back"
|
||
>
|
||
{t("executiveMeetings.alert.back")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{activeTab === "reschedule" ? (
|
||
<div
|
||
role="tabpanel"
|
||
id="postpone-panel-reschedule"
|
||
aria-labelledby="postpone-tab-reschedule"
|
||
tabIndex={0}
|
||
className="space-y-2 focus:outline-none"
|
||
data-testid="postpone-reschedule-panel"
|
||
>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("executiveMeetings.alert.rescheduleHint")}
|
||
</p>
|
||
{cascadePrompt && cascadePrompt.kind === "reschedule" ? (
|
||
<CascadePromptBlock
|
||
prompt={cascadePrompt}
|
||
isRtl={isRtl}
|
||
busy={busy !== null}
|
||
meetingNumbersById={meetingNumbersById}
|
||
accent={accent}
|
||
lang={i18n.language}
|
||
onShiftFollowing={() =>
|
||
void rescheduleSubmit(cascadePrompt.draft, true)
|
||
}
|
||
onKeepTimes={() =>
|
||
void rescheduleSubmit(cascadePrompt.draft, false)
|
||
}
|
||
onBack={() => setCascadePrompt(null)}
|
||
/>
|
||
) : null}
|
||
{/* Responsive grid: 1 col on narrow / 2 cols on sm /
|
||
3 cols on md+. Prevents horizontal overflow inside the
|
||
dialog on phones and split panes. */}
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">
|
||
{t("executiveMeetings.alert.rescheduleDate")}
|
||
</Label>
|
||
<Input
|
||
type="date"
|
||
value={resDate}
|
||
onChange={(e) => setResDate(e.target.value)}
|
||
disabled={busy !== null}
|
||
data-testid="reschedule-date"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">
|
||
{t("executiveMeetings.alert.rescheduleStart")}
|
||
</Label>
|
||
<Input
|
||
type="time"
|
||
value={resStart}
|
||
onChange={(e) => setResStart(e.target.value)}
|
||
disabled={busy !== null}
|
||
data-testid="reschedule-start"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">
|
||
{t("executiveMeetings.alert.rescheduleEnd")}
|
||
</Label>
|
||
<Input
|
||
type="time"
|
||
value={resEnd}
|
||
onChange={(e) => setResEnd(e.target.value)}
|
||
disabled={busy !== null}
|
||
data-testid="reschedule-end"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<Textarea
|
||
value={resNote}
|
||
onChange={(e) => setResNote(e.target.value)}
|
||
placeholder={t("executiveMeetings.alert.rescheduleNote")}
|
||
disabled={busy !== null}
|
||
rows={2}
|
||
data-testid="reschedule-note"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={onReschedule}
|
||
disabled={busy !== null || !resDate || !resStart || !resEnd}
|
||
data-testid="reschedule-apply"
|
||
>
|
||
{t("executiveMeetings.alert.rescheduleApply")}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
{activeTab === "cancel" ? (
|
||
<div
|
||
role="tabpanel"
|
||
id="postpone-panel-cancel"
|
||
aria-labelledby="postpone-tab-cancel"
|
||
tabIndex={0}
|
||
className="space-y-2 focus:outline-none"
|
||
data-testid="postpone-cancel-panel"
|
||
>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("executiveMeetings.alert.cancelHint")}
|
||
</p>
|
||
{!confirmCancel ? (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="destructive"
|
||
onClick={() => setConfirmCancel(true)}
|
||
disabled={busy !== null}
|
||
data-testid="cancel-meeting"
|
||
>
|
||
{t("executiveMeetings.alert.cancelApply")}
|
||
</Button>
|
||
) : (
|
||
<div
|
||
className="flex flex-col gap-2"
|
||
data-testid="cancel-confirm-block"
|
||
>
|
||
<p className="text-xs font-medium text-destructive">
|
||
{t("executiveMeetings.alert.cancelConfirmPrompt")}
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="destructive"
|
||
onClick={onCancelMeeting}
|
||
disabled={busy !== null}
|
||
data-testid="cancel-meeting-confirm"
|
||
>
|
||
{t("executiveMeetings.alert.cancelConfirmYes")}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setConfirmCancel(false)}
|
||
disabled={busy !== null}
|
||
data-testid="cancel-meeting-back"
|
||
>
|
||
{t("executiveMeetings.alert.back")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ---------- Inline details panel ----------
|
||
//
|
||
// Replaces the old "Open meetings" navigate-out button. When the user
|
||
// clicks "Details" we expand this section between the body and the
|
||
// action footer of the same draggable popup. We deliberately keep the
|
||
// rendering compact: long lists scroll inside a max-height container so
|
||
// the alert never grows taller than the viewport. Styling pulls from
|
||
// the same theme tokens as the parent so the panel always matches the
|
||
// user's chosen preset.
|
||
|
||
type DetailsPanelProps = {
|
||
meeting: Meeting;
|
||
isRtl: boolean;
|
||
t: (k: string, opts?: Record<string, unknown>) => string;
|
||
lang: string;
|
||
dividerColor: string;
|
||
sectionBg: string;
|
||
};
|
||
|
||
function DetailsPanel({
|
||
meeting,
|
||
isRtl,
|
||
t,
|
||
lang,
|
||
dividerColor,
|
||
sectionBg,
|
||
}: DetailsPanelProps) {
|
||
// Walk through attendees in order. Subheading rows act as group
|
||
// headers (e.g. "الحضور الخارجي", "الحضور الداخلي"); person rows
|
||
// following a subheading belong to that group. If persons appear
|
||
// before any subheading they land in a header-less group.
|
||
type AttendeeGroup = { heading: string | null; members: Attendee[] };
|
||
const attendeeGroups: AttendeeGroup[] = [];
|
||
let currentGroup: AttendeeGroup = { heading: null, members: [] };
|
||
for (const a of meeting.attendees ?? []) {
|
||
const cleanName = (a.name ?? "").replace(/<[^>]+>/g, "").trim();
|
||
if (!cleanName) continue;
|
||
if (a.kind === "subheading") {
|
||
if (currentGroup.heading !== null || currentGroup.members.length > 0) {
|
||
attendeeGroups.push(currentGroup);
|
||
}
|
||
currentGroup = { heading: cleanName, members: [] };
|
||
} else {
|
||
currentGroup.members.push(a);
|
||
}
|
||
}
|
||
if (currentGroup.heading !== null || currentGroup.members.length > 0) {
|
||
attendeeGroups.push(currentGroup);
|
||
}
|
||
const personCount = attendeeGroups.reduce(
|
||
(sum, g) => sum + g.members.length,
|
||
0,
|
||
);
|
||
|
||
const safeUrl = (() => {
|
||
const raw = (meeting.meetingUrl ?? "").trim();
|
||
if (!raw) return null;
|
||
try {
|
||
const u = new URL(raw);
|
||
return u.protocol === "http:" || u.protocol === "https:" ? raw : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();
|
||
|
||
// #344: The "time" row in the details panel duplicated the time
|
||
// window already shown in the header strip, so it was removed at
|
||
// the user's request. Header is the single source of truth for the
|
||
// start/end times.
|
||
|
||
// Each row renders a label cell + a value cell inside the parent
|
||
// CSS grid. Skipping rows whose value is missing keeps the table
|
||
// tight and avoids empty cells. The Icon is decorative — labels do
|
||
// the real work for screen readers.
|
||
type Row = {
|
||
key: string;
|
||
icon: ReactNode;
|
||
label: string;
|
||
value: ReactNode;
|
||
};
|
||
const rows: Row[] = [];
|
||
|
||
if (meeting.location && meeting.location.trim()) {
|
||
rows.push({
|
||
key: "location",
|
||
icon: <Calendar className="h-3.5 w-3.5 opacity-70" aria-hidden="true" />,
|
||
label: t("executiveMeetings.alert.detailsLocation"),
|
||
value: (
|
||
<span
|
||
className="break-words"
|
||
data-testid="alert-details-location"
|
||
>
|
||
{meeting.location}
|
||
</span>
|
||
),
|
||
});
|
||
}
|
||
|
||
if (safeUrl) {
|
||
rows.push({
|
||
key: "url",
|
||
icon: (
|
||
<ExternalLink
|
||
className="h-3.5 w-3.5 opacity-70"
|
||
aria-hidden="true"
|
||
/>
|
||
),
|
||
label: t("executiveMeetings.alert.detailsUrl"),
|
||
value: (
|
||
<a
|
||
href={safeUrl}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
className="block truncate underline hover:no-underline"
|
||
data-testid="alert-details-url"
|
||
>
|
||
{safeUrl}
|
||
</a>
|
||
),
|
||
});
|
||
}
|
||
|
||
rows.push({
|
||
key: "attendees",
|
||
icon: <Users className="h-3.5 w-3.5 opacity-70" aria-hidden="true" />,
|
||
label: t("executiveMeetings.alert.detailsAttendees", {
|
||
n: personCount,
|
||
}),
|
||
value:
|
||
personCount === 0 ? (
|
||
<div className="opacity-80" data-testid="alert-details-no-attendees">
|
||
{t("executiveMeetings.alert.detailsNoAttendees")}
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="max-h-40 overflow-y-auto"
|
||
data-testid="alert-details-attendees"
|
||
>
|
||
{/* #479: render each attendee group as its own numbered
|
||
table. Numbering restarts at 1 per group; if a future
|
||
spec wants a single continuous count across all groups,
|
||
swap the per-group `idx + 1` for a counter incremented in
|
||
the outer .map. */}
|
||
{attendeeGroups.map((group, gIdx) => {
|
||
if (group.members.length === 0) return null;
|
||
return (
|
||
<table
|
||
key={`group-${gIdx}`}
|
||
className="mb-2 w-full last:mb-0"
|
||
aria-label={group.heading || undefined}
|
||
>
|
||
{group.heading ? (
|
||
<caption className="mb-0.5 text-start text-xs font-bold">
|
||
{group.heading}
|
||
</caption>
|
||
) : null}
|
||
<tbody className="leading-snug">
|
||
{group.members.map((p, idx) => {
|
||
const cleanName = p.name
|
||
.replace(/<[^>]+>/g, "")
|
||
.trim();
|
||
if (!cleanName) return null;
|
||
return (
|
||
<tr key={`${gIdx}-${p.id ?? idx}-${p.name}`}>
|
||
<th
|
||
scope="row"
|
||
className="w-7 pe-1 text-start align-top font-normal tabular-nums opacity-70"
|
||
>
|
||
{idx + 1}.
|
||
</th>
|
||
<td className="align-top">{cleanName}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
})}
|
||
</div>
|
||
),
|
||
});
|
||
|
||
return (
|
||
<div
|
||
id="alert-details-panel"
|
||
className="border-t px-3 py-3 text-xs"
|
||
style={{ borderColor: dividerColor, backgroundColor: sectionBg }}
|
||
data-testid="alert-details-panel"
|
||
dir={isRtl ? "rtl" : "ltr"}
|
||
>
|
||
<div
|
||
className="grid items-start gap-x-3 gap-y-2"
|
||
style={{ gridTemplateColumns: "auto 1fr" }}
|
||
>
|
||
{rows.map((row, idx) => (
|
||
<Fragment key={row.key}>
|
||
<div
|
||
className="flex items-center gap-1.5 self-start whitespace-nowrap font-semibold opacity-80"
|
||
style={{
|
||
paddingTop: idx === 0 ? 0 : 6,
|
||
borderTop:
|
||
idx === 0 ? "none" : `1px solid ${dividerColor}`,
|
||
}}
|
||
>
|
||
{row.icon}
|
||
<span>{row.label}</span>
|
||
</div>
|
||
<div
|
||
className="min-w-0 self-start text-start"
|
||
style={{
|
||
paddingTop: idx === 0 ? 0 : 6,
|
||
borderTop:
|
||
idx === 0 ? "none" : `1px solid ${dividerColor}`,
|
||
}}
|
||
>
|
||
{row.value}
|
||
</div>
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|