import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { Check, ChevronDown, ChevronRight, ChevronsUpDown, ChevronUp, Clipboard, ClipboardCheck, Download, LogOut, MessageSquare, Search, X, } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; import { orpc } from "@/utils/orpc"; export const Route = createFileRoute("/admin/")({ component: AdminPage, }); type Guest = { firstName: string; lastName: string; email?: string; phone?: string; }; function parseGuests(raw: unknown): Guest[] { if (!raw) return []; try { const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function StatCard({ label, value, sub, accent, }: { label: string; value: string | number; sub?: React.ReactNode; accent?: "amber" | "teal" | "pink" | "white"; }) { const colorMap = { amber: "text-amber-300", teal: "text-teal-300", pink: "text-pink-300", white: "text-white", }; const borderMap = { amber: "border-amber-400/20 bg-amber-400/5", teal: "border-teal-400/20 bg-teal-400/5", pink: "border-pink-400/20 bg-pink-400/5", white: "border-white/10 bg-white/5", }; const a = accent ?? "white"; return (

{label}

{value}

{sub &&
{sub}
}
); } function PayBadge({ status, paymentAmount, isPerformer, }: { status: string | null; paymentAmount: number | null; isPerformer: boolean; }) { if (isPerformer) return ; if (status === "paid") return ( Betaald ); if (status === "extra_payment_pending") return ( € {((paymentAmount ?? 0) / 100).toFixed(0)} extra ); return ( Open ); } function AdminPage() { const navigate = useNavigate(); const [search, setSearch] = useState(""); const [registrationType, setRegistrationType] = useState< "performer" | "watcher" | "" >(""); const [artForm, setArtForm] = useState(""); const [fromDate, setFromDate] = useState(""); const [toDate, setToDate] = useState(""); const [page, setPage] = useState(1); const pageSize = 20; const [copiedId, setCopiedId] = useState(null); const [expandedRow, setExpandedRow] = useState(null); const sessionQuery = useQuery({ queryKey: ["session"], queryFn: () => authClient.getSession(), }); const handleCopyManageUrl = (token: string, id: string) => { const url = `${window.location.origin}/manage/${token}`; navigator.clipboard.writeText(url).then(() => { setCopiedId(id); toast.success("Beheerlink gekopieerd"); setTimeout(() => setCopiedId(null), 2000); }); }; const toggleRow = (id: string) => { setExpandedRow((prev) => (prev === id ? null : id)); }; type SortKey = | "naam" | "email" | "type" | "details" | "gasten" | "gift" | "betaling" | "datum"; type SortDir = "asc" | "desc"; const [sortKey, setSortKey] = useState("datum"); const [sortDir, setSortDir] = useState("desc"); const handleSort = (key: SortKey) => { if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc")); else { setSortKey(key); setSortDir("asc"); } }; const statsQuery = useQuery(orpc.getRegistrationStats.queryOptions()); const registrationsQuery = useQuery( orpc.getRegistrations.queryOptions({ input: { search: search || undefined, registrationType: registrationType || undefined, artForm: artForm || undefined, fromDate: fromDate || undefined, toDate: toDate || undefined, page, pageSize, }, }), ); const adminRequestsQuery = useQuery(orpc.getAdminRequests.queryOptions()); const exportMutation = useMutation({ ...orpc.exportRegistrations.mutationOptions(), onSuccess: (data: { csv: string; filename: string }) => { const blob = new Blob([data.csv], { type: "text/csv" }); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = data.filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); }, }); const approveRequestMutation = useMutation({ ...orpc.approveAdminRequest.mutationOptions(), onSuccess: () => { toast.success("Admin toegang goedgekeurd"); adminRequestsQuery.refetch(); }, onError: (err) => toast.error(`Fout: ${err.message}`), }); const rejectRequestMutation = useMutation({ ...orpc.rejectAdminRequest.mutationOptions(), onSuccess: () => { toast.success("Admin toegang geweigerd"); adminRequestsQuery.refetch(); }, onError: (err) => toast.error(`Fout: ${err.message}`), }); const requestAdminMutation = useMutation({ ...orpc.requestAdminAccess.mutationOptions(), onSuccess: () => toast.success("Admin toegang aangevraagd!"), onError: (err) => toast.error(`Aanvraag mislukt: ${err.message}`), }); const stats = statsQuery.data; const registrations = registrationsQuery.data?.data ?? []; const pagination = registrationsQuery.data?.pagination; const adminRequests = adminRequestsQuery.data ?? []; const pendingRequests = adminRequests.filter((r) => r.status === "pending"); const performerCount = stats?.byType.find((t) => t.registrationType === "performer")?.count ?? 0; const watcherCount = stats?.byType.find((t) => t.registrationType === "watcher")?.count ?? 0; const totalGuestCount = (stats as { totalGuestCount?: number } | undefined)?.totalGuestCount ?? 0; const totalWatcherAttendees = watcherCount + totalGuestCount; const totalGiftRevenue = (stats?.totalGiftRevenue as number | undefined) ?? 0; const totalDrinkCardValue = registrations .filter((r) => r.registrationType === "watcher") .reduce((s, r) => s + (r.drinkCardValue ?? 0), 0); const sortedRegistrations = useMemo(() => { return [...registrations].sort((a, b) => { let valA: string | number = 0; let valB: string | number = 0; switch (sortKey) { case "naam": valA = `${a.firstName} ${a.lastName}`.toLowerCase(); valB = `${b.firstName} ${b.lastName}`.toLowerCase(); break; case "email": valA = a.email.toLowerCase(); valB = b.email.toLowerCase(); break; case "type": valA = a.registrationType ?? ""; valB = b.registrationType ?? ""; break; case "details": valA = (a.registrationType === "performer" ? a.artForm : `€${a.drinkCardValue ?? 5}`) ?? ""; valB = (b.registrationType === "performer" ? b.artForm : `€${b.drinkCardValue ?? 5}`) ?? ""; break; case "gasten": valA = parseGuests(a.guests).length; valB = parseGuests(b.guests).length; break; case "gift": valA = a.giftAmount ?? 0; valB = b.giftAmount ?? 0; break; case "betaling": valA = a.paymentStatus ?? "pending"; valB = b.paymentStatus ?? "pending"; break; case "datum": valA = new Date(a.createdAt).getTime(); valB = new Date(b.createdAt).getTime(); break; } if (valA < valB) return sortDir === "asc" ? -1 : 1; if (valA > valB) return sortDir === "asc" ? 1 : -1; return 0; }); }, [registrations, sortKey, sortDir]); const SortIcon = ({ col }: { col: SortKey }) => { if (sortKey !== col) return ; return sortDir === "asc" ? ( ) : ( ); }; const formatCents = (cents: number | null | undefined) => { if (!cents || cents <= 0) return null; const euros = cents / 100; return `€${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`; }; const user = sessionQuery.data?.data?.user as | { role?: string; name?: string } | undefined; const isAdmin = user?.role === "admin"; const isAuthenticated = !!sessionQuery.data?.data?.user; if (sessionQuery.isLoading) { return (

laden...

); } if (!isAuthenticated) { return (

Login vereist

← Terug naar website
); } if (!isAdmin) { return (

Geen toegang

Je hebt geen admin rechten

← Terug naar website
); } const thCls = "px-3 py-3 text-left font-mono text-[10px] uppercase tracking-widest text-white/35 cursor-pointer select-none hover:text-white/70 whitespace-nowrap"; return (
{/* ── Top bar ── */}
← site | Admin {pendingRequests.length > 0 && ( {pendingRequests.length} aanvraag {pendingRequests.length !== 1 ? "en" : ""} )}
Drinkkaart
{/* ── Pending admin requests ── */} {pendingRequests.length > 0 && (

Openstaande admin aanvragen ({pendingRequests.length})

{pendingRequests.map((req) => (

{req.userName}

{req.userEmail}

))}
)} {/* ── Stats grid ── */}
{stats?.today ?? 0} vandaag nieuw } /> {stats?.byArtForm.slice(0, 3).map((item) => (
{item.artForm || "Onbekend"} {item.count}
))} } />
Inschrijvingen {watcherCount}
{totalGuestCount > 0 && (
Gasten +{totalGuestCount}
)}
Drinkkaart €{totalDrinkCardValue}
} /> Vrijwillige bijdragen } /> Nieuwe registraties} />
{/* ── Filters + export row ── */}
{/* Search */}
{ setSearch(e.target.value); setPage(1); }} className="border-white/15 bg-white/8 pl-9 font-mono text-sm text-white placeholder:text-white/25 focus-visible:ring-teal-500/40" />
{/* Type */}
{/* Art form */}
{ setArtForm(e.target.value); setPage(1); }} className="border-white/15 bg-white/8 font-mono text-sm text-white placeholder:text-white/25 focus-visible:ring-teal-500/40" />
{/* Date from */}
{ setFromDate(e.target.value); setPage(1); }} className="border-white/15 bg-white/8 font-mono text-sm text-white [color-scheme:dark] focus-visible:ring-teal-500/40" />
{/* Date to + export */}
{ setToDate(e.target.value); setPage(1); }} className="min-w-0 flex-1 border-white/15 bg-white/8 font-mono text-sm text-white [color-scheme:dark] focus-visible:ring-teal-500/40" />

