caaa333f02
Deploy / deploy (push) Failing after 19m11s
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>
598 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|