Files
vfxreview/app/(dashboard)/schedule/SchedulePageClient.tsx
T
twotalesanimation caaa333f02
Deploy / deploy (push) Failing after 19m11s
feat(pipeline): render-derived shot thumbnails, and bulk shot delete
Two independent features landing together:

Generated shot thumbnails — a shot can now carry a thumbnail promoted
from a render export (Shot.generatedThumbnailUrl/generatedThumbnailFrom)
alongside the existing brief-supplied thumbnailUrl, which always takes
display precedence (lib/shot-thumbnail.ts) so a promoted frame can only
ever fill a gap, never overwrite the brief. Shows with
pipelineConfig.autoShotThumbnail promote automatically on every
finished export (lib/render-pipeline/preview.ts); promoteExportThumbnail/
clearGeneratedThumbnail (actions/shots.ts) are the manual equivalent for
everyone else. Worker-side seek logic for picking the promoted frame is
covered by RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs.

Bulk shot delete — the shot-status page's selection bar gets a "Bulk
Actions" dropdown (alongside the existing Change Due Date) with Delete
Shots. bulkDeleteShots() (actions/shots.ts) deletes one at a time rather
than in a single statement: exports_shotId_fkey is ON DELETE RESTRICT
by design, so a shot with renders or deliveries on record is protected
and gets skipped and reported by name rather than aborting the whole
selection or being silently destroyed along with its history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:06:55 +02:00

598 lines
19 KiB
TypeScript

