diff --git a/apps/web/src/routes/admin/index.tsx b/apps/web/src/routes/admin/index.tsx index 7ce5d60..efb3fb1 100644 --- a/apps/web/src/routes/admin/index.tsx +++ b/apps/web/src/routes/admin/index.tsx @@ -10,20 +10,13 @@ import { ClipboardCheck, Download, LogOut, + MessageSquare, Search, - Users, X, } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; import { orpc } from "@/utils/orpc"; @@ -49,6 +42,74 @@ function parseGuests(raw: unknown): Guest[] { } } +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(""); @@ -62,9 +123,8 @@ function AdminPage() { const pageSize = 20; const [copiedId, setCopiedId] = useState(null); - const [expandedGuests, setExpandedGuests] = useState>(new Set()); + const [expandedRow, setExpandedRow] = useState(null); - // Get current session to check user role const sessionQuery = useQuery({ queryKey: ["session"], queryFn: () => authClient.getSession(), @@ -79,13 +139,8 @@ function AdminPage() { }); }; - const toggleGuestExpand = (id: string) => { - setExpandedGuests((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); + const toggleRow = (id: string) => { + setExpandedRow((prev) => (prev === id ? null : id)); }; type SortKey = @@ -102,16 +157,14 @@ function AdminPage() { const [sortDir, setSortDir] = useState("desc"); const handleSort = (key: SortKey) => { - if (sortKey === key) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - } else { + 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: { @@ -125,7 +178,6 @@ function AdminPage() { }, }), ); - const adminRequestsQuery = useQuery(orpc.getAdminRequests.queryOptions()); const exportMutation = useMutation({ @@ -149,9 +201,7 @@ function AdminPage() { toast.success("Admin toegang goedgekeurd"); adminRequestsQuery.refetch(); }, - onError: (error) => { - toast.error(`Fout: ${error.message}`); - }, + onError: (err) => toast.error(`Fout: ${err.message}`), }); const rejectRequestMutation = useMutation({ @@ -160,42 +210,15 @@ function AdminPage() { toast.success("Admin toegang geweigerd"); adminRequestsQuery.refetch(); }, - onError: (error) => { - toast.error(`Fout: ${error.message}`); - }, + onError: (err) => toast.error(`Fout: ${err.message}`), }); const requestAdminMutation = useMutation({ ...orpc.requestAdminAccess.mutationOptions(), - onSuccess: () => { - toast.success("Admin toegang aangevraagd!"); - }, - onError: (error) => { - toast.error(`Aanvraag mislukt: ${error.message}`); - }, + onSuccess: () => toast.success("Admin toegang aangevraagd!"), + onError: (err) => toast.error(`Aanvraag mislukt: ${err.message}`), }); - const handleSignOut = async () => { - await authClient.signOut(); - navigate({ to: "/" }); - }; - - const handleExport = () => { - exportMutation.mutate(undefined); - }; - - const handleApprove = (requestId: string) => { - approveRequestMutation.mutate({ requestId }); - }; - - const handleReject = (requestId: string) => { - rejectRequestMutation.mutate({ requestId }); - }; - - const handleRequestAdmin = () => { - requestAdminMutation.mutate(undefined); - }; - const stats = statsQuery.data; const registrations = registrationsQuery.data?.data ?? []; const pagination = registrationsQuery.data?.pagination; @@ -206,19 +229,16 @@ function AdminPage() { stats?.byType.find((t) => t.registrationType === "performer")?.count ?? 0; const watcherCount = stats?.byType.find((t) => t.registrationType === "watcher")?.count ?? 0; - - // Use server-side totalGuestCount from stats (accurate across all pages) 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((sum, r) => sum + (r.drinkCardValue ?? 0), 0); - const totalGiftRevenue = (stats?.totalGiftRevenue as number | undefined) ?? 0; + .reduce((s, r) => s + (r.drinkCardValue ?? 0), 0); const sortedRegistrations = useMemo(() => { - const sorted = [...registrations].sort((a, b) => { + return [...registrations].sort((a, b) => { let valA: string | number = 0; let valB: string | number = 0; switch (sortKey) { @@ -244,11 +264,10 @@ function AdminPage() { ? b.artForm : `€${b.drinkCardValue ?? 5}`) ?? ""; break; - case "gasten": { + case "gasten": valA = parseGuests(a.guests).length; valB = parseGuests(b.guests).length; break; - } case "gift": valA = a.giftAmount ?? 0; valB = b.giftAmount ?? 0; @@ -266,927 +285,962 @@ function AdminPage() { if (valA > valB) return sortDir === "asc" ? 1 : -1; return 0; }); - return sorted; }, [registrations, sortKey, sortDir]); const SortIcon = ({ col }: { col: SortKey }) => { if (sortKey !== col) - return ; + return ; return sortDir === "asc" ? ( - + ) : ( - + ); }; - const thClass = - "px-4 py-3 text-left font-medium text-xs text-white/60 uppercase tracking-wider cursor-pointer select-none hover:text-white/90 whitespace-nowrap"; - const formatCents = (cents: number | null | undefined) => { - if (!cents || cents <= 0) return "-"; + if (!cents || cents <= 0) return null; const euros = cents / 100; return `€${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`; }; - // Check if user is admin const user = sessionQuery.data?.data?.user as | { role?: string; name?: string } | undefined; const isAdmin = user?.role === "admin"; const isAuthenticated = !!sessionQuery.data?.data?.user; - // Show loading state while checking session if (sessionQuery.isLoading) { return ( -
-

Laden...

+
+

laden...

); } - // Show login prompt for non-authenticated users if (!isAuthenticated) { return ( -
- - - - Inloggen Vereist - - - Je moet ingelogd zijn om admin toegang aan te vragen - - - -
- - - - ← Terug naar website - -
-
-
+
+
+

