Files
vfxreview/components/tasks/KanbanCard.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

200 lines
6.1 KiB
TypeScript

"use client";
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import Link from "next/link";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { cn, getInitials } from "@/lib/utils";
import {
CalendarDays,
Layers,
MessageSquare,
CheckCircle2,
XCircle,
Clock,
GripVertical,
Film,
} from "lucide-react";
import { TaskStatus, TaskType } from "@prisma/client";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "./TaskCard";
import { formatDistanceToNow } from "date-fns";
import { shotThumbnail } from "@/lib/shot-thumbnail";
const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-zinc-500",
NORMAL: "bg-blue-500",
HIGH: "bg-amber-500",
URGENT: "bg-red-500",
};
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;
}[];
}
interface KanbanCardProps {
task: KanbanTask;
isDragOverlay?: boolean;
}
export function KanbanCard({ task, isDragOverlay = false }: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
data: { task },
});
const style = transform
? { transform: CSS.Translate.toString(transform) }
: undefined;
const latestVersion = task.versions?.[0];
const isOverdue =
task.dueDate &&
new Date(task.dueDate) < new Date() &&
task.status !== "DONE";
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
// Resolve thumbnail: prefer latest version thumbnail, then the shot's own
// (reference frame first, render-derived one only as a fallback).
const thumbnail =
latestVersion?.thumbnailUrl ??
latestVersion?.posterUrl ??
shotThumbnail(task.shot) ??
null;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group bg-zinc-900 border rounded-lg p-3 space-y-2.5 cursor-grab active:cursor-grabbing transition-all",
isDragging && !isDragOverlay
? "opacity-30 border-dashed border-zinc-600"
: "border-zinc-800 hover:border-zinc-600 shadow-sm",
isDragOverlay && "shadow-xl border-amber-500/30 rotate-1"
)}
{...listeners}
{...attributes}
>
{/* Header: type badge + context code */}
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-medium text-zinc-500 uppercase tracking-wider">
{TASK_TYPE_LABELS[task.type]}
</span>
{contextCode && (
<span className="text-[10px] font-mono text-zinc-400 bg-zinc-800 px-1.5 py-0.5 rounded">
{contextCode}
</span>
)}
</div>
{/* Thumbnail */}
{thumbnail ? (
<div className="w-full aspect-video rounded-md overflow-hidden bg-zinc-800">
<img
src={thumbnail}
alt={contextCode ?? task.title}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
) : contextCode && task.shot ? (
<div className="w-full aspect-video rounded-md bg-zinc-800 flex items-center justify-center">
<Film className="h-5 w-5 text-zinc-700" />
</div>
) : null}
{/* Title */}
<Link
href={`/tasks/${task.id}`}
className="block text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors leading-tight"
onClick={(e) => e.stopPropagation()}
>
{task.title}
</Link>
{/* Latest version + approval */}
{latestVersion && (
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500 font-mono">
v{latestVersion.versionNumber}
</span>
{latestVersion.approvalStatus === "APPROVED" ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
) : latestVersion.approvalStatus === "CHANGES_REQUESTED" ? (
<XCircle className="h-3.5 w-3.5 text-red-500" />
) : (
<Clock className="h-3.5 w-3.5 text-zinc-600" />
)}
</div>
)}
{/* Footer: due date + priority dot + artist */}
<div className="flex items-center justify-between gap-2 pt-0.5">
<div className="flex items-center gap-2">
{/* Priority dot */}
<span
className={cn(
"inline-block w-1.5 h-1.5 rounded-full shrink-0",
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
)}
/>
{/* Due date */}
{task.dueDate && (
<span
className={cn(
"flex items-center gap-1 text-[10px]",
isOverdue ? "text-red-400" : "text-zinc-500"
)}
>
<CalendarDays className="h-3 w-3" />
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
</span>
)}
{/* Version count */}
{(task._count?.versions ?? 0) > 0 && (
<span className="flex items-center gap-1 text-[10px] text-zinc-500">
<Layers className="h-3 w-3" />
{task._count!.versions}
</span>
)}
</div>
{/* Assignee avatar */}
{task.assignedArtist && (
<Avatar className="h-5 w-5 shrink-0">
<AvatarImage src={task.assignedArtist.image ?? undefined} />
<AvatarFallback className="text-[8px] bg-zinc-700 text-zinc-300">
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
</AvatarFallback>
</Avatar>
)}
</div>
</div>
);
}