import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useRoute } from "wouter"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, ArrowRight, LayoutDashboard, DoorOpen, CalendarClock, Handshake, Gift, Warehouse, HandHeart, ChevronDown, BarChart3, ScrollText, Plus, Pencil, Trash2, Check, X, Ban, CalendarDays, List as ListIcon, Search as SearchIcon, Link2, Copy, ExternalLink, Upload, Loader2, Download, Image as ImageIcon, Settings as SettingsIcon, Clock3, Menu as MenuIcon, } from "lucide-react"; import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid, AreaChart, Area, PieChart, Pie, Cell, Legend, } from "recharts"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { AppDock } from "@/components/app-dock"; import { AttendeeTableEditor, emptyAttendeeRow, syncAttendeeRows, type EditableAttendeeRow, } from "@/components/attendee-table-editor"; import { Calendar } from "@/components/ui/calendar"; import { useNow } from "@/components/clock"; import { useToast } from "@/hooks/use-toast"; import { apiJson, ApiError } from "@/lib/api-json"; import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; import { cn } from "@/lib/utils"; const API = `${import.meta.env.BASE_URL}api`; // --------------------------------------------------------------------------- // Types (mirror of the /api/protocol/* response shapes) // --------------------------------------------------------------------------- type Capabilities = { canRead: boolean; canRequest: boolean; canMutate: boolean; canApprove: boolean; canManageRooms: boolean; canViewAudit: boolean; }; type Room = { id: number; nameAr: string; nameEn: string; capacity: number | null; location: string | null; isActive: boolean; sortOrder: number; }; type Slot = { id: number; startTime: string; // "HH:mm" Riyadh wall clock durationMinutes: number; daysOfWeek: number[]; // 0=Sunday .. 6=Saturday (Riyadh calendar) isActive: boolean; sortOrder: number; }; // Minimal shape returned by /protocol/booking-slots/active (readable by any // protocol user; the full Slot list is admin-only). type ActiveSlotInfo = { id: number; startTime: string; durationMinutes: number; daysOfWeek: number[]; }; // Weekday (0=Sunday .. 6=Saturday) of a plain "YYYY-MM-DD" calendar day. function ymdWeekday(ymd: string): number { return new Date(`${ymd}T00:00:00Z`).getUTCDay(); } const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6]; // Saudi work week: Sunday..Thursday. const WORKDAYS = [0, 1, 2, 3, 4]; function fmtSlotDays( days: number[], t: (k: string) => string, isAr: boolean, ): string { if (ALL_DAYS.every((d) => days.includes(d))) { return t("protocol.slots.allDays"); } return [...days] .sort((a, b) => a - b) .map((d) => t(`protocol.slots.dayNames.${d}`)) .join(isAr ? "، " : ", "); } type BookingStatus = "pending" | "approved" | "rejected" | "cancelled"; type MeetingType = "internal" | "external"; type KeyAttendee = { name: string; position: string; side: "internal" | "external"; }; type Booking = { id: number; roomId: number; title: string; requesterName: string | null; requesterPhone: string | null; requesterOrg: string | null; attendeeCount: number | null; department: string | null; meetingType: MeetingType; purpose: string | null; keyAttendees: KeyAttendee[] | null; bookingReference: string | null; source: "internal" | "public"; startsAt: string; endsAt: string; status: BookingStatus; notes: string | null; rejectionReason: string | null; }; type ExternalMeeting = { id: number; title: string; partyName: string | null; location: string | null; startsAt: string; endsAt: string | null; status: "pending" | "approved" | "rejected" | "completed" | "cancelled"; notes: string | null; rejectionReason: string | null; }; type GiftKind = "gift" | "shield"; type Gift = { id: number; nameAr: string; nameEn: string; kind: GiftKind; descriptionAr: string | null; descriptionEn: string | null; quantityInStock: number; isActive: boolean; }; type IssueStatus = "pending" | "approved" | "rejected" | "issued"; type GiftIssue = { id: number; giftId: number; recipientName: string; occasion: string | null; quantity: number; status: IssueStatus; notes: string | null; rejectionReason: string | null; }; type Dashboard = { activeRooms: number; roomsAvailableNow: number; pendingBookings: number; todayBookings: number; upcomingExternalMeetings: number; giftCatalogCount: number; totalGiftStock: number; pendingGiftIssues: number; giftsIssuedThisMonth: number; giftsIssuedThisMonthQty: number; }; type ReportRow = { bookingsByRoom: Array<{ roomId: number; roomNameAr: string; roomNameEn: string; total: number; approved: number; pending: number; rejected: number; cancelled: number; }>; issuesByGift: Array<{ giftId: number; giftNameAr: string; giftNameEn: string; kind: GiftKind; totalRequests: number; issued: number; issuedQty: number; pending: number; rejected: number; }>; externalMeetings: { total: number; pending: number; approved: number; rejected: number; completed: number; cancelled: number; }; }; type TabKey = | "dashboard" | "roomsDashboard" | "rooms" | "bookings" | "bookingSettings" | "external" | "gifts" | "issues" | "photos" | "reports" | "audit"; // URL slugs for each tab so pages are deep-linkable under /protocol/*. const TAB_SLUGS: Record = { dashboard: "dashboard", roomsDashboard: "rooms-dashboard", rooms: "rooms", bookings: "bookings", bookingSettings: "booking-settings", external: "external-meetings", gifts: "gifts", issues: "gift-issues", photos: "photos", reports: "reports", audit: "audit", }; const SLUG_TABS: Record = Object.fromEntries( Object.entries(TAB_SLUGS).map(([k, v]) => [v, k as TabKey]), ) as Record; // --------------------------------------------------------------------------- // Small helpers // --------------------------------------------------------------------------- function toLocalInput(iso: string): string { // Convert an ISO string into a value for . const d = new Date(iso); const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad( d.getHours(), )}:${pad(d.getMinutes())}`; } function fromLocalInput(v: string): string { return new Date(v).toISOString(); } const STATUS_PILL: Record = { pending: "bg-amber-100 text-amber-800", approved: "bg-emerald-100 text-emerald-700", issued: "bg-emerald-100 text-emerald-700", rejected: "bg-rose-100 text-rose-700", cancelled: "bg-slate-200 text-slate-600", scheduled: "bg-sky-100 text-sky-700", completed: "bg-emerald-100 text-emerald-700", }; // ---- Day time-grid (Google-Calendar style) constants & helpers ---- const GRID_HOUR_START = 7; // 7 AM const GRID_HOUR_END = 22; // 10 PM const GRID_PX_PER_HOUR = 56; // Block fill + inline-start accent border, colored by booking status. const BOOKING_BLOCK_STYLE: Record = { pending: "bg-amber-100 border-amber-400 text-amber-900", approved: "bg-emerald-100 border-emerald-500 text-emerald-900", issued: "bg-emerald-100 border-emerald-500 text-emerald-900", rejected: "bg-rose-100 border-rose-400 text-rose-900", cancelled: "bg-slate-100 border-slate-300 text-slate-500", scheduled: "bg-sky-100 border-sky-400 text-sky-900", completed: "bg-emerald-100 border-emerald-500 text-emerald-900", }; // --- Booking slots (أوقات الحجز) helpers. Slot times are Riyadh wall-clock // "HH:mm"; Riyadh has no DST so a fixed +03:00 offset is always correct. const riyadhTimeFmt = new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Riyadh", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }); function riyadhHHmm(iso: string): string { return riyadhTimeFmt.format(new Date(iso)); } function riyadhYmd(iso: string): string { return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Riyadh" }).format( new Date(iso), ); } function slotToIso(date: string, startTime: string, plusMinutes = 0): string { const start = new Date(`${date}T${startTime}:00+03:00`); return new Date(start.getTime() + plusMinutes * 60_000).toISOString(); } function fmtSlotRange( startTime: string, durationMinutes: number, isAr: boolean, ): string { const [h, m] = startTime.split(":").map(Number); const endTotal = h * 60 + m + durationMinutes; const eh = Math.floor(endTotal / 60) % 24; const em = endTotal % 60; const pad = (n: number) => String(n).padStart(2, "0"); const to12 = (hh: number, mm: number) => { const period = hh < 12 ? (isAr ? "ص" : "AM") : isAr ? "م" : "PM"; let h12 = hh % 12; if (h12 === 0) h12 = 12; return `${h12}:${pad(mm)} ${period}`; }; return `${to12(h, m)} – ${to12(eh, em)}`; } function sameYmd(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } function minutesOfDay(iso: string): number { const d = new Date(iso); return d.getHours() * 60 + d.getMinutes(); } type PlacedBooking = { b: Booking; s: number; // start minutes-of-day e: number; // end minutes-of-day (>= s + 15) col: number; cols: number; }; // Assign side-by-side columns to overlapping bookings (interval // partitioning), clustering chained overlaps so each cluster shares a // column count. View-only layout — no drag/resize. function layoutDayBookings(items: Booking[]): PlacedBooking[] { const evs = items .map((b) => { const s = minutesOfDay(b.startsAt); const e = Math.max(minutesOfDay(b.endsAt), s + 15); return { b, s, e, col: 0, cols: 1 }; }) .sort((a, b) => a.s - b.s || a.e - b.e); const out: PlacedBooking[] = []; let i = 0; while (i < evs.length) { let j = i + 1; let clusterEnd = evs[i].e; const cluster = [evs[i]]; while (j < evs.length && evs[j].s < clusterEnd) { cluster.push(evs[j]); clusterEnd = Math.max(clusterEnd, evs[j].e); j++; } const colEnds: number[] = []; for (const ev of cluster) { let placed = false; for (let c = 0; c < colEnds.length; c++) { if (colEnds[c] <= ev.s) { ev.col = c; colEnds[c] = ev.e; placed = true; break; } } if (!placed) { ev.col = colEnds.length; colEnds.push(ev.e); } } const cols = colEnds.length; for (const ev of cluster) { ev.cols = cols; out.push(ev); } i = j; } return out; } function BookingDayGrid({ bookings, selectedDay, now, isAr, roomName, emptyText, scrollSignal = 0, onSelect, onSlotClick, }: { bookings: Booking[]; selectedDay: Date; now: Date; isAr: boolean; roomName: (id: number) => string; emptyText: string; scrollSignal?: number; onSelect?: (b: Booking) => void; onSlotClick?: (start: Date) => void; }) { const hours: number[] = []; for (let h = GRID_HOUR_START; h <= GRID_HOUR_END; h++) hours.push(h); const dayItems = bookings.filter((b) => sameYmd(new Date(b.startsAt), selectedDay), ); const placed = layoutDayBookings(dayItems); const isToday = sameYmd(now, selectedDay); const nowMin = now.getHours() * 60 + now.getMinutes(); const showNow = isToday && nowMin >= GRID_HOUR_START * 60 && nowMin <= GRID_HOUR_END * 60; const nowTop = ((nowMin - GRID_HOUR_START * 60) / 60) * GRID_PX_PER_HOUR; const gridHeight = (GRID_HOUR_END - GRID_HOUR_START) * GRID_PX_PER_HOUR; const hourLabel = (h: number) => { const d = new Date(); d.setHours(h, 0, 0, 0); return new Intl.DateTimeFormat(isAr ? "ar-u-nu-latn" : "en-US", { hour: "numeric", hour12: true, }).format(d); }; const timeLabel = (iso: string) => new Intl.DateTimeFormat(isAr ? "ar-u-nu-latn" : "en-US", { hour: "numeric", minute: "2-digit", hour12: true, }).format(new Date(iso)); // The grid scrolls inside its own fixed-height container so we can jump // straight to the red "now" line without touching the page scroll. When // the selected day is today, position the line ~1/3 from the top of the // viewport; for other days start at the top. const scrollRef = useRef(null); const dayKey = `${selectedDay.getFullYear()}-${selectedDay.getMonth()}-${selectedDay.getDate()}`; useEffect(() => { const el = scrollRef.current; if (!el) return; if (showNow) { el.scrollTop = Math.max(0, nowTop - el.clientHeight / 3); } else { el.scrollTop = 0; } // Re-run only when the day changes (or the now-line appears/disappears), // not on every minute tick — so we never fight the user's scrolling. // eslint-disable-next-line react-hooks/exhaustive-deps }, [dayKey, showNow, scrollSignal]); return (
{dayItems.length === 0 && (
{emptyText}
)}
{hours.map((h) => (
{hourLabel(h)}
))}
{ if (!onSlotClick) return; // Only clicks on the empty grid itself — booking blocks stop // propagation in their own handler. const rect = e.currentTarget.getBoundingClientRect(); const offsetY = e.clientY - rect.top; const rawMin = GRID_HOUR_START * 60 + (offsetY / GRID_PX_PER_HOUR) * 60; // Snap down to the nearest 30 minutes, keep a full hour inside // the visible grid range. const snapped = Math.floor(rawMin / 30) * 30; const startMin = Math.min( Math.max(snapped, GRID_HOUR_START * 60), GRID_HOUR_END * 60 - 60, ); const start = new Date(selectedDay); start.setHours(Math.floor(startMin / 60), startMin % 60, 0, 0); onSlotClick(start); }} > {hours.map((h) => (
))} {placed.map((ev) => { const topM = Math.max(ev.s, GRID_HOUR_START * 60); const botM = Math.min(ev.e, GRID_HOUR_END * 60); // Skip bookings with no visible intersection with the grid range. if (botM <= topM) return null; const top = ((topM - GRID_HOUR_START * 60) / 60) * GRID_PX_PER_HOUR; const height = Math.max( ((botM - topM) / 60) * GRID_PX_PER_HOUR, 22, ); const widthPct = 100 / ev.cols; return ( ); })} {showNow && (
)}
); } export default function ProtocolPage() { const { t, i18n } = useTranslation(); const lang = i18n.language; const isAr = lang === "ar"; const [, setLocation] = useLocation(); const { toast } = useToast(); const qc = useQueryClient(); const [matchTab, tabParams] = useRoute("/protocol/:tab"); const tab: TabKey = (matchTab && SLUG_TABS[tabParams?.tab ?? ""]) || "dashboard"; const setTab = (k: TabKey) => setLocation(`/protocol/${TAB_SLUGS[k]}`); const nameOf = (ar: string, en: string) => (isAr ? ar : en || ar); const caps = useQuery({ queryKey: ["protocol", "me"], queryFn: () => apiJson(`${API}/protocol/me`), }); const rooms = useQuery({ queryKey: ["protocol", "rooms"], queryFn: () => apiJson(`${API}/protocol/rooms`), }); // Active booking slots (أوقات الحجز) — minimal read available to every // protocol user; the booking dialog restricts times to these whenever at // least one is configured. The full admin list lives in SlotsAdminSection // (requires rooms-manage permission). const slots = useQuery({ queryKey: ["protocol", "slots"], queryFn: () => apiJson(`${API}/protocol/booking-slots/active`), }); const [bookingSearch, setBookingSearch] = useState(""); const [debouncedBookingSearch, setDebouncedBookingSearch] = useState(""); useEffect(() => { const id = setTimeout( () => setDebouncedBookingSearch(bookingSearch.trim()), 300, ); return () => clearTimeout(id); }, [bookingSearch]); const bookings = useQuery({ queryKey: ["protocol", "bookings", debouncedBookingSearch], queryFn: () => apiJson( `${API}/protocol/bookings${ debouncedBookingSearch ? `?q=${encodeURIComponent(debouncedBookingSearch)}` : "" }`, ), }); const external = useQuery({ queryKey: ["protocol", "external"], queryFn: () => apiJson(`${API}/protocol/external-meetings`), }); const gifts = useQuery({ queryKey: ["protocol", "gifts"], queryFn: () => apiJson(`${API}/protocol/gifts`), }); const issues = useQuery({ queryKey: ["protocol", "issues"], queryFn: () => apiJson(`${API}/protocol/gift-issues`), }); const dashboard = useQuery({ queryKey: ["protocol", "dashboard"], queryFn: () => apiJson(`${API}/protocol/dashboard`), }); const reports = useQuery({ queryKey: ["protocol", "reports"], queryFn: () => apiJson(`${API}/protocol/reports`), enabled: tab === "reports", }); const audit = useQuery< Array<{ id: number; action: string; entityType: string; entityId: number | null; performedAt: string; actorNameAr: string | null; actorNameEn: string | null; actorUsername: string | null; }> >({ queryKey: ["protocol", "audit"], queryFn: () => apiJson(`${API}/protocol/audit`), enabled: tab === "audit", }); const c = caps.data; const roomName = (id: number) => { const r = rooms.data?.find((x) => x.id === id); return r ? nameOf(r.nameAr, r.nameEn) : `#${id}`; }; const giftName = (id: number) => { const g = gifts.data?.find((x) => x.id === id); return g ? nameOf(g.nameAr, g.nameEn) : `#${id}`; }; const invalidate = (...keys: string[]) => { for (const k of keys) qc.invalidateQueries({ queryKey: ["protocol", k] }); qc.invalidateQueries({ queryKey: ["protocol", "dashboard"] }); }; const fmtDateTime = (iso: string | null) => { if (!iso) return "—"; return new Date(iso).toLocaleString(isAr ? "ar" : "en", { dateStyle: "medium", timeStyle: "short", }); }; const onApiError = (e: unknown) => { const msg = e instanceof ApiError ? e.message : t("protocol.errors.generic"); toast({ title: msg, variant: "destructive" }); }; // ---- Booking dialog state ---- const [bookingDialog, setBookingDialog] = useState<{ open: boolean; edit?: Booking; day?: Date; start?: Date; }>({ open: false }); const [rejectTarget, setRejectTarget] = useState<{ kind: "booking" | "issue" | "external"; id: number; } | null>(null); const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list"); const [expandedBookings, setExpandedBookings] = useState>( () => new Set(), ); const toggleBookingExpanded = (id: number) => setExpandedBookings((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); // Bulk selection of bookings (list view, canMutate only). const [selectedBookings, setSelectedBookings] = useState>( () => new Set(), ); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const toggleBookingSelected = (id: number) => setSelectedBookings((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); // Clear selection whenever the visible list changes (search, view, tab). useEffect(() => { setSelectedBookings(new Set()); }, [debouncedBookingSearch, bookingsView, tab]); const [selectedDay, setSelectedDay] = useState(() => new Date()); // Bumped by the "اليوم" button to re-scroll the day grid to the now-line // even when the selected day is already today. const [todayScrollSignal, setTodayScrollSignal] = useState(0); // Booking selected from the calendar day grid → read-only details dialog. const [detailsBooking, setDetailsBooking] = useState(null); const [calendarMonth, setCalendarMonth] = useState(() => new Date()); const now = useNow(60000); const bookedDates = useMemo(() => { const map = new Map(); for (const b of bookings.data ?? []) { const d = new Date(b.startsAt); const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; if (!map.has(key)) map.set(key, new Date(d.getFullYear(), d.getMonth(), d.getDate())); } return Array.from(map.values()); }, [bookings.data]); const publicBookingUrl = `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, "")}/protocol/request`; const [linkCopied, setLinkCopied] = useState(false); const linkCopiedTimer = useRef(null); const copyBookingLink = async () => { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(publicBookingUrl); } else { const ta = document.createElement("textarea"); ta.value = publicBookingUrl; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.focus(); ta.select(); let ok = false; try { ok = document.execCommand("copy"); } finally { document.body.removeChild(ta); } if (!ok) throw new Error("execCommand copy failed"); } setLinkCopied(true); toast({ title: t("protocol.bookings.linkCopied") }); if (linkCopiedTimer.current) window.clearTimeout(linkCopiedTimer.current); linkCopiedTimer.current = window.setTimeout( () => setLinkCopied(false), 2000, ); } catch { toast({ title: t("protocol.bookings.linkCopyFailed"), variant: "destructive", }); } }; const [deleteTarget, setDeleteTarget] = useState< | { kind: "room" | "booking" | "external" | "gift" | "issue"; id: number } | null >(null); const [roomDialog, setRoomDialog] = useState<{ open: boolean; edit?: Room; }>({ open: false }); const [externalDialog, setExternalDialog] = useState<{ open: boolean; edit?: ExternalMeeting; }>({ open: false }); const [giftDialog, setGiftDialog] = useState<{ open: boolean; edit?: Gift; }>({ open: false }); const [issueDialog, setIssueDialog] = useState<{ open: boolean }>({ open: false, }); const [giftsGroupOpen, setGiftsGroupOpen] = useState(false); const [roomsGroupOpen, setRoomsGroupOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(true); const bookingAction = useMutation({ mutationFn: (args: { id: number; action: "approve" | "cancel" }) => apiJson(`${API}/protocol/bookings/${args.id}/${args.action}`, { method: "POST", body: JSON.stringify({}), }), onSuccess: () => invalidate("bookings"), onError: onApiError, }); const rejectMut = useMutation({ mutationFn: (args: { kind: "booking" | "issue" | "external"; id: number }) => { const path = args.kind === "booking" ? "bookings" : args.kind === "issue" ? "gift-issues" : "external-meetings"; return apiJson(`${API}/protocol/${path}/${args.id}/reject`, { method: "POST", body: JSON.stringify({}), }); }, onSuccess: () => { setRejectTarget(null); invalidate("bookings", "issues", "external"); }, onError: onApiError, }); const externalApprove = useMutation({ mutationFn: (id: number) => apiJson(`${API}/protocol/external-meetings/${id}/approve`, { method: "POST", body: JSON.stringify({}), }), onSuccess: () => invalidate("external"), onError: onApiError, }); const issueApprove = useMutation({ mutationFn: (id: number) => apiJson(`${API}/protocol/gift-issues/${id}/approve`, { method: "POST", body: JSON.stringify({}), }), onSuccess: () => invalidate("issues", "gifts"), onError: onApiError, }); const deleteMut = useMutation({ mutationFn: (target: { kind: string; id: number }) => { const path = target.kind === "room" ? "rooms" : target.kind === "booking" ? "bookings" : target.kind === "external" ? "external-meetings" : target.kind === "gift" ? "gifts" : "gift-issues"; return apiJson(`${API}/protocol/${path}/${target.id}`, { method: "DELETE", }); }, onSuccess: () => { setDeleteTarget(null); invalidate("rooms", "bookings", "external", "gifts", "issues"); }, onError: (e) => { setDeleteTarget(null); onApiError(e); }, }); const bulkDeleteMut = useMutation({ mutationFn: (ids: number[]) => apiJson<{ requested: number; deleted: number; deletedIds: number[] }>( `${API}/protocol/bookings/bulk-delete`, { method: "POST", body: JSON.stringify({ ids }) }, ), onSuccess: (data, ids) => { setBulkDeleteOpen(false); setSelectedBookings(new Set()); if (data.deleted < ids.length) { toast({ title: t("protocol.bookings.bulk.deletePartial", { deleted: data.deleted, total: ids.length, }), }); } else { toast({ title: t("protocol.bookings.bulk.deleted", { count: data.deleted }), }); } invalidate("bookings"); }, onError: (e) => { setBulkDeleteOpen(false); onApiError(e); }, }); const back = () => setLocation("/"); const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [ { key: "dashboard", label: t("protocol.tabs.dashboard"), icon: LayoutDashboard }, { key: "roomsDashboard", label: t("protocol.tabs.roomsDashboard"), icon: LayoutDashboard }, { key: "rooms", label: t("protocol.tabs.rooms"), icon: DoorOpen }, { key: "bookings", label: t("protocol.tabs.bookings"), icon: CalendarClock }, ...(c?.canManageRooms ? [ { key: "bookingSettings" as TabKey, label: t("protocol.tabs.bookingSettings"), icon: SettingsIcon, }, ] : []), { key: "external", label: t("protocol.tabs.external"), icon: Handshake }, { key: "gifts", label: t("protocol.tabs.gifts"), icon: Warehouse }, { key: "issues", label: t("protocol.tabs.issues"), icon: Gift }, { key: "photos", label: t("protocol.tabs.photos"), icon: ImageIcon }, { key: "reports", label: t("protocol.tabs.reports"), icon: BarChart3 }, ...(c?.canViewAudit ? [{ key: "audit" as TabKey, label: t("protocol.tabs.audit"), icon: ScrollText }] : []), ]; const BackIcon = isAr ? ArrowRight : ArrowLeft; const bookingNumber = (b: Booking) => { const digits = (b.bookingReference ?? "") .replace(/[^\d-]/g, "") .replace(/^-+|-+$/g, ""); return digits || String(b.id).padStart(4, "0"); }; const fmtDateOnly = (iso: string) => new Date(iso).toLocaleDateString(isAr ? "ar" : "en", { dateStyle: "medium", }); // Latin digits even in Arabic (ar-u-nu-latn) so times look like "12:30 م". // Rendered inside RTL containers WITHOUT dir="ltr": forcing LTR on text // that mixes Latin digits with Arabic ص/م makes the bidi algorithm flip // the order (e.g. "م 1:30 – م 12:30"). const fmtTimeOnly = (iso: string) => new Date(iso).toLocaleTimeString(isAr ? "ar-u-nu-latn" : "en", { timeStyle: "short", }); const renderBookingRow = (b: Booking, index: number) => { const expanded = expandedBookings.has(b.id); const bookingIsToday = sameYmd(new Date(b.startsAt), now); const detailColSpan = c?.canMutate ? 9 : 8; return ( <> toggleBookingExpanded(b.id)} aria-expanded={expanded} className={cn( "cursor-pointer", bookingIsToday ? "bg-emerald-50/60 hover:bg-emerald-50" : "odd:bg-white even:bg-slate-50/60", expanded && "[&>td]:border-t-2 [&>td]:border-t-slate-500 [&>td:first-child]:border-s-2 [&>td:first-child]:border-s-slate-500 [&>td:last-child]:border-e-2 [&>td:last-child]:border-e-slate-500", )} data-testid={`booking-row-${b.id}`} > {c?.canMutate && ( e.stopPropagation()} > toggleBookingSelected(b.id)} aria-label={t("protocol.bookings.bulk.selectRow", { number: bookingNumber(b), })} data-testid={`booking-select-${b.id}`} /> )} {index}
{bookingNumber(b)} {bookingIsToday && ( {t("protocol.bookings.today")} )}
{b.requesterName || "—"}
{b.source === "public" && ( {t("protocol.bookings.publicRequest")} )}
{fmtDateOnly(b.startsAt)}
{fmtTimeOnly(b.startsAt)} – {fmtTimeOnly(b.endsAt)}
{t(`protocol.bookings.meetingTypesShort.${b.meetingType}`)}
{roomName(b.roomId)}
{b.attendeeCount != null && (
{t("protocol.bookings.table.attendeesShort")}:{" "} {b.attendeeCount}
)}
{t(`protocol.status.${b.status}`)} e.stopPropagation()} >
{c?.canApprove && b.status === "pending" && ( <> )} {c?.canMutate && (b.status === "pending" || b.status === "approved") && ( )} {c?.canMutate && ( <> )} toggleBookingExpanded(b.id)} />
{expanded && (
{b.title}
{( [ [t("protocol.bookings.requester"), b.requesterName], [t("protocol.bookings.department"), b.department], [ t("protocol.bookings.phone"), b.requesterPhone ? ( {b.requesterPhone} ) : null, ], ...(b.meetingType === "external" ? [ [ t("protocol.bookings.entity"), b.requesterOrg, ] as const, ] : []), [ t("protocol.bookings.meetingType"), t(`protocol.bookings.meetingTypes.${b.meetingType}`), ], [ t("protocol.bookings.attendeeCount"), b.attendeeCount != null ? b.attendeeCount : null, ], [ t("protocol.bookings.reference"), b.bookingReference ? ( {b.bookingReference} ) : null, ], [t("protocol.bookings.purpose"), b.purpose], ] as Array<[string, React.ReactNode]> ).map(([label, value], i) => (
{label}
{value != null && value !== "" ? value : "—"}
))}
{t("protocol.bookings.keyAttendees")}
{b.keyAttendees && b.keyAttendees.length > 0 ? ( ) : ( )}
{b.status === "rejected" && b.rejectionReason && (
{t("protocol.bookings.details.rejectionReason")}
{b.rejectionReason}
)}
)} ); }; const renderBookingsTable = (list: Booking[]) => { const selectedInList = list.filter((b) => selectedBookings.has(b.id)); const allSelected = list.length > 0 && selectedInList.length === list.length; const someSelected = selectedInList.length > 0 && !allSelected; return (
{c?.canMutate && selectedInList.length > 0 && (
{t("protocol.bookings.bulk.selectedCount", { count: selectedInList.length, })}
)}
{c?.canMutate && ( { if (el) el.indeterminate = someSelected; }} onChange={() => setSelectedBookings( allSelected ? new Set() : new Set(list.map((b) => b.id)), ) } aria-label={t("protocol.bookings.bulk.selectAll")} title={t("protocol.bookings.bulk.selectAll")} data-testid="booking-select-all" /> )} # {t("protocol.bookings.table.number")} {t("protocol.bookings.table.requester")} {t("protocol.bookings.table.date")} {t("protocol.bookings.table.type")} {t("protocol.bookings.table.roomAttendees")} {t("protocol.bookings.table.status")} {t("protocol.bookings.table.actions")} {list.map((b, i) => ( {renderBookingRow(b, i + 1)} ))}
); }; return (

{t("protocol.title")}

{menuOpen && ( )}
{tab === "dashboard" && ( )} {tab === "bookings" && (

{t("protocol.tabs.bookings")}

{c?.canRequest && ( )}

{t("protocol.bookings.shareLinkTitle")}

{bookingsView === "list" && (
setBookingSearch(e.target.value)} placeholder={t("protocol.bookings.searchPlaceholder")} className="ps-9" />
)} {bookings.data?.length === 0 && !debouncedBookingSearch && ( )} {bookingsView === "list" ? ( (() => { const list = bookings.data ?? []; if (debouncedBookingSearch && list.length === 0) { return ( ); } if (list.length === 0) return null; return renderBookingsTable(list); })() ) : (
d && setSelectedDay(d)} month={calendarMonth} onMonthChange={setCalendarMonth} modifiers={{ booked: bookedDates }} modifiersClassNames={{ booked: "relative after:absolute after:bottom-1 after:start-1/2 after:-translate-x-1/2 after:h-1 after:w-1 after:rounded-full after:bg-sky-500", }} />
{selectedDay.toLocaleDateString(isAr ? "ar" : "en", { weekday: "long", year: "numeric", month: "long", day: "numeric", })}
{c?.canRequest && ( )}
setDetailsBooking(b)} onSlotClick={ c?.canRequest ? (start) => setBookingDialog({ open: true, start }) : undefined } />
)}
)} {tab === "rooms" && (

{t("protocol.tabs.rooms")}

{c?.canManageRooms && ( )}
{(rooms.data ?? []).map((r) => (
{nameOf(r.nameAr, r.nameEn)}
{r.capacity ? `${t("protocol.rooms.capacity")}: ${r.capacity}` : ""}
{r.isActive ? t("protocol.rooms.active") : t("protocol.rooms.inactive")}
{c?.canManageRooms && (
)}
))} {rooms.data?.length === 0 && ( )}
)} {tab === "bookingSettings" && c?.canManageRooms && (

{t("protocol.tabs.bookingSettings")}

invalidate("slots")} onError={onApiError} />
)} {tab === "roomsDashboard" && ( )} {tab === "bookingSettings" && !c?.canManageRooms && ( )} {tab === "external" && (

{t("protocol.tabs.external")}

{c?.canMutate && ( )}
{(external.data ?? []).map((m) => (
{m.title}
{m.partyName ? `${m.partyName} · ` : ""} {fmtDateTime(m.startsAt)}
{m.location && (
{m.location}
)}
{t(`protocol.status.${m.status}`)}
{m.status === "rejected" && m.rejectionReason && (
{t("protocol.actions.reject")}: {m.rejectionReason}
)}
{c?.canApprove && m.status === "pending" && ( <> )} {c?.canMutate && ( <> )}
))} {external.data?.length === 0 && ( )}
)} {tab === "gifts" && (

{t("protocol.tabs.gifts")}

{c?.canMutate && ( )}
{(gifts.data ?? []).map((g) => (
{nameOf(g.nameAr, g.nameEn)}
{t(`protocol.giftKind.${g.kind}`)} ·{" "} {t("protocol.gifts.stock")}: {g.quantityInStock}
{c?.canRequest && ( )} {c?.canMutate && ( <> )}
))} {gifts.data?.length === 0 && ( )}
)} {tab === "issues" && (

{t("protocol.tabs.issues")}

{c?.canRequest && ( )}
{(issues.data ?? []).map((it) => (
{giftName(it.giftId)} × {it.quantity}
{t("protocol.issues.recipient")}: {it.recipientName} {it.occasion ? ` · ${it.occasion}` : ""}
{t(`protocol.status.${it.status}`)}
{c?.canApprove && it.status === "pending" && (
)}
))} {issues.data?.length === 0 && ( )}
)} {tab === "photos" && ( )} {tab === "reports" && ( )} {tab === "audit" && (

{t("protocol.tabs.audit")}

{(audit.data ?? []).map((a) => (
{t(`protocol.audit.${a.action}`, a.action)} {" "} · {a.entityType} {a.entityId ? `#${a.entityId}` : ""} {isAr ? a.actorNameAr : a.actorNameEn || a.actorUsername} ·{" "} {fmtDateTime(a.performedAt)}
))} {audit.data?.length === 0 && ( )}
)}
{/* Dialogs */} {bookingDialog.open && ( setBookingDialog({ open: false })} onSaved={() => { setBookingDialog({ open: false }); invalidate("bookings"); }} onError={onApiError} /> )} {roomDialog.open && ( setRoomDialog({ open: false })} onSaved={() => { setRoomDialog({ open: false }); invalidate("rooms"); }} onError={onApiError} /> )} {externalDialog.open && ( setExternalDialog({ open: false })} onSaved={() => { setExternalDialog({ open: false }); invalidate("external"); }} onError={onApiError} /> )} {giftDialog.open && ( setGiftDialog({ open: false })} onSaved={() => { setGiftDialog({ open: false }); invalidate("gifts"); }} onError={onApiError} /> )} {issueDialog.open && ( g.isActive)} nameOf={nameOf} onClose={() => setIssueDialog({ open: false })} onSaved={() => { setIssueDialog({ open: false }); invalidate("issues"); }} onError={onApiError} /> )} !o && setRejectTarget(null)} > {t("protocol.actions.reject")} {t("protocol.confirmReject")} {t("common.cancel")} rejectTarget && rejectMut.mutate(rejectTarget)} > {t("protocol.actions.reject")} !o && setDeleteTarget(null)} > {t("common.confirmDelete")} {t("protocol.confirmDelete")} {t("common.cancel")} deleteTarget && deleteMut.mutate(deleteTarget)} > {t("common.delete")} !o && setBulkDeleteOpen(false)} > {t("protocol.bookings.bulk.deleteConfirmTitle", { count: selectedBookings.size, })} {t("protocol.bookings.bulk.deleteConfirmBody", { count: selectedBookings.size, })} {t("common.cancel")} selectedBookings.size > 0 && bulkDeleteMut.mutate(Array.from(selectedBookings)) } > {t("common.delete")} !o && setDetailsBooking(null)} > {detailsBooking && ( <> {detailsBooking.title} {t(`protocol.status.${detailsBooking.status}`)} {detailsBooking.source === "public" && ( {t("protocol.bookings.publicRequest")} )}
{fmtTimeOnly(detailsBooking.startsAt)} –{" "} {fmtTimeOnly(detailsBooking.endsAt)} } /> {detailsBooking.bookingReference} ) : null } />
{t("protocol.bookings.details.requesterSection")}
{detailsBooking.requesterPhone} ) : null } /> {detailsBooking.meetingType === "external" && ( )}
{(detailsBooking.purpose || (detailsBooking.keyAttendees?.length ?? 0) > 0) && (
{t("protocol.bookings.details.meetingSection")}
{(detailsBooking.keyAttendees?.length ?? 0) > 0 && (
{t("protocol.bookings.keyAttendees")}
)}
)} {detailsBooking.notes && (
{t("protocol.bookings.details.notes")}
{detailsBooking.notes}
)} {detailsBooking.status === "rejected" && detailsBooking.rejectionReason && (
{t("protocol.bookings.details.rejectionReason")}
{detailsBooking.rejectionReason}
)}
)}
); } // --------------------------------------------------------------------------- // Sub-views & dialogs // --------------------------------------------------------------------------- function AttendeesTable({ attendees, t, flush = false, }: { attendees: KeyAttendee[]; t: (key: string) => string; flush?: boolean; }) { return (
# {(["name", "position", "side"] as const).map((col) => ( {t(`protocol.bookings.details.attendeeCols.${col}`)} ))} {attendees.map((a, i) => ( {i + 1} {a.name || "—"} {a.position || "—"} {t(`protocol.bookings.attendeeSides.${a.side}`)} ))}
); } function DetailRow({ label, value, }: { label: string; value: React.ReactNode; }) { if (value == null || value === "") return null; return (
{label}
{value}
); } function formatDuration( startIso: string, endIso: string, t: (key: string, opts?: Record) => string, ): string { const mins = Math.max( 0, Math.round( (new Date(endIso).getTime() - new Date(startIso).getTime()) / 60000, ), ); const h = Math.floor(mins / 60); const m = mins % 60; const parts: string[] = []; if (h > 0) parts.push(t("protocol.bookings.details.durationHours", { count: h })); if (m > 0 || h === 0) parts.push(t("protocol.bookings.details.durationMinutes", { count: m })); return parts.join(" "); } // Admin management of booking slots (أوقات الحجز) + the minimum lead-time // setting for public requests. Shown in the rooms tab for canManageRooms. function SlotsAdminSection({ isAr, onChanged, onError, }: { isAr: boolean; onChanged: () => void; onError: (e: unknown) => void; }) { const { t } = useTranslation(); const { toast } = useToast(); const qc = useQueryClient(); const [newTime, setNewTime] = useState(""); const [newDuration, setNewDuration] = useState("30"); // Interval-generation form (Calendly-style weekly availability). const [intFrom, setIntFrom] = useState(""); const [intTo, setIntTo] = useState(""); const [intDuration, setIntDuration] = useState("30"); const [intDays, setIntDays] = useState(WORKDAYS); const toggleDay = (d: number) => setIntDays((prev) => prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d].sort((a, b) => a - b), ); // Inline slot editing (dialog) — startTime / duration / days. const [editing, setEditing] = useState(null); const [editTime, setEditTime] = useState(""); const [editDuration, setEditDuration] = useState("30"); const [editDays, setEditDays] = useState([]); const openEdit = (s: Slot) => { setEditing(s); setEditTime(s.startTime); setEditDuration(String(s.durationMinutes)); setEditDays([...s.daysOfWeek]); }; const toggleEditDay = (d: number) => setEditDays((prev) => prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d].sort((a, b) => a - b), ); // Full slot list (incl. inactive) — admin-only endpoint; this component is // only rendered for canManageRooms. const slotsQuery = useQuery({ queryKey: ["protocol", "slots-admin"], queryFn: () => apiJson(`${API}/protocol/booking-slots`), }); const slots = slotsQuery.data ?? []; const sortedSlots = [...slots].sort((a, b) => a.startTime < b.startTime ? -1 : a.startTime > b.startTime ? 1 : 0, ); const changed = () => { qc.invalidateQueries({ queryKey: ["protocol", "slots-admin"] }); onChanged(); }; const settings = useQuery<{ minLeadMinutes: number }>({ queryKey: ["protocol", "settings"], queryFn: () => apiJson<{ minLeadMinutes: number }>(`${API}/protocol/settings`), }); const [leadInput, setLeadInput] = useState(null); const leadValue = leadInput ?? (settings.data ? String(settings.data.minLeadMinutes) : ""); const addMut = useMutation({ mutationFn: () => apiJson(`${API}/protocol/booking-slots`, { method: "POST", body: JSON.stringify({ startTime: newTime, durationMinutes: Number(newDuration), }), }), onSuccess: () => { setNewTime(""); changed(); }, onError, }); const generateMut = useMutation({ mutationFn: () => apiJson<{ created: number; merged: number; skipped: number }>( `${API}/protocol/booking-slots/generate`, { method: "POST", body: JSON.stringify({ startTime: intFrom, endTime: intTo, durationMinutes: Number(intDuration), daysOfWeek: intDays, }), }, ), onSuccess: (r) => { toast({ title: r.created === 0 && r.merged === 0 ? t("protocol.slots.generatedNone") : t("protocol.slots.generated", { created: r.created, merged: r.merged, skipped: r.skipped, }), }); changed(); }, onError, }); const toggleMut = useMutation({ mutationFn: (s: Slot) => apiJson(`${API}/protocol/booking-slots/${s.id}`, { method: "PATCH", body: JSON.stringify({ isActive: !s.isActive }), }), onSuccess: changed, onError, }); const editMut = useMutation({ mutationFn: (s: Slot) => apiJson(`${API}/protocol/booking-slots/${s.id}`, { method: "PATCH", body: JSON.stringify({ startTime: editTime, durationMinutes: Number(editDuration), daysOfWeek: editDays, }), }), onSuccess: () => { setEditing(null); toast({ title: t("protocol.slots.editSaved") }); changed(); }, onError: (e: unknown) => { if (e instanceof ApiError && e.status === 409) { toast({ title: t("protocol.slots.duplicateSlot"), variant: "destructive", }); return; } onError(e); }, }); const deleteMut = useMutation({ mutationFn: (id: number) => apiJson(`${API}/protocol/booking-slots/${id}`, { method: "DELETE" }), onSuccess: changed, onError, }); const leadMut = useMutation({ mutationFn: () => apiJson(`${API}/protocol/settings`, { method: "PATCH", body: JSON.stringify({ minLeadMinutes: Number(leadValue) }), }), onSuccess: () => { setLeadInput(null); settings.refetch(); toast({ title: t("protocol.slots.leadSaved") }); }, onError, }); const canAdd = /^\d{2}:\d{2}$/.test(newTime) && Number(newDuration) >= 5; const canSaveEdit = /^\d{2}:\d{2}$/.test(editTime) && Number(editDuration) >= 5 && Number(editDuration) <= 480 && editDays.length > 0; const canGenerate = /^\d{2}:\d{2}$/.test(intFrom) && /^\d{2}:\d{2}$/.test(intTo) && intFrom < intTo && Number(intDuration) >= 5 && intDays.length > 0; const leadDirty = leadInput !== null && settings.data !== undefined && leadInput !== String(settings.data.minLeadMinutes) && /^\d+$/.test(leadInput); return (

