From d25f4aa813d03efd77cf064cc2f26ad1ec37957c Mon Sep 17 00:00:00 2001 From: zias Date: Wed, 11 Mar 2026 11:37:51 +0100 Subject: [PATCH] feat: show real attendee count with guests in admin, expandable guest details, fix CSV export --- apps/web/src/routes/admin/index.tsx | 587 +++++++++++++++------------- packages/api/src/routers/index.ts | 144 +++++-- 2 files changed, 423 insertions(+), 308 deletions(-) diff --git a/apps/web/src/routes/admin/index.tsx b/apps/web/src/routes/admin/index.tsx index 7bb4ba9..7ce5d60 100644 --- a/apps/web/src/routes/admin/index.tsx +++ b/apps/web/src/routes/admin/index.tsx @@ -3,6 +3,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { Check, ChevronDown, + ChevronRight, ChevronsUpDown, ChevronUp, Clipboard, @@ -31,6 +32,23 @@ 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(""); @@ -44,6 +62,7 @@ function AdminPage() { const pageSize = 20; const [copiedId, setCopiedId] = useState(null); + const [expandedGuests, setExpandedGuests] = useState>(new Set()); // Get current session to check user role 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 = | "naam" | "email" @@ -179,21 +207,12 @@ function AdminPage() { 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); + // 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 = totalRegistrations + + const totalDrinkCardValue = registrations .filter((r) => r.registrationType === "watcher") .reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0); const totalGiftRevenue = (stats?.totalGiftRevenue as number | undefined) ?? 0; @@ -226,17 +245,8 @@ function AdminPage() { : `€${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); + valA = parseGuests(a.guests).length; + valB = parseGuests(b.guests).length; break; } case "gift": @@ -466,8 +476,7 @@ function AdminPage() { className="bg-green-600 text-white hover:bg-green-700" > - Goedkeuren - Goedkeuren + Goedkeuren - ) : ( - - )} - - + {isPerformer ? "Artiest" : "Bezoeker"} + + + + {detailLabel} + + + {guestCount > 0 ? ( + + ) : ( + "-" + )} + + + {formatCents(reg.giftAmount)} + + + {isPerformer ? ( + - + ) : reg.paymentStatus === "paid" ? ( + + + Betaald + + ) : reg.paymentStatus === + "extra_payment_pending" ? ( + + + Extra (€ + {((reg.paymentAmount ?? 0) / 100).toFixed(0)}) + + ) : ( + + Open + + )} + + + {dateLabel} + + + {reg.managementToken ? ( + + ) : ( + + )} + + + {/* Expandable guest details row */} + {isExpanded && guestCount > 0 && ( + + +
+

+ Gasten van {reg.firstName} {reg.lastName} +

+ {guests.map((g, i) => ( +
+ + {i + 1}. + + + {g.firstName} {g.lastName} + + {g.email && ( + + {g.email} + + )} + {g.phone && ( + + {g.phone} + + )} + {!g.email && !g.phone && ( + + geen contactgegevens + + )} +
+ ))} +
+ + + )} + ); }) )} @@ -991,16 +989,9 @@ function AdminPage() {
{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 guests = parseGuests(reg.guests); + const guestCount = guests.length; + const isExpanded = expandedGuests.has(reg.id); const detailLabel = isPerformer ? reg.artForm || "-" @@ -1022,88 +1013,138 @@ function AdminPage() { })(); return ( -
-
-
-
- - {reg.firstName} {reg.lastName} - +
+
+
+
+
+ + {reg.firstName} {reg.lastName} + +
+
+ {reg.email} + {reg.phone && ( + + • {reg.phone} + + )} +
-
- {reg.email} - {reg.phone && ( - • {reg.phone} +
+ + {isPerformer ? "Artiest" : "Bezoeker"} + + {reg.managementToken && ( + )}
-
- - {isPerformer ? "Artiest" : "Bezoeker"} - - {reg.managementToken && ( + +
+
+ Details:{" "} + {detailLabel} +
+ {guestCount > 0 && ( )} + {(reg.giftAmount ?? 0) > 0 && ( +
+ Gift:{" "} + {formatCents(reg.giftAmount)} +
+ )} + {!isPerformer && ( +
+ Betaling:{" "} + {reg.paymentStatus === "paid" ? ( + + + Betaald + + ) : reg.paymentStatus === + "extra_payment_pending" ? ( + + + Extra (€ + {((reg.paymentAmount ?? 0) / 100).toFixed( + 0, + )} + ) + + ) : ( + Open + )} +
+ )} +
{dateLabel}
-
-
- Details:{" "} - {detailLabel} + {/* Expandable guest details — mobile */} + {isExpanded && guestCount > 0 && ( +
+

+ Gasten +

+
+ {guests.map((g, i) => ( +
+ + {i + 1}. + + + {g.firstName} {g.lastName} + + {g.email && ( +
+ {g.email} +
+ )} + {g.phone && ( +
+ {g.phone} +
+ )} +
+ ))} +
- {guestCount > 0 && ( -
- Gasten:{" "} - {guestCount} -
- )} - {(reg.giftAmount ?? 0) > 0 && ( -
- Gift:{" "} - {formatCents(reg.giftAmount)} -
- )} - {!isPerformer && ( -
- Betaling:{" "} - {reg.paymentStatus === "paid" ? ( - - - Betaald - - ) : reg.paymentStatus === - "extra_payment_pending" ? ( - - - Extra (€ - {((reg.paymentAmount ?? 0) / 100).toFixed(0)}) - - ) : ( - Open - )} -
- )} -
{dateLabel}
-
+ )}
); })} diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index 9b41877..688b9ae 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -474,27 +474,49 @@ export const appRouter = { const today = new Date(); today.setHours(0, 0, 0, 0); - const [totalResult, todayResult, artFormResult, typeResult, giftResult] = - await Promise.all([ - db.select({ count: count() }).from(registration), - db - .select({ count: count() }) - .from(registration) - .where(gte(registration.createdAt, today)), - db - .select({ artForm: registration.artForm, count: count() }) - .from(registration) - .where(eq(registration.registrationType, "performer")) - .groupBy(registration.artForm), - db - .select({ - registrationType: registration.registrationType, - count: count(), - }) - .from(registration) - .groupBy(registration.registrationType), - db.select({ total: sum(registration.giftAmount) }).from(registration), - ]); + const [ + totalResult, + todayResult, + artFormResult, + typeResult, + giftResult, + watcherGuestRows, + ] = await Promise.all([ + db.select({ count: count() }).from(registration), + db + .select({ count: count() }) + .from(registration) + .where(gte(registration.createdAt, today)), + db + .select({ artForm: registration.artForm, count: count() }) + .from(registration) + .where(eq(registration.registrationType, "performer")) + .groupBy(registration.artForm), + db + .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 { total: totalResult[0]?.count ?? 0, @@ -508,6 +530,7 @@ export const appRouter = { count: r.count, })), totalGiftRevenue: giftResult[0]?.total ?? 0, + totalGuestCount, }; }), @@ -517,6 +540,9 @@ export const appRouter = { .from(registration) .orderBy(desc(registration.createdAt)); + const escapeCell = (val: string) => `"${val.replace(/"/g, '""')}"`; + + // Main registration headers const headers = [ "ID", "First Name", @@ -527,49 +553,97 @@ export const appRouter = { "Art Form", "Experience", "Is Over 16", - "Drink Card Value", - "Gift Amount", + "Drink Card Value (EUR)", + "Gift Amount (cents)", "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", "Paid At", "Extra Questions", + "Cancelled At", "Created At", ]; + const MAX_GUESTS = 9; + const rows = data.map((r) => { const guests = parseGuestsJson(r.guests); - const guestSummary = guests - .map( - (g) => - `${g.firstName} ${g.lastName}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`, - ) - .join(" | "); + + // Build guest columns (up to 9 guests, 4 fields each) + const guestCols: string[] = []; + for (let i = 0; i < MAX_GUESTS; i++) { + const g = guests[i]; + guestCols.push(g?.firstName ?? ""); + guestCols.push(g?.lastName ?? ""); + guestCols.push(g?.email ?? ""); + guestCols.push(g?.phone ?? ""); + } + return [ r.id, r.firstName, r.lastName, r.email, r.phone || "", - r.registrationType, + r.registrationType ?? "", r.artForm || "", r.experience || "", r.isOver16 ? "Yes" : "No", String(r.drinkCardValue ?? 0), String(r.giftAmount ?? 0), String(guests.length), - guestSummary, - r.paymentStatus === "paid" ? "Paid" : "Pending", + ...guestCols, + r.paymentStatus === "paid" + ? "Paid" + : r.paymentStatus === "extra_payment_pending" + ? "Extra Payment Pending" + : "Pending", r.paidAt ? r.paidAt.toISOString() : "", r.extraQuestions || "", + r.cancelledAt ? r.cancelledAt.toISOString() : "", r.createdAt.toISOString(), ]; }); const csvContent = [ - headers.join(","), + headers.map(escapeCell).join(","), ...rows.map((row) => - row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","), + row.map((cell) => escapeCell(String(cell))).join(","), ), ].join("\n");