diff --git a/src/app/(app)/timeline/page.tsx b/src/app/(app)/timeline/page.tsx new file mode 100644 index 0000000..1d64765 --- /dev/null +++ b/src/app/(app)/timeline/page.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useState } from "react"; +import dynamic from "next/dynamic"; +import { + GanttChart, + ZoomIn, + ZoomOut, + AlertTriangle, + FolderOpen, + Package, + CheckCircle2, +} from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTimeline } from "@/hooks/use-timeline"; +import type { ZoomLevel } from "@/components/views/production-timeline"; +import { cn } from "@/lib/utils"; + +const ProductionTimeline = dynamic( + () => + import("@/components/views/production-timeline").then( + (m) => m.ProductionTimeline + ), + { ssr: false } +); + +const ZOOM_LABELS: Record = { + day: "Day", + week: "Week", + month: "Month", +}; + +export default function TimelinePage() { + const [zoom, setZoom] = useState("week"); + const [statusFilter, setStatusFilter] = useState("ACTIVE"); + const [priorityFilter, setPriorityFilter] = useState("all"); + + const { data: projects, isLoading } = useTimeline({ + status: statusFilter !== "all" ? statusFilter : undefined, + priority: priorityFilter !== "all" ? priorityFilter : undefined, + }); + + const projectList = (projects as any[]) ?? []; + + // Summary stats + const totalProjects = projectList.length; + const totalDeliverables = projectList.reduce( + (sum: number, p: any) => sum + (p.deliverableCount || 0), + 0 + ); + const totalOverdue = projectList.reduce( + (sum: number, p: any) => sum + (p.overdueDeliverables || 0), + 0 + ); + const avgCompletion = + totalProjects > 0 + ? Math.round( + projectList.reduce( + (sum: number, p: any) => sum + (p.completionPercent || 0), + 0 + ) / totalProjects + ) + : 0; + + const cycleZoom = (direction: "in" | "out") => { + const levels: ZoomLevel[] = ["month", "week", "day"]; + const current = levels.indexOf(zoom); + if (direction === "in" && current < levels.length - 1) { + setZoom(levels[current + 1]); + } + if (direction === "out" && current > 0) { + setZoom(levels[current - 1]); + } + }; + + return ( +
+ {/* Header */} +
+ +
+

+ Production Timeline +

+

+ Cross-project Gantt view of all production activity +

+
+
+ + {/* KPI Summary */} +
+ + +
+ +
+
+

+ {isLoading ? "—" : totalProjects} +

+

+ Projects +

+
+
+
+ + + +
+ +
+
+

+ {isLoading ? "—" : totalDeliverables} +

+

+ Deliverables +

+
+
+
+ + + +
+ +
+
+

+ {isLoading ? "—" : `${avgCompletion}%`} +

+

+ Avg Completion +

+
+
+
+ + + +
0 + ? "bg-red-500/10 text-red-500" + : "bg-emerald-500/10 text-emerald-500" + )} + > + +
+
+

+ {isLoading ? "—" : totalOverdue} +

+

+ Overdue Deliverables +