{t("protocol.slots.title")}

{t("protocol.slots.hint")}

setNewTime(e.target.value)} />
setNewDuration(e.target.value)} />

{t("protocol.slots.intervalTitle")}

{t("protocol.slots.intervalHint")}

setIntFrom(e.target.value)} />
setIntTo(e.target.value)} />
setIntDuration(e.target.value)} />
{ALL_DAYS.map((d) => ( ))}
{intDays.length === 0 && (

{t("protocol.slots.pickDay")}

)}
{slots.length === 0 ? (

{t("protocol.slots.empty")}

) : (
# {t("protocol.slots.colTime")} {t("protocol.slots.duration")} {t("protocol.slots.days")} {t("protocol.slots.colStatus")} {t("protocol.slots.colActions")} {sortedSlots.map((s, i) => ( {i + 1} {fmtSlotRange(s.startTime, s.durationMinutes, isAr)} {t("protocol.slots.durationValue", { count: s.durationMinutes, })} {fmtSlotDays(s.daysOfWeek, t, isAr)} {s.isActive ? t("protocol.rooms.active") : t("protocol.rooms.inactive")}
))}
)}
{ if (!open) setEditing(null); }} > {t("protocol.slots.editTitle")}
setEditTime(e.target.value)} />
setEditDuration(e.target.value)} />
{ALL_DAYS.map((d) => ( ))}
{editDays.length === 0 && (

{t("protocol.slots.pickDay")}

)}

