feat: dasboard stats
Deploy On Server / Prepare Repository Remotely (push) Successful in 9s
Deploy On Server / Docker Compose Up (push) Successful in 17s
Deploy On Server / Prune Images (push) Successful in 2s
Deploy On Server / Remove .env File (push) Successful in 3s

This commit is contained in:
Raul Lugo
2026-08-11 22:56:32 +02:00
parent aed8684e09
commit 5ff287a911
3 changed files with 314 additions and 35 deletions
+200 -35
View File
@@ -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 (
<div className="space-y-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-2">
<Badge variant="secondary" className="w-fit">
Milestone 02
Live dashboard
</Badge>
<h1 className="text-3xl font-semibold tracking-tight">Admin dashboard</h1>
<p className="max-w-2xl text-muted-foreground">
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.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href="/admin/guests">View guest list</Link>
</Button>
<Button asChild>
<Link href="/admin/guests/new">Create invitation</Link>
</Button>
</div>
</div>
<Alert>
<CalendarHeart />
<AlertTitle>Admin access is protected</AlertTitle>
<AlertDescription>
Only Google accounts listed in <code>ADMIN_EMAILS</code> can access this area.
</AlertDescription>
</Alert>
{metrics.totalInvitations === 0 ? (
<Alert>
<UsersRound />
<AlertTitle>No invitations yet</AlertTitle>
<AlertDescription>
Create the first invitation group to start tracking RSVP progress.
</AlertDescription>
</Alert>
) : null}
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{dashboardCards.map((card) => {
{metricCards.map((card) => {
const Icon = card.icon
return (
@@ -69,12 +164,82 @@ export default function AdminPage() {
<Icon className="size-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-semibold">{card.value}</div>
<div className="text-3xl font-semibold">{formatNumber(card.value)}</div>
</CardContent>
</Card>
)
})}
</section>
<section className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Attendance overview</CardTitle>
<CardDescription>
Individual guest answers across all invitation groups.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{attendanceRows.map((row) => (
<div key={row.label} className="space-y-2">
<div className="flex items-center justify-between gap-4 text-sm">
<span className="font-medium">{row.label}</span>
<span className="text-muted-foreground">
{formatNumber(row.value)} / {formatNumber(metrics.totalMembers)} · {row.percent}%
</span>
</div>
<Progress
value={row.percent}
aria-label={`${row.label} ${row.percent}%`}
className="h-2"
indicatorClassName={row.className}
/>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Invitation responses</CardTitle>
<CardDescription>
Group-level RSVP submission rate.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<div className="flex items-center justify-between gap-4 text-sm">
<span className="font-medium">Submitted groups</span>
<span className="text-muted-foreground">
{formatNumber(metrics.submittedInvitations)} / {formatNumber(metrics.totalInvitations)} · {submittedPercent}%
</span>
</div>
<Progress value={submittedPercent} aria-label={`${submittedPercent}% submitted`} />
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4" />
Submitted
</div>
<p className="mt-2 text-2xl font-semibold">
{formatNumber(metrics.submittedInvitations)}
</p>
</div>
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<TrendingUp className="size-4" />
Awaiting RSVP
</div>
<p className="mt-2 text-2xl font-semibold">
{formatNumber(metrics.pendingInvitations)}
</p>
</div>
</div>
</CardContent>
</Card>
</section>
</div>
)
}
+31
View File
@@ -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<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string
}) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
+83
View File
@@ -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<AdminDashboardMetrics> {
const [
totalInvitations,
totalMembers,
attendingMembers,
declinedMembers,
pendingMembers,
submittedInvitations,
uploadedPhotos,
pendingToastRequests,
] = await Promise.all([
db
.selectFrom("invitations")
.select((eb) => eb.fn.countAll<string>().as("count"))
.executeTakeFirstOrThrow(),
db
.selectFrom("invitation_members")
.select((eb) => eb.fn.countAll<string>().as("count"))
.executeTakeFirstOrThrow(),
db
.selectFrom("invitation_members")
.select((eb) => eb.fn.countAll<string>().as("count"))
.where("attending", "=", true)
.executeTakeFirstOrThrow(),
db
.selectFrom("invitation_members")
.select((eb) => eb.fn.countAll<string>().as("count"))
.where("attending", "=", false)
.executeTakeFirstOrThrow(),
db
.selectFrom("invitation_members")
.select((eb) => eb.fn.countAll<string>().as("count"))
.where("attending", "is", null)
.executeTakeFirstOrThrow(),
db
.selectFrom("invitation_responses")
.select((eb) => eb.fn.countAll<string>().as("count"))
.where("submitted_at", "is not", null)
.executeTakeFirstOrThrow(),
db
.selectFrom("photo_uploads")
.select((eb) => eb.fn.countAll<string>().as("count"))
.executeTakeFirstOrThrow(),
db
.selectFrom("toast_requests")
.select((eb) => eb.fn.countAll<string>().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),
}
}