+
+
+
+
+ + {/* Controls */} +
+ {/* Zoom controls */} +
+ + + {ZOOM_LABELS[zoom]} + + +
+ + {/* Status filter */} + + + {/* Priority filter */} + +
+ + {/* Timeline */} + {isLoading ? ( +
+ + +
+ ) : ( + + )} +
+ ); +} diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts new file mode 100644 index 0000000..11a64b7 --- /dev/null +++ b/src/app/api/timeline/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthSession, serverError } from "@/lib/api-utils"; +import { prisma } from "@/lib/prisma"; + +// GET /api/timeline?status=ACTIVE&priority=&projectIds= +export async function GET(request: NextRequest) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const { searchParams } = new URL(request.url); + const statusFilter = searchParams.get("status") || undefined; + const priorityFilter = searchParams.get("priority") || undefined; + const projectIdsParam = searchParams.get("projectIds") || undefined; + const projectIds = projectIdsParam ? projectIdsParam.split(",") : undefined; + + const organizationId = session!.user.organizationId!; + + // Build project filter + const projectWhere: any = { organizationId }; + if (statusFilter) projectWhere.status = statusFilter; + if (projectIds && projectIds.length > 0) { + projectWhere.id = { in: projectIds }; + } + + const delivWhere: any = {}; + if (priorityFilter) delivWhere.priority = priorityFilter; + + const projects = await prisma.project.findMany({ + where: projectWhere, + select: { + id: true, + name: true, + projectCode: true, + status: true, + priority: true, + startDate: true, + dueDate: true, + deliverables: { + where: delivWhere, + select: { + id: true, + name: true, + status: true, + priority: true, + dueDate: true, + stages: { + select: { + id: true, + status: true, + startDate: true, + completedDate: true, + dueDate: true, + template: { + select: { + name: true, + slug: true, + order: true, + }, + }, + assignments: { + select: { + user: { + select: { + id: true, + name: true, + image: true, + }, + }, + }, + take: 2, + }, + }, + orderBy: { template: { order: "asc" } }, + }, + }, + orderBy: { name: "asc" }, + }, + }, + orderBy: [{ priority: "asc" }, { dueDate: "asc" }, { name: "asc" }], + }); + + // Compute project-level date ranges for summary bars + const result = projects.map((project) => { + let earliestStart: Date | null = null; + let latestEnd: Date | null = null; + let totalStages = 0; + let completedStages = 0; + let overdueDeliverables = 0; + + for (const deliv of project.deliverables) { + if ( + deliv.dueDate && + new Date(deliv.dueDate) < new Date() && + deliv.status !== "APPROVED" + ) { + overdueDeliverables++; + } + + for (const stage of deliv.stages) { + totalStages++; + if (["APPROVED", "DELIVERED", "SKIPPED"].includes(stage.status)) { + completedStages++; + } + + if (stage.startDate) { + const sd = new Date(stage.startDate); + if (!earliestStart || sd < earliestStart) earliestStart = sd; + } + const endDate = stage.completedDate || stage.dueDate; + if (endDate) { + const ed = new Date(endDate); + if (!latestEnd || ed > latestEnd) latestEnd = ed; + } + } + } + + return { + ...project, + earliestStart: earliestStart?.toISOString() ?? null, + latestEnd: latestEnd?.toISOString() ?? null, + totalStages, + completedStages, + completionPercent: + totalStages > 0 ? Math.round((completedStages / totalStages) * 100) : 0, + overdueDeliverables, + deliverableCount: project.deliverables.length, + }; + }); + + return NextResponse.json(result); + } catch (e) { + return serverError(e); + } +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index c9408b0..df6259e 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -8,6 +8,7 @@ import { ClipboardList, Bell, Users, + GanttChart, Settings, PanelLeftClose, PanelLeft, @@ -25,6 +26,7 @@ const navItems = [ { href: "/projects", label: "Projects", icon: FolderKanban }, { href: "/my-work", label: "My Work", icon: ClipboardList }, { href: "/workload", label: "Workload", icon: Users }, + { href: "/timeline", label: "Timeline", icon: GanttChart }, { href: "/notifications", label: "Notifications", icon: Bell }, ]; diff --git a/src/components/views/production-timeline.tsx b/src/components/views/production-timeline.tsx new file mode 100644 index 0000000..6ce1f7f --- /dev/null +++ b/src/components/views/production-timeline.tsx @@ -0,0 +1,571 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { + differenceInDays, + addDays, + startOfWeek, + endOfWeek, + startOfMonth, + format, + max, + min, +} from "date-fns"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { ChevronRight, ChevronDown, AlertTriangle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// ─── Constants ────────────────────────────────────────── + +const STATUS_COLORS: Record = { + BLOCKED: "var(--status-blocked)", + NOT_STARTED: "var(--status-not-started)", + IN_PROGRESS: "var(--status-in-progress)", + IN_REVIEW: "var(--status-in-review)", + CHANGES_REQUESTED: "var(--status-in-review)", + APPROVED: "var(--status-approved)", + DELIVERED: "var(--status-approved)", + SKIPPED: "var(--status-skipped)", +}; + +const PROJECT_STATUS_STYLES: Record = { + ACTIVE: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", + ON_HOLD: "bg-amber-500/15 text-amber-600 dark:text-amber-400", + COMPLETED: "bg-blue-500/15 text-blue-600 dark:text-blue-400", + ARCHIVED: "bg-gray-500/15 text-gray-500", +}; + +const LABEL_WIDTH = 280; +const ROW_HEIGHT = 32; +const PROJECT_ROW_HEIGHT = 38; +const HEADER_HEIGHT = 44; + +// ─── Types ────────────────────────────────────────────── + +interface Stage { + id: string; + status: string; + startDate: string | null; + completedDate: string | null; + dueDate: string | null; + template: { name: string; slug: string; order: number }; + assignments: { user: { id: string; name: string | null; image: string | null } }[]; +} + +interface Deliverable { + id: string; + name: string; + status: string; + priority: string; + dueDate: string | null; + stages: Stage[]; +} + +interface TimelineProject { + id: string; + name: string; + projectCode: string; + status: string; + priority: string; + dueDate: string | null; + earliestStart: string | null; + latestEnd: string | null; + totalStages: number; + completedStages: number; + completionPercent: number; + overdueDeliverables: number; + deliverableCount: number; + deliverables: Deliverable[]; +} + +export type ZoomLevel = "day" | "week" | "month"; + +interface ProductionTimelineProps { + projects: TimelineProject[]; + zoom?: ZoomLevel; +} + +// ─── Helpers ──────────────────────────────────────────── + +function getDayWidth(zoom: ZoomLevel): number { + switch (zoom) { + case "day": return 32; + case "week": return 14; + case "month": return 5; + } +} + +function getInitials(name: string | null): string { + if (!name) return "?"; + return name + .split(" ") + .map((n) => n[0]) + .join("") + .substring(0, 2) + .toUpperCase(); +} + +// ─── Component ────────────────────────────────────────── + +export function ProductionTimeline({ + projects, + zoom = "week", +}: ProductionTimelineProps) { + const [expandedProjects, setExpandedProjects] = useState>( + new Set() + ); + + const DAY_WIDTH = getDayWidth(zoom); + + const toggleProject = (projectId: string) => { + setExpandedProjects((prev) => { + const next = new Set(prev); + if (next.has(projectId)) { + next.delete(projectId); + } else { + next.add(projectId); + } + return next; + }); + }; + + // Calculate the overall date range across all projects + const { rangeStart, rangeEnd, totalDays } = useMemo(() => { + const now = new Date(); + let earliest = now; + let latest = addDays(now, 30); + + for (const project of projects) { + for (const deliv of project.deliverables) { + for (const stage of deliv.stages) { + if (stage.startDate) { + earliest = min([earliest, new Date(stage.startDate)]); + } + if (stage.dueDate) { + latest = max([latest, new Date(stage.dueDate)]); + } + if (stage.completedDate) { + latest = max([latest, new Date(stage.completedDate)]); + } + } + if (deliv.dueDate) { + latest = max([latest, new Date(deliv.dueDate)]); + } + } + if (project.dueDate) { + latest = max([latest, new Date(project.dueDate)]); + } + } + + const rangeStart = startOfWeek(addDays(earliest, -7), { weekStartsOn: 1 }); + const rangeEnd = endOfWeek(addDays(latest, 14), { weekStartsOn: 1 }); + const totalDays = differenceInDays(rangeEnd, rangeStart) + 1; + + return { rangeStart, rangeEnd, totalDays }; + }, [projects]); + + // Build header labels based on zoom + const headerLabels = useMemo(() => { + const labels: { label: string; dayIndex: number; spanDays: number }[] = []; + + if (zoom === "day" || zoom === "week") { + let d = rangeStart; + while (d <= rangeEnd) { + labels.push({ + label: format(d, "MMM d"), + dayIndex: differenceInDays(d, rangeStart), + spanDays: 7, + }); + d = addDays(d, 7); + } + } else { + // Month + let d = startOfMonth(rangeStart); + while (d <= rangeEnd) { + const dayIndex = Math.max(0, differenceInDays(d, rangeStart)); + const nextMonth = startOfMonth(addDays(d, 32)); + const spanDays = differenceInDays(nextMonth, d); + labels.push({ + label: format(d, "MMM yyyy"), + dayIndex, + spanDays, + }); + d = nextMonth; + } + } + + return labels; + }, [rangeStart, rangeEnd, zoom]); + + // Today marker position + const todayOffset = differenceInDays(new Date(), rangeStart); + const chartWidth = totalDays * DAY_WIDTH; + + // Build all rows + const rows: { + type: "project" | "deliverable"; + project: TimelineProject; + deliverable?: Deliverable; + }[] = []; + + for (const project of projects) { + rows.push({ type: "project", project }); + if (expandedProjects.has(project.id)) { + for (const deliv of project.deliverables) { + rows.push({ type: "deliverable", project, deliverable: deliv }); + } + } + } + + if (projects.length === 0) { + return ( +
+

