1196 lines
39 KiB
TypeScript
1196 lines
39 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,
|
|
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";
|
|
|
|
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 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 [expandedGuests, setExpandedGuests] = useState<Set<string>>(new Set());
|
|
|
|
// Get current session to check user role
|
|
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 toggleGuestExpand = (id: string) => {
|
|
setExpandedGuests((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
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: (error) => {
|
|
toast.error(`Fout: ${error.message}`);
|
|
},
|
|
});
|
|
|
|
const rejectRequestMutation = useMutation({
|
|
...orpc.rejectAdminRequest.mutationOptions(),
|
|
onSuccess: () => {
|
|
toast.success("Admin toegang geweigerd");
|
|
adminRequestsQuery.refetch();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Fout: ${error.message}`);
|
|
},
|
|
});
|
|
|
|
const requestAdminMutation = useMutation({
|
|
...orpc.requestAdminAccess.mutationOptions(),
|
|
onSuccess: () => {
|
|
toast.success("Admin toegang aangevraagd!");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Aanvraag mislukt: ${error.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;
|
|
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;
|
|
|
|
// Use server-side totalGuestCount from stats (accurate across all pages)
|
|
const totalGuestCount =
|
|
(stats as { totalGuestCount?: number } | undefined)?.totalGuestCount ?? 0;
|
|
const totalWatcherAttendees = watcherCount + totalGuestCount;
|
|
|
|
const totalDrinkCardValue = registrations
|
|
.filter((r) => r.registrationType === "watcher")
|
|
.reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0);
|
|
const totalGiftRevenue = (stats?.totalGiftRevenue as number | undefined) ?? 0;
|
|
|
|
const sortedRegistrations = useMemo(() => {
|
|
const sorted = [...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;
|
|
});
|
|
return sorted;
|
|
}, [registrations, sortKey, sortDir]);
|
|
|
|
const SortIcon = ({ col }: { col: SortKey }) => {
|
|
if (sortKey !== col)
|
|
return <ChevronsUpDown className="ml-1 inline h-3.5 w-3.5 opacity-40" />;
|
|
return sortDir === "asc" ? (
|
|
<ChevronUp className="ml-1 inline h-3.5 w-3.5" />
|
|
) : (
|
|
<ChevronDown className="ml-1 inline h-3.5 w-3.5" />
|
|
);
|
|
};
|
|
|
|
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 "-";
|
|
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 (
|
|
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
|
|
<p className="text-white/60">Laden...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show login prompt for non-authenticated users
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
|
|
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
|
|
<CardHeader className="text-center">
|
|
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
|
|
Inloggen Vereist
|
|
</CardTitle>
|
|
<CardDescription className="text-white/60">
|
|
Je moet ingelogd zijn om admin toegang aan te vragen
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<Button
|
|
onClick={() => navigate({ to: "/login" })}
|
|
className="w-full bg-white text-[#214e51] hover:bg-white/90"
|
|
>
|
|
Inloggen
|
|
</Button>
|
|
|
|
<Link
|
|
to="/"
|
|
className="block text-center text-sm text-white/40 hover:text-white"
|
|
>
|
|
← Terug naar website
|
|
</Link>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show request admin UI for non-admin users
|
|
if (!isAdmin) {
|
|
return (
|
|
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
|
|
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
|
|
<CardHeader className="text-center">
|
|
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
|
|
Admin Toegang Vereist
|
|
</CardTitle>
|
|
<CardDescription className="text-white/60">
|
|
Je hebt geen admin rechten voor dit dashboard
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<div className="rounded-lg border border-white/10 bg-white/5 p-4">
|
|
<p className="mb-3 text-center text-sm text-white/60">
|
|
Admin-toegang aanvragen?
|
|
</p>
|
|
<Button
|
|
onClick={handleRequestAdmin}
|
|
disabled={requestAdminMutation.isPending}
|
|
className="w-full bg-white text-[#214e51] hover:bg-white/90"
|
|
>
|
|
{requestAdminMutation.isPending
|
|
? "Bezig..."
|
|
: "Vraag Admin Toegang Aan"}
|
|
</Button>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={() => navigate({ to: "/account" })}
|
|
variant="outline"
|
|
className="w-full border-white/20 bg-transparent text-white hover:bg-white/10"
|
|
>
|
|
Ga naar mijn account
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handleSignOut}
|
|
variant="outline"
|
|
className="w-full border-white/20 bg-transparent text-white/60 hover:bg-white/10 hover:text-white"
|
|
>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
Uitloggen
|
|
</Button>
|
|
|
|
<Link
|
|
to="/"
|
|
className="block text-center text-sm text-white/40 hover:text-white"
|
|
>
|
|
← Terug naar website
|
|
</Link>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<header className="border-white/10 border-b bg-[#214e51]/95 px-4 py-4 sm:px-8 sm:py-6">
|
|
<div className="mx-auto flex max-w-7xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
|
<Link
|
|
to="/"
|
|
className="text-sm text-white hover:opacity-80 sm:text-base"
|
|
>
|
|
← Terug naar website
|
|
</Link>
|
|
<h1 className="font-['Intro',sans-serif] text-2xl text-white sm:text-3xl">
|
|
Admin Dashboard
|
|
</h1>
|
|
</div>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
<Link
|
|
to="/admin/drinkkaart"
|
|
className="inline-flex items-center justify-center rounded-lg border border-white/30 px-4 py-2 text-sm text-white/80 transition-colors hover:bg-white/10 hover:text-white"
|
|
>
|
|
Drinkkaart beheer
|
|
</Link>
|
|
<Button
|
|
onClick={handleSignOut}
|
|
variant="outline"
|
|
className="border-white/30 bg-transparent text-white hover:bg-white/10"
|
|
>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
Uitloggen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<main className="mx-auto max-w-7xl px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
|
{/* Pending Admin Requests */}
|
|
{pendingRequests.length > 0 && (
|
|
<Card className="mb-4 border-yellow-500/30 bg-yellow-500/10 sm:mb-6">
|
|
<CardHeader className="px-4 py-4 sm:px-6">
|
|
<CardTitle className="font-['Intro',sans-serif] text-lg text-yellow-200 sm:text-xl">
|
|
Openstaande Admin Aanvragen ({pendingRequests.length})
|
|
</CardTitle>
|
|
<CardDescription className="text-sm text-yellow-200/60">
|
|
Gebruikers die admin toegang hebben aangevraagd
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4 sm:px-6 sm:pb-6">
|
|
<div className="space-y-3">
|
|
{pendingRequests.map((request) => (
|
|
<div
|
|
key={request.id}
|
|
className="flex flex-col gap-3 rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4"
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="truncate font-medium text-white">
|
|
{request.userName}
|
|
</p>
|
|
<p className="truncate text-sm text-white/60">
|
|
{request.userEmail}
|
|
</p>
|
|
<p className="text-white/40 text-xs">
|
|
Aangevraagd:{" "}
|
|
{new Date(request.requestedAt).toLocaleDateString(
|
|
"nl-BE",
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
<Button
|
|
onClick={() => handleApprove(request.id)}
|
|
disabled={approveRequestMutation.isPending}
|
|
size="sm"
|
|
className="bg-green-600 text-white hover:bg-green-700"
|
|
>
|
|
<Check className="mr-1 h-4 w-4" />
|
|
Goedkeuren
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleReject(request.id)}
|
|
disabled={rejectRequestMutation.isPending}
|
|
size="sm"
|
|
variant="outline"
|
|
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
|
>
|
|
<X className="mr-1 h-4 w-4" />
|
|
Weigeren
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Stats Cards */}
|
|
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-5 lg:gap-6">
|
|
{/* Total registrations */}
|
|
<Card className="border-white/10 bg-white/5">
|
|
<CardHeader className="pb-2">
|
|
<CardDescription className="text-white/60 text-xs sm:text-sm">
|
|
Totaal inschrijvingen
|
|
</CardDescription>
|
|
<CardTitle className="font-['Intro',sans-serif] text-2xl text-white sm:text-3xl lg:text-4xl">
|
|
{stats?.total ?? 0}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Users className="h-5 w-5 text-white/40" />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Today */}
|
|
<Card className="border-white/10 bg-white/5">
|
|
<CardHeader className="pb-2">
|
|
<CardDescription className="text-white/60 text-xs sm:text-sm">
|
|
Vandaag ingeschreven
|
|
</CardDescription>
|
|
<CardTitle className="font-['Intro',sans-serif] text-2xl text-white sm:text-3xl lg:text-4xl">
|
|
{stats?.today ?? 0}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<span className="text-white/40 text-xs sm:text-sm">
|
|
Nieuwe registraties vandaag
|
|
</span>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Performers */}
|
|
<Card className="border-amber-400/20 bg-amber-400/5">
|
|
<CardHeader className="pb-2">
|
|
<CardDescription className="text-amber-300/70 text-xs sm:text-sm">
|
|
Artiesten
|
|
</CardDescription>
|
|
<CardTitle className="font-['Intro',sans-serif] text-2xl text-amber-300 sm:text-3xl lg:text-4xl">
|
|
{performerCount}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-1">
|
|
{stats?.byArtForm.slice(0, 2).map((item) => (
|
|
<div
|
|
key={item.artForm}
|
|
className="flex items-center justify-between text-xs"
|
|
>
|
|
<span className="truncate text-amber-300/70">
|
|
{item.artForm || "Onbekend"}
|
|
</span>
|
|
<span className="text-amber-300">{item.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Watchers + guests (real attendees count) */}
|
|
<Card className="border-teal-400/20 bg-teal-400/5">
|
|
<CardHeader className="pb-2">
|
|
<CardDescription className="text-teal-300/70 text-xs sm:text-sm">
|
|
Bezoekers
|
|
</CardDescription>
|
|
<CardTitle className="font-['Intro',sans-serif] text-2xl text-teal-300 sm:text-3xl lg:text-4xl">
|
|
{totalWatcherAttendees}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-1 text-xs">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-teal-300/70">Inschrijvingen</span>
|
|
<span className="text-teal-300">{watcherCount}</span>
|
|
</div>
|
|
{totalGuestCount > 0 && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-teal-300/70">Gasten</span>
|
|
<span className="text-teal-300">+{totalGuestCount}</span>
|
|
</div>
|
|
)}
|
|
<div className="hidden sm:flex sm:items-center sm:justify-between">
|
|
<span className="text-teal-300/70">Drinkkaart</span>
|
|
<span className="text-teal-300">€{totalDrinkCardValue}</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Gifts */}
|
|
<Card className="border-pink-400/20 bg-pink-400/5">
|
|
<CardHeader className="pb-2">
|
|
<CardDescription className="text-pink-300/70 text-xs sm:text-sm">
|
|
Vrijwillige Gifts
|
|
</CardDescription>
|
|
<CardTitle className="font-['Intro',sans-serif] text-2xl text-pink-300 sm:text-3xl lg:text-4xl">
|
|
€{Math.round(totalGiftRevenue / 100)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<span className="text-white/40 text-xs sm:text-sm">
|
|
Totale gift opbrengst
|
|
</span>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card className="mb-6 border-white/10 bg-white/5">
|
|
<CardHeader className="px-4 py-4 sm:px-6 sm:py-6">
|
|
<CardTitle className="font-['Intro',sans-serif] text-lg text-white sm:text-xl">
|
|
Filters
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-4 pb-4 sm:px-6 sm:pb-6">
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-5">
|
|
<div>
|
|
<label
|
|
htmlFor="search"
|
|
className="mb-1.5 block text-white/60 text-xs sm:mb-2 sm:text-sm"
|
|
>
|
|
Zoeken
|
|
</label>
|
|
<div className="relative">
|
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-white/40" />
|
|
<Input
|
|
id="search"
|
|
placeholder="Naam of email..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="border-white/20 bg-white/10 pl-10 text-sm text-white placeholder:text-white/40 sm:text-base"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="typeFilter"
|
|
className="mb-1.5 block text-white/60 text-xs sm:mb-2 sm:text-sm"
|
|
>
|
|
Type
|
|
</label>
|
|
<select
|
|
id="typeFilter"
|
|
value={registrationType}
|
|
onChange={(e) =>
|
|
setRegistrationType(
|
|
e.target.value as "performer" | "watcher" | "",
|
|
)
|
|
}
|
|
className="w-full rounded-md border border-white/20 bg-white/10 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-white/20 sm:text-base"
|
|
>
|
|
<option value="" className="bg-[#214e51]">
|
|
Alle types
|
|
</option>
|
|
<option value="performer" className="bg-[#214e51]">
|
|
Artiesten
|
|
</option>
|
|
<option value="watcher" className="bg-[#214e51]">
|
|
Bezoekers
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="artForm"
|
|
className="mb-1.5 block text-white/60 text-xs sm:mb-2 sm:text-sm"
|
|
>
|
|
Kunstvorm
|
|
</label>
|
|
<Input
|
|
id="artForm"
|
|
placeholder="Filter op kunstvorm..."
|
|
value={artForm}
|
|
onChange={(e) => setArtForm(e.target.value)}
|
|
className="border-white/20 bg-white/10 text-sm text-white placeholder:text-white/40 sm:text-base"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="fromDate"
|
|
className="mb-1.5 block text-white/60 text-xs sm:mb-2 sm:text-sm"
|
|
>
|
|
Vanaf
|
|
</label>
|
|
<Input
|
|
id="fromDate"
|
|
type="date"
|
|
value={fromDate}
|
|
onChange={(e) => setFromDate(e.target.value)}
|
|
className="border-white/20 bg-white/10 text-sm text-white [color-scheme:dark] sm:text-base"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="toDate"
|
|
className="mb-1.5 block text-white/60 text-xs sm:mb-2 sm:text-sm"
|
|
>
|
|
Tot
|
|
</label>
|
|
<Input
|
|
id="toDate"
|
|
type="date"
|
|
value={toDate}
|
|
onChange={(e) => setToDate(e.target.value)}
|
|
className="border-white/20 bg-white/10 text-sm text-white [color-scheme:dark] sm:text-base"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Export Button */}
|
|
<div className="mb-4 flex flex-col gap-2 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="text-sm text-white/60">
|
|
{pagination?.total ?? 0} registraties gevonden
|
|
{totalGuestCount > 0 && (
|
|
<span className="ml-2 text-teal-300/70">
|
|
(+{totalGuestCount} gasten ={" "}
|
|
{(pagination?.total ?? 0) + totalGuestCount} aanwezigen totaal)
|
|
</span>
|
|
)}
|
|
</p>
|
|
<Button
|
|
onClick={handleExport}
|
|
disabled={exportMutation.isPending}
|
|
className="w-full bg-white text-[#214e51] hover:bg-white/90 sm:w-auto"
|
|
>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Registrations Table / Cards */}
|
|
<Card className="border-white/10 bg-white/5">
|
|
<CardContent className="p-0">
|
|
{/* Desktop Table */}
|
|
<div className="hidden overflow-x-auto lg:block">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-white/10 border-b">
|
|
<th className={thClass} onClick={() => handleSort("naam")}>
|
|
Naam <SortIcon col="naam" />
|
|
</th>
|
|
<th className={thClass} onClick={() => handleSort("email")}>
|
|
Email <SortIcon col="email" />
|
|
</th>
|
|
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
|
|
Telefoon
|
|
</th>
|
|
<th className={thClass} onClick={() => handleSort("type")}>
|
|
Type <SortIcon col="type" />
|
|
</th>
|
|
<th
|
|
className={thClass}
|
|
onClick={() => handleSort("details")}
|
|
>
|
|
Details <SortIcon col="details" />
|
|
</th>
|
|
<th
|
|
className={thClass}
|
|
onClick={() => handleSort("gasten")}
|
|
>
|
|
Gasten <SortIcon col="gasten" />
|
|
</th>
|
|
<th className={thClass} onClick={() => handleSort("gift")}>
|
|
Gift <SortIcon col="gift" />
|
|
</th>
|
|
<th
|
|
className={thClass}
|
|
onClick={() => handleSort("betaling")}
|
|
>
|
|
Betaling <SortIcon col="betaling" />
|
|
</th>
|
|
<th className={thClass} onClick={() => handleSort("datum")}>
|
|
Datum <SortIcon col="datum" />
|
|
</th>
|
|
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
|
|
Link
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{registrationsQuery.isLoading ? (
|
|
<tr>
|
|
<td
|
|
colSpan={10}
|
|
className="px-4 py-8 text-center text-white/60"
|
|
>
|
|
Laden...
|
|
</td>
|
|
</tr>
|
|
) : sortedRegistrations.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={10}
|
|
className="px-4 py-8 text-center text-white/60"
|
|
>
|
|
Geen registraties gevonden
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
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 (
|
|
<>
|
|
<tr
|
|
key={reg.id}
|
|
className="border-white/5 border-b hover:bg-white/5"
|
|
>
|
|
<td className="px-4 py-3 font-medium text-white">
|
|
{reg.firstName} {reg.lastName}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-white/70">
|
|
{reg.email}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-white/60">
|
|
{reg.phone || "-"}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={`inline-flex items-center rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
|
|
>
|
|
{isPerformer ? "Artiest" : "Bezoeker"}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-white/70">
|
|
{detailLabel}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-white/70">
|
|
{guestCount > 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleGuestExpand(reg.id)}
|
|
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-teal-300 transition-colors hover:bg-teal-400/10"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
)}
|
|
{guestCount} gast
|
|
{guestCount === 1 ? "" : "en"}
|
|
</button>
|
|
) : (
|
|
"-"
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-pink-300/70 text-sm">
|
|
{formatCents(reg.giftAmount)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{isPerformer ? (
|
|
<span className="text-sm text-white/30">-</span>
|
|
) : reg.paymentStatus === "paid" ? (
|
|
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm">
|
|
<Check className="h-3.5 w-3.5" />
|
|
Betaald
|
|
</span>
|
|
) : reg.paymentStatus ===
|
|
"extra_payment_pending" ? (
|
|
<span className="inline-flex items-center gap-1 font-medium text-orange-400 text-sm">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
|
|
Extra (€
|
|
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
|
|
</span>
|
|
) : (
|
|
<span className="text-sm text-yellow-400">
|
|
Open
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
|
|
{dateLabel}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{reg.managementToken ? (
|
|
<button
|
|
type="button"
|
|
title="Kopieer beheerlink"
|
|
onClick={() =>
|
|
handleCopyManageUrl(
|
|
reg.managementToken as string,
|
|
reg.id,
|
|
)
|
|
}
|
|
className="inline-flex items-center justify-center rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
|
|
>
|
|
{copiedId === reg.id ? (
|
|
<ClipboardCheck className="h-4 w-4 text-green-400" />
|
|
) : (
|
|
<Clipboard className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<span className="text-sm text-white/20">—</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
{/* Expandable guest details row */}
|
|
{isExpanded && guestCount > 0 && (
|
|
<tr
|
|
key={`${reg.id}-guests`}
|
|
className="border-white/5 border-b bg-teal-900/20"
|
|
>
|
|
<td colSpan={10} className="px-6 py-3">
|
|
<div className="space-y-1.5">
|
|
<p className="mb-2 font-medium text-teal-300/70 text-xs uppercase tracking-wider">
|
|
Gasten van {reg.firstName} {reg.lastName}
|
|
</p>
|
|
{guests.map((g, i) => (
|
|
<div
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: guest order is stable within a registration
|
|
key={i}
|
|
className="flex flex-wrap items-center gap-x-4 gap-y-0.5 rounded bg-teal-400/5 px-3 py-1.5 text-sm"
|
|
>
|
|
<span className="w-5 shrink-0 text-white/40 text-xs">
|
|
{i + 1}.
|
|
</span>
|
|
<span className="font-medium text-white">
|
|
{g.firstName} {g.lastName}
|
|
</span>
|
|
{g.email && (
|
|
<span className="text-white/60">
|
|
{g.email}
|
|
</span>
|
|
)}
|
|
{g.phone && (
|
|
<span className="text-white/50">
|
|
{g.phone}
|
|
</span>
|
|
)}
|
|
{!g.email && !g.phone && (
|
|
<span className="text-white/30 text-xs italic">
|
|
geen contactgegevens
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Mobile Cards */}
|
|
<div className="lg:hidden">
|
|
{registrationsQuery.isLoading ? (
|
|
<div className="px-4 py-8 text-center text-white/60">
|
|
Laden...
|
|
</div>
|
|
) : sortedRegistrations.length === 0 ? (
|
|
<div className="px-4 py-8 text-center text-white/60">
|
|
Geen registraties gevonden
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-white/5">
|
|
{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: "2-digit",
|
|
},
|
|
);
|
|
} catch {
|
|
return "-";
|
|
}
|
|
})();
|
|
|
|
return (
|
|
<div key={reg.id}>
|
|
<div className="p-4 hover:bg-white/5">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate font-medium text-white">
|
|
{reg.firstName} {reg.lastName}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 flex items-center gap-2 text-white/60 text-xs">
|
|
<span className="truncate">{reg.email}</span>
|
|
{reg.phone && (
|
|
<span className="shrink-0">
|
|
• {reg.phone}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`shrink-0 rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
|
|
>
|
|
{isPerformer ? "Artiest" : "Bezoeker"}
|
|
</span>
|
|
{reg.managementToken && (
|
|
<button
|
|
type="button"
|
|
title="Kopieer beheerlink"
|
|
onClick={() =>
|
|
handleCopyManageUrl(
|
|
reg.managementToken as string,
|
|
reg.id,
|
|
)
|
|
}
|
|
className="shrink-0 rounded p-1.5 text-white/40 transition-colors hover:bg-white/10 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-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
|
|
<div className="text-white/70">
|
|
<span className="text-white/40">Details:</span>{" "}
|
|
{detailLabel}
|
|
</div>
|
|
{guestCount > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleGuestExpand(reg.id)}
|
|
className="inline-flex items-center gap-1 text-teal-300"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-3 w-3" />
|
|
) : (
|
|
<ChevronRight className="h-3 w-3" />
|
|
)}
|
|
{guestCount} gast{guestCount === 1 ? "" : "en"}
|
|
</button>
|
|
)}
|
|
{(reg.giftAmount ?? 0) > 0 && (
|
|
<div className="text-pink-300">
|
|
<span className="text-white/40">Gift:</span>{" "}
|
|
{formatCents(reg.giftAmount)}
|
|
</div>
|
|
)}
|
|
{!isPerformer && (
|
|
<div>
|
|
<span className="text-white/40">Betaling:</span>{" "}
|
|
{reg.paymentStatus === "paid" ? (
|
|
<span className="inline-flex items-center gap-1 font-medium text-green-400">
|
|
<Check className="h-3 w-3" />
|
|
Betaald
|
|
</span>
|
|
) : reg.paymentStatus ===
|
|
"extra_payment_pending" ? (
|
|
<span className="inline-flex items-center gap-1 font-medium text-orange-400">
|
|
<span className="h-1 w-1 rounded-full bg-orange-400" />
|
|
Extra (€
|
|
{((reg.paymentAmount ?? 0) / 100).toFixed(
|
|
0,
|
|
)}
|
|
)
|
|
</span>
|
|
) : (
|
|
<span className="text-yellow-400">Open</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="text-white/50">{dateLabel}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expandable guest details — mobile */}
|
|
{isExpanded && guestCount > 0 && (
|
|
<div className="border-teal-400/10 border-t bg-teal-900/20 px-4 pt-3 pb-4">
|
|
<p className="mb-2 font-medium text-teal-300/70 text-xs uppercase tracking-wider">
|
|
Gasten
|
|
</p>
|
|
<div className="space-y-2">
|
|
{guests.map((g, i) => (
|
|
<div
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: guest order is stable within a registration
|
|
key={i}
|
|
className="rounded bg-teal-400/5 px-3 py-2 text-sm"
|
|
>
|
|
<span className="mr-2 text-white/40 text-xs">
|
|
{i + 1}.
|
|
</span>
|
|
<span className="font-medium text-white">
|
|
{g.firstName} {g.lastName}
|
|
</span>
|
|
{g.email && (
|
|
<div className="mt-0.5 text-white/60 text-xs">
|
|
{g.email}
|
|
</div>
|
|
)}
|
|
{g.phone && (
|
|
<div className="text-white/50 text-xs">
|
|
{g.phone}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Pagination */}
|
|
{pagination && pagination.totalPages > 1 && (
|
|
<div className="mt-4 flex items-center justify-center gap-2 sm:mt-6">
|
|
<Button
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-white/20 bg-transparent px-2 text-sm text-white hover:bg-white/10 disabled:opacity-50 sm:px-4"
|
|
>
|
|
<span className="hidden sm:inline">Vorige</span>
|
|
<span className="sm:hidden">←</span>
|
|
</Button>
|
|
<span className="mx-2 text-sm text-white sm:mx-4">
|
|
<span className="sm:hidden">
|
|
{page}/{pagination.totalPages}
|
|
</span>
|
|
<span className="hidden sm:inline">
|
|
Pagina {page} van {pagination.totalPages}
|
|
</span>
|
|
</span>
|
|
<Button
|
|
onClick={() =>
|
|
setPage((p) => Math.min(pagination.totalPages, p + 1))
|
|
}
|
|
disabled={page === pagination.totalPages}
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-white/20 bg-transparent px-2 text-sm text-white hover:bg-white/10 disabled:opacity-50 sm:px-4"
|
|
>
|
|
<span className="hidden sm:inline">Volgende</span>
|
|
<span className="sm:hidden">→</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|