{pagination?.total ?? 0} registraties {totalGuestCount > 0 && ( · {totalWatcherAttendees + performerCount} aanwezigen totaal (incl. {totalGuestCount} gasten) )}

{/* ── Table ── */}
{/* Desktop table */}
{registrationsQuery.isLoading ? ( ) : sortedRegistrations.length === 0 ? ( ) : ( sortedRegistrations.map((reg) => { const isPerformer = reg.registrationType === "performer"; const guests = parseGuests(reg.guests); const guestCount = guests.length; const isExpanded = expandedRow === reg.id; const hasNotes = !!reg.extraQuestions?.trim(); const hasExtra = guestCount > 0 || hasNotes; const detailLabel = isPerformer ? reg.artForm || "—" : `€${reg.drinkCardValue ?? 5} drinkkaart`; const dateLabel = (() => { try { return new Date(reg.createdAt).toLocaleDateString( "nl-BE", { day: "2-digit", month: "2-digit", year: "numeric" }, ); } catch { return "—"; } })(); const giftFmt = formatCents(reg.giftAmount); return ( <> hasExtra && toggleRow(reg.id)} className={`border-white/5 border-b transition-colors ${hasExtra ? "cursor-pointer" : ""} ${isExpanded ? "bg-white/5" : "hover:bg-white/3"}`} > {/* Expand toggle */} {/* Expanded detail row */} {isExpanded && ( )} ); }) )}
handleSort("naam")}> Naam handleSort("email")}> Email handleSort("type")}> Type handleSort("details")}> Details handleSort("gasten")}> Gasten Notities handleSort("gift")}> Gift handleSort("betaling")}> Betaling handleSort("datum")}> Datum Link
laden...
Geen registraties gevonden
{hasExtra ? ( isExpanded ? ( ) : ( ) ) : null} {reg.firstName} {reg.lastName} {reg.email} {reg.phone || "—"} {isPerformer ? "Artiest" : "Bezoeker"} {detailLabel} {guestCount > 0 ? ( {guestCount}× ) : ( )} {hasNotes ? ( ) : ( )} {giftFmt ?? ( )} {dateLabel} e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > {reg.managementToken ? ( ) : ( )}
{/* Guests */} {guestCount > 0 && (

Gasten ({guestCount})

{guests.map((g, i) => (
{i + 1}. {g.firstName} {g.lastName} {g.email && ( {g.email} )} {g.phone && ( {g.phone} )} {!g.email && !g.phone && ( geen contactgegevens )}
))}
)} {/* Notes */} {hasNotes && (

Vragen / opmerkingen

{reg.extraQuestions}
)} {/* Performer extras */} {isPerformer && (reg.experience || reg.artForm) && (

Optreden details

{reg.artForm && (

Kunstvorm:{" "} {reg.artForm}

)} {reg.experience && (

Ervaring:{" "} {reg.experience}

)}

16+:{" "} {reg.isOver16 ? "ja" : "nee"}

)}
{/* Mobile cards */}
{registrationsQuery.isLoading ? (
laden...
) : sortedRegistrations.length === 0 ? (
Geen registraties
) : ( sortedRegistrations.map((reg) => { const isPerformer = reg.registrationType === "performer"; const guests = parseGuests(reg.guests); const guestCount = guests.length; const isExpanded = expandedRow === reg.id; const hasNotes = !!reg.extraQuestions?.trim(); const hasExtra = guestCount > 0 || hasNotes; const giftFmt = formatCents(reg.giftAmount); return (
{hasExtra ? ( )}
{isPerformer ? ( {reg.artForm || "—"} ) : ( €{reg.drinkCardValue ?? 5} drinkkaart )} {guestCount > 0 && ( {guestCount} gast{guestCount !== 1 ? "en" : ""} )} {giftFmt && ( gift {giftFmt} )} {hasNotes && ( notitie )} {(() => { try { return new Date( reg.createdAt, ).toLocaleDateString("nl-BE", { day: "2-digit", month: "2-digit", year: "2-digit", }); } catch { return "—"; } })()}
) : (
{reg.firstName} {reg.lastName}

{reg.email}

{reg.phone && (

{reg.phone}

)}
{isPerformer ? "Artiest" : "Bezoeker"} {reg.managementToken && ( )}
{isPerformer ? ( {reg.artForm || "—"} ) : ( €{reg.drinkCardValue ?? 5} drinkkaart )} {giftFmt && ( gift {giftFmt} )} {(() => { try { return new Date( reg.createdAt, ).toLocaleDateString("nl-BE", { day: "2-digit", month: "2-digit", year: "2-digit", }); } catch { return "—"; } })()}
)} {/* Mobile expanded */} {isExpanded && (
{guestCount > 0 && (

Gasten

{guests.map((g, i) => (

{i + 1}. {g.firstName} {g.lastName}

{g.email && (

{g.email}

)} {g.phone && (

{g.phone}

)}
))}
)} {hasNotes && (

Vragen / opmerkingen

{reg.extraQuestions}

)} {isPerformer && (reg.experience || reg.artForm) && (

Optreden details

{reg.artForm && (

Kunstvorm:{" "} {reg.artForm}

)} {reg.experience && (

Ervaring:{" "} {reg.experience}

)}

16+: {reg.isOver16 ? "ja" : "nee"}

)}
)}
); }) )}
{pagination && pagination.totalPages > 1 && (
{page} / {pagination.totalPages}
)} ); }