feat: show real attendee count with guests in admin, expandable guest details, fix CSV export
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user