feat(timeline): implement production timeline view and API integration

This commit is contained in:
Leivur Djurhuus 2026-03-03 15:54:17 -06:00
parent c8f88c6ab8
commit fd92956e9d
5 changed files with 974 additions and 0 deletions

View 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>
);
}

View 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);
}
}

View file

@ -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 },
];

View 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
View 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();
},
});
}