import { 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, BarChart3, ScrollText, Plus, Pencil, Trash2, Check, X, PackageCheck, CalendarDays, List as ListIcon, Link2, Copy, Camera, Upload, Loader2, Download, Image as ImageIcon, } from "lucide-react"; 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 { AppDock } from "@/components/app-dock"; 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 BookingStatus = "pending" | "approved" | "rejected" | "cancelled"; type Booking = { id: number; roomId: number; title: string; requesterName: string | null; requesterPhone: string | null; requesterOrg: string | null; attendeeCount: number | 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" | "rooms" | "bookings" | "external" | "gifts" | "issues" | "photos" | "reports" | "audit"; // URL slugs for each tab so pages are deep-linkable under /protocol/*. const TAB_SLUGS: Record = { dashboard: "dashboard", rooms: "rooms", bookings: "bookings", 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(); } // Group bookings by calendar day (ascending) for the calendar view. function groupBookingsByDay( items: Booking[], isAr: boolean, ): Array<{ key: string; label: string; items: Booking[] }> { const map = new Map(); for (const b of items) { const d = new Date(b.startsAt); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart( 2, "0", )}-${String(d.getDate()).padStart(2, "0")}`; if (!map.has(key)) map.set(key, []); map.get(key)!.push(b); } return Array.from(map.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, group]) => ({ key, label: new Date(`${key}T00:00:00`).toLocaleDateString( isAr ? "ar" : "en", { weekday: "long", year: "numeric", month: "long", day: "numeric" }, ), items: group.sort( (a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(), ), })); } 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", }; 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`), }); const bookings = useQuery({ queryKey: ["protocol", "bookings"], queryFn: () => apiJson(`${API}/protocol/bookings`), }); 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; }>({ open: false }); const [rejectTarget, setRejectTarget] = useState<{ kind: "booking" | "issue" | "external"; id: number; } | null>(null); const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list"); 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 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 back = () => setLocation("/"); const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [ { key: "dashboard", label: t("protocol.tabs.dashboard"), icon: LayoutDashboard }, { key: "rooms", label: t("protocol.tabs.rooms"), icon: DoorOpen }, { key: "bookings", label: t("protocol.tabs.bookings"), icon: CalendarClock }, { key: "external", label: t("protocol.tabs.external"), icon: Handshake }, { key: "gifts", label: t("protocol.tabs.gifts"), icon: Gift }, { key: "issues", label: t("protocol.tabs.issues"), icon: PackageCheck }, { key: "photos", label: t("protocol.tabs.photos"), icon: Camera }, { 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 renderBookingCard = (b: Booking) => (
{b.title}
{roomName(b.roomId)} · {fmtDateTime(b.startsAt)} –{" "} {fmtDateTime(b.endsAt)}
{b.requesterName && (
{t("protocol.bookings.requester")}: {b.requesterName}
)} {b.requesterOrg && (
{t("protocol.bookings.org")}: {b.requesterOrg}
)} {b.requesterPhone && (
{t("protocol.bookings.phone")}:{" "} {b.requesterPhone}
)} {b.attendeeCount != null && (
{t("protocol.bookings.attendeeCount")}: {b.attendeeCount}
)}
{b.source === "public" && ( {t("protocol.bookings.publicRequest")} )} {t(`protocol.status.${b.status}`)}
{c?.canApprove && b.status === "pending" && ( <> )} {c?.canMutate && (b.status === "pending" || b.status === "approved") && ( )} {c?.canMutate && ( <> )}
); return (

{t("protocol.title")}

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

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

{c?.canRequest && ( )}

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

{publicBookingUrl}

{bookings.data?.length === 0 && ( )} {bookingsView === "list" ? (
{(bookings.data ?? []).map((b) => renderBookingCard(b))}
) : (
{groupBookingsByDay(bookings.data ?? [], isAr).map((group) => (
{group.label}
{group.items.map((b) => renderBookingCard(b))}
))}
)}
)} {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 === "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")}
); } // --------------------------------------------------------------------------- // Sub-views & dialogs // --------------------------------------------------------------------------- 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) => ( ))}
); } 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(); }} > {children}
); } function BookingDialog({ rooms, edit, nameOf, onClose, onSaved, onError, }: { rooms: Room[]; edit?: Booking; nameOf: (ar: string, en: string) => string; onClose: () => void; onSaved: () => void; onError: (e: unknown) => void; }) { const { t } = useTranslation(); 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( edit ? toLocalInput(edit.startsAt) : "", ); const [endsAt, setEndsAt] = useState(edit ? toLocalInput(edit.endsAt) : ""); const [notes, setNotes] = useState(edit?.notes ?? ""); const mut = useMutation({ mutationFn: () => { const body = { roomId: Number(roomId), title, requesterName: requesterName || null, startsAt: fromLocalInput(startsAt), endsAt: 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 ( mut.mutate()} submitLabel={t("common.save")} saving={mut.isPending} >
setTitle(e.target.value)} required />
setRequesterName(e.target.value)} />
setStartsAt(e.target.value)} required />
setEndsAt(e.target.value)} required />