Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useDroppable, useDraggable } from "@dnd-kit/core";
import { format, parseISO, formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
import {
CalendarDays,
Clock,
GripVertical,
Layers,
ChevronDown,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { TaskStatus, TaskType } from "@prisma/client";
import {
ScheduleTask,
ActiveDragData,
} from "@/app/(dashboard)/schedule/SchedulePageClient";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-zinc-500",
NORMAL: "bg-blue-500",
HIGH: "bg-amber-500",
URGENT: "bg-red-500",
};
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
function BacklogItem({
task,
canEdit,
}: {
task: ScheduleTask;
canEdit: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: task.id,
disabled: !canEdit,
data: {
type: "backlog",
taskId: task.id,
estimatedHours: task.estimatedHours,
} as ActiveDragData,
});
const cfg = TASK_STATUS_CONFIG[task.status];
const StatusIcon = cfg.icon;
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
const dueDate = toDate(task.dueDate);
const isOverdue =
dueDate && dueDate < new Date() && task.status !== "DONE";
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: 50,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group flex items-start gap-2 px-3 py-2.5 rounded-lg border transition-colors",
"border-zinc-800 bg-zinc-900 hover:border-zinc-700 hover:bg-zinc-800/80",
isDragging && "opacity-30 border-amber-500/40",
canEdit && "cursor-grab active:cursor-grabbing"
)}
{...listeners}
{...attributes}
>
{/* Grip */}
{canEdit && (
<GripVertical className="h-3.5 w-3.5 text-zinc-600 shrink-0 mt-0.5 group-hover:text-zinc-400" />
)}
{/* Priority dot */}
<div
className={cn(
"w-1.5 h-1.5 rounded-full shrink-0 mt-1.5",
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
)}
/>
{/* Content */}
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-1.5">
{contextCode && (
<span className="text-[10px] font-mono bg-zinc-800 text-zinc-400 px-1.5 py-0.5 rounded shrink-0">
{contextCode}
</span>
)}
<span className="text-xs font-medium text-zinc-200 truncate">
{task.title}
</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-zinc-500">
{TASK_TYPE_LABELS[task.type as TaskType]} · {task.project.code}
</span>
{task.estimatedHours && (
<span className="flex items-center gap-0.5 text-[10px] text-zinc-600">
<Clock className="h-2.5 w-2.5" />
{task.estimatedHours}h
</span>
)}
</div>
<div className="flex items-center gap-2">
<Badge
className={cn(
"text-[9px] border px-1 py-0 h-3.5 gap-0.5 shrink-0",
cfg.color
)}
>
<StatusIcon className="h-2 w-2" />
{cfg.label}
</Badge>
{dueDate && (
<span
className={cn(
"flex items-center gap-0.5 text-[10px]",
isOverdue ? "text-red-400" : "text-zinc-600"
)}
>
<CalendarDays className="h-2.5 w-2.5" />
{formatDistanceToNow(dueDate, { addSuffix: true })}
</span>
)}
</div>
</div>
</div>
);
}
interface BacklogPanelProps {
tasks: ScheduleTask[];
canEdit: boolean;
}
export function BacklogPanel({ tasks, canEdit }: BacklogPanelProps) {
const { setNodeRef, isOver } = useDroppable({
id: "backlog-drop-zone",
});
return (
<div
className={cn(
"flex flex-col border-l border-zinc-800 bg-zinc-900 transition-colors",
"w-72 shrink-0"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-zinc-800 shrink-0">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-zinc-400" />
<span className="text-sm font-medium text-zinc-200">Backlog</span>
<span className="text-xs text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded-full">
{tasks.length}
</span>
</div>
{canEdit && (
<span className="text-[10px] text-zinc-600">
Drag onto timeline
</span>
)}
</div>
{/* Drop zone + list */}
<div
ref={setNodeRef}
className={cn(
"flex-1 transition-colors",
isOver && "bg-amber-500/5 ring-1 ring-inset ring-amber-500/20"
)}
>
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-zinc-600">
<Layers className="h-8 w-8 mb-2 opacity-30" />
<p className="text-xs">All tasks scheduled</p>
{canEdit && (
<p className="text-[10px] mt-1 text-zinc-700">
Drop here to unschedule
</p>
)}
</div>
) : (
<ScrollArea className="h-full">
<div className="p-2 space-y-1.5">
{canEdit && isOver && (
<div className="flex items-center justify-center py-3 rounded-lg border border-dashed border-amber-500/40 text-amber-400 text-xs">
Drop to unschedule
</div>
)}
{tasks.map((task) => (
<BacklogItem key={task.id} task={task} canEdit={canEdit} />
))}
</div>
</ScrollArea>
)}
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { addWeeks, subWeeks, addDays, format } from "date-fns";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, CalendarDays } from "lucide-react";
import { ScheduleArtist } from "@/app/(dashboard)/schedule/SchedulePageClient";
const STATUS_OPTIONS = [
{ value: "TODO", label: "To Do" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "INTERNAL_REVIEW", label: "Internal Review" },
{ value: "CLIENT_REVIEW", label: "Client Review" },
{ value: "CHANGES", label: "Changes" },
];
interface ScheduleFiltersProps {
projects: { id: string; name: string; code: string }[];
artists: ScheduleArtist[];
filterProject: string;
filterArtist: string;
filterStatus: string;
viewStart: Date;
onProjectChange: (v: string) => void;
onArtistChange: (v: string) => void;
onStatusChange: (v: string) => void;
onViewStartChange: (d: Date) => void;
}
export function ScheduleFilters({
projects,
artists,
filterProject,
filterArtist,
filterStatus,
viewStart,
onProjectChange,
onArtistChange,
onStatusChange,
onViewStartChange,
}: ScheduleFiltersProps) {
const goPrev = () => onViewStartChange(subWeeks(viewStart, 1));
const goNext = () => onViewStartChange(addWeeks(viewStart, 1));
const goToday = () => {
const today = new Date();
const monday = addDays(today, -((today.getDay() + 6) % 7));
onViewStartChange(monday);
};
return (
<div className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-950 shrink-0 flex-wrap">
{/* Week navigation */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-zinc-400 hover:text-white"
onClick={goPrev}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-zinc-400 hover:text-white gap-1.5"
onClick={goToday}
>
<CalendarDays className="h-3 w-3" />
Today
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-zinc-400 hover:text-white"
onClick={goNext}
>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="text-xs text-zinc-500 ml-1">
{format(viewStart, "MMM d")} {format(addDays(viewStart, 34), "MMM d, yyyy")}
</span>
</div>
<div className="h-4 w-px bg-zinc-800" />
{/* Project filter */}
<Select
value={filterProject || "__all__"}
onValueChange={(v) => onProjectChange(v === "__all__" ? "" : v)}
>
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="__all__" className="text-xs">
All projects
</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-xs">
[{p.code}] {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Artist filter */}
<Select
value={filterArtist || "__all__"}
onValueChange={(v) => onArtistChange(v === "__all__" ? "" : v)}
>
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
<SelectValue placeholder="All artists" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="__all__" className="text-xs">
All artists
</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id} className="text-xs">
{a.name ?? a.email.split("@")[0]}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status filter */}
<Select
value={filterStatus || "__all__"}
onValueChange={(v) => onStatusChange(v === "__all__" ? "" : v)}
>
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="__all__" className="text-xs">
All statuses
</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value} className="text-xs">
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
{(filterProject || filterArtist || filterStatus) && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-zinc-500 hover:text-white"
onClick={() => {
onProjectChange("");
onArtistChange("");
onStatusChange("");
}}
>
Clear filters
</Button>
)}
</div>
);
}
+300
View File
@@ -0,0 +1,300 @@
"use client";
import { useDraggable } from "@dnd-kit/core";
import {
format,
isAfter,
parseISO,
} from "date-fns";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { CalendarDays, Clock, ExternalLink, CalendarOff } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { TaskStatus, TaskType } from "@prisma/client";
import {
ScheduleTask,
ActiveDragData,
} from "@/app/(dashboard)/schedule/SchedulePageClient";
import { TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
const STATUS_STYLES: Record<
TaskStatus,
{ bg: string; border: string; text: string; dot: string }
> = {
TODO: {
bg: "bg-zinc-800/80",
border: "border-zinc-600/60",
text: "text-zinc-300",
dot: "bg-zinc-500",
},
IN_PROGRESS: {
bg: "bg-blue-900/50",
border: "border-blue-600/60",
text: "text-blue-200",
dot: "bg-blue-400",
},
INTERNAL_REVIEW: {
bg: "bg-purple-900/50",
border: "border-purple-600/60",
text: "text-purple-200",
dot: "bg-purple-400",
},
CLIENT_REVIEW: {
bg: "bg-amber-900/50",
border: "border-amber-600/60",
text: "text-amber-200",
dot: "bg-amber-400",
},
CHANGES: {
bg: "bg-orange-900/50",
border: "border-orange-600/60",
text: "text-orange-200",
dot: "bg-orange-400",
},
DONE: {
bg: "bg-emerald-900/50",
border: "border-emerald-600/60",
text: "text-emerald-200",
dot: "bg-emerald-400",
},
};
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
interface ScheduleTaskBlockProps {
task: ScheduleTask;
dayIndex: number;
duration: number;
artistIndex: number;
viewStart: Date;
days: Date[];
canEdit: boolean;
isDragging: boolean;
laneTop: number;
taskHeight: number;
onResizeMouseDown: (
taskId: string,
currentEndDate: string
) => (e: React.MouseEvent) => void;
onUnschedule: (taskId: string) => void;
dayWidth: number;
}
export function ScheduleTaskBlock({
task,
dayIndex,
duration,
canEdit,
isDragging,
laneTop,
taskHeight,
onResizeMouseDown,
onUnschedule,
dayWidth,
}: ScheduleTaskBlockProps) {
const router = useRouter();
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: task.id,
disabled: !canEdit,
data: {
type: "scheduled",
taskId: task.id,
duration,
} as ActiveDragData,
});
const taskWidth = Math.max(20, (task.estimatedHours ?? 8) / 8 * dayWidth - 4);
const style = {
position: "absolute" as const,
left: Math.max(0, dayIndex) * dayWidth + 2,
width: taskWidth,
top: laneTop,
height: taskHeight,
transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined,
zIndex: isDragging ? 10 : 1,
};
const statusStyle = STATUS_STYLES[task.status];
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
const contextName = task.shot?.shotCode ?? task.asset?.name ?? task.title;
const thumbnail = task.shot?.thumbnailUrl ?? null;
const isOverdue =
task.dueDate &&
task.scheduledEndDate &&
isAfter(
toDate(task.scheduledEndDate)!,
toDate(task.dueDate)!
);
const dueDate = toDate(task.dueDate);
const tooNarrow = (task.estimatedHours ?? 8) < 3; // < 3h = too narrow for labels
const tooShort = taskHeight < 16;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
<div
ref={setNodeRef}
style={style}
className={cn(
"absolute rounded-md border flex items-center overflow-hidden select-none group",
"transition-opacity duration-100",
statusStyle.bg,
statusStyle.border,
isDragging && "opacity-30",
canEdit && "cursor-grab active:cursor-grabbing",
isOverdue && "ring-1 ring-red-500/50"
)}
{...listeners}
{...attributes}
>
{/* Status dot */}
<div
className={cn(
"shrink-0 w-1 self-stretch rounded-l-md",
statusStyle.dot
)}
/>
{/* Thumbnail */}
{thumbnail && !tooNarrow && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={thumbnail}
alt=""
className="shrink-0 h-full object-cover"
style={{ width: taskHeight * 2.39, minWidth: 0 }}
/>
)}
{/* Content */}
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-hidden px-1">
{!tooShort && (
<span
className={cn(
"text-[10px] font-mono font-semibold truncate shrink-0 max-w-[50%]",
statusStyle.text
)}
>
{contextName}
</span>
)}
{!tooNarrow && !tooShort && (
<span className="text-[9px] text-zinc-400 truncate">
{TASK_TYPE_LABELS[task.type as TaskType]}
</span>
)}
{!tooNarrow && !tooShort && task.estimatedHours && (
<span className="text-[9px] text-zinc-500 shrink-0 ml-auto">
{task.estimatedHours}h
</span>
)}
</div>
{/* Overdue indicator */}
{isOverdue && (
<div className="shrink-0 w-1.5 h-1.5 rounded-full bg-red-400 mr-1" />
)}
{/* Resize handle */}
{canEdit && task.scheduledEndDate && (
<div
className="absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={onResizeMouseDown(task.id, task.scheduledEndDate)}
onClick={(e) => e.stopPropagation()}
>
<div className="w-0.5 h-3/4 rounded-full bg-current opacity-50" />
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-zinc-900 border-zinc-700 text-zinc-100 max-w-[240px]"
>
<div className="space-y-1">
<div className="font-medium text-xs">
{contextCode && (
<span className="text-amber-400 mr-1">{contextCode}</span>
)}
{task.title}
</div>
<div className="text-[11px] text-zinc-400">
{TASK_TYPE_LABELS[task.type as TaskType]} ·{" "}
{task.project.code}
</div>
{task.scheduledStartDate && task.scheduledEndDate && (
<div className="flex items-center gap-1 text-[11px] text-zinc-400">
<CalendarDays className="h-3 w-3" />
{format(toDate(task.scheduledStartDate)!, "MMM d")} {" "}
{format(toDate(task.scheduledEndDate)!, "MMM d")}
</div>
)}
{task.estimatedHours && (
<div className="flex items-center gap-1 text-[11px] text-zinc-400">
<Clock className="h-3 w-3" />
{task.estimatedHours}h estimated
</div>
)}
{dueDate && (
<div
className={cn(
"flex items-center gap-1 text-[11px]",
isOverdue ? "text-red-400" : "text-zinc-400"
)}
>
<CalendarDays className="h-3 w-3" />
Due {format(dueDate, "MMM d")}
{isOverdue && " — Late!"}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</ContextMenuTrigger>
<ContextMenuContent className="bg-zinc-900 border-zinc-700 text-zinc-100 w-48">
<ContextMenuItem
className="gap-2 cursor-pointer focus:bg-zinc-800 focus:text-white"
onSelect={() => router.push(`/tasks/${task.id}`)}
>
<ExternalLink className="h-3.5 w-3.5 text-zinc-400" />
View Task
</ContextMenuItem>
{canEdit && (
<>
<ContextMenuSeparator className="bg-zinc-800" />
<ContextMenuItem
className="gap-2 cursor-pointer focus:bg-zinc-800 focus:text-red-400 text-red-400"
onSelect={() => onUnschedule(task.id)}
>
<CalendarOff className="h-3.5 w-3.5" />
Unschedule
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
}
+385
View File
@@ -0,0 +1,385 @@
"use client";
import { useRef, RefObject } from "react";
import { useDroppable } from "@dnd-kit/core";
import {
format,
differenceInDays,
startOfDay,
isToday,
isWeekend,
isBefore,
parseISO,
addDays,
} from "date-fns";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials } from "@/lib/utils";
import { ScheduleTaskBlock } from "./ScheduleTaskBlock";
import {
ScheduleTask,
ScheduleArtist,
DAY_WIDTH,
ROW_HEIGHT,
HEADER_HEIGHT,
} from "@/app/(dashboard)/schedule/SchedulePageClient";
import { AlertTriangle } from "lucide-react";
interface ResizePreview {
taskId: string;
endDate: Date;
estimatedHours: number;
}
interface ScheduleTimelineProps {
artists: ScheduleArtist[];
tasks: ScheduleTask[];
days: Date[];
viewStart: Date;
canEdit: boolean;
timelineRef: RefObject<HTMLDivElement | null>;
resizePreview: ResizePreview | null;
onResizeMouseDown: (
taskId: string,
currentEndDate: string
) => (e: React.MouseEvent) => void;
activeDragId: string | null;
onUnschedule: (taskId: string) => void;
dayWidth: number;
rowHeight: number;
}
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
function getDayIndex(date: Date | null, viewStart: Date): number {
if (!date) return -1;
return differenceInDays(date, viewStart);
}
/** Greedy lane assignment based on hour-ranges so task width = hours.
* Two tasks for the same artist conflict when their [startHour, endHour]
* ranges overlap. Each task occupies exactly one lane (fixed height).
*/
function computeLanes(
tasks: ScheduleTask[],
viewStart: Date
): Map<string, number> {
// Build hour ranges: startHour = dayIndex * 8, endHour = startHour + estimatedHours
const ranges = tasks
.filter((t) => t.scheduledStartDate)
.map((t) => {
const dayIdx = Math.max(
0,
differenceInDays(toDate(t.scheduledStartDate)!, viewStart)
);
const startHour = dayIdx * 8;
const endHour = startHour + (t.estimatedHours ?? 8);
return { id: t.id, startHour, endHour };
})
.sort((a, b) => a.startHour - b.startHour);
const laneEndHours: number[] = [];
const result = new Map<string, number>();
for (const task of ranges) {
let lane = laneEndHours.findIndex((end) => task.startHour >= end);
if (lane === -1) lane = laneEndHours.length;
laneEndHours[lane] = task.endHour;
result.set(task.id, lane);
}
return result;
}
function getTaskDuration(task: ScheduleTask): number {
return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8));
}
// Calculate daily load (hours) for each artist
function calcDailyLoad(
tasks: ScheduleTask[],
artistId: string,
days: Date[]
): Record<string, number> {
const result: Record<string, number> = {};
const artistTasks = tasks.filter((t) => t.assignedArtistId === artistId);
for (const day of days) {
const dayStr = format(day, "yyyy-MM-dd");
let totalHours = 0;
for (const task of artistTasks) {
const start = toDate(task.scheduledStartDate);
const end = toDate(task.scheduledEndDate) ?? start;
if (!start || !end) continue;
const dayStart = new Date(day);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(day);
dayEnd.setHours(23, 59, 59, 999);
if (start <= dayEnd && end >= dayStart) {
const dur = Math.max(1, differenceInDays(end, start) + 1);
const hoursPerDay = (task.estimatedHours ?? 8) / dur;
totalHours += hoursPerDay;
}
}
result[dayStr] = totalHours;
}
return result;
}
function ArtistLabel({
artist,
isOverloaded,
rowHeight,
}: {
artist: ScheduleArtist;
isOverloaded: boolean;
rowHeight: number;
}) {
return (
<div
className="flex items-center gap-2.5 px-3 border-b border-zinc-800 bg-zinc-900"
style={{ height: rowHeight }}
>
<Avatar className="h-7 w-7 shrink-0">
<AvatarImage src={artist.image ?? undefined} />
<AvatarFallback className="text-[10px] bg-zinc-700 text-zinc-300">
{getInitials(artist.name ?? artist.email)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-zinc-200 truncate">
{artist.name ?? artist.email.split("@")[0]}
</span>
{isOverloaded && (
<AlertTriangle className="h-3 w-3 text-orange-400 shrink-0" />
)}
</div>
<span className="text-[10px] text-zinc-500 capitalize">
{artist.role.toLowerCase()}
</span>
</div>
</div>
);
}
function TimelineDropZone({ id }: { id: string }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={cn(
"absolute inset-0 transition-colors pointer-events-none",
isOver && "bg-amber-500/5"
)}
/>
);
}
export function ScheduleTimeline({
artists,
tasks,
days,
viewStart,
canEdit,
timelineRef,
resizePreview,
onResizeMouseDown,
activeDragId,
onUnschedule,
dayWidth,
rowHeight,
}: ScheduleTimelineProps) {
const totalWidth = days.length * dayWidth;
return (
<div className="flex flex-1 overflow-hidden">
{/* Sticky left: artist labels */}
<div
className="flex-shrink-0 bg-zinc-900 border-r border-zinc-800 z-10"
style={{ width: 208 }}
>
{/* Header spacer */}
<div
className="border-b border-zinc-800 bg-zinc-900"
style={{ height: HEADER_HEIGHT }}
/>
{artists.map((artist) => {
const dailyLoad = calcDailyLoad(tasks, artist.id, days);
const maxLoad = Math.max(...Object.values(dailyLoad), 0);
const isOverloaded = maxLoad > 8;
return (
<ArtistLabel
key={artist.id}
artist={artist}
isOverloaded={isOverloaded}
rowHeight={rowHeight}
/>
);
})}
</div>
{/* Scrollable timeline */}
<div
ref={timelineRef}
className="flex-1 overflow-x-auto overflow-y-hidden relative"
style={{ scrollbarColor: "#3f3f46 transparent" }}
>
{/* Date header - sticky top */}
<div
className="flex sticky top-0 z-20 bg-zinc-900 border-b border-zinc-800"
style={{ width: totalWidth, height: HEADER_HEIGHT }}
>
{days.map((day, i) => {
const isT = isToday(day);
const isWE = isWeekend(day);
return (
<div
key={i}
className={cn(
"flex flex-col items-center justify-center border-r text-center shrink-0 select-none",
isWE ? "border-zinc-800" : "border-zinc-800/60",
isT ? "bg-amber-500/10" : isWE ? "bg-zinc-900/80" : ""
)}
style={{ width: dayWidth }}
>
<span
className={cn(
"text-[9px] font-medium uppercase tracking-wider",
isT ? "text-amber-400" : "text-zinc-600"
)}
>
{format(day, "EEE")}
</span>
<span
className={cn(
"text-xs font-semibold",
isT ? "text-amber-300" : isWE ? "text-zinc-600" : "text-zinc-400"
)}
>
{format(day, "d")}
</span>
{format(day, "d") === "1" || i === 0 ? (
<span className="text-[9px] text-zinc-600 absolute bottom-0.5">
{format(day, "MMM")}
</span>
) : null}
</div>
);
})}
</div>
{/* Artist rows */}
<div style={{ width: totalWidth }}>
{artists.map((artist, artistIndex) => {
const artistTasks = tasks.filter(
(t) => t.assignedArtistId === artist.id
);
const dailyLoad = calcDailyLoad(tasks, artist.id, days);
// Apply resize preview so width animates live
const tasksWithPreview = artistTasks.map((t) =>
resizePreview?.taskId === t.id
? {
...t,
scheduledEndDate: resizePreview.endDate.toISOString(),
estimatedHours: resizePreview.estimatedHours,
}
: t
);
return (
<div
key={artist.id}
className="relative border-b border-zinc-800/60"
style={{ height: rowHeight }}
>
{/* Drop zone overlay */}
<TimelineDropZone id={`timeline-row-${artist.id}`} />
{/* Day grid lines + overload indicators */}
{days.map((day, dayIndex) => {
const dayStr = format(day, "yyyy-MM-dd");
const load = dailyLoad[dayStr] ?? 0;
const isT = isToday(day);
const isWE = isWeekend(day);
const isOverloaded = load > 8;
return (
<div
key={dayIndex}
className={cn(
"absolute top-0 bottom-0 border-r",
isWE ? "border-zinc-800 bg-zinc-900/30" : "border-zinc-800/40",
isT && "bg-amber-500/5",
isOverloaded && "bg-orange-500/10"
)}
style={{
left: dayIndex * dayWidth,
width: dayWidth,
}}
/>
);
})}
{/* Task blocks */}
{tasksWithPreview.map((task) => {
const startDate = toDate(task.scheduledStartDate);
if (!startDate) return null;
// dayIndex may be fractional: integer part = day column,
// fractional part = intra-day hour offset stored in the time component.
const dayIdx = getDayIndex(startOfDay(startDate), viewStart);
const hourOffset = startDate.getHours(); // hours into the 8h workday
const dayIndex = dayIdx + hourOffset / 8;
const duration = getTaskDuration(task);
// Skip tasks outside view
if (dayIndex + duration < 0 || dayIndex >= days.length)
return null;
const taskHeight = rowHeight - 8;
const laneTop = 4;
return (
<ScheduleTaskBlock
key={task.id}
task={task}
dayIndex={dayIndex}
duration={duration}
artistIndex={artistIndex}
viewStart={viewStart}
days={days}
canEdit={canEdit}
isDragging={activeDragId === task.id}
onResizeMouseDown={onResizeMouseDown}
laneTop={laneTop}
taskHeight={taskHeight}
onUnschedule={onUnschedule}
dayWidth={dayWidth}
/>
);
})}
</div>
);
})}
{/* Empty state */}
{artists.length === 0 && (
<div className="flex items-center justify-center py-16 text-zinc-600 text-sm">
No artists to display
</div>
)}
</div>
</div>
</div>
);
}