Files
TX/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx
T
riyadhafraa 7a2ae8434d Update project documentation and code comments to remove platform-specific references
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
2026-05-14 06:23:49 +00:00

2090 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #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>
);
}