Add dashboard with KPI cards, charts, and overdue alerts

Dashboard service aggregates project/deliverable/stage stats.
KPI cards for active projects, deliverables, overdue count, and
pipeline completion rate. Recharts pie chart for status distribution,
stacked bar chart for stage completion by template type.
Overdue deliverables table and recent completions feed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Leivur R. Djurhuus 2026-02-28 21:43:55 -06:00
parent 4f841c73b7
commit 4b6576233e
3 changed files with 566 additions and 6 deletions

View file

@ -1,10 +1,371 @@
export default function DashboardPage() {
"use client";
import Link from "next/link";
import { format } from "date-fns";
import { useQuery } from "@tanstack/react-query";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip as RTooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Legend,
} from "recharts";
import {
FolderOpen,
Package,
AlertTriangle,
TrendingUp,
CheckCircle2,
Clock,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
import { cn } from "@/lib/utils";
const STATUS_COLORS: Record<string, string> = {
NOT_STARTED: "#6B7280",
IN_PROGRESS: "#2563EB",
IN_REVIEW: "#D97706",
APPROVED: "#16A34A",
ON_HOLD: "#9CA3AF",
BLOCKED: "#DC2626",
SKIPPED: "#9CA3AF",
CHANGES_REQUESTED: "#D97706",
};
const PRIORITY_STYLES: Record<string, string> = {
LOW: "bg-[var(--status-not-started)]/10 text-[var(--status-not-started)]",
MEDIUM: "bg-[var(--status-in-progress)]/10 text-[var(--status-in-progress)]",
HIGH: "bg-[var(--status-in-review)]/10 text-[var(--status-in-review)]",
URGENT: "bg-[var(--status-blocked)]/10 text-[var(--status-blocked)]",
};
function useDashboardStats() {
return useQuery({
queryKey: ["dashboard-stats"],
queryFn: async () => {
const res = await fetch("/api/dashboard/stats");
if (!res.ok) throw new Error("Failed to load dashboard");
return res.json();
},
});
}
function KpiCard({
title,
value,
subtitle,
icon: Icon,
accent = false,
}: {
title: string;
value: number | string;
subtitle?: string;
icon: typeof FolderOpen;
accent?: boolean;
}) {
return (
<div>
<h1 className="font-heading text-2xl font-bold">Dashboard</h1>
<p className="mt-2 text-[var(--muted-foreground)]">
Production pipeline overview KPIs and charts coming in Phase 3.
</p>
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
accent
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "bg-[var(--primary)]/10 text-[var(--primary)]"
)}
>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-[var(--muted-foreground)]">{title}</p>
{subtitle && (
<p className="text-[10px] text-[var(--muted-foreground)]">
{subtitle}
</p>
)}
</div>
</CardContent>
</Card>
);
}
export default function DashboardPage() {
const { data, isLoading } = useDashboardStats();
const stats = data as any;
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-64" />
<Skeleton className="h-64" />
</div>
</div>
);
}
const kpis = stats?.kpis ?? {};
const deliverablesByStatus = (stats?.deliverablesByStatus ?? []).map(
(d: any) => ({
...d,
name: d.status.replace(/_/g, " "),
fill: STATUS_COLORS[d.status] ?? "#6B7280",
})
);
const templateStats = stats?.templateStats ?? [];
const overdueDeliverables = stats?.overdueDeliverables ?? [];
const recentActivity = stats?.recentActivity ?? [];
return (
<div className="space-y-6">
<div>
<h1 className="font-heading text-2xl font-bold">Dashboard</h1>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
Production pipeline overview
</p>
</div>
{/* KPI Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard
title="Active Projects"
value={kpis.activeProjects ?? 0}
subtitle={`${kpis.totalProjects ?? 0} total`}
icon={FolderOpen}
/>
<KpiCard
title="Deliverables"
value={kpis.totalDeliverables ?? 0}
icon={Package}
/>
<KpiCard
title="Overdue"
value={kpis.overdueCount ?? 0}
icon={AlertTriangle}
accent={kpis.overdueCount > 0}
/>
<KpiCard
title="Pipeline Completion"
value={`${kpis.completionRate ?? 0}%`}
subtitle={`${kpis.approvedStages ?? 0}/${kpis.totalStages ?? 0} stages`}
icon={TrendingUp}
/>
</div>
{/* Charts */}
<div className="grid gap-4 md:grid-cols-2">
{/* Deliverables by Status */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-semibold">
Deliverables by Status
</CardTitle>
</CardHeader>
<CardContent>
{deliverablesByStatus.length === 0 ? (
<p className="py-8 text-center text-sm text-[var(--muted-foreground)]">
No data yet
</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={deliverablesByStatus}
dataKey="count"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
>
{deliverablesByStatus.map((entry: any, i: number) => (
<Cell key={i} fill={entry.fill} />
))}
</Pie>
<Legend
wrapperStyle={{ fontSize: "11px" }}
iconType="circle"
iconSize={8}
/>
<RTooltip
contentStyle={{
fontSize: "12px",
borderRadius: "6px",
}}
/>
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Stage Completion by Template */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-semibold">
Stage Completion by Type
</CardTitle>
</CardHeader>
<CardContent>
{templateStats.length === 0 ? (
<p className="py-8 text-center text-sm text-[var(--muted-foreground)]">
No data yet
</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={templateStats}
margin={{ top: 5, right: 5, bottom: 5, left: -20 }}
>
<XAxis
dataKey="name"
tick={{ fontSize: 10 }}
interval={0}
angle={-45}
textAnchor="end"
height={60}
/>
<YAxis tick={{ fontSize: 10 }} allowDecimals={false} />
<RTooltip
contentStyle={{
fontSize: "12px",
borderRadius: "6px",
}}
/>
<Bar
dataKey="approved"
name="Approved"
fill="#16A34A"
stackId="stack"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="inProgress"
name="In Progress"
fill="#2563EB"
stackId="stack"
/>
<Bar
dataKey="blocked"
name="Blocked"
fill="#DC2626"
stackId="stack"
radius={[2, 2, 0, 0]}
/>
<Legend
wrapperStyle={{ fontSize: "11px" }}
iconType="circle"
iconSize={8}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
{/* Overdue Deliverables */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4 text-[var(--accent)]" />
Overdue Deliverables
</CardTitle>
</CardHeader>
<CardContent>
{overdueDeliverables.length === 0 ? (
<div className="flex items-center gap-2 py-4 text-sm text-[var(--muted-foreground)]">
<CheckCircle2 className="h-4 w-4 text-[var(--status-approved)]" />
No overdue deliverables
</div>
) : (
<div className="space-y-2">
{overdueDeliverables.map((deliv: any) => (
<div
key={deliv.id}
className="flex items-center gap-3 rounded border border-[var(--status-blocked)]/20 px-3 py-2"
>
<div className="min-w-0 flex-1">
<Link
href={`/projects/${deliv.project.id}/deliverables/${deliv.id}`}
className="text-sm font-medium hover:underline"
>
{deliv.name}
</Link>
<p className="text-[10px] text-[var(--muted-foreground)]">
{deliv.project.name}
</p>
</div>
<StageStatusBadge status={deliv.status} />
<Badge
variant="secondary"
className={PRIORITY_STYLES[deliv.priority]}
>
{deliv.priority}
</Badge>
<span className="shrink-0 text-xs font-medium text-[var(--status-blocked)]">
Due {format(new Date(deliv.dueDate), "MMM d")}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Recent Activity */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Clock className="h-4 w-4" />
Recent Completions
</CardTitle>
</CardHeader>
<CardContent>
{recentActivity.length === 0 ? (
<p className="py-4 text-sm text-[var(--muted-foreground)]">
No recent activity
</p>
) : (
<div className="space-y-2">
{recentActivity.map((item: any) => (
<div
key={item.id}
className="flex items-center gap-3 text-sm"
>
<CheckCircle2 className="h-4 w-4 shrink-0 text-[var(--status-approved)]" />
<span className="font-medium">{item.template.name}</span>
<span className="text-[var(--muted-foreground)]">
on {item.deliverable.name}
</span>
<span className="ml-auto shrink-0 text-xs text-[var(--muted-foreground)]">
{item.completedDate
? format(new Date(item.completedDate), "MMM d")
: ""}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { getAuthSession, serverError } from "@/lib/api-utils";
import { getDashboardStats } from "@/lib/services/dashboard-service";
// GET /api/dashboard/stats
export async function GET() {
const { session, error } = await getAuthSession();
if (error) return error;
try {
const organizationId = (session as any)?.user?.organizationId;
if (!organizationId) {
// If no org, return empty stats
return NextResponse.json({
kpis: {
totalProjects: 0,
activeProjects: 0,
totalDeliverables: 0,
overdueCount: 0,
completionRate: 0,
approvedStages: 0,
totalStages: 0,
},
deliverablesByStatus: [],
stagesByStatus: [],
templateStats: [],
overdueDeliverables: [],
recentActivity: [],
});
}
const stats = await getDashboardStats(organizationId);
return NextResponse.json(stats);
} catch (e) {
return serverError(e);
}
}

View file

@ -0,0 +1,161 @@
import { prisma } from "@/lib/prisma";
/**
* Get dashboard KPI stats for an organization.
*/
export async function getDashboardStats(organizationId: string) {
const [
projectStats,
deliverableStats,
stageStats,
overdueDeliverables,
recentActivity,
stagesByTemplate,
] = await Promise.all([
// Project counts by status
prisma.project.groupBy({
by: ["status"],
where: { organizationId },
_count: true,
}),
// Deliverable counts by status
prisma.deliverable.groupBy({
by: ["status"],
where: { project: { organizationId } },
_count: true,
}),
// Stage counts by status
prisma.deliverableStage.groupBy({
by: ["status"],
where: { deliverable: { project: { organizationId } } },
_count: true,
}),
// Overdue deliverables
prisma.deliverable.findMany({
where: {
project: { organizationId },
dueDate: { lt: new Date() },
status: { notIn: ["APPROVED", "ON_HOLD"] },
},
select: {
id: true,
name: true,
status: true,
priority: true,
dueDate: true,
project: { select: { id: true, name: true } },
},
orderBy: { dueDate: "asc" },
take: 20,
}),
// Recent stage completions (last 30 days)
prisma.deliverableStage.findMany({
where: {
deliverable: { project: { organizationId } },
status: "APPROVED",
completedDate: {
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
},
},
select: {
id: true,
completedDate: true,
template: { select: { name: true } },
deliverable: {
select: {
name: true,
project: { select: { name: true } },
},
},
},
orderBy: { completedDate: "desc" },
take: 10,
}),
// Stage completion rates by template
prisma.deliverableStage.groupBy({
by: ["templateId", "status"],
where: { deliverable: { project: { organizationId } } },
_count: true,
}),
]);
// Calculate totals
const totalProjects = projectStats.reduce((sum, s) => sum + s._count, 0);
const activeProjects = projectStats
.filter((s) => s.status === "ACTIVE")
.reduce((sum, s) => sum + s._count, 0);
const totalDeliverables = deliverableStats.reduce((sum, s) => sum + s._count, 0);
const totalStages = stageStats.reduce((sum, s) => sum + s._count, 0);
const approvedStages = stageStats
.filter((s) => s.status === "APPROVED")
.reduce((sum, s) => sum + s._count, 0);
const skippedStages = stageStats
.filter((s) => s.status === "SKIPPED")
.reduce((sum, s) => sum + s._count, 0);
const completionRate =
totalStages > 0
? Math.round(((approvedStages + skippedStages) / totalStages) * 100)
: 0;
// Get template names for the chart
const templates = await prisma.pipelineStageTemplate.findMany({
orderBy: { order: "asc" },
select: { id: true, name: true, order: true },
});
const templateMap = new Map(templates.map((t) => [t.id, t]));
// Build per-template stats for chart
const templateStats = templates.map((template) => {
const entries = stagesByTemplate.filter(
(s) => s.templateId === template.id
);
const total = entries.reduce((sum, e) => sum + e._count, 0);
const approved =
entries.find((e) => e.status === "APPROVED")?._count ?? 0;
const inProgress =
entries.find((e) => e.status === "IN_PROGRESS")?._count ?? 0;
const blocked =
entries.find((e) => e.status === "BLOCKED")?._count ?? 0;
return {
name: template.name,
order: template.order,
total,
approved,
inProgress,
blocked,
};
});
return {
kpis: {
totalProjects,
activeProjects,
totalDeliverables,
overdueCount: overdueDeliverables.length,
completionRate,
approvedStages,
totalStages,
},
deliverablesByStatus: deliverableStats.map((s) => ({
status: s.status,
count: s._count,
})),
stagesByStatus: stageStats.map((s) => ({
status: s.status,
count: s._count,
})),
templateStats,
overdueDeliverables,
recentActivity,
};
}