No projects match the current filters.

+
+ ); + } + + return ( +
+
+ {/* ─── Label Column ───────────────────────── */} +
+ {/* Header */} +
+ Project / Deliverable +
+ + {/* Rows */} + {rows.map((row) => { + if (row.type === "project") { + const p = row.project; + const isExpanded = expandedProjects.has(p.id); + + return ( +
toggleProject(p.id)} + > + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {p.name} + + {p.overdueDeliverables > 0 && ( + + )} +
+
+ + {p.projectCode} + + + {p.status} + + + {p.completionPercent}% + +
+
+ + {p.deliverableCount} + +
+ ); + } + + // Deliverable row + const d = row.deliverable!; + return ( +
+ e.stopPropagation()} + > + {d.name} + +
+ ); + })} +
+ + {/* ─── Chart Area ─────────────────────────── */} +
+ {/* Header with date labels */} +
+ {headerLabels.map((label) => ( +
+ {label.label} +
+ ))} +
+ + {/* Today line */} +
+ {/* Today label */} +
+ Today +
+ + {/* Rows */} + {rows.map((row) => { + if (row.type === "project") { + const p = row.project; + const isExpanded = expandedProjects.has(p.id); + + // Project summary bar + if (p.earliestStart && p.latestEnd) { + const barStart = Math.max( + 0, + differenceInDays(new Date(p.earliestStart), rangeStart) + ); + const barEnd = differenceInDays( + new Date(p.latestEnd), + rangeStart + ); + const barWidth = Math.max((barEnd - barStart) * DAY_WIDTH, 4); + + // Due date marker + const dueDayOffset = p.dueDate + ? differenceInDays(new Date(p.dueDate), rangeStart) + : null; + const isOverdue = + p.dueDate && new Date(p.dueDate) < new Date() && p.status !== "COMPLETED"; + + return ( +
+ {/* Background grid lines */} + {headerLabels.map((label) => ( +
+ ))} + + {/* Summary bar */} + + +
+ + +

{p.name}

+

+ {p.completedStages}/{p.totalStages} stages ({p.completionPercent}%) +

+

+ {p.deliverableCount} deliverables + {p.overdueDeliverables > 0 && ( + + ({p.overdueDeliverables} overdue) + + )} +

+
+ + + {/* Due date diamond */} + {dueDayOffset !== null && ( +
+ )} +
+ ); + } + + // No dates — empty row + return ( +
+ {headerLabels.map((label) => ( +
+ ))} +
+ ); + } + + // Deliverable row with stage bars + const d = row.deliverable!; + const activeBars = d.stages + .filter((s) => s.status !== "BLOCKED" && s.status !== "SKIPPED") + .map((stage) => { + const start = stage.startDate + ? new Date(stage.startDate) + : new Date(); + const end = stage.completedDate + ? new Date(stage.completedDate) + : stage.dueDate + ? new Date(stage.dueDate) + : addDays(start, 3); + + const left = differenceInDays(start, rangeStart); + const width = Math.max(differenceInDays(end, start), 1); + + // Check if overdue + const isOverdue = + stage.dueDate && + new Date(stage.dueDate) < new Date() && + !["APPROVED", "DELIVERED", "SKIPPED"].includes(stage.status); + + return { stage, left, width, isOverdue }; + }); + + return ( +
+ {/* Grid lines */} + {headerLabels.map((label) => ( +
+ ))} + + {/* Stage bars */} + {activeBars.map(({ stage, left, width, isOverdue }) => ( + + +
+ {/* Artist initials on bar */} + {width * DAY_WIDTH > 30 && stage.assignments.length > 0 && ( +
+ {stage.assignments.map((a) => ( + + {getInitials(a.user.name)} + + ))} +
+ )} +
+
+ +

{stage.template.name}

+

{stage.status.replace(/_/g, " ")}

+ {stage.assignments.length > 0 && ( +

+ {stage.assignments.map((a) => a.user.name).join(", ")} +

+ )} + {isOverdue && ( +

⚠ Overdue

+ )} +
+
+ ))} +
+ ); + })} +
+
+
+ ); +} diff --git a/src/hooks/use-timeline.ts b/src/hooks/use-timeline.ts new file mode 100644 index 0000000..e79950b --- /dev/null +++ b/src/hooks/use-timeline.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +export function useTimeline(options?: { + status?: string; + priority?: string; + projectIds?: string[]; +}) { + const params = new URLSearchParams(); + if (options?.status) params.set("status", options.status); + if (options?.priority) params.set("priority", options.priority); + if (options?.projectIds?.length) params.set("projectIds", options.projectIds.join(",")); + + const qs = params.toString(); + + return useQuery({ + queryKey: ["timeline", options?.status, options?.priority, options?.projectIds], + queryFn: async () => { + const res = await fetch(`/api/timeline${qs ? `?${qs}` : ""}`); + if (!res.ok) throw new Error("Failed to fetch timeline data"); + return res.json(); + }, + }); +}