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>
229 lines
6.3 KiB
TypeScript
229 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback } from "react";
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragStartEvent,
|
|
type DragEndEvent,
|
|
} from "@dnd-kit/core";
|
|
import { useDroppable } from "@dnd-kit/core";
|
|
import { useRouter } from "next/navigation";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { KanbanCard } from "./KanbanCard";
|
|
import { TASK_STATUS_CONFIG } from "./TaskCard";
|
|
import { updateTaskStatus } from "@/actions/tasks";
|
|
import { TaskStatus, TaskType } from "@prisma/client";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const COLUMN_ORDER: TaskStatus[] = [
|
|
"TODO",
|
|
"IN_PROGRESS",
|
|
"INTERNAL_REVIEW",
|
|
"CLIENT_REVIEW",
|
|
"CHANGES",
|
|
"DONE",
|
|
];
|
|
|
|
interface KanbanTask {
|
|
id: string;
|
|
title: string;
|
|
type: TaskType;
|
|
status: TaskStatus;
|
|
priority: string;
|
|
dueDate: Date | null;
|
|
shot?: {
|
|
shotCode: string;
|
|
thumbnailUrl?: string | null;
|
|
generatedThumbnailUrl?: string | null;
|
|
} | null;
|
|
asset?: { assetCode: string; name: string } | null;
|
|
assignedArtist?: {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
image: string | null;
|
|
} | null;
|
|
_count?: { versions: number };
|
|
versions?: {
|
|
id: string;
|
|
versionNumber: number;
|
|
approvalStatus: string;
|
|
createdAt: Date;
|
|
thumbnailUrl?: string | null;
|
|
posterUrl?: string | null;
|
|
}[];
|
|
}
|
|
|
|
function KanbanColumn({
|
|
status,
|
|
tasks,
|
|
}: {
|
|
status: TaskStatus;
|
|
tasks: KanbanTask[];
|
|
}) {
|
|
const { setNodeRef, isOver } = useDroppable({ id: status });
|
|
const cfg = TASK_STATUS_CONFIG[status];
|
|
const Icon = cfg.icon;
|
|
|
|
return (
|
|
<div className="flex flex-col min-w-[240px] w-[240px] shrink-0">
|
|
{/* Column header */}
|
|
<div className="flex items-center gap-2 mb-3 px-1">
|
|
<Icon className="h-3.5 w-3.5 text-zinc-500" />
|
|
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
|
{cfg.label}
|
|
</span>
|
|
<span className="ml-auto text-xs text-zinc-600 bg-zinc-800 rounded-full px-1.5 py-0.5 font-mono">
|
|
{tasks.length}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Drop zone */}
|
|
<div
|
|
ref={setNodeRef}
|
|
className={cn(
|
|
"flex-1 flex flex-col gap-2 rounded-xl p-2 min-h-[200px] transition-colors",
|
|
isOver ? "bg-zinc-800/60 ring-1 ring-amber-500/30" : "bg-zinc-900/40"
|
|
)}
|
|
>
|
|
{tasks.map((task) => (
|
|
<KanbanCard key={task.id} task={task} />
|
|
))}
|
|
|
|
{tasks.length === 0 && (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<p className="text-xs text-zinc-700">Drop here</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface KanbanBoardProps {
|
|
tasks: KanbanTask[];
|
|
projectId: string;
|
|
artists?: { id: string; name: string | null; email: string }[];
|
|
}
|
|
|
|
export function KanbanBoard({ tasks: initialTasks, projectId, artists = [] }: KanbanBoardProps) {
|
|
const [tasks, setTasks] = useState<KanbanTask[]>(initialTasks);
|
|
const [draggingTask, setDraggingTask] = useState<KanbanTask | null>(null);
|
|
const [filterArtist, setFilterArtist] = useState<string>("__all__");
|
|
const { toast } = useToast();
|
|
const router = useRouter();
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
|
|
);
|
|
|
|
const filteredTasks =
|
|
filterArtist === "__all__"
|
|
? tasks
|
|
: tasks.filter((t) => t.assignedArtist?.id === filterArtist);
|
|
|
|
const columns = COLUMN_ORDER.map((status) => ({
|
|
status,
|
|
tasks: filteredTasks.filter((t) => t.status === status),
|
|
}));
|
|
|
|
const handleDragStart = useCallback(
|
|
(event: DragStartEvent) => {
|
|
const task = tasks.find((t) => t.id === event.active.id);
|
|
if (task) setDraggingTask(task);
|
|
},
|
|
[tasks]
|
|
);
|
|
|
|
const handleDragEnd = useCallback(
|
|
async (event: DragEndEvent) => {
|
|
setDraggingTask(null);
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const taskId = active.id as string;
|
|
const newStatus = over.id as TaskStatus;
|
|
|
|
// Validate target is a column
|
|
if (!COLUMN_ORDER.includes(newStatus)) return;
|
|
|
|
const task = tasks.find((t) => t.id === taskId);
|
|
if (!task || task.status === newStatus) return;
|
|
|
|
// Optimistic update
|
|
setTasks((prev) =>
|
|
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t))
|
|
);
|
|
|
|
try {
|
|
await updateTaskStatus(taskId, newStatus);
|
|
router.refresh();
|
|
} catch {
|
|
// Revert on failure
|
|
setTasks((prev) =>
|
|
prev.map((t) => (t.id === taskId ? { ...t, status: task.status } : t))
|
|
);
|
|
toast({ title: "Failed to update task status", variant: "destructive" });
|
|
}
|
|
},
|
|
[tasks, router, toast]
|
|
);
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
{/* Artist filter */}
|
|
{artists.length > 0 && (
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<span className="text-xs text-zinc-500 font-medium">Filter by artist:</span>
|
|
<Select value={filterArtist} onValueChange={setFilterArtist}>
|
|
<SelectTrigger className="w-44 h-8 text-sm">
|
|
<SelectValue placeholder="All artists" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__all__">All artists</SelectItem>
|
|
{artists.map((a) => (
|
|
<SelectItem key={a.id} value={a.id}>
|
|
{a.name ?? a.email}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{filterArtist !== "__all__" && (
|
|
<button
|
|
onClick={() => setFilterArtist("__all__")}
|
|
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[400px]">
|
|
{columns.map(({ status, tasks: colTasks }) => (
|
|
<KanbanColumn key={status} status={status} tasks={colTasks} />
|
|
))}
|
|
</div>
|
|
|
|
<DragOverlay dropAnimation={null}>
|
|
{draggingTask && <KanbanCard task={draggingTask} isDragOverlay />}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
);
|
|
}
|