"use client";
import { useState, useRef, useCallback, useMemo } from "react";
import {
DndContext,
DragStartEvent,
DragEndEvent,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
addDays,
startOfWeek,
startOfDay,
format,
differenceInDays,
isBefore,
parseISO,
} from "date-fns";
import { scheduleTask, unscheduleTask } from "@/actions/schedule";
import { ScheduleTimeline } from "@/components/schedule/ScheduleTimeline";
import { BacklogPanel } from "@/components/schedule/BacklogPanel";
import { ScheduleFilters } from "@/components/schedule/ScheduleFilters";
import { CalendarDays, PanelRightOpen, PanelRightClose, Columns2, Rows } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { TaskStatus, TaskType } from "@prisma/client";
export const DAY_WIDTH = 216;
export const ROW_HEIGHT = 56;
export const HEADER_HEIGHT = 44;
export const LABEL_WIDTH = 208;
export const NUM_DAYS = 35;
export const SLOT_HEIGHT = 48; // task height = ROW_HEIGHT - 8
export interface ScheduleTask {
id: string;
title: string;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: string | null;
estimatedHours: number | null;
scheduledStartDate: string | null;
scheduledEndDate: string | null;
scheduleNotes: string | null;
assignedArtistId: string | null;
assignedArtist: {
id: string;
name: string | null;
email: string;
image: string | null;
} | null;
shot: {
id: string;
shotCode: string;
thumbnailUrl: string | null;
generatedThumbnailUrl: string | null;
} | null;
asset: { id: string; assetCode: string; name: string } | null;
project: { id: string; name: string; code: string };
}
export interface ScheduleArtist {
id: string;
name: string | null;
email: string;
image: string | null;
role: string;
}
export interface ActiveDragData {
type: "scheduled" | "backlog" | "resize";
taskId: string;
duration?: number;
estimatedHours?: number | null;
originalEndDate?: string;
}
interface SchedulePageClientProps {
artists: ScheduleArtist[];
tasks: ScheduleTask[];
backlog: ScheduleTask[];
projects: { id: string; name: string; code: string }[];
canEdit: boolean;
currentUserId: string;
activeProject?: string;
activeArtist?: string;
}
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
function calcDuration(task: ScheduleTask): number {
return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8));
}
export function SchedulePageClient({
artists,
tasks,
backlog,
projects,
canEdit,
currentUserId,
activeProject,
activeArtist,
}: SchedulePageClientProps) {
const { toast } = useToast();
const [localTasks, setLocalTasks] = useState<ScheduleTask[]>(tasks);
const [localBacklog, setLocalBacklog] = useState<ScheduleTask[]>(backlog);
const [viewStart, setViewStart] = useState<Date>(() =>
startOfWeek(new Date(), { weekStartsOn: 1 })
);
const [showBacklog, setShowBacklog] = useState(true);
const [filterProject, setFilterProject] = useState(activeProject ?? "");
const [filterArtist, setFilterArtist] = useState(activeArtist ?? "");
const [filterStatus, setFilterStatus] = useState("");
const [activeDrag, setActiveDrag] = useState<ActiveDragData | null>(null);
const [resizePreview, setResizePreview] = useState<{
taskId: string;
endDate: Date;
estimatedHours: number;
} | null>(null);
const [dayWidth, setDayWidth] = useState(DAY_WIDTH);
const [rowHeight, setRowHeight] = useState(ROW_HEIGHT);
const timelineRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
})
);
const days = useMemo(
() => Array.from({ length: NUM_DAYS }, (_, i) => addDays(viewStart, i)),
[viewStart]
);
const filteredArtists = useMemo(() => {
if (!filterArtist) return artists;
return artists.filter((a) => a.id === filterArtist);
}, [artists, filterArtist]);
const filteredScheduledTasks = useMemo(() => {
return localTasks.filter((t) => {
if (filterProject && t.project.id !== filterProject) return false;
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
if (filterStatus && t.status !== filterStatus) return false;
return true;
});
}, [localTasks, filterProject, filterArtist, filterStatus]);
const filteredBacklog = useMemo(() => {
return localBacklog.filter((t) => {
if (filterProject && t.project.id !== filterProject) return false;
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
if (filterStatus && t.status !== filterStatus) return false;
return true;
});
}, [localBacklog, filterProject, filterArtist, filterStatus]);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const data = event.active.data.current as ActiveDragData;
setActiveDrag(data);
},
[]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveDrag(null);
const { active, delta, over } = event;
if (!active.data.current) return;
const dragData = active.data.current as ActiveDragData;
// Resize handle - only deltaX matters
if (dragData.type === "resize") {
const task = localTasks.find((t) => t.id === dragData.taskId);
if (!task?.scheduledStartDate || !dragData.originalEndDate) return;
const originalEnd = toDate(dragData.originalEndDate)!;
const daysDelta = Math.round(delta.x / dayWidth);
let newEnd = addDays(originalEnd, daysDelta);
const startDate = toDate(task.scheduledStartDate)!;
if (isBefore(newEnd, startDate)) newEnd = startDate;
const prevEnd = task.scheduledEndDate;
setLocalTasks((prev) =>
prev.map((t) =>
t.id === dragData.taskId
? { ...t, scheduledEndDate: newEnd.toISOString() }
: t
)
);
scheduleTask({
taskId: dragData.taskId,
scheduledStartDate: task.scheduledStartDate,
scheduledEndDate: newEnd.toISOString(),
}).catch(() => {
setLocalTasks((prev) =>
prev.map((t) =>
t.id === dragData.taskId
? { ...t, scheduledEndDate: prevEnd }
: t
)
);
toast({ title: "Failed to resize task", variant: "destructive" });
});
return;
}
// Check if dropped over the backlog panel (unschedule)
if (over?.id === "backlog-drop-zone" && dragData.type === "scheduled") {
const task = localTasks.find((t) => t.id === dragData.taskId);
if (!task) return;
setLocalTasks((prev) => prev.filter((t) => t.id !== dragData.taskId));
setLocalBacklog((prev) => [
{
...task,
scheduledStartDate: null,
scheduledEndDate: null,
},
...prev,
]);
unscheduleTask(dragData.taskId).catch(() => {
setLocalTasks((prev) => [...prev, task]);
setLocalBacklog((prev) =>
prev.filter((t) => t.id !== dragData.taskId)
);
toast({ title: "Failed to unschedule task", variant: "destructive" });
});
return;
}
// Drop on timeline - calculate position from translated rect
const translatedRect = active.rect.current.translated;
if (!translatedRect || !timelineRef.current) return;
const timelineBounds = timelineRef.current.getBoundingClientRect();
const scrollLeft = timelineRef.current.scrollLeft;
const centerX = translatedRect.left + translatedRect.width / 2;
const centerY = translatedRect.top + translatedRect.height / 2;
// Check if drop is within timeline bounds
const inTimeline =
centerX >= timelineBounds.left &&
centerX <= timelineBounds.right &&
centerY >= timelineBounds.top + HEADER_HEIGHT &&
centerY <= timelineBounds.bottom;
if (!inTimeline && dragData.type !== "backlog") return;
if (!inTimeline && dragData.type === "backlog") return;
const dayIndex = Math.max(
0,
Math.min(
Math.floor((centerX - timelineBounds.left + scrollLeft) / dayWidth),
NUM_DAYS - 1
)
);
const artistIndex = Math.max(
0,
Math.min(
Math.floor(
(centerY - timelineBounds.top - HEADER_HEIGHT) / rowHeight
),
filteredArtists.length - 1
)
);
const newArtist = filteredArtists[artistIndex];
if (!newArtist) return;
const taskId = dragData.taskId;
// Duration always derived from estimatedHours so width = hours
let duration = 1;
if (dragData.type === "scheduled") {
const task = localTasks.find((t) => t.id === taskId);
if (task) duration = calcDuration(task);
} else if (dragData.type === "backlog") {
const task = localBacklog.find((t) => t.id === taskId);
if (task) duration = calcDuration(task);
}
// Pack task within the dropped day using hour offsets.
// Sum hours already scheduled for this artist on that day,
// and start the new task at that hour (stored in scheduledStartDate's time component).
const existingForArtist = localTasks.filter(
(t) =>
t.assignedArtistId === newArtist.id &&
t.id !== taskId &&
t.scheduledStartDate
);
const droppedDay = startOfDay(addDays(viewStart, dayIndex));
const hoursOnDay = existingForArtist
.filter((t) => {
const tStart = toDate(t.scheduledStartDate!);
return tStart && differenceInDays(startOfDay(tStart), droppedDay) === 0;
})
.reduce((sum, t) => sum + (t.estimatedHours ?? 8), 0);
const newStartDate = new Date(droppedDay);
newStartDate.setHours(hoursOnDay);
const newEndDate = addDays(newStartDate, duration - 1);
if (dragData.type === "backlog") {
const task = localBacklog.find((t) => t.id === taskId);
if (!task) return;
const scheduledTask: ScheduleTask = {
...task,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
assignedArtist: {
id: newArtist.id,
name: newArtist.name,
email: newArtist.email,
image: newArtist.image,
},
};
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
setLocalTasks((prev) => [...prev, scheduledTask]);
scheduleTask({
taskId,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
}).catch(() => {
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
setLocalBacklog((prev) => [task, ...prev]);
toast({ title: "Failed to schedule task", variant: "destructive" });
});
} else if (dragData.type === "scheduled") {
const prevTask = localTasks.find((t) => t.id === taskId);
setLocalTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
assignedArtist: {
id: newArtist.id,
name: newArtist.name,
email: newArtist.email,
image: newArtist.image,
},
}
: t
)
);
scheduleTask({
taskId,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
}).catch(() => {
if (prevTask) {
setLocalTasks((prev) =>
prev.map((t) => (t.id === taskId ? prevTask : t))
);
}
toast({ title: "Failed to move task", variant: "destructive" });
});
}
},
[localTasks, localBacklog, viewStart, filteredArtists, toast, dayWidth, rowHeight]
);
const handleResizeMouseDown = useCallback(
(taskId: string, _currentEndDate: string) =>
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const prevTask = localTasks.find((t) => t.id === taskId);
if (!prevTask?.scheduledStartDate) return;
const startX = e.clientX;
const startDate = toDate(prevTask.scheduledStartDate)!;
const originalHours = prevTask.estimatedHours ?? 8;
const HOUR_WIDTH = dayWidth / 8; // px per 1 hour
let currentHours = originalHours;
const onMouseMove = (me: MouseEvent) => {
const deltaPx = me.clientX - startX;
const hoursDelta = Math.round(deltaPx / HOUR_WIDTH);
currentHours = Math.max(1, originalHours + hoursDelta);
const newDays = Math.max(1, Math.ceil(currentHours / 8));
const newEndDate = addDays(startDate, newDays - 1);
setResizePreview({
taskId,
endDate: newEndDate,
estimatedHours: currentHours,
});
};
const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
const newDays = Math.max(1, Math.ceil(currentHours / 8));
const finalEnd = addDays(startDate, newDays - 1);
setResizePreview(null);
setLocalTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
scheduledEndDate: finalEnd.toISOString(),
estimatedHours: currentHours,
}
: t
)
);
scheduleTask({
taskId,
scheduledStartDate: prevTask.scheduledStartDate,
scheduledEndDate: finalEnd.toISOString(),
estimatedHours: currentHours,
}).catch(() => {
setLocalTasks((prev) =>
prev.map((t) => (t.id === taskId ? prevTask : t))
);
toast({ title: "Failed to resize task", variant: "destructive" });
});
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
},
[localTasks, toast, dayWidth]
);
const activeDragTask = activeDrag
? localTasks.find((t) => t.id === activeDrag.taskId) ??
localBacklog.find((t) => t.id === activeDrag.taskId)
: null;
const handleUnschedule = useCallback(
(taskId: string) => {
const task = localTasks.find((t) => t.id === taskId);
if (!task) return;
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
setLocalBacklog((prev) => [
{ ...task, scheduledStartDate: null, scheduledEndDate: null },
...prev,
]);
unscheduleTask(taskId).catch(() => {
setLocalTasks((prev) => [...prev, task]);
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
toast({ title: "Failed to unschedule task", variant: "destructive" });
});
},
[localTasks, toast]
);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col h-full overflow-hidden bg-zinc-950">
{/* Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-zinc-800 bg-zinc-900 shrink-0">
<div className="flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-amber-400" />
<h1 className="text-lg font-semibold text-white">Schedule</h1>
<span className="text-sm text-zinc-500">
{format(viewStart, "MMM d")} —{" "}
{format(addDays(viewStart, NUM_DAYS - 1), "MMM d, yyyy")}
</span>
</div>
<div className="flex items-center gap-2">
{/* Zoom controls */}
<div className="flex items-center gap-3 border border-zinc-700 rounded-md px-2.5 py-1">
<div className="flex items-center gap-1.5">
<Columns2 className="h-3 w-3 text-zinc-500" />
<input
type="range"
min={60}
max={400}
step={8}
value={dayWidth}
onChange={(e) => setDayWidth(Number(e.target.value))}
className="w-20 accent-amber-400 cursor-pointer"
/>
</div>
<div className="flex items-center gap-1.5">
<Rows className="h-3 w-3 text-zinc-500" />
<input
type="range"
min={40}
max={120}
step={4}
value={rowHeight}
onChange={(e) => setRowHeight(Number(e.target.value))}
className="w-20 accent-amber-400 cursor-pointer"
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowBacklog((b) => !b)}
className="text-zinc-400 hover:text-white gap-2"
>
{showBacklog ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRightOpen className="h-4 w-4" />
)}
{showBacklog ? "Hide" : "Backlog"}
</Button>
</div>
</div>
{/* Filters */}
<ScheduleFilters
projects={projects}
artists={artists}
filterProject={filterProject}
filterArtist={filterArtist}
filterStatus={filterStatus}
viewStart={viewStart}
onProjectChange={setFilterProject}
onArtistChange={setFilterArtist}
onStatusChange={setFilterStatus}
onViewStartChange={setViewStart}
/>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
<ScheduleTimeline
artists={filteredArtists}
tasks={filteredScheduledTasks}
days={days}
viewStart={viewStart}
canEdit={canEdit}
timelineRef={timelineRef}
resizePreview={resizePreview}
onResizeMouseDown={handleResizeMouseDown}
activeDragId={activeDrag?.taskId ?? null}
onUnschedule={handleUnschedule}
dayWidth={dayWidth}
rowHeight={rowHeight}
/>
{showBacklog && (
<BacklogPanel
tasks={filteredBacklog}
canEdit={canEdit}
/>
)}
</div>
{/* Drag overlay */}
<DragOverlay dropAnimation={null}>
{activeDragTask && (
<div className="rounded-md border border-amber-500/60 bg-amber-500/20 px-2 py-1.5 text-xs font-medium text-amber-200 shadow-xl opacity-90 max-w-[200px] truncate pointer-events-none">
{activeDragTask.shot?.shotCode ??
activeDragTask.asset?.assetCode ??
activeDragTask.title}
</div>
)}
</DragOverlay>
</div>
</DndContext>
);
}