diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 1417f0fe..81801af2 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -22,6 +22,7 @@ import { executiveMeetingAuditLogsTable, executiveMeetingPdfArchivesTable, executiveMeetingFontSettingsTable, + executiveMeetingAlertStateTable, rolesTable, usersTable, type ExecutiveMeetingAttendee, @@ -680,6 +681,471 @@ router.patch( }, ); +// #273: ---- Upcoming-meeting alert (5-minute pre-alarm) endpoints ---- + +// Parse "HH:MM" or "HH:MM:SS" -> minutes-of-day. Returns null on missing/invalid. +function parseTimeToMinutes(t: string | null | undefined): number | null { + if (!t) return null; + const m = /^(\d{1,2}):(\d{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 formatMinutesAsTime(mins: number): string { + const safe = ((mins % (24 * 60)) + 24 * 60) % (24 * 60); + const h = Math.floor(safe / 60); + const m = safe % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`; +} +// #273: pass a transaction-like executor so the conflict scan reads inside +// the same DB snapshot as the UPDATE that just shifted the row. +async function detectMeetingConflicts( + exec: typeof db | Parameters[0]>[0], + date: string, + excludeId: number, + startTime: string, + endTime: string, +): Promise { + const newStart = parseTimeToMinutes(startTime); + const newEnd = parseTimeToMinutes(endTime); + if (newStart == null || newEnd == null) return false; + const others = await exec + .select({ + id: executiveMeetingsTable.id, + start: executiveMeetingsTable.startTime, + end: executiveMeetingsTable.endTime, + status: executiveMeetingsTable.status, + }) + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.meetingDate, date)); + for (const o of others) { + if (o.id === excludeId) continue; + if (o.status === "cancelled" || o.status === "completed") continue; + const s = parseTimeToMinutes(o.start); + const e = parseTimeToMinutes(o.end); + if (s == null || e == null) continue; + if (s < newEnd && e > newStart) return true; + } + return false; +} + +const alertActionSchema = z.object({ + action: z.enum(["shown", "acknowledged", "dismissed"]), +}); +const postponeMinutesSchema = z.object({ + minutes: z.number().int().min(1).max(24 * 60), +}); +const rescheduleSchema = z.object({ + meetingDate: dateSchema, + startTime: timeSchema, + endTime: timeSchema, + note: z.string().max(500).optional(), +}); + +router.get( + "/executive-meetings/alert-state", + requireExecutiveAccess, + async (req, res): Promise => { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const dateRaw = String(req.query.date ?? ""); + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) { + res.status(400).json({ error: "Invalid date", code: "invalid_date" }); + return; + } + const userId = req.session.userId; + const rows = await db + .select({ + meetingId: executiveMeetingAlertStateTable.meetingId, + dismissed: executiveMeetingAlertStateTable.dismissed, + acknowledged: executiveMeetingAlertStateTable.acknowledged, + }) + .from(executiveMeetingAlertStateTable) + .innerJoin( + executiveMeetingsTable, + eq(executiveMeetingsTable.id, executiveMeetingAlertStateTable.meetingId), + ) + .where( + and( + eq(executiveMeetingAlertStateTable.userId, userId), + eq(executiveMeetingsTable.meetingDate, dateRaw), + ), + ); + res.json({ states: rows }); + }, +); + +router.post( + "/executive-meetings/:id/alert-state", + requireExecutiveAccess, + async (req, res): Promise => { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const body = parseBody(res, alertActionSchema, req.body); + if (!body) return; + const meeting = await fetchMeetingWithAttendees(id); + if (!meeting) { + res.status(404).json({ error: "Meeting not found", code: "not_found" }); + return; + } + const userId = req.session.userId; + await db.transaction(async (tx) => { + // Race-safe upsert: when two tabs surface the same alert at once they + // both POST {action:"shown"}; using ON CONFLICT DO NOTHING means only + // one INSERT actually creates the row, so we audit "shown" once. + const inserted = await tx + .insert(executiveMeetingAlertStateTable) + .values({ + meetingId: id, + userId, + dismissed: body.action === "dismissed", + acknowledged: body.action === "acknowledged", + }) + .onConflictDoNothing({ + target: [ + executiveMeetingAlertStateTable.meetingId, + executiveMeetingAlertStateTable.userId, + ], + }) + .returning({ id: executiveMeetingAlertStateTable.id }); + + if (inserted.length > 0) { + await logAudit(tx, { + action: "meeting_alert_shown", + entityType: "meeting", + entityId: id, + newValue: { userId }, + performedBy: userId, + }); + if (body.action === "acknowledged") { + await logAudit(tx, { + action: "meeting_alert_acknowledged", + entityType: "meeting", + entityId: id, + newValue: { userId }, + performedBy: userId, + }); + } else if (body.action === "dismissed") { + await logAudit(tx, { + action: "meeting_alert_dismissed", + entityType: "meeting", + entityId: id, + newValue: { userId }, + performedBy: userId, + }); + } + return; + } + // Row exists. Use a conditional UPDATE so concurrent clicks across + // tabs only fire one audit row per real transition (false -> true). + if (body.action === "acknowledged") { + const upd = await tx + .update(executiveMeetingAlertStateTable) + .set({ acknowledged: true }) + .where( + and( + eq(executiveMeetingAlertStateTable.meetingId, id), + eq(executiveMeetingAlertStateTable.userId, userId), + eq(executiveMeetingAlertStateTable.acknowledged, false), + ), + ) + .returning({ id: executiveMeetingAlertStateTable.id }); + if (upd.length > 0) { + await logAudit(tx, { + action: "meeting_alert_acknowledged", + entityType: "meeting", + entityId: id, + newValue: { userId }, + performedBy: userId, + }); + } + } else if (body.action === "dismissed") { + const upd = await tx + .update(executiveMeetingAlertStateTable) + .set({ dismissed: true }) + .where( + and( + eq(executiveMeetingAlertStateTable.meetingId, id), + eq(executiveMeetingAlertStateTable.userId, userId), + eq(executiveMeetingAlertStateTable.dismissed, false), + ), + ) + .returning({ id: executiveMeetingAlertStateTable.id }); + if (upd.length > 0) { + await logAudit(tx, { + action: "meeting_alert_dismissed", + entityType: "meeting", + entityId: id, + newValue: { userId }, + performedBy: userId, + }); + } + } + }); + res.json({ ok: true }); + }, +); + +router.post( + "/executive-meetings/:id/postpone-minutes", + requireExecutiveAccess, + requireMutate, + async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const body = parseBody(res, postponeMinutesSchema, req.body); + if (!body) return; + const userId = req.session.userId!; + // #273: lock-then-mutate inside a single transaction so concurrent + // postpone/reschedule/cancel requests serialize and audit logs reflect + // the actual committed transition. + type TxResult = + | { + ok: true; + meetingDate: string; + newStart: string; + newEnd: string; + conflicts: boolean; + } + | { ok: false; status: number; error: string; code: string }; + const txResult = await db.transaction(async (tx): Promise => { + const [locked] = await tx + .select() + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.id, id)) + .for("update"); + if (!locked) { + return { ok: false, status: 404, error: "Meeting not found", code: "not_found" }; + } + const startMin = parseTimeToMinutes(locked.startTime); + const endMin = parseTimeToMinutes(locked.endTime); + if (startMin == null || endMin == null) { + return { + ok: false, + status: 400, + error: "Meeting has no scheduled time to postpone", + code: "no_time_window", + }; + } + if ( + startMin + body.minutes >= 24 * 60 || + endMin + body.minutes > 24 * 60 + ) { + return { + ok: false, + status: 400, + error: "Postponing by that many minutes would cross midnight", + code: "crosses_midnight", + }; + } + const newStart = formatMinutesAsTime(startMin + body.minutes); + const newEnd = formatMinutesAsTime(endMin + body.minutes); + await tx + .update(executiveMeetingsTable) + .set({ startTime: newStart, endTime: newEnd, status: "postponed" }) + .where(eq(executiveMeetingsTable.id, id)); + await logAudit(tx, { + action: "meeting_postponed_minutes", + entityType: "meeting", + entityId: id, + oldValue: { + startTime: locked.startTime, + endTime: locked.endTime, + status: locked.status, + }, + newValue: { + startTime: newStart, + endTime: newEnd, + status: "postponed", + minutes: body.minutes, + }, + performedBy: userId, + }); + const conflicts = await detectMeetingConflicts( + tx, + locked.meetingDate, + id, + newStart, + newEnd, + ); + return { + ok: true, + meetingDate: locked.meetingDate, + newStart, + newEnd, + conflicts, + }; + }); + if (!txResult.ok) { + res.status(txResult.status).json({ error: txResult.error, code: txResult.code }); + return; + } + void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]); + const updated = await fetchMeetingWithAttendees(id); + res.json({ ok: true, conflicts: txResult.conflicts, meeting: updated }); + }, +); + +router.post( + "/executive-meetings/:id/reschedule", + requireExecutiveAccess, + requireMutate, + async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const body = parseBody(res, rescheduleSchema, req.body); + if (!body) return; + const startMin = parseTimeToMinutes(body.startTime); + const endMin = parseTimeToMinutes(body.endTime); + if (startMin == null || endMin == null || startMin >= endMin) { + res.status(400).json({ + error: "End time must be after start time", + code: "invalid_time_range", + }); + return; + } + const userId = req.session.userId!; + // #273: lock the row inside the tx so a concurrent edit cannot race us. + type TxResult = + | { ok: true; oldDate: string; conflicts: boolean } + | { ok: false; status: number; error: string; code: string }; + const txResult = await db.transaction(async (tx): Promise => { + const [locked] = await tx + .select() + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.id, id)) + .for("update"); + if (!locked) { + return { ok: false, status: 404, error: "Meeting not found", code: "not_found" }; + } + // If the daily number would clash on the new date, allocate a fresh one. + let dailyNumber = locked.dailyNumber; + if (body.meetingDate !== locked.meetingDate) { + dailyNumber = await nextDailyNumber(tx, body.meetingDate); + } + await tx + .update(executiveMeetingsTable) + .set({ + meetingDate: body.meetingDate, + startTime: body.startTime, + endTime: body.endTime, + status: "postponed", + dailyNumber, + }) + .where(eq(executiveMeetingsTable.id, id)); + await logAudit(tx, { + action: "meeting_rescheduled", + entityType: "meeting", + entityId: id, + oldValue: { + meetingDate: locked.meetingDate, + startTime: locked.startTime, + endTime: locked.endTime, + status: locked.status, + dailyNumber: locked.dailyNumber, + }, + newValue: { + meetingDate: body.meetingDate, + startTime: body.startTime, + endTime: body.endTime, + status: "postponed", + dailyNumber, + note: body.note ?? null, + }, + performedBy: userId, + }); + const conflicts = await detectMeetingConflicts( + tx, + body.meetingDate, + id, + body.startTime, + body.endTime, + ); + return { ok: true, oldDate: locked.meetingDate, conflicts }; + }); + if (!txResult.ok) { + res.status(txResult.status).json({ error: txResult.error, code: txResult.code }); + return; + } + void emitExecutiveMeetingsDaysChanged([ + txResult.oldDate, + body.meetingDate, + ]); + const updated = await fetchMeetingWithAttendees(id); + res.json({ ok: true, conflicts: txResult.conflicts, meeting: updated }); + }, +); + +router.post( + "/executive-meetings/:id/cancel", + requireExecutiveAccess, + requireMutate, + async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const userId = req.session.userId!; + // #273: lock + idempotent UPDATE — only audit on the real status change. + type TxResult = + | { ok: true; meetingDate: string; changed: boolean } + | { ok: false; status: number; error: string; code: string }; + const txResult = await db.transaction(async (tx): Promise => { + const [locked] = await tx + .select() + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.id, id)) + .for("update"); + if (!locked) { + return { ok: false, status: 404, error: "Meeting not found", code: "not_found" }; + } + if (locked.status === "cancelled") { + return { ok: true, meetingDate: locked.meetingDate, changed: false }; + } + await tx + .update(executiveMeetingsTable) + .set({ status: "cancelled" }) + .where(eq(executiveMeetingsTable.id, id)); + await logAudit(tx, { + action: "meeting_cancelled", + entityType: "meeting", + entityId: id, + oldValue: { status: locked.status }, + newValue: { status: "cancelled" }, + performedBy: userId, + }); + return { ok: true, meetingDate: locked.meetingDate, changed: true }; + }); + if (!txResult.ok) { + res.status(txResult.status).json({ error: txResult.error, code: txResult.code }); + return; + } + if (txResult.changed) { + void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]); + } + const updated = await fetchMeetingWithAttendees(id); + res.json({ ok: true, meeting: updated }); + }, +); + router.delete( "/executive-meetings/:id", requireExecutiveAccess, diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 7f5d35cb..c3ff5c90 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/App.tsx b/artifacts/tx-os/src/App.tsx index 1f5c5891..b6f6afcc 100644 --- a/artifacts/tx-os/src/App.tsx +++ b/artifacts/tx-os/src/App.tsx @@ -4,6 +4,7 @@ import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AuthProvider } from "@/contexts/AuthContext"; import { useNotificationsSocket } from "@/hooks/use-notifications-socket"; +import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert"; import NotFound from "@/pages/not-found"; import LoginPage from "@/pages/login"; import RegisterPage from "@/pages/register"; @@ -37,6 +38,7 @@ function Router() { return ( + diff --git a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx new file mode 100644 index 00000000..e6535fba --- /dev/null +++ b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx @@ -0,0 +1,813 @@ +// #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 { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useLocation } from "wouter"; +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, Clock, GripVertical, X } from "lucide-react"; + +const POSITION_KEY = "txos.upcomingMeetingAlert.position"; +const ALERT_LEAD_MINUTES = 5; +const POLL_MS = 30_000; + +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; +}; +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(); +} +function formatTime(t: string | null): string { + if (!t) return ""; + const m = /^(\d{1,2}):(\d{2})/.exec(t); + if (!m) return t; + return `${m[1].padStart(2, "0")}:${m[2]}`; +} + +async function apiJson(input: string, init?: RequestInit): Promise { + const res = await fetch(input, { + credentials: "include", + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + ...init, + }); + if (res.status === 204) return undefined as T; + const text = await res.text(); + const data = text ? (JSON.parse(text) as unknown) : ({} as unknown); + if (!res.ok) { + throw new Error( + (data as { error?: string }).error || `HTTP ${res.status}`, + ); + } + return data as T; +} + +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 [, setLocation] = useLocation(); + const [now, setNow] = useState(() => nowLocalMinutes()); + const [postponeOpen, setPostponeOpen] = useState(false); + const [savingAction, setSavingAction] = useState(null); + + // 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({ + 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({ + 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(); + for (const s of alertStateData?.states ?? []) map.set(s.meetingId, s); + return map; + }, [alertStateData]); + + // Pick the soonest meeting that: + // - is on today, + // - has a known startTime, + // - is not cancelled or completed, + // - hasn't already started + ended (i.e. start - now is in [-5, 5]), + // - hasn't been acknowledged or dismissed by this user. + // We allow up to 5 minutes "after" the scheduled start so the alert + // doesn't vanish the second the meeting begins. + 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 > ALERT_LEAD_MINUTES) 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>(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 [pos, setPos] = useState( + () => loadPosition() ?? defaultPosition(), + ); + const dragState = useRef<{ + pointerId: number; + offsetX: number; + offsetY: number; + } | null>(null); + const onDragStart = (e: ReactPointerEvent) => { + 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) => { + const st = dragState.current; + if (!st || st.pointerId !== e.pointerId) return; + const w = window.innerWidth; + const h = window.innerHeight; + const target = e.currentTarget.parentElement; + const elW = target?.offsetWidth ?? 360; + const elH = target?.offsetHeight ?? 160; + const x = Math.max(8, Math.min(w - elW - 8, e.clientX - st.offsetX)); + const y = Math.max(8, Math.min(h - elH - 8, e.clientY - st.offsetY)); + setPos({ x, y }); + }; + const onDragEnd = (e: ReactPointerEvent) => { + 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(); + toast({ title: t("executiveMeetings.alert.doneToast") }); + } 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]); + + if (!enabled || !eligible) 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", { n: minutesAway }); + + return ( + <> +
+
+
+
+ +
+
+
+ {title.replace(/<[^>]+>/g, "").trim() || title} +
+
+ + {headline} + + {meeting.startTime ? ( + + + ) : null} + {meeting.location ? ( + + + ) : null} +
+
+
+ + {caps?.canMutate ? ( + + ) : null} + +
+
+ {caps?.canMutate ? ( + { + 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 ---------- + +type PostponeDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + meeting: Meeting; + isRtl: boolean; + onSaved: () => void | Promise; +}; + +function PostponeDialog({ + open, + onOpenChange, + meeting, + isRtl, + onSaved, +}: PostponeDialogProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + const [minutes, setMinutes] = useState("10"); + const [resDate, setResDate] = useState(meeting.meetingDate); + const [resStart, setResStart] = useState( + meeting.startTime ? meeting.startTime.slice(0, 5) : "", + ); + const [resEnd, setResEnd] = useState( + meeting.endTime ? meeting.endTime.slice(0, 5) : "", + ); + const [resNote, setResNote] = useState(""); + const [busy, setBusy] = useState(null); + const [confirmCancel, setConfirmCancel] = useState(false); + + // 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); + }, [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) { + toast({ + title: t("executiveMeetings.alert.conflictWarning"), + variant: "destructive", + }); + } + }; + const handleErr = (err: unknown) => { + toast({ + title: t("executiveMeetings.alert.saveFailed"), + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + }; + + const onPostponeMinutes = async () => { + const n = Number(minutes); + if (!Number.isFinite(n) || n <= 0) return; + setBusy("minutes"); + try { + const res = await apiJson<{ conflicts?: boolean }>( + `/api/executive-meetings/${meeting.id}/postpone-minutes`, + { method: "POST", body: JSON.stringify({ minutes: Math.floor(n) }) }, + ); + handleResult(res, "executiveMeetings.alert.saved"); + await onSaved(); + } catch (err) { + handleErr(err); + } finally { + setBusy(null); + } + }; + const onReschedule = async () => { + if (!resDate || !resStart || !resEnd) return; + setBusy("reschedule"); + try { + const res = await apiJson<{ conflicts?: boolean }>( + `/api/executive-meetings/${meeting.id}/reschedule`, + { + method: "POST", + body: JSON.stringify({ + meetingDate: resDate, + startTime: `${resStart}:00`, + endTime: `${resEnd}:00`, + note: resNote || undefined, + }), + }, + ); + handleResult(res, "executiveMeetings.alert.saved"); + await onSaved(); + } catch (err) { + handleErr(err); + } finally { + setBusy(null); + } + }; + 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 ( + + + + {t("executiveMeetings.alert.postponeTitle")} + + {t("executiveMeetings.alert.postponeIntro", { title: titleText })} + + + +
+ +

+ {noTimeWindow + ? t("executiveMeetings.alert.noTimeWindow") + : t("executiveMeetings.alert.postponeMinutesHint")} +

+
+ {[5, 10, 15, 30, 45, 60].map((n) => ( + + ))} +
+
+ setMinutes(e.target.value)} + placeholder={t( + "executiveMeetings.alert.postponeMinutesPlaceholder", + )} + disabled={noTimeWindow || busy !== null} + data-testid="postpone-minutes-input" + className="w-28" + /> + +
+
+ +
+ +
+
+ + setResDate(e.target.value)} + disabled={busy !== null} + data-testid="reschedule-date" + /> +
+
+ + setResStart(e.target.value)} + disabled={busy !== null} + data-testid="reschedule-start" + /> +
+
+ + setResEnd(e.target.value)} + disabled={busy !== null} + data-testid="reschedule-end" + /> +
+
+