{t("protocol.slots.leadTitle")}

{t("protocol.slots.leadHint")}

setLeadInput(e.target.value)} disabled={settings.isLoading} />
); } function EmptyState({ text }: { text: string }) { return (
{text}
); } function DashboardView({ data, t, goTab, }: { data: Dashboard | undefined; t: (k: string) => string; goTab: (k: TabKey) => void; }) { const cards: Array<{ label: string; value?: number; tab: TabKey }> = [ { label: t("protocol.dashboard.roomsAvailableNow"), value: data?.roomsAvailableNow, tab: "rooms", }, { label: t("protocol.dashboard.activeRooms"), value: data?.activeRooms, tab: "rooms", }, { label: t("protocol.dashboard.todayBookings"), value: data?.todayBookings, tab: "bookings", }, { label: t("protocol.dashboard.pendingBookings"), value: data?.pendingBookings, tab: "bookings", }, { label: t("protocol.dashboard.upcomingExternal"), value: data?.upcomingExternalMeetings, tab: "external", }, { label: t("protocol.dashboard.giftStock"), value: data?.totalGiftStock, tab: "gifts", }, { label: t("protocol.dashboard.pendingIssues"), value: data?.pendingGiftIssues, tab: "issues", }, { label: t("protocol.dashboard.giftsIssuedThisMonth"), value: data?.giftsIssuedThisMonth, tab: "issues", }, ]; return (
{cards.map((c) => ( ))}
); } // --------------------------------------------------------------------------- // Rooms dashboard (لوحة بيانات القاعات) — recharts KPI + charts view. // --------------------------------------------------------------------------- type RoomsStats = { rooms: { active: number; inactive: number }; todayBookings: number; weekBookings: number; pendingBookings: number; nextBooking: { id: number; title: string; roomNameAr: string; roomNameEn: string; startsAt: string; endsAt: string; status: BookingStatus; } | null; daily: Array<{ date: string; count: number }>; byRoom: Array<{ roomId: number; nameAr: string; nameEn: string; count: number; }>; byStatus: Array<{ status: BookingStatus; count: number }>; }; const STATUS_CHART_COLORS: Record = { pending: "#f59e0b", approved: "#10b981", rejected: "#f43f5e", cancelled: "#94a3b8", }; function RoomsDashboardView({ isAr, now, goTab, }: { isAr: boolean; now: Date; goTab: (k: TabKey) => void; }) { const { t } = useTranslation(); const stats = useQuery({ queryKey: ["protocol", "rooms-stats"], queryFn: () => apiJson(`${API}/protocol/rooms-stats`), }); const d = stats.data; const [range, setRange] = useState<14 | 30>(14); const nameOf = (ar: string, en: string) => (isAr ? ar : en || ar); const locale = isAr ? "ar-u-nu-latn" : "en"; // Countdown to the next upcoming booking: "بعد X يوم" or "بعد X ساعة ودقيقة". const countdown = useMemo(() => { if (!d?.nextBooking) return null; const ms = new Date(d.nextBooking.startsAt).getTime() - now.getTime(); if (ms <= 0) return t("protocol.roomsDash.startingNow"); const totalMinutes = Math.floor(ms / 60_000); const days = Math.floor(totalMinutes / (24 * 60)); if (days >= 1) return t("protocol.roomsDash.inDays", { count: days }); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; if (hours >= 1) { return t("protocol.roomsDash.inHoursMinutes", { hours, minutes }); } return t("protocol.roomsDash.inMinutes", { count: minutes }); }, [d?.nextBooking, now, t]); const dailyData = useMemo(() => { const rows = d?.daily ?? []; const window = rows.slice(-range); return window.map((r) => ({ ...r, label: new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", }).format(new Date(`${r.date}T00:00:00+03:00`)), })); }, [d?.daily, range, locale]); const roomData = useMemo( () => (d?.byRoom ?? []).map((r) => ({ name: nameOf(r.nameAr, r.nameEn), count: r.count, })), // eslint-disable-next-line react-hooks/exhaustive-deps [d?.byRoom, isAr], ); const statusData = useMemo( () => (d?.byStatus ?? []) .filter((s) => s.count > 0) .map((s) => ({ name: t(`protocol.status.${s.status}`), status: s.status, value: s.count, })), [d?.byStatus, t], ); const fmtNextTime = (iso: string) => new Date(iso).toLocaleString(isAr ? "ar" : "en", { dateStyle: "medium", timeStyle: "short", }); const kpis: Array<{ label: string; value?: number; sub?: string; tab: TabKey }> = [ { label: t("protocol.roomsDash.roomsCount"), value: d ? d.rooms.active + d.rooms.inactive : undefined, sub: d ? t("protocol.roomsDash.roomsBreakdown", { active: d.rooms.active, inactive: d.rooms.inactive, }) : undefined, tab: "rooms", }, { label: t("protocol.roomsDash.todayBookings"), value: d?.todayBookings, tab: "bookings", }, { label: t("protocol.roomsDash.weekBookings"), value: d?.weekBookings, tab: "bookings", }, { label: t("protocol.roomsDash.pendingBookings"), value: d?.pendingBookings, tab: "bookings", }, ]; return (

