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>
301 lines
8.7 KiB
TypeScript
301 lines
8.7 KiB
TypeScript
"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";
|
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
|
|
|
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 = shotThumbnail(task.shot);
|
|
|
|
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>
|
|
);
|
|
} |