From 5ff287a911177fd6f40faca53adbf88ff57a98c0 Mon Sep 17 00:00:00 2001 From: Raul Lugo Date: Tue, 11 Aug 2026 22:56:32 +0200 Subject: [PATCH] feat: dasboard stats --- app/admin/page.tsx | 235 ++++++++++++++++++++++++++++++------ components/ui/progress.tsx | 31 +++++ lib/db/queries/dashboard.ts | 83 +++++++++++++ 3 files changed, 314 insertions(+), 35 deletions(-) create mode 100644 components/ui/progress.tsx create mode 100644 lib/db/queries/dashboard.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx index e9d656e..54137b9 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,7 +1,19 @@ -import { CalendarHeart, ImageIcon, MailCheck, UsersRound } from "lucide-react" +import Link from "next/link" +import { + Camera, + CheckCircle2, + Clock3, + MailCheck, + MessageSquareText, + TrendingUp, + UserRoundCheck, + UserRoundX, + UsersRound, +} from "lucide-react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" import { Card, CardContent, @@ -9,54 +21,137 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" +import { Progress } from "@/components/ui/progress" +import { getAdminDashboardMetrics } from "@/lib/db/queries/dashboard" -const dashboardCards = [ - { - title: "Invitations", - value: "—", - description: "Guest group management starts in the next milestone.", - icon: UsersRound, - }, - { - title: "RSVPs", - value: "—", - description: "Per-member responses will appear here later.", - icon: MailCheck, - }, - { - title: "Photo uploads", - value: "—", - description: "MinIO uploads are planned after RSVP interactions.", - icon: ImageIcon, - }, -] +export const dynamic = "force-dynamic" + +function percentage(value: number, total: number) { + if (total <= 0) return 0 + + return Math.round((value / total) * 100) +} + +function formatNumber(value: number) { + return new Intl.NumberFormat("en").format(value) +} + +export default async function AdminPage() { + const metrics = await getAdminDashboardMetrics() + + const attendanceRows = [ + { + label: "Attending", + value: metrics.attendingMembers, + percent: percentage(metrics.attendingMembers, metrics.totalMembers), + className: "bg-emerald-600", + }, + { + label: "Declined", + value: metrics.declinedMembers, + percent: percentage(metrics.declinedMembers, metrics.totalMembers), + className: "bg-rose-600", + }, + { + label: "Pending", + value: metrics.pendingMembers, + percent: percentage(metrics.pendingMembers, metrics.totalMembers), + className: "bg-amber-500", + }, + ] + + const submittedPercent = percentage( + metrics.submittedInvitations, + metrics.totalInvitations, + ) + const uploadedPhotoPercent = percentage(metrics.uploadedPhotos, metrics.totalInvitations) + + const metricCards = [ + { + title: "Invitation groups", + value: metrics.totalInvitations, + description: `${formatNumber(metrics.pendingInvitations)} awaiting RSVP`, + icon: UsersRound, + }, + { + title: "Invited people", + value: metrics.totalMembers, + description: "Across all invitation groups", + icon: UsersRound, + }, + { + title: "Attending", + value: metrics.attendingMembers, + description: `${percentage(metrics.attendingMembers, metrics.totalMembers)}% of invited people`, + icon: UserRoundCheck, + }, + { + title: "Declined", + value: metrics.declinedMembers, + description: `${percentage(metrics.declinedMembers, metrics.totalMembers)}% of invited people`, + icon: UserRoundX, + }, + { + title: "Pending responses", + value: metrics.pendingMembers, + description: `${percentage(metrics.pendingMembers, metrics.totalMembers)}% of invited people`, + icon: Clock3, + }, + { + title: "Submitted RSVPs", + value: metrics.submittedInvitations, + description: `${submittedPercent}% of invitation groups`, + icon: MailCheck, + }, + { + title: "Uploaded photos", + value: metrics.uploadedPhotos, + description: `${uploadedPhotoPercent}% of invitation groups`, + icon: Camera, + }, + { + title: "Pending toasts", + value: metrics.pendingToastRequests, + description: + metrics.pendingToastRequests > 0 ? "Need review or scheduling" : "No pending toast requests", + icon: MessageSquareText, + }, + ] -export default function AdminPage() { return (
-
+
- Milestone 02 + Live dashboard

Admin dashboard

- The protected admin shell is ready. Invitation management will be added - in the next milestone. + Track invitation groups, RSVP answers, photo uploads, and toast requests.

+
+ + +
- - - Admin access is protected - - Only Google accounts listed in ADMIN_EMAILS can access this area. - - + {metrics.totalInvitations === 0 ? ( + + + No invitations yet + + Create the first invitation group to start tracking RSVP progress. + + + ) : null}
- {dashboardCards.map((card) => { + {metricCards.map((card) => { const Icon = card.icon return ( @@ -69,12 +164,82 @@ export default function AdminPage() { -
{card.value}
+
{formatNumber(card.value)}
) })}
+ +
+ + + Attendance overview + + Individual guest answers across all invitation groups. + + + + {attendanceRows.map((row) => ( +
+
+ {row.label} + + {formatNumber(row.value)} / {formatNumber(metrics.totalMembers)} · {row.percent}% + +
+ +
+ ))} +
+
+ + + + Invitation responses + + Group-level RSVP submission rate. + + + +
+
+ Submitted groups + + {formatNumber(metrics.submittedInvitations)} / {formatNumber(metrics.totalInvitations)} · {submittedPercent}% + +
+ +
+ +
+
+
+ + Submitted +
+

+ {formatNumber(metrics.submittedInvitations)} +

+
+
+
+ + Awaiting RSVP +
+

+ {formatNumber(metrics.pendingInvitations)} +

+
+
+
+
+
) } diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx new file mode 100644 index 0000000..4f5b492 --- /dev/null +++ b/components/ui/progress.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import { Progress as ProgressPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + indicatorClassName, + value, + ...props +}: React.ComponentProps & { + indicatorClassName?: string +}) { + return ( + + + + ) +} + +export { Progress } diff --git a/lib/db/queries/dashboard.ts b/lib/db/queries/dashboard.ts new file mode 100644 index 0000000..5869da6 --- /dev/null +++ b/lib/db/queries/dashboard.ts @@ -0,0 +1,83 @@ +import { db } from "@/lib/db/client" + +export interface AdminDashboardMetrics { + totalInvitations: number + totalMembers: number + attendingMembers: number + declinedMembers: number + pendingMembers: number + submittedInvitations: number + pendingInvitations: number + uploadedPhotos: number + pendingToastRequests: number +} + +function toNumber(value: string | number | bigint | null | undefined) { + return Number(value ?? 0) +} + +export async function getAdminDashboardMetrics(): Promise { + const [ + totalInvitations, + totalMembers, + attendingMembers, + declinedMembers, + pendingMembers, + submittedInvitations, + uploadedPhotos, + pendingToastRequests, + ] = await Promise.all([ + db + .selectFrom("invitations") + .select((eb) => eb.fn.countAll().as("count")) + .executeTakeFirstOrThrow(), + db + .selectFrom("invitation_members") + .select((eb) => eb.fn.countAll().as("count")) + .executeTakeFirstOrThrow(), + db + .selectFrom("invitation_members") + .select((eb) => eb.fn.countAll().as("count")) + .where("attending", "=", true) + .executeTakeFirstOrThrow(), + db + .selectFrom("invitation_members") + .select((eb) => eb.fn.countAll().as("count")) + .where("attending", "=", false) + .executeTakeFirstOrThrow(), + db + .selectFrom("invitation_members") + .select((eb) => eb.fn.countAll().as("count")) + .where("attending", "is", null) + .executeTakeFirstOrThrow(), + db + .selectFrom("invitation_responses") + .select((eb) => eb.fn.countAll().as("count")) + .where("submitted_at", "is not", null) + .executeTakeFirstOrThrow(), + db + .selectFrom("photo_uploads") + .select((eb) => eb.fn.countAll().as("count")) + .executeTakeFirstOrThrow(), + db + .selectFrom("toast_requests") + .select((eb) => eb.fn.countAll().as("count")) + .where("status", "=", "pending") + .executeTakeFirstOrThrow(), + ]) + + const invitationsCount = toNumber(totalInvitations.count) + const submittedInvitationsCount = toNumber(submittedInvitations.count) + + return { + totalInvitations: invitationsCount, + totalMembers: toNumber(totalMembers.count), + attendingMembers: toNumber(attendingMembers.count), + declinedMembers: toNumber(declinedMembers.count), + pendingMembers: toNumber(pendingMembers.count), + submittedInvitations: submittedInvitationsCount, + pendingInvitations: Math.max(invitationsCount - submittedInvitationsCount, 0), + uploadedPhotos: toNumber(uploadedPhotos.count), + pendingToastRequests: toNumber(pendingToastRequests.count), + } +}