Login vereist

+ + + ← Terug naar website + +
); } - // Show request admin UI for non-admin users if (!isAdmin) { return ( -
- - - - Admin Toegang Vereist - - - Je hebt geen admin rechten voor dit dashboard - - - -
-
-

- Admin-toegang aanvragen? -

- -
- - - - - - - ← Terug naar website - -
-
-
-
- ); - } - - return ( -
- {/* Header */} -
-
-
- - ← Terug naar website - -

- Admin Dashboard -

-
-
- - Drinkkaart beheer - +
+
+

+ 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 + + +
- {/* Main Content */} -
- {/* Pending Admin Requests */} +
+ {/* ── Pending admin requests ── */} {pendingRequests.length > 0 && ( - - - - Openstaande Admin Aanvragen ({pendingRequests.length}) - - - Gebruikers die admin toegang hebben aangevraagd - - - -
- {pendingRequests.map((request) => ( -
-
-

- {request.userName} -

-

- {request.userEmail} -

-

- Aangevraagd:{" "} - {new Date(request.requestedAt).toLocaleDateString( - "nl-BE", - )} -

-
-
- - -
+
+

+ Openstaande admin aanvragen ({pendingRequests.length}) +

+
+ {pendingRequests.map((req) => ( +
+
+

{req.userName}

+

{req.userEmail}

- ))} -
- - +
+ + +
+
+ ))} +
+
)} - {/* Stats Cards */} -
- {/* Total registrations */} - - - - Totaal inschrijvingen - - - {stats?.total ?? 0} - - - - - - - - {/* Today */} - - - - Vandaag ingeschreven - - - {stats?.today ?? 0} - - - - - Nieuwe registraties vandaag + {/* ── Stats grid ── */} +
+ + {stats?.today ?? 0} vandaag nieuw - - - - {/* Performers */} - - - - Artiesten - - - {performerCount} - - - -
- {stats?.byArtForm.slice(0, 2).map((item) => ( + } + /> + + {stats?.byArtForm.slice(0, 3).map((item) => (
- + {item.artForm || "Onbekend"} - {item.count} + + {item.count} +
))} -
-
-
- - {/* Watchers + guests (real attendees count) */} - - - - Bezoekers - - - {totalWatcherAttendees} - - - -
-
- Inschrijvingen - {watcherCount} + + } + /> + +
+ Inschrijvingen + + {watcherCount} +
{totalGuestCount > 0 && ( -
- Gasten - +{totalGuestCount} +
+ Gasten + + +{totalGuestCount} +
)} -
- Drinkkaart - €{totalDrinkCardValue} +
+ Drinkkaart + + €{totalDrinkCardValue} +
-
- - - - {/* Gifts */} - - - - Vrijwillige Gifts - - - €{Math.round(totalGiftRevenue / 100)} - - - - - Totale gift opbrengst - - - + + } + /> + Vrijwillige bijdragen + } + /> + Nieuwe registraties} + />
- {/* Filters */} - - - - Filters - - - -
-
- -
- - setSearch(e.target.value)} - className="border-white/20 bg-white/10 pl-10 text-sm text-white placeholder:text-white/40 sm:text-base" - /> -
-
- -
- - -
- -
- + {/* ── Filters + export row ── */} +
+
+ {/* Search */} +
+
+ setArtForm(e.target.value)} - className="border-white/20 bg-white/10 text-sm text-white placeholder:text-white/40 sm:text-base" - /> -
- -
- - setFromDate(e.target.value)} - className="border-white/20 bg-white/10 text-sm text-white [color-scheme:dark] sm:text-base" - /> -
- -
- - setToDate(e.target.value)} - className="border-white/20 bg-white/10 text-sm text-white [color-scheme:dark] sm:text-base" + placeholder="Naam of e-mail zoeken…" + value={search} + onChange={(e) => { + 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" />
- - - - {/* Export Button */} -
-

- {pagination?.total ?? 0} registraties gevonden + {/* 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 && ( - - (+{totalGuestCount} gasten ={" "} - {(pagination?.total ?? 0) + totalGuestCount} aanwezigen totaal) + + · {totalWatcherAttendees + performerCount} aanwezigen totaal + (incl. {totalGuestCount} gasten) )}

-
- {/* Registrations Table / Cards */} - - - {/* Desktop Table */} -
- - - - - - - - + + + ) : ( + sortedRegistrations.map((reg) => { const isPerformer = reg.registrationType === "performer"; const guests = parseGuests(reg.guests); const guestCount = guests.length; - const isExpanded = expandedGuests.has(reg.id); + const isExpanded = expandedRow === reg.id; + const hasNotes = !!reg.extraQuestions?.trim(); + const hasExtra = guestCount > 0 || hasNotes; const detailLabel = isPerformer - ? reg.artForm || "-" + ? reg.artForm || "—" : `€${reg.drinkCardValue ?? 5} drinkkaart`; const dateLabel = (() => { try { return new Date(reg.createdAt).toLocaleDateString( "nl-BE", - { - day: "2-digit", - month: "2-digit", - year: "2-digit", - }, + { day: "2-digit", month: "2-digit", year: "numeric" }, ); } catch { - return "-"; + return "—"; } })(); - return ( -
-
-
-
-
- - {reg.firstName} {reg.lastName} - -
-
- {reg.email} - {reg.phone && ( - - • {reg.phone} - - )} -
-
-
- - {isPerformer ? "Artiest" : "Bezoeker"} - - {reg.managementToken && ( - - )} -
-
+ const giftFmt = formatCents(reg.giftAmount); -
-
- Details:{" "} - {detailLabel} -
- {guestCount > 0 && ( + 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 - - Telefoon - handleSort("type")}> - Type - handleSort("details")} + {/* ── Table ── */} +
+ {/* Desktop table */} +
+ + + + + + + + + + + + + + + + + + {registrationsQuery.isLoading ? ( + + - - - - + laden... + - - - {registrationsQuery.isLoading ? ( - - - - ) : sortedRegistrations.length === 0 ? ( - - - - ) : ( - sortedRegistrations.map((reg) => { - const isPerformer = reg.registrationType === "performer"; - const guests = parseGuests(reg.guests); - const guestCount = guests.length; - const isExpanded = expandedGuests.has(reg.id); - - 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 "-"; - } - })(); - - return ( - <> - - - - - - - - - - - - - {/* Expandable guest details row */} - {isExpanded && guestCount > 0 && ( - - - - )} - - ); - }) - )} - -
+ 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
- Details - - handleSort("gasten")} - > - Gasten - handleSort("gift")}> - Gift - handleSort("betaling")} - > - Betaling - handleSort("datum")}> - Datum - - Link -
- Laden... -
- Geen registraties gevonden -
- {reg.firstName} {reg.lastName} - - {reg.email} - - {reg.phone || "-"} - - - {isPerformer ? "Artiest" : "Bezoeker"} - - - {detailLabel} - - {guestCount > 0 ? ( - - ) : ( - "-" - )} - - {formatCents(reg.giftAmount)} - - {isPerformer ? ( - - - ) : reg.paymentStatus === "paid" ? ( - - - Betaald - - ) : reg.paymentStatus === - "extra_payment_pending" ? ( - - - Extra (€ - {((reg.paymentAmount ?? 0) / 100).toFixed(0)}) - - ) : ( - - Open - - )} - - {dateLabel} - - {reg.managementToken ? ( - - ) : ( - - )} -
-
-

- Gasten van {reg.firstName} {reg.lastName} -

- {guests.map((g, i) => ( -
- - {i + 1}. - - - {g.firstName} {g.lastName} - - {g.email && ( - - {g.email} - - )} - {g.phone && ( - - {g.phone} - - )} - {!g.email && !g.phone && ( - - geen contactgegevens - - )} -
- ))} -
-
-
- - {/* Mobile Cards */} -
- {registrationsQuery.isLoading ? ( -
- Laden... -
- ) : sortedRegistrations.length === 0 ? ( -
- Geen registraties gevonden -
- ) : ( -
- {sortedRegistrations.map((reg) => { + ) : sortedRegistrations.length === 0 ? ( +
+ 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 ? ( + )} - {(reg.giftAmount ?? 0) > 0 && ( -
- Gift:{" "} - {formatCents(reg.giftAmount)} -
- )} - {!isPerformer && ( -
- Betaling:{" "} - {reg.paymentStatus === "paid" ? ( - - - Betaald - - ) : reg.paymentStatus === - "extra_payment_pending" ? ( - - - Extra (€ - {((reg.paymentAmount ?? 0) / 100).toFixed( - 0, - )} - ) - - ) : ( - Open - )} -
- )} -
{dateLabel}
+
+ {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 "—"; + } + })()} + +
+
+ )} - {/* Expandable guest details — mobile */} - {isExpanded && guestCount > 0 && ( -
-

+ {/* Mobile expanded */} + {isExpanded && ( +

+ {guestCount > 0 && ( +
+

Gasten

{guests.map((g, i) => (
- - {i + 1}. - - - {g.firstName} {g.lastName} - +

+ {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 && pagination.totalPages > 1 && ( -
- - - - {page}/{pagination.totalPages} - - - Pagina {page} van {pagination.totalPages} - + ← vorige + + + {page} / {pagination.totalPages} - + volgende → +
)}