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:
parent
4f841c73b7
commit
4b6576233e
3 changed files with 566 additions and 6 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
38
src/app/api/dashboard/stats/route.ts
Normal file
38
src/app/api/dashboard/stats/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
161
src/lib/services/dashboard-service.ts
Normal file
161
src/lib/services/dashboard-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue