- 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
1250 lines
41 KiB
TypeScript
1250 lines
41 KiB
TypeScript
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>
|
||
);
|
||
}
|