From 4b6576233e8d5032233b13af7c08a28af623e2bd Mon Sep 17 00:00:00 2001 From: "Leivur R. Djurhuus" Date: Sat, 28 Feb 2026 21:43:55 -0600 Subject: [PATCH] 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 --- src/app/(app)/dashboard/page.tsx | 373 +++++++++++++++++++++++++- src/app/api/dashboard/stats/route.ts | 38 +++ src/lib/services/dashboard-service.ts | 161 +++++++++++ 3 files changed, 566 insertions(+), 6 deletions(-) create mode 100644 src/app/api/dashboard/stats/route.ts create mode 100644 src/lib/services/dashboard-service.ts diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index e008e63..ef3c4f1 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -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 = { + 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 = { + 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 ( -
-

Dashboard

-

- Production pipeline overview — KPIs and charts coming in Phase 3. -

+ + +
+ +
+
+

{value}

+

{title}

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
+
+ ); +} + +export default function DashboardPage() { + const { data, isLoading } = useDashboardStats(); + + const stats = data as any; + + if (isLoading) { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ + +
+
+ ); + } + + 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 ( +
+
+

Dashboard

+

+ Production pipeline overview +

+
+ + {/* KPI Cards */} +
+ + + 0} + /> + +
+ + {/* Charts */} +
+ {/* Deliverables by Status */} + + + + Deliverables by Status + + + + {deliverablesByStatus.length === 0 ? ( +

+ No data yet +

+ ) : ( + + + + {deliverablesByStatus.map((entry: any, i: number) => ( + + ))} + + + + + + )} +
+
+ + {/* Stage Completion by Template */} + + + + Stage Completion by Type + + + + {templateStats.length === 0 ? ( +

+ No data yet +

+ ) : ( + + + + + + + + + + + + )} +
+
+
+ + {/* Overdue Deliverables */} + + + + + Overdue Deliverables + + + + {overdueDeliverables.length === 0 ? ( +
+ + No overdue deliverables +
+ ) : ( +
+ {overdueDeliverables.map((deliv: any) => ( +
+
+ + {deliv.name} + +

+ {deliv.project.name} +

+
+ + + {deliv.priority} + + + Due {format(new Date(deliv.dueDate), "MMM d")} + +
+ ))} +
+ )} +
+
+ + {/* Recent Activity */} + + + + + Recent Completions + + + + {recentActivity.length === 0 ? ( +

+ No recent activity +

+ ) : ( +
+ {recentActivity.map((item: any) => ( +
+ + {item.template.name} + + on {item.deliverable.name} + + + {item.completedDate + ? format(new Date(item.completedDate), "MMM d") + : ""} + +
+ ))} +
+ )} +
+
); } diff --git a/src/app/api/dashboard/stats/route.ts b/src/app/api/dashboard/stats/route.ts new file mode 100644 index 0000000..a8b2a1c --- /dev/null +++ b/src/app/api/dashboard/stats/route.ts @@ -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); + } +} diff --git a/src/lib/services/dashboard-service.ts b/src/lib/services/dashboard-service.ts new file mode 100644 index 0000000..6f02e4c --- /dev/null +++ b/src/lib/services/dashboard-service.ts @@ -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, + }; +}