{t("protocol.roomsDash.title")}

{kpis.map((k) => ( ))}
{d?.nextBooking ? (
{t("protocol.roomsDash.nextBooking")} {d.nextBooking.title} {nameOf(d.nextBooking.roomNameAr, d.nextBooking.roomNameEn)} {" · "} {fmtNextTime(d.nextBooking.startsAt)}
{countdown}
) : (
{t("protocol.roomsDash.nextBooking")} {stats.isLoading ? t("common.loading") : t("protocol.roomsDash.noUpcoming")}
)}

{t("protocol.roomsDash.bookingsOverTime")}

{([14, 30] as const).map((r) => ( ))}
[ v, t("protocol.roomsDash.bookings"), ]} labelStyle={{ color: "#334155" }} />

{t("protocol.roomsDash.byRoom")}

{roomData.length === 0 ? ( ) : (
[ v, t("protocol.roomsDash.bookings"), ]} />
)}

{t("protocol.roomsDash.byStatus")}

{statusData.length === 0 ? ( ) : (
{statusData.map((s) => ( ))} ( {value} )} /> [ v, t("protocol.roomsDash.bookings"), ]} />
)}

{t("protocol.roomsDash.windowHint")}

); } function ReportsView({ data, nameOf, t, }: { data: ReportRow | undefined; nameOf: (ar: string, en: string) => string; t: (k: string) => string; }) { return (

{t("protocol.reports.byRoom")}

{(data?.bookingsByRoom ?? []).map((r) => ( ))} {data?.bookingsByRoom.length === 0 && ( )}
{t("protocol.reports.room")} {t("protocol.reports.total")} {t("protocol.status.approved")} {t("protocol.status.pending")}
{nameOf(r.roomNameAr, r.roomNameEn)} {r.total} {r.approved} {r.pending}
{t("protocol.reports.empty")}

{t("protocol.reports.byGift")}

{(data?.issuesByGift ?? []).map((r) => ( ))} {data?.issuesByGift.length === 0 && ( )}
{t("protocol.reports.gift")} {t("protocol.reports.requests")} {t("protocol.reports.issued")} {t("protocol.reports.issuedQty")}
{nameOf(r.giftNameAr, r.giftNameEn)} {r.totalRequests} {r.issued} {r.issuedQty}
{t("protocol.reports.empty")}

{t("protocol.reports.byExternal")}

{( [ ["total", "protocol.reports.total"], ["pending", "protocol.status.pending"], ["approved", "protocol.status.approved"], ["rejected", "protocol.status.rejected"], ["completed", "protocol.status.completed"], ["cancelled", "protocol.status.cancelled"], ] as const ).map(([key, label]) => (
{data?.externalMeetings?.[key] ?? "—"}
{t(label)}
))}
); } function DialogShell({ title, onClose, children, onSubmit, submitLabel, saving, }: { title: string; onClose: () => void; children: React.ReactNode; onSubmit: () => void; submitLabel: string; saving: boolean; }) { const { i18n, t } = useTranslation(); return ( !o && onClose()}> {title}
{ e.preventDefault(); onSubmit(); }} > {/* Long forms (e.g. the booking form) scroll here while the title and the save/cancel buttons stay visible. */}
{children}
); } function BookingDialog({ rooms, slots, edit, initialDay, initialStart, nameOf, onClose, onSaved, onError, }: { rooms: Room[]; // Active booking slots. When non-empty the dialog restricts times to the // slot grid (same rule the server enforces); when empty it keeps the // legacy free start/end inputs. slots: ActiveSlotInfo[]; edit?: Booking; initialDay?: Date; initialStart?: Date; nameOf: (ar: string, en: string) => string; onClose: () => void; onSaved: () => void; onError: (e: unknown) => void; }) { const { t, i18n } = useTranslation(); const isAr = i18n.language === "ar"; const dayAt = (hour: number) => { if (!initialDay) return ""; const d = new Date(initialDay); d.setHours(hour, 0, 0, 0); return toLocalInput(d.toISOString()); }; const [title, setTitle] = useState(edit?.title ?? ""); const [roomId, setRoomId] = useState( edit ? String(edit.roomId) : rooms[0] ? String(rooms[0].id) : "", ); const [requesterName, setRequesterName] = useState(edit?.requesterName ?? ""); const [startsAt, setStartsAt] = useState(() => { if (edit) return toLocalInput(edit.startsAt); if (initialStart) return toLocalInput(initialStart.toISOString()); return dayAt(9); }); const [endsAt, setEndsAt] = useState(() => { if (edit) return toLocalInput(edit.endsAt); if (initialStart) return toLocalInput( new Date(initialStart.getTime() + 60 * 60 * 1000).toISOString(), ); return dayAt(10); }); const slotMode = slots.length > 0; // Slot-mode state: a Riyadh calendar day + one of the active slots. const [slotDate, setSlotDate] = useState(() => { if (edit) return riyadhYmd(edit.startsAt); if (initialStart) return riyadhYmd(initialStart.toISOString()); if (initialDay) return riyadhYmd(initialDay.toISOString()); return ""; }); const [slotId, setSlotId] = useState(() => { if (!slotMode) return ""; const wanted = edit ? riyadhHHmm(edit.startsAt) : initialStart ? riyadhHHmm(initialStart.toISOString()) : ""; const match = slots.find((s) => s.startTime === wanted); return match ? String(match.id) : ""; }); const selectedSlot = slots.find((s) => String(s.id) === slotId); // Only slots offered on the chosen date's weekday (Riyadh calendar) can // be picked — same rule the server enforces. const daySlots = slotDate ? slots.filter((s) => s.daysOfWeek.includes(ymdWeekday(slotDate))) : slots; useEffect(() => { if (slotId && !daySlots.some((s) => String(s.id) === slotId)) { setSlotId(""); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [slotDate]); const [notes, setNotes] = useState(edit?.notes ?? ""); const [requesterPhone, setRequesterPhone] = useState( edit?.requesterPhone ?? "", ); const [department, setDepartment] = useState(edit?.department ?? ""); const [meetingType, setMeetingType] = useState( edit?.meetingType ?? "internal", ); const [requesterOrg, setRequesterOrg] = useState(edit?.requesterOrg ?? ""); const [attendeeCount, setAttendeeCount] = useState( edit?.attendeeCount != null ? String(edit.attendeeCount) : "", ); const [purpose, setPurpose] = useState(edit?.purpose ?? ""); // One numbered table of key attendees; each row carries its own side // (من الهيئة / خارج الهيئة). Existing rows first, then padded with empty // rows up to the expected attendee count. const [attendees, setAttendees] = useState(() => { const existing = (edit?.keyAttendees ?? []).map((a) => ({ name: a.name, position: a.position, side: a.side, })); const base = existing.length > 0 ? existing : [emptyAttendeeRow()]; return syncAttendeeRows( base, edit?.attendeeCount != null ? edit.attendeeCount : null, ); }); useEffect(() => { const n = attendeeCount.trim() === "" ? null : Number(attendeeCount); setAttendees((rs) => syncAttendeeRows(rs, n)); }, [attendeeCount]); const isExternal = meetingType === "external"; const mut = useMutation({ mutationFn: () => { const keyAttendees = attendees .filter((r) => r.name.trim() !== "") .map((r) => ({ name: r.name.trim(), position: r.position.trim(), side: r.side, })); const body = { roomId: Number(roomId), title, requesterName: requesterName || null, requesterPhone: requesterPhone.trim() || null, department: department.trim() || null, meetingType, requesterOrg: isExternal ? requesterOrg.trim() || null : null, attendeeCount: attendeeCount.trim() === "" ? null : Number(attendeeCount), purpose: purpose.trim() || null, keyAttendees, startsAt: slotMode && selectedSlot ? slotToIso(slotDate, selectedSlot.startTime) : fromLocalInput(startsAt), endsAt: slotMode && selectedSlot ? slotToIso( slotDate, selectedSlot.startTime, selectedSlot.durationMinutes, ) : fromLocalInput(endsAt), notes: notes || null, }; return edit ? apiJson(`${API}/protocol/bookings/${edit.id}`, { method: "PATCH", body: JSON.stringify(body), }) : apiJson(`${API}/protocol/bookings`, { method: "POST", body: JSON.stringify(body), }); }, onSuccess: onSaved, onError, }); return ( { // Slot mode: both a date and a slot are required; block the submit // client-side instead of round-tripping to a server invalid_slot. if (slotMode && (!slotDate || !selectedSlot)) return; mut.mutate(); }} submitLabel={t("common.save")} saving={mut.isPending} >
setRequesterName(e.target.value)} />
setRequesterPhone(e.target.value)} placeholder="05xxxxxxxx" />
setDepartment(e.target.value)} />
setTitle(e.target.value)} required />
{(["internal", "external"] as const).map((opt) => ( ))}
{isExternal && (
setRequesterOrg(e.target.value)} />
)}
setAttendeeCount(e.target.value)} />