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, + }; +}