Files
kunstenkamp/apps/web/src/routes/admin/index.tsx
zias f214660ab2 feat: redesign admin page with dense ops-tool aesthetic and expandable rows
- Replace generic card layout with near-black background and monospace data display
- Add expandable rows for guest details, notes/questions, and performer details
- Fix duplicate stat cards (artiesten/bezoekers were shown twice)
- Fix gift revenue card showing wrong value (stats.today → totalGiftRevenue)
- Fix mobile card view to use accessible <button> for expandable rows
- Remove unused Users and Card component imports
2026-03-11 11:52:26 +01:00

1250 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className={`rounded-xl border p-4 ${borderMap[a]}`}>
<p className="mb-1 font-mono text-[10px] text-white/40 uppercase tracking-widest">
{label}
</p>
<p className={`font-bold font-mono text-3xl tabular-nums ${colorMap[a]}`}>
{value}
</p>
{sub && <div className="mt-2 space-y-0.5 text-xs">{sub}</div>}
</div>
);
}
function PayBadge({
status,
paymentAmount,
isPerformer,
}: {
status: string | null;
paymentAmount: number | null;
isPerformer: boolean;
}) {
if (isPerformer) return <span className="text-white/25 text-xs"></span>;
if (status === "paid")
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 font-semibold text-green-400 text-xs">
<Check className="h-3 w-3" />
Betaald
</span>
);
if (status === "extra_payment_pending")
return (
<span className="inline-flex items-center gap-1 rounded-full bg-orange-500/15 px-2 py-0.5 font-semibold text-orange-400 text-xs">
<span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
{((paymentAmount ?? 0) / 100).toFixed(0)} extra
</span>
);
return (
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 font-semibold text-xs text-yellow-400">
Open
</span>
);
}
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<string | null>(null);
const [expandedRow, setExpandedRow] = useState<string | null>(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<SortKey>("datum");
const [sortDir, setSortDir] = useState<SortDir>("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 <ChevronsUpDown className="ml-1 inline h-3 w-3 opacity-30" />;
return sortDir === "asc" ? (
<ChevronUp className="ml-1 inline h-3 w-3 text-teal-300" />
) : (
<ChevronDown className="ml-1 inline h-3 w-3 text-teal-300" />
);
};
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 (
<div className="flex min-h-screen items-center justify-center">
<p className="font-mono text-sm text-white/40">laden...</p>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-white/5 p-8 text-center">
<p className="mb-6 font-mono text-sm text-white/60">Login vereist</p>
<Button
onClick={() => navigate({ to: "/login" })}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
Inloggen
</Button>
<Link
to="/"
className="mt-4 block text-center text-sm text-white/30 hover:text-white/70"
>
Terug naar website
</Link>
</div>
</div>
);
}
if (!isAdmin) {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-white/5 p-8 text-center">
<p className="mb-2 font-['Intro',sans-serif] text-2xl text-white">
Geen toegang
</p>
<p className="mb-6 text-sm text-white/50">
Je hebt geen admin rechten
</p>
<Button
onClick={() => requestAdminMutation.mutate(undefined)}
disabled={requestAdminMutation.isPending}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
{requestAdminMutation.isPending ? "Bezig..." : "Vraag toegang aan"}
</Button>
<div className="mt-3 flex flex-col gap-2">
<Button
onClick={() => navigate({ to: "/account" })}
variant="outline"
className="w-full border-white/20 bg-transparent text-white hover:bg-white/10"
>
Mijn account
</Button>
<Button
onClick={async () => {
await authClient.signOut();
navigate({ to: "/" });
}}
variant="outline"
className="w-full border-white/10 bg-transparent text-white/40 hover:bg-white/5 hover:text-white"
>
<LogOut className="mr-2 h-4 w-4" />
Uitloggen
</Button>
</div>
<Link
to="/"
className="mt-4 block text-center text-sm text-white/30 hover:text-white/70"
>
Terug naar website
</Link>
</div>
</div>
);
}
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 (
<div className="min-h-screen">
{/* ── Top bar ── */}
<header className="sticky top-0 z-30 border-white/8 border-b bg-[#0d1f20]/95 backdrop-blur-md">
<div className="mx-auto flex max-w-[1600px] items-center justify-between px-5 py-3">
<div className="flex items-center gap-4">
<Link
to="/"
className="font-mono text-white/40 text-xs hover:text-white/80"
>
site
</Link>
<span className="text-white/15">|</span>
<span className="font-['Intro',sans-serif] text-lg text-white">
Admin
</span>
{pendingRequests.length > 0 && (
<span className="rounded-full bg-yellow-500/20 px-2 py-0.5 font-mono text-[10px] text-yellow-400">
{pendingRequests.length} aanvraag
{pendingRequests.length !== 1 ? "en" : ""}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Link
to="/admin/drinkkaart"
className="rounded-lg border border-white/15 px-3 py-1.5 font-mono text-white/60 text-xs transition hover:border-white/30 hover:text-white"
>
Drinkkaart
</Link>
<button
type="button"
onClick={async () => {
await authClient.signOut();
navigate({ to: "/" });
}}
className="rounded-lg border border-white/10 px-3 py-1.5 font-mono text-white/40 text-xs transition hover:border-white/25 hover:text-white/80"
>
<LogOut className="inline h-3 w-3" />
</button>
</div>
</div>
</header>
<main className="mx-auto max-w-[1600px] space-y-6 px-5 py-6">
{/* ── Pending admin requests ── */}
{pendingRequests.length > 0 && (
<div className="rounded-xl border border-yellow-500/25 bg-yellow-500/8 p-4">
<p className="mb-3 font-mono text-[10px] text-yellow-400/70 uppercase tracking-widest">
Openstaande admin aanvragen ({pendingRequests.length})
</p>
<div className="space-y-2">
{pendingRequests.map((req) => (
<div
key={req.id}
className="flex flex-col gap-3 rounded-lg border border-yellow-500/15 bg-yellow-500/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-medium text-white">{req.userName}</p>
<p className="text-white/50 text-xs">{req.userEmail}</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
approveRequestMutation.mutate({ requestId: req.id })
}
disabled={approveRequestMutation.isPending}
className="rounded-lg bg-green-600/80 px-4 py-1.5 font-semibold text-white text-xs transition hover:bg-green-600 disabled:opacity-50"
>
<Check className="mr-1 inline h-3 w-3" />
Goedkeuren
</button>
<button
type="button"
onClick={() =>
rejectRequestMutation.mutate({ requestId: req.id })
}
disabled={rejectRequestMutation.isPending}
className="rounded-lg border border-red-500/30 px-4 py-1.5 font-semibold text-red-400 text-xs transition hover:bg-red-500/10 disabled:opacity-50"
>
<X className="mr-1 inline h-3 w-3" />
Weigeren
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* ── Stats grid ── */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<StatCard
label="Totaal inschrijvingen"
value={stats?.total ?? 0}
accent="white"
sub={
<span className="text-white/35">
{stats?.today ?? 0} vandaag nieuw
</span>
}
/>
<StatCard
label="Artiesten"
accent="amber"
value={performerCount}
sub={
<>
{stats?.byArtForm.slice(0, 3).map((item) => (
<div
key={item.artForm}
className="flex justify-between text-amber-300/60"
>
<span className="truncate">
{item.artForm || "Onbekend"}
</span>
<span className="ml-2 shrink-0 font-mono text-amber-300">
{item.count}
</span>
</div>
))}
</>
}
/>
<StatCard
label="Bezoekers aanwezig"
accent="teal"
value={totalWatcherAttendees}
sub={
<>
<div className="flex justify-between text-teal-300/60">
<span>Inschrijvingen</span>
<span className="font-mono text-teal-300">
{watcherCount}
</span>
</div>
{totalGuestCount > 0 && (
<div className="flex justify-between text-teal-300/60">
<span>Gasten</span>
<span className="font-mono text-teal-300">
+{totalGuestCount}
</span>
</div>
)}
<div className="flex justify-between text-teal-300/60">
<span>Drinkkaart</span>
<span className="font-mono text-teal-300">
{totalDrinkCardValue}
</span>
</div>
</>
}
/>
<StatCard
label="Gifts totaal"
accent="pink"
value={`${Math.round(totalGiftRevenue / 100)}`}
sub={
<span className="text-pink-300/50">Vrijwillige bijdragen</span>
}
/>
<StatCard
label="Vandaag"
accent="white"
value={stats?.today ?? 0}
sub={<span className="text-white/35">Nieuwe registraties</span>}
/>
</div>
{/* ── Filters + export row ── */}
<div className="rounded-xl border border-white/8 bg-white/3 p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-6">
{/* Search */}
<div className="lg:col-span-2">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2 text-white/30" />
<Input
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"
/>
</div>
</div>
{/* Type */}
<div>
<select
value={registrationType}
onChange={(e) => {
setRegistrationType(
e.target.value as "performer" | "watcher" | "",
);
setPage(1);
}}
className="w-full rounded-md border border-white/15 bg-white/8 px-3 py-2 font-mono text-sm text-white focus:outline-none focus:ring-2 focus:ring-teal-500/40"
>
<option value="" className="bg-[#0d1f20]">
Alle types
</option>
<option value="performer" className="bg-[#0d1f20]">
Artiesten
</option>
<option value="watcher" className="bg-[#0d1f20]">
Bezoekers
</option>
</select>
</div>
{/* Art form */}
<div>
<Input
placeholder="Kunstvorm…"
value={artForm}
onChange={(e) => {
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"
/>
</div>
{/* Date from */}
<div>
<Input
type="date"
value={fromDate}
onChange={(e) => {
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"
/>
</div>
{/* Date to + export */}
<div className="flex gap-2">
<Input
type="date"
value={toDate}
onChange={(e) => {
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"
/>
<button
type="button"
onClick={() => exportMutation.mutate(undefined)}
disabled={exportMutation.isPending}
title="Exporteer CSV"
className="shrink-0 rounded-md border border-white/15 bg-white/8 px-3 text-white/60 transition hover:border-teal-400/40 hover:bg-teal-400/10 hover:text-teal-300 disabled:opacity-40"
>
<Download className="h-4 w-4" />
</button>
</div>
</div>
<p className="mt-3 font-mono text-[11px] text-white/30">
{pagination?.total ?? 0} registraties
{totalGuestCount > 0 && (
<span className="ml-2 text-teal-400/60">
· {totalWatcherAttendees + performerCount} aanwezigen totaal
(incl. {totalGuestCount} gasten)
</span>
)}
</p>
</div>
{/* ── Table ── */}
<div className="overflow-hidden rounded-xl border border-white/8 bg-white/3">
{/* Desktop table */}
<div className="hidden overflow-x-auto lg:block">
<table className="w-full border-collapse">
<thead>
<tr className="border-white/8 border-b">
<th className={thCls} style={{ width: "2.5rem" }} />
<th className={thCls} onClick={() => handleSort("naam")}>
Naam <SortIcon col="naam" />
</th>
<th className={thCls} onClick={() => handleSort("email")}>
Email <SortIcon col="email" />
</th>
<th className={`${thCls} hidden xl:table-cell`}>Telefoon</th>
<th className={thCls} onClick={() => handleSort("type")}>
Type <SortIcon col="type" />
</th>
<th className={thCls} onClick={() => handleSort("details")}>
Details <SortIcon col="details" />
</th>
<th className={thCls} onClick={() => handleSort("gasten")}>
Gasten <SortIcon col="gasten" />
</th>
<th className={thCls}>Notities</th>
<th className={thCls} onClick={() => handleSort("gift")}>
Gift <SortIcon col="gift" />
</th>
<th className={thCls} onClick={() => handleSort("betaling")}>
Betaling <SortIcon col="betaling" />
</th>
<th className={thCls} onClick={() => handleSort("datum")}>
Datum <SortIcon col="datum" />
</th>
<th className={thCls}>Link</th>
</tr>
</thead>
<tbody>
{registrationsQuery.isLoading ? (
<tr>
<td
colSpan={12}
className="px-4 py-12 text-center font-mono text-sm text-white/30"
>
laden...
</td>
</tr>
) : sortedRegistrations.length === 0 ? (
<tr>
<td
colSpan={12}
className="px-4 py-12 text-center font-mono text-sm text-white/30"
>
Geen registraties gevonden
</td>
</tr>
) : (
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 (
<>
<tr
key={reg.id}
onClick={() => 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 */}
<td className="py-3 pr-1 pl-3 text-center">
{hasExtra ? (
isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-white/40" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-white/25" />
)
) : null}
</td>
<td className="px-3 py-3 font-medium text-white">
{reg.firstName} {reg.lastName}
</td>
<td className="px-3 py-3 font-mono text-white/60 text-xs">
{reg.email}
</td>
<td className="hidden px-3 py-3 font-mono text-white/45 text-xs xl:table-cell">
{reg.phone || "—"}
</td>
<td className="px-3 py-3">
<span
className={`rounded-full px-2 py-0.5 font-mono font-semibold text-[10px] uppercase tracking-wide ${isPerformer ? "bg-amber-400/12 text-amber-300" : "bg-teal-400/12 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
</span>
</td>
<td className="px-3 py-3 font-mono text-white/60 text-xs">
{detailLabel}
</td>
<td className="px-3 py-3 font-mono text-xs">
{guestCount > 0 ? (
<span className="text-teal-300">
{guestCount}×
</span>
) : (
<span className="text-white/20"></span>
)}
</td>
<td className="px-3 py-3">
{hasNotes ? (
<MessageSquare className="h-3.5 w-3.5 text-white/40" />
) : (
<span className="text-white/15 text-xs"></span>
)}
</td>
<td className="px-3 py-3 font-mono text-pink-300/70 text-xs">
{giftFmt ?? (
<span className="text-white/20"></span>
)}
</td>
<td className="px-3 py-3">
<PayBadge
status={reg.paymentStatus ?? null}
paymentAmount={reg.paymentAmount ?? null}
isPerformer={isPerformer}
/>
</td>
<td className="px-3 py-3 font-mono text-white/40 text-xs tabular-nums">
{dateLabel}
</td>
<td
className="px-3 py-3"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{reg.managementToken ? (
<button
type="button"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="rounded p-1 text-white/30 transition hover:bg-white/10 hover:text-white"
>
{copiedId === reg.id ? (
<ClipboardCheck className="h-3.5 w-3.5 text-green-400" />
) : (
<Clipboard className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="text-white/15"></span>
)}
</td>
</tr>
{/* Expanded detail row */}
{isExpanded && (
<tr
key={`${reg.id}-detail`}
className="border-white/5 border-b bg-white/5"
>
<td colSpan={12} className="px-6 pt-3 pb-5">
<div className="grid gap-4 sm:grid-cols-2">
{/* Guests */}
{guestCount > 0 && (
<div>
<p className="mb-2 font-mono text-[10px] text-teal-400/60 uppercase tracking-widest">
Gasten ({guestCount})
</p>
<div className="space-y-1.5">
{guests.map((g, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: stable order
key={i}
className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 rounded-lg border border-teal-400/10 bg-teal-400/5 px-3 py-2 text-sm"
>
<span className="font-mono text-[10px] text-white/30">
{i + 1}.
</span>
<span className="font-medium text-white">
{g.firstName} {g.lastName}
</span>
{g.email && (
<span className="font-mono text-white/50 text-xs">
{g.email}
</span>
)}
{g.phone && (
<span className="font-mono text-white/40 text-xs">
{g.phone}
</span>
)}
{!g.email && !g.phone && (
<span className="font-mono text-white/25 text-xs italic">
geen contactgegevens
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Notes */}
{hasNotes && (
<div>
<p className="mb-2 font-mono text-[10px] text-white/40 uppercase tracking-widest">
Vragen / opmerkingen
</p>
<div className="rounded-lg border border-white/8 bg-white/5 px-4 py-3 text-sm text-white/80 leading-relaxed">
{reg.extraQuestions}
</div>
</div>
)}
{/* Performer extras */}
{isPerformer &&
(reg.experience || reg.artForm) && (
<div>
<p className="mb-2 font-mono text-[10px] text-amber-400/60 uppercase tracking-widest">
Optreden details
</p>
<div className="rounded-lg border border-amber-400/10 bg-amber-400/5 px-4 py-3 text-sm text-white/80">
{reg.artForm && (
<p>
<span className="text-amber-300/60">
Kunstvorm:{" "}
</span>
{reg.artForm}
</p>
)}
{reg.experience && (
<p>
<span className="text-amber-300/60">
Ervaring:{" "}
</span>
{reg.experience}
</p>
)}
<p>
<span className="text-amber-300/60">
16+:{" "}
</span>
{reg.isOver16 ? "ja" : "nee"}
</p>
</div>
</div>
)}
</div>
</td>
</tr>
)}
</>
);
})
)}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="divide-y divide-white/5 lg:hidden">
{registrationsQuery.isLoading ? (
<div className="py-10 text-center font-mono text-sm text-white/30">
laden...
</div>
) : sortedRegistrations.length === 0 ? (
<div className="py-10 text-center font-mono text-sm text-white/30">
Geen registraties
</div>
) : (
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 (
<div key={reg.id}>
{hasExtra ? (
<button
type="button"
className={`w-full p-4 text-left transition-colors ${isExpanded ? "bg-white/5" : ""}`}
onClick={() => toggleRow(reg.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-white/40" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-white/25" />
)}
<span className="truncate font-medium text-white">
{reg.firstName} {reg.lastName}
</span>
</div>
<p className="mt-0.5 truncate font-mono text-white/50 text-xs">
{reg.email}
</p>
{reg.phone && (
<p className="font-mono text-white/35 text-xs">
{reg.phone}
</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
<span
className={`rounded-full px-2 py-0.5 font-mono font-semibold text-[10px] uppercase ${isPerformer ? "bg-amber-400/12 text-amber-300" : "bg-teal-400/12 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
</span>
<PayBadge
status={reg.paymentStatus ?? null}
paymentAmount={reg.paymentAmount ?? null}
isPerformer={isPerformer}
/>
{reg.managementToken && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
);
}}
className="rounded p-1 text-white/30 hover:text-white"
>
{copiedId === reg.id ? (
<ClipboardCheck className="h-4 w-4 text-green-400" />
) : (
<Clipboard className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 font-mono text-white/50 text-xs">
{isPerformer ? (
<span>{reg.artForm || "—"}</span>
) : (
<span>{reg.drinkCardValue ?? 5} drinkkaart</span>
)}
{guestCount > 0 && (
<span className="text-teal-300">
{guestCount} gast{guestCount !== 1 ? "en" : ""}
</span>
)}
{giftFmt && (
<span className="text-pink-300/70">
gift {giftFmt}
</span>
)}
{hasNotes && (
<span className="text-white/35">
<MessageSquare className="mr-0.5 inline h-3 w-3" />
notitie
</span>
)}
<span className="text-white/30">
{(() => {
try {
return new Date(
reg.createdAt,
).toLocaleDateString("nl-BE", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
});
} catch {
return "—";
}
})()}
</span>
</div>
</button>
) : (
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="truncate font-medium text-white">
{reg.firstName} {reg.lastName}
</span>
<p className="mt-0.5 truncate font-mono text-white/50 text-xs">
{reg.email}
</p>
{reg.phone && (
<p className="font-mono text-white/35 text-xs">
{reg.phone}
</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
<span
className={`rounded-full px-2 py-0.5 font-mono font-semibold text-[10px] uppercase ${isPerformer ? "bg-amber-400/12 text-amber-300" : "bg-teal-400/12 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
</span>
<PayBadge
status={reg.paymentStatus ?? null}
paymentAmount={reg.paymentAmount ?? null}
isPerformer={isPerformer}
/>
{reg.managementToken && (
<button
type="button"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="rounded p-1 text-white/30 hover:text-white"
>
{copiedId === reg.id ? (
<ClipboardCheck className="h-4 w-4 text-green-400" />
) : (
<Clipboard className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 font-mono text-white/50 text-xs">
{isPerformer ? (
<span>{reg.artForm || "—"}</span>
) : (
<span>{reg.drinkCardValue ?? 5} drinkkaart</span>
)}
{giftFmt && (
<span className="text-pink-300/70">
gift {giftFmt}
</span>
)}
<span className="text-white/30">
{(() => {
try {
return new Date(
reg.createdAt,
).toLocaleDateString("nl-BE", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
});
} catch {
return "—";
}
})()}
</span>
</div>
</div>
)}
{/* Mobile expanded */}
{isExpanded && (
<div className="space-y-4 border-white/5 border-t bg-white/4 px-4 pt-3 pb-4">
{guestCount > 0 && (
<div>
<p className="mb-2 font-mono text-[10px] text-teal-400/60 uppercase tracking-widest">
Gasten
</p>
<div className="space-y-2">
{guests.map((g, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: stable order
key={i}
className="rounded-lg border border-teal-400/10 bg-teal-400/5 px-3 py-2 text-sm"
>
<p className="font-medium text-white">
{i + 1}. {g.firstName} {g.lastName}
</p>
{g.email && (
<p className="font-mono text-white/50 text-xs">
{g.email}
</p>
)}
{g.phone && (
<p className="font-mono text-white/40 text-xs">
{g.phone}
</p>
)}
</div>
))}
</div>
</div>
)}
{hasNotes && (
<div>
<p className="mb-2 font-mono text-[10px] text-white/40 uppercase tracking-widest">
Vragen / opmerkingen
</p>
<p className="rounded-lg border border-white/8 bg-white/5 px-4 py-3 text-sm text-white/80 leading-relaxed">
{reg.extraQuestions}
</p>
</div>
)}
{isPerformer && (reg.experience || reg.artForm) && (
<div>
<p className="mb-2 font-mono text-[10px] text-amber-400/60 uppercase tracking-widest">
Optreden details
</p>
<div className="rounded-lg border border-amber-400/10 bg-amber-400/5 px-3 py-2 text-sm text-white/80">
{reg.artForm && (
<p>
<span className="text-amber-300/60">
Kunstvorm:{" "}
</span>
{reg.artForm}
</p>
)}
{reg.experience && (
<p>
<span className="text-amber-300/60">
Ervaring:{" "}
</span>
{reg.experience}
</p>
)}
<p>
<span className="text-amber-300/60">16+: </span>
{reg.isOver16 ? "ja" : "nee"}
</p>
</div>
</div>
)}
</div>
)}
</div>
);
})
)}
</div>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="rounded-lg border border-white/15 bg-white/5 px-4 py-1.5 font-mono text-white/60 text-xs transition hover:border-white/30 hover:text-white disabled:opacity-30"
>
vorige
</button>
<span className="font-mono text-white/40 text-xs">
{page} / {pagination.totalPages}
</span>
<button
type="button"
onClick={() =>
setPage((p) => Math.min(pagination!.totalPages, p + 1))
}
disabled={page === pagination.totalPages}
className="rounded-lg border border-white/15 bg-white/5 px-4 py-1.5 font-mono text-white/60 text-xs transition hover:border-white/30 hover:text-white disabled:opacity-30"
>
volgende
</button>
</div>
)}
</main>
</div>
);
}