feat: dasboard stats
This commit is contained in:
+188
-23
@@ -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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -9,54 +21,137 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { getAdminDashboardMetrics } from "@/lib/db/queries/dashboard"
|
||||||
|
|
||||||
const dashboardCards = [
|
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 = [
|
||||||
{
|
{
|
||||||
title: "Invitations",
|
label: "Attending",
|
||||||
value: "—",
|
value: metrics.attendingMembers,
|
||||||
description: "Guest group management starts in the next milestone.",
|
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,
|
icon: UsersRound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "RSVPs",
|
title: "Invited people",
|
||||||
value: "—",
|
value: metrics.totalMembers,
|
||||||
description: "Per-member responses will appear here later.",
|
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,
|
icon: MailCheck,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Photo uploads",
|
title: "Uploaded photos",
|
||||||
value: "—",
|
value: metrics.uploadedPhotos,
|
||||||
description: "MinIO uploads are planned after RSVP interactions.",
|
description: `${uploadedPhotoPercent}% of invitation groups`,
|
||||||
icon: ImageIcon,
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<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">
|
<div className="space-y-2">
|
||||||
<Badge variant="secondary" className="w-fit">
|
<Badge variant="secondary" className="w-fit">
|
||||||
Milestone 02
|
Live dashboard
|
||||||
</Badge>
|
</Badge>
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">Admin dashboard</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">Admin dashboard</h1>
|
||||||
<p className="max-w-2xl text-muted-foreground">
|
<p className="max-w-2xl text-muted-foreground">
|
||||||
The protected admin shell is ready. Invitation management will be added
|
Track invitation groups, RSVP answers, photo uploads, and toast requests.
|
||||||
in the next milestone.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{metrics.totalInvitations === 0 ? (
|
||||||
<Alert>
|
<Alert>
|
||||||
<CalendarHeart />
|
<UsersRound />
|
||||||
<AlertTitle>Admin access is protected</AlertTitle>
|
<AlertTitle>No invitations yet</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Only Google accounts listed in <code>ADMIN_EMAILS</code> can access this area.
|
Create the first invitation group to start tracking RSVP progress.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
{dashboardCards.map((card) => {
|
{metricCards.map((card) => {
|
||||||
const Icon = card.icon
|
const Icon = card.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,12 +164,82 @@ export default function AdminPage() {
|
|||||||
<Icon className="size-5 text-muted-foreground" />
|
<Icon className="size-5 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-semibold">{card.value}</div>
|
<div className="text-3xl font-semibold">{formatNumber(card.value)}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user