feat:drinkkaart

This commit is contained in:
2026-03-03 23:52:49 +01:00
parent b8cefd373d
commit 79869a9a21
35 changed files with 3189 additions and 76 deletions

View File

@@ -0,0 +1,817 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
createFileRoute,
Link,
redirect,
useNavigate,
} from "@tanstack/react-router";
import {
Check,
ChevronDown,
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,
beforeLoad: async () => {
const session = await authClient.getSession();
if (!session.data?.user) {
throw redirect({ to: "/login" });
}
const user = session.data.user as { role?: string };
if (user.role !== "admin") {
throw redirect({ to: "/login" });
}
},
});
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 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);
});
};
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 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 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;
// Calculate total attendees including guests
const totalRegistrations = registrationsQuery.data?.data ?? [];
const watcherWithGuests = totalRegistrations.filter(
(r) => r.registrationType === "watcher" && r.guests,
);
const totalGuestCount = watcherWithGuests.reduce((sum, r) => {
try {
const guests = JSON.parse(r.guests as string);
return sum + (Array.isArray(guests) ? guests.length : 0);
} catch {
return sum;
}
}, 0);
const totalWatcherAttendees = watcherCount + totalGuestCount;
const totalDrinkCardValue = totalRegistrations
.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": {
const countGuests = (g: string | null) => {
if (!g) return 0;
try {
const p = JSON.parse(g);
return Array.isArray(p) ? p.length : 0;
} catch {
return 0;
}
};
valA = countGuests(a.guests as string | null);
valB = countGuests(b.guests as string | null);
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)}`;
};
return (
<div className="min-h-screen bg-[#214e51]">
{/* Header */}
<header className="border-white/10 border-b bg-[#214e51]/95 px-8 py-6">
<div className="mx-auto flex max-w-7xl items-center justify-between">
<div className="flex items-center gap-4">
<Link to="/" className="text-white hover:opacity-80">
Terug naar website
</Link>
<h1 className="font-['Intro',sans-serif] text-3xl text-white">
Admin Dashboard
</h1>
</div>
<div className="flex items-center gap-3">
<Link
to="/admin/drinkkaart"
className="inline-flex items-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 p-8">
{/* Pending Admin Requests */}
{pendingRequests.length > 0 && (
<Card className="mb-6 border-yellow-500/30 bg-yellow-500/10">
<CardHeader>
<CardTitle className="font-['Intro',sans-serif] text-xl text-yellow-200">
Openstaande Admin Aanvragen ({pendingRequests.length})
</CardTitle>
<CardDescription className="text-yellow-200/60">
Gebruikers die admin toegang hebben aangevraagd
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{pendingRequests.map((request) => (
<div
key={request.id}
className="flex items-center justify-between rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-4"
>
<div>
<p className="font-medium text-white">
{request.userName}
</p>
<p className="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 gap-2">
<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-1 gap-6 md:grid-cols-4">
<Card className="border-white/10 bg-white/5">
<CardHeader className="pb-2">
<CardDescription className="text-white/60">
Totaal inschrijvingen
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-white">
{stats?.total ?? 0}
</CardTitle>
</CardHeader>
<CardContent>
<Users className="h-5 w-5 text-white/40" />
</CardContent>
</Card>
<Card className="border-white/10 bg-white/5">
<CardHeader className="pb-2">
<CardDescription className="text-white/60">
Vandaag ingeschreven
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-white">
{stats?.today ?? 0}
</CardTitle>
</CardHeader>
<CardContent>
<span className="text-sm text-white/40">
Nieuwe registraties vandaag
</span>
</CardContent>
</Card>
<Card className="border-amber-400/20 bg-amber-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-amber-300/70">
Artiesten
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-amber-300">
{performerCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{stats?.byArtForm.slice(0, 4).map((item) => (
<div
key={item.artForm}
className="flex items-center justify-between text-xs"
>
<span className="text-amber-300/70">
{item.artForm || "Onbekend"}
</span>
<span className="text-amber-300">{item.count}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-teal-400/20 bg-teal-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-teal-300/70">
Bezoekers
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-teal-300">
{watcherCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-xs">
{totalGuestCount > 0 && (
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Inclusief gasten</span>
<span className="text-teal-300">+{totalGuestCount}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Totaal aanwezig</span>
<span className="font-semibold text-teal-300">
{totalWatcherAttendees}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Drinkkaart</span>
<span className="text-teal-300">{totalDrinkCardValue}</span>
</div>
</div>
</CardContent>
</Card>
<Card className="border-pink-400/20 bg-pink-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-pink-300/70">
Vrijwillige Gifts
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-pink-300">
{Math.round(totalGiftRevenue / 100)}
</CardTitle>
</CardHeader>
<CardContent>
<span className="text-sm text-white/40">
Totale gift opbrengst
</span>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card className="mb-6 border-white/10 bg-white/5">
<CardHeader>
<CardTitle className="font-['Intro',sans-serif] text-white text-xl">
Filters
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-5">
<div>
<label
htmlFor="search"
className="mb-2 block text-sm text-white/60"
>
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-white placeholder:text-white/40"
/>
</div>
</div>
<div>
<label
htmlFor="typeFilter"
className="mb-2 block text-sm text-white/60"
>
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-white focus:outline-none focus:ring-2 focus:ring-white/20"
>
<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-2 block text-sm text-white/60"
>
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-white placeholder:text-white/40"
/>
</div>
<div>
<label
htmlFor="fromDate"
className="mb-2 block text-sm text-white/60"
>
Vanaf
</label>
<Input
id="fromDate"
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="border-white/20 bg-white/10 text-white [color-scheme:dark]"
/>
</div>
<div>
<label
htmlFor="toDate"
className="mb-2 block text-sm text-white/60"
>
Tot
</label>
<Input
id="toDate"
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="border-white/20 bg-white/10 text-white [color-scheme:dark]"
/>
</div>
</div>
</CardContent>
</Card>
{/* Export Button */}
<div className="mb-6 flex items-center justify-between">
<p className="text-white/60">
{pagination?.total ?? 0} registraties gevonden
</p>
<Button
onClick={handleExport}
disabled={exportMutation.isPending}
className="bg-white text-[#214e51] hover:bg-white/90"
>
<Download className="mr-2 h-4 w-4" />
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
</Button>
</div>
{/* Registrations Table */}
<Card className="border-white/10 bg-white/5">
<CardContent className="p-0">
<div className="overflow-x-auto">
<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 guestCount = (() => {
if (!reg.guests) return 0;
try {
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
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
? `${guestCount} gast${guestCount === 1 ? "" : "en"}`
: "-"}
</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>
);
})
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="mt-6 flex items-center justify-center gap-2">
<Button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
variant="outline"
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50"
>
Vorige
</Button>
<span className="mx-4 text-white">
Pagina {page} van {pagination.totalPages}
</span>
<Button
onClick={() =>
setPage((p) => Math.min(pagination.totalPages, p + 1))
}
disabled={page === pagination.totalPages}
variant="outline"
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50"
>
Volgende
</Button>
</div>
)}
</main>
</div>
);
}