feat: show real attendee count with guests in admin, expandable guest details, fix CSV export

This commit is contained in:
2026-03-11 11:37:51 +01:00
parent 8a6d3035cb
commit d25f4aa813
2 changed files with 423 additions and 308 deletions

View File

@@ -3,6 +3,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { import {
Check, Check,
ChevronDown, ChevronDown,
ChevronRight,
ChevronsUpDown, ChevronsUpDown,
ChevronUp, ChevronUp,
Clipboard, Clipboard,
@@ -31,6 +32,23 @@ export const Route = createFileRoute("/admin/")({
component: AdminPage, 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() { function AdminPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -44,6 +62,7 @@ function AdminPage() {
const pageSize = 20; const pageSize = 20;
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandedGuests, setExpandedGuests] = useState<Set<string>>(new Set());
// Get current session to check user role // Get current session to check user role
const sessionQuery = useQuery({ const sessionQuery = useQuery({
@@ -60,6 +79,15 @@ function AdminPage() {
}); });
}; };
const toggleGuestExpand = (id: string) => {
setExpandedGuests((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
type SortKey = type SortKey =
| "naam" | "naam"
| "email" | "email"
@@ -179,21 +207,12 @@ function AdminPage() {
const watcherCount = const watcherCount =
stats?.byType.find((t) => t.registrationType === "watcher")?.count ?? 0; stats?.byType.find((t) => t.registrationType === "watcher")?.count ?? 0;
// Calculate total attendees including guests // Use server-side totalGuestCount from stats (accurate across all pages)
const totalRegistrations = registrationsQuery.data?.data ?? []; const totalGuestCount =
const watcherWithGuests = totalRegistrations.filter( (stats as { totalGuestCount?: number } | undefined)?.totalGuestCount ?? 0;
(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 totalWatcherAttendees = watcherCount + totalGuestCount;
const totalDrinkCardValue = totalRegistrations
const totalDrinkCardValue = registrations
.filter((r) => r.registrationType === "watcher") .filter((r) => r.registrationType === "watcher")
.reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0); .reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0);
const totalGiftRevenue = (stats?.totalGiftRevenue as number | undefined) ?? 0; const totalGiftRevenue = (stats?.totalGiftRevenue as number | undefined) ?? 0;
@@ -226,17 +245,8 @@ function AdminPage() {
: `${b.drinkCardValue ?? 5}`) ?? ""; : `${b.drinkCardValue ?? 5}`) ?? "";
break; break;
case "gasten": { case "gasten": {
const countGuests = (g: string | null) => { valA = parseGuests(a.guests).length;
if (!g) return 0; valB = parseGuests(b.guests).length;
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; break;
} }
case "gift": case "gift":
@@ -466,8 +476,7 @@ function AdminPage() {
className="bg-green-600 text-white hover:bg-green-700" className="bg-green-600 text-white hover:bg-green-700"
> >
<Check className="mr-1 h-4 w-4" /> <Check className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">Goedkeuren</span> Goedkeuren
<span className="sm:hidden">Goedkeuren</span>
</Button> </Button>
<Button <Button
onClick={() => handleReject(request.id)} onClick={() => handleReject(request.id)}
@@ -489,6 +498,7 @@ function AdminPage() {
{/* Stats Cards */} {/* 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"> <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"> <Card className="border-white/10 bg-white/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-white/60 text-xs sm:text-sm"> <CardDescription className="text-white/60 text-xs sm:text-sm">
@@ -503,6 +513,7 @@ function AdminPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Today */}
<Card className="border-white/10 bg-white/5"> <Card className="border-white/10 bg-white/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-white/60 text-xs sm:text-sm"> <CardDescription className="text-white/60 text-xs sm:text-sm">
@@ -519,6 +530,7 @@ function AdminPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Performers */}
<Card className="border-amber-400/20 bg-amber-400/5"> <Card className="border-amber-400/20 bg-amber-400/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-amber-300/70 text-xs sm:text-sm"> <CardDescription className="text-amber-300/70 text-xs sm:text-sm">
@@ -545,29 +557,28 @@ function AdminPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Watchers + guests (real attendees count) */}
<Card className="border-teal-400/20 bg-teal-400/5"> <Card className="border-teal-400/20 bg-teal-400/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-teal-300/70 text-xs sm:text-sm"> <CardDescription className="text-teal-300/70 text-xs sm:text-sm">
Bezoekers Bezoekers
</CardDescription> </CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-teal-300 sm:text-3xl lg:text-4xl"> <CardTitle className="font-['Intro',sans-serif] text-2xl text-teal-300 sm:text-3xl lg:text-4xl">
{watcherCount} {totalWatcherAttendees}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-1 text-xs"> <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 && ( {totalGuestCount > 0 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-teal-300/70">Inclusief gasten</span> <span className="text-teal-300/70">Gasten</span>
<span className="text-teal-300">+{totalGuestCount}</span> <span className="text-teal-300">+{totalGuestCount}</span>
</div> </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="hidden sm:flex sm:items-center sm:justify-between"> <div className="hidden sm:flex sm:items-center sm:justify-between">
<span className="text-teal-300/70">Drinkkaart</span> <span className="text-teal-300/70">Drinkkaart</span>
<span className="text-teal-300">{totalDrinkCardValue}</span> <span className="text-teal-300">{totalDrinkCardValue}</span>
@@ -576,84 +587,12 @@ function AdminPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Gifts */}
<Card className="border-pink-400/20 bg-pink-400/5"> <Card className="border-pink-400/20 bg-pink-400/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-pink-300/70 text-xs sm:text-sm"> <CardDescription className="text-pink-300/70 text-xs sm:text-sm">
Vrijwillige Gifts Vrijwillige Gifts
</CardDescription> </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>
<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-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>
<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-2xl text-teal-300 sm:text-3xl lg:text-4xl">
{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="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>
<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-2xl text-pink-300 sm:text-3xl lg:text-4xl"> <CardTitle className="font-['Intro',sans-serif] text-2xl text-pink-300 sm:text-3xl lg:text-4xl">
{Math.round(totalGiftRevenue / 100)} {Math.round(totalGiftRevenue / 100)}
</CardTitle> </CardTitle>
@@ -778,6 +717,12 @@ function AdminPage() {
<div className="mb-4 flex flex-col gap-2 sm:mb-6 sm:flex-row sm:items-center sm:justify-between"> <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"> <p className="text-sm text-white/60">
{pagination?.total ?? 0} registraties gevonden {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> </p>
<Button <Button
onClick={handleExport} onClick={handleExport}
@@ -860,16 +805,9 @@ function AdminPage() {
) : ( ) : (
sortedRegistrations.map((reg) => { sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer"; const isPerformer = reg.registrationType === "performer";
const guests = parseGuests(reg.guests);
const guestCount = (() => { const guestCount = guests.length;
if (!reg.guests) return 0; const isExpanded = expandedGuests.has(reg.id);
try {
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
const detailLabel = isPerformer const detailLabel = isPerformer
? reg.artForm || "-" ? reg.artForm || "-"
@@ -891,85 +829,145 @@ function AdminPage() {
})(); })();
return ( return (
<tr <>
key={reg.id} <tr
className="border-white/5 border-b hover:bg-white/5" 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 className="px-4 py-3 font-medium text-white">
</td> {reg.firstName} {reg.lastName}
<td className="px-4 py-3 text-sm text-white/70"> </td>
{reg.email} <td className="px-4 py-3 text-sm text-white/70">
</td> {reg.email}
<td className="px-4 py-3 text-sm text-white/60"> </td>
{reg.phone || "-"} <td className="px-4 py-3 text-sm text-white/60">
</td> {reg.phone || "-"}
<td className="px-4 py-3"> </td>
<span <td className="px-4 py-3">
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"}`} <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 ? ( {isPerformer ? "Artiest" : "Bezoeker"}
<ClipboardCheck className="h-4 w-4 text-green-400" /> </span>
) : ( </td>
<Clipboard className="h-4 w-4" /> <td className="px-4 py-3 text-sm text-white/70">
)} {detailLabel}
</button> </td>
) : ( <td className="px-4 py-3 text-sm text-white/70">
<span className="text-sm text-white/20"></span> {guestCount > 0 ? (
)} <button
</td> type="button"
</tr> 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>
)}
</>
); );
}) })
)} )}
@@ -991,16 +989,9 @@ function AdminPage() {
<div className="divide-y divide-white/5"> <div className="divide-y divide-white/5">
{sortedRegistrations.map((reg) => { {sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer"; const isPerformer = reg.registrationType === "performer";
const guests = parseGuests(reg.guests);
const guestCount = (() => { const guestCount = guests.length;
if (!reg.guests) return 0; const isExpanded = expandedGuests.has(reg.id);
try {
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
const detailLabel = isPerformer const detailLabel = isPerformer
? reg.artForm || "-" ? reg.artForm || "-"
@@ -1022,88 +1013,138 @@ function AdminPage() {
})(); })();
return ( return (
<div key={reg.id} className="p-4 hover:bg-white/5"> <div key={reg.id}>
<div className="flex items-start justify-between gap-3"> <div className="p-4 hover:bg-white/5">
<div className="min-w-0 flex-1"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2"> <div className="min-w-0 flex-1">
<span className="truncate font-medium text-white"> <div className="flex items-center gap-2">
{reg.firstName} {reg.lastName} <span className="truncate font-medium text-white">
</span> {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>
<div className="mt-1 flex items-center gap-2 text-white/60 text-xs"> <div className="flex items-center gap-2">
<span className="truncate">{reg.email}</span> <span
{reg.phone && ( 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"}`}
<span className="shrink-0"> {reg.phone}</span> >
{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> </div>
<div className="flex items-center gap-2">
<span <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
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"}`} <div className="text-white/70">
> <span className="text-white/40">Details:</span>{" "}
{isPerformer ? "Artiest" : "Bezoeker"} {detailLabel}
</span> </div>
{reg.managementToken && ( {guestCount > 0 && (
<button <button
type="button" type="button"
title="Kopieer beheerlink" onClick={() => toggleGuestExpand(reg.id)}
onClick={() => className="inline-flex items-center gap-1 text-teal-300"
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 ? ( {isExpanded ? (
<ClipboardCheck className="h-4 w-4 text-green-400" /> <ChevronDown className="h-3 w-3" />
) : ( ) : (
<Clipboard className="h-4 w-4" /> <ChevronRight className="h-3 w-3" />
)} )}
{guestCount} gast{guestCount === 1 ? "" : "en"}
</button> </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>
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs"> {/* Expandable guest details — mobile */}
<div className="text-white/70"> {isExpanded && guestCount > 0 && (
<span className="text-white/40">Details:</span>{" "} <div className="border-teal-400/10 border-t bg-teal-900/20 px-4 pt-3 pb-4">
{detailLabel} <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>
{guestCount > 0 && ( )}
<div className="text-white/70">
<span className="text-white/40">Gasten:</span>{" "}
{guestCount}
</div>
)}
{(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> </div>
); );
})} })}

View File

@@ -474,27 +474,49 @@ export const appRouter = {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const [totalResult, todayResult, artFormResult, typeResult, giftResult] = const [
await Promise.all([ totalResult,
db.select({ count: count() }).from(registration), todayResult,
db artFormResult,
.select({ count: count() }) typeResult,
.from(registration) giftResult,
.where(gte(registration.createdAt, today)), watcherGuestRows,
db ] = await Promise.all([
.select({ artForm: registration.artForm, count: count() }) db.select({ count: count() }).from(registration),
.from(registration) db
.where(eq(registration.registrationType, "performer")) .select({ count: count() })
.groupBy(registration.artForm), .from(registration)
db .where(gte(registration.createdAt, today)),
.select({ db
registrationType: registration.registrationType, .select({ artForm: registration.artForm, count: count() })
count: count(), .from(registration)
}) .where(eq(registration.registrationType, "performer"))
.from(registration) .groupBy(registration.artForm),
.groupBy(registration.registrationType), db
db.select({ total: sum(registration.giftAmount) }).from(registration), .select({
]); registrationType: registration.registrationType,
count: count(),
})
.from(registration)
.groupBy(registration.registrationType),
db.select({ total: sum(registration.giftAmount) }).from(registration),
// Fetch guests column for all watcher registrations to count total guests
db
.select({ guests: registration.guests })
.from(registration)
.where(
and(
eq(registration.registrationType, "watcher"),
isNull(registration.cancelledAt),
),
),
]);
// Sum up all guest counts across all watcher registrations
const totalGuestCount = watcherGuestRows.reduce((sum, r) => {
const guests = parseGuestsJson(r.guests);
return sum + guests.length;
}, 0);
return { return {
total: totalResult[0]?.count ?? 0, total: totalResult[0]?.count ?? 0,
@@ -508,6 +530,7 @@ export const appRouter = {
count: r.count, count: r.count,
})), })),
totalGiftRevenue: giftResult[0]?.total ?? 0, totalGiftRevenue: giftResult[0]?.total ?? 0,
totalGuestCount,
}; };
}), }),
@@ -517,6 +540,9 @@ export const appRouter = {
.from(registration) .from(registration)
.orderBy(desc(registration.createdAt)); .orderBy(desc(registration.createdAt));
const escapeCell = (val: string) => `"${val.replace(/"/g, '""')}"`;
// Main registration headers
const headers = [ const headers = [
"ID", "ID",
"First Name", "First Name",
@@ -527,49 +553,97 @@ export const appRouter = {
"Art Form", "Art Form",
"Experience", "Experience",
"Is Over 16", "Is Over 16",
"Drink Card Value", "Drink Card Value (EUR)",
"Gift Amount", "Gift Amount (cents)",
"Guest Count", "Guest Count",
"Guests", "Guest 1 First Name",
"Guest 1 Last Name",
"Guest 1 Email",
"Guest 1 Phone",
"Guest 2 First Name",
"Guest 2 Last Name",
"Guest 2 Email",
"Guest 2 Phone",
"Guest 3 First Name",
"Guest 3 Last Name",
"Guest 3 Email",
"Guest 3 Phone",
"Guest 4 First Name",
"Guest 4 Last Name",
"Guest 4 Email",
"Guest 4 Phone",
"Guest 5 First Name",
"Guest 5 Last Name",
"Guest 5 Email",
"Guest 5 Phone",
"Guest 6 First Name",
"Guest 6 Last Name",
"Guest 6 Email",
"Guest 6 Phone",
"Guest 7 First Name",
"Guest 7 Last Name",
"Guest 7 Email",
"Guest 7 Phone",
"Guest 8 First Name",
"Guest 8 Last Name",
"Guest 8 Email",
"Guest 8 Phone",
"Guest 9 First Name",
"Guest 9 Last Name",
"Guest 9 Email",
"Guest 9 Phone",
"Payment Status", "Payment Status",
"Paid At", "Paid At",
"Extra Questions", "Extra Questions",
"Cancelled At",
"Created At", "Created At",
]; ];
const MAX_GUESTS = 9;
const rows = data.map((r) => { const rows = data.map((r) => {
const guests = parseGuestsJson(r.guests); const guests = parseGuestsJson(r.guests);
const guestSummary = guests
.map( // Build guest columns (up to 9 guests, 4 fields each)
(g) => const guestCols: string[] = [];
`${g.firstName} ${g.lastName}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`, for (let i = 0; i < MAX_GUESTS; i++) {
) const g = guests[i];
.join(" | "); guestCols.push(g?.firstName ?? "");
guestCols.push(g?.lastName ?? "");
guestCols.push(g?.email ?? "");
guestCols.push(g?.phone ?? "");
}
return [ return [
r.id, r.id,
r.firstName, r.firstName,
r.lastName, r.lastName,
r.email, r.email,
r.phone || "", r.phone || "",
r.registrationType, r.registrationType ?? "",
r.artForm || "", r.artForm || "",
r.experience || "", r.experience || "",
r.isOver16 ? "Yes" : "No", r.isOver16 ? "Yes" : "No",
String(r.drinkCardValue ?? 0), String(r.drinkCardValue ?? 0),
String(r.giftAmount ?? 0), String(r.giftAmount ?? 0),
String(guests.length), String(guests.length),
guestSummary, ...guestCols,
r.paymentStatus === "paid" ? "Paid" : "Pending", r.paymentStatus === "paid"
? "Paid"
: r.paymentStatus === "extra_payment_pending"
? "Extra Payment Pending"
: "Pending",
r.paidAt ? r.paidAt.toISOString() : "", r.paidAt ? r.paidAt.toISOString() : "",
r.extraQuestions || "", r.extraQuestions || "",
r.cancelledAt ? r.cancelledAt.toISOString() : "",
r.createdAt.toISOString(), r.createdAt.toISOString(),
]; ];
}); });
const csvContent = [ const csvContent = [
headers.join(","), headers.map(escapeCell).join(","),
...rows.map((row) => ...rows.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","), row.map((cell) => escapeCell(String(cell))).join(","),
), ),
].join("\n"); ].join("\n");