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.
+
+
+ View guest list
+
+
+ Create invitation
+
+
-
-
- 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),
+ }
+}