feat(timeline): implement production timeline view and API integration
This commit is contained in:
parent
c8f88c6ab8
commit
fd92956e9d
5 changed files with 974 additions and 0 deletions
241
src/app/(app)/timeline/page.tsx
Normal file
241
src/app/(app)/timeline/page.tsx
Normal file
|
|
@ -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<ZoomLevel, string> = {
|
||||
day: "Day",
|
||||
week: "Week",
|
||||
month: "Month",
|
||||
};
|
||||
|
||||
export default function TimelinePage() {
|
||||
const [zoom, setZoom] = useState<ZoomLevel>("week");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("ACTIVE");
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>("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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<GanttChart className="h-6 w-6 text-[var(--primary)]" />
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-bold">
|
||||
Production Timeline
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Cross-project Gantt view of all production activity
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Summary */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--primary)]/10 text-[var(--primary)]">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-bold">
|
||||
{isLoading ? "—" : totalProjects}
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
Projects
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<Package className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-bold">
|
||||
{isLoading ? "—" : totalDeliverables}
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
Deliverables
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-bold">
|
||||
{isLoading ? "—" : `${avgCompletion}%`}
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
Avg Completion
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg",
|
||||
totalOverdue > 0
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: "bg-emerald-500/10 text-emerald-500"
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-bold">
|
||||
{isLoading ? "—" : totalOverdue}
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
Overdue Deliverables
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-1 rounded-lg border p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => cycleZoom("out")}
|
||||
disabled={zoom === "month"}
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="px-2 text-xs font-medium min-w-[50px] text-center">
|
||||
{ZOOM_LABELS[zoom]}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => cycleZoom("in")}
|
||||
disabled={zoom === "day"}
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||
<SelectItem value="ON_HOLD">On Hold</SelectItem>
|
||||
<SelectItem value="COMPLETED">Completed</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Priority filter */}
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Priorities</SelectItem>
|
||||
<SelectItem value="URGENT">Urgent</SelectItem>
|
||||
<SelectItem value="HIGH">High</SelectItem>
|
||||
<SelectItem value="MEDIUM">Medium</SelectItem>
|
||||
<SelectItem value="LOW">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<ProductionTimeline projects={projectList} zoom={zoom} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/app/api/timeline/route.ts
Normal file
135
src/app/api/timeline/route.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
];
|
||||
|
||||
|
|
|
|||
571
src/components/views/production-timeline.tsx
Normal file
571
src/components/views/production-timeline.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<Set<string>>(
|
||||
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 (
|
||||
<div className="py-16 text-center text-[var(--muted-foreground)]">
|
||||
<p>No projects match the current filters.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-auto rounded-lg border bg-[var(--card)]"
|
||||
role="region"
|
||||
aria-label="Production timeline"
|
||||
>
|
||||
<div className="flex" style={{ minWidth: LABEL_WIDTH + chartWidth }}>
|
||||
{/* ─── Label Column ───────────────────────── */}
|
||||
<div
|
||||
className="shrink-0 border-r bg-[var(--card)]"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="sticky top-0 z-20 flex items-center border-b bg-[var(--muted)]/50 px-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
Project / Deliverable
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{rows.map((row) => {
|
||||
if (row.type === "project") {
|
||||
const p = row.project;
|
||||
const isExpanded = expandedProjects.has(p.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`proj-${p.id}`}
|
||||
className="flex items-center gap-2 border-b px-2 cursor-pointer hover:bg-[var(--muted)]/30 transition-colors"
|
||||
style={{ height: PROJECT_ROW_HEIGHT }}
|
||||
onClick={() => toggleProject(p.id)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-[var(--muted-foreground)]" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{p.name}
|
||||
</span>
|
||||
{p.overdueDeliverables > 0 && (
|
||||
<AlertTriangle className="h-3 w-3 shrink-0 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] font-mono text-[var(--muted-foreground)]">
|
||||
{p.projectCode}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-3.5 text-[8px] px-1",
|
||||
PROJECT_STATUS_STYLES[p.status] || ""
|
||||
)}
|
||||
>
|
||||
{p.status}
|
||||
</Badge>
|
||||
<span className="text-[8px] text-[var(--muted-foreground)]">
|
||||
{p.completionPercent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[9px] text-[var(--muted-foreground)] shrink-0">
|
||||
{p.deliverableCount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Deliverable row
|
||||
const d = row.deliverable!;
|
||||
return (
|
||||
<div
|
||||
key={`deliv-${d.id}`}
|
||||
className="flex items-center gap-2 border-b pl-8 pr-3 hover:bg-[var(--muted)]/20 transition-colors"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
<Link
|
||||
href={`/projects/${row.project.id}/deliverables/${d.id}`}
|
||||
className="text-[11px] truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{d.name}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ─── Chart Area ─────────────────────────── */}
|
||||
<div className="flex-1 relative" style={{ width: chartWidth }}>
|
||||
{/* Header with date labels */}
|
||||
<div
|
||||
className="sticky top-0 z-20 relative border-b bg-[var(--muted)]/50"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
{headerLabels.map((label) => (
|
||||
<div
|
||||
key={label.dayIndex}
|
||||
className="absolute border-l border-[var(--border)]/50 flex items-center pl-1.5 text-[10px] text-[var(--muted-foreground)]"
|
||||
style={{
|
||||
left: label.dayIndex * DAY_WIDTH,
|
||||
width: label.spanDays * DAY_WIDTH,
|
||||
height: HEADER_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{label.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Today line */}
|
||||
<div
|
||||
className="absolute z-10 top-0 w-px bg-[var(--accent)]"
|
||||
style={{
|
||||
left: todayOffset * DAY_WIDTH,
|
||||
height: rows.length * PROJECT_ROW_HEIGHT + HEADER_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
{/* Today label */}
|
||||
<div
|
||||
className="absolute z-20 text-[8px] font-bold text-[var(--accent)] bg-[var(--accent)]/15 px-1 rounded"
|
||||
style={{
|
||||
left: todayOffset * DAY_WIDTH - 12,
|
||||
top: 2,
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
key={`proj-chart-${p.id}`}
|
||||
className="relative border-b"
|
||||
style={{ height: PROJECT_ROW_HEIGHT }}
|
||||
>
|
||||
{/* Background grid lines */}
|
||||
{headerLabels.map((label) => (
|
||||
<div
|
||||
key={label.dayIndex}
|
||||
className="absolute top-0 h-full border-l border-[var(--border)]/20"
|
||||
style={{ left: label.dayIndex * DAY_WIDTH }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Summary bar */}
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-2 rounded-sm transition-opacity hover:opacity-100",
|
||||
isExpanded ? "opacity-30 h-1.5" : "opacity-80 h-5"
|
||||
)}
|
||||
style={{
|
||||
left: barStart * DAY_WIDTH,
|
||||
width: barWidth,
|
||||
background: `linear-gradient(90deg, var(--status-approved) ${p.completionPercent}%, var(--status-in-progress) ${p.completionPercent}%)`,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-semibold">{p.name}</p>
|
||||
<p className="text-xs">
|
||||
{p.completedStages}/{p.totalStages} stages ({p.completionPercent}%)
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{p.deliverableCount} deliverables
|
||||
{p.overdueDeliverables > 0 && (
|
||||
<span className="text-red-400 ml-1">
|
||||
({p.overdueDeliverables} overdue)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Due date diamond */}
|
||||
{dueDayOffset !== null && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-2.5 h-3 w-3 rotate-45 border",
|
||||
isOverdue
|
||||
? "bg-red-500 border-red-600"
|
||||
: "bg-amber-400 border-amber-500"
|
||||
)}
|
||||
style={{ left: dueDayOffset * DAY_WIDTH - 6 }}
|
||||
title={`Due: ${format(new Date(p.dueDate!), "MMM d, yyyy")}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No dates — empty row
|
||||
return (
|
||||
<div
|
||||
key={`proj-chart-${p.id}`}
|
||||
className="relative border-b"
|
||||
style={{ height: PROJECT_ROW_HEIGHT }}
|
||||
>
|
||||
{headerLabels.map((label) => (
|
||||
<div
|
||||
key={label.dayIndex}
|
||||
className="absolute top-0 h-full border-l border-[var(--border)]/20"
|
||||
style={{ left: label.dayIndex * DAY_WIDTH }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
key={`deliv-chart-${d.id}`}
|
||||
className="relative border-b"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{headerLabels.map((label) => (
|
||||
<div
|
||||
key={label.dayIndex}
|
||||
className="absolute top-0 h-full border-l border-[var(--border)]/10"
|
||||
style={{ left: label.dayIndex * DAY_WIDTH }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Stage bars */}
|
||||
{activeBars.map(({ stage, left, width, isOverdue }) => (
|
||||
<Tooltip key={stage.id} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1.5 rounded-sm transition-opacity hover:opacity-100",
|
||||
isOverdue
|
||||
? "opacity-90 ring-1 ring-red-500/60 ring-offset-1 ring-offset-[var(--card)]"
|
||||
: "opacity-80"
|
||||
)}
|
||||
style={{
|
||||
left: left * DAY_WIDTH,
|
||||
width: width * DAY_WIDTH,
|
||||
height: ROW_HEIGHT - 12,
|
||||
backgroundColor:
|
||||
STATUS_COLORS[stage.status] ?? "var(--status-not-started)",
|
||||
}}
|
||||
>
|
||||
{/* Artist initials on bar */}
|
||||
{width * DAY_WIDTH > 30 && stage.assignments.length > 0 && (
|
||||
<div className="absolute right-0.5 top-0 flex gap-px">
|
||||
{stage.assignments.map((a) => (
|
||||
<span
|
||||
key={a.user.id}
|
||||
className="text-[7px] font-bold text-white/80"
|
||||
title={a.user.name || ""}
|
||||
>
|
||||
{getInitials(a.user.name)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-semibold">{stage.template.name}</p>
|
||||
<p className="text-xs">{stage.status.replace(/_/g, " ")}</p>
|
||||
{stage.assignments.length > 0 && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{stage.assignments.map((a) => a.user.name).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{isOverdue && (
|
||||
<p className="text-xs text-red-400 font-medium">⚠ Overdue</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/hooks/use-timeline.ts
Normal file
25
src/hooks/use-timeline.ts
Normal file
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue