Files
vfxreview/app/(dashboard)/pipeline/deliveries/DeliveriesClient.tsx
T
twotalesanimation f30acef101
Deploy / deploy (push) Successful in 5m17s
feat(deliveries): delivery requests and upload/share evidence
A built package used to become DELIVERED on one click with nothing behind
it, and nothing recorded what a client had asked for.

- Marking delivered is now two recorded steps: Record upload (the link on
  the client's portal, contents confirmed) then Share link (who it was sent
  to). The second is refused until the first is on record.
- Delivery requests log a client's ask by episode, pasted shot codes or a
  Shot Status selection. A line closes only when its shot goes out with
  evidence after the request was logged; a request can otherwise only be
  cancelled with a reason.
- Delivered Outside Pipeline takes an upload link and recipient, required
  for shots on an open request.
- The dashboard gains an Awaiting delivery section listing open requests
  and built packages the client does not have yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:21:24 +02:00

416 lines
17 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Package, CheckCircle2, XCircle, RefreshCw, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { handoffStage } from "@/lib/render-pipeline/delivery-handoff";
import { DELIVERY_STATUS_STYLES, statusLabel } from "../status-colors";
import { RecordShareDialog, RecordUploadDialog } from "./HandoffDialogs";
import { RequestsPanel } from "./RequestsPanel";
interface ReadyExport {
exportId: string;
shotCode: string;
episode: string;
versionString: string;
fileCount: number | null;
totalBytes: number | null;
deliveryMovPath: string | null;
hasChecksum: boolean;
qc: { result: string; reviewer: string | null; at: string } | null;
}
interface ReadyResponse {
episodes: { episode: string; count: number; exports: ReadyExport[] }[];
}
interface PackageRow {
id: string;
packageNumber: number;
name: string;
kind: "EXR_DELIVERY" | "MOV_REVIEW";
episode: string;
status: string;
rootPath: string | null;
errorMessage: string | null;
projectName: string;
projectCode: string;
createdAt: string;
builtAt: string | null;
uploadUrl: string | null;
uploadedAt: string | null;
uploadedByName: string | null;
sharedWith: string | null;
sharedNote: string | null;
deliveredAt: string | null;
deliveredByName: string | null;
itemCount: number;
verifiedCount: number;
exrFileCount: number;
movCount: number;
totalBytes: number;
items: {
id: string;
shotCode: string;
clientCode: string | null;
versionString: string;
status: string;
fileCount: number | null;
linkMode: string | null;
errorMessage: string | null;
}[];
}
interface ProjectOption {
id: string;
name: string;
code: string;
}
function formatBytes(bytes: number | null): string {
if (bytes == null) return "—";
const gb = bytes / 1_073_741_824;
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1_048_576).toFixed(0)} MB`;
}
export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [projectId, setProjectId] = useState<string>("");
const [episode, setEpisode] = useState<string>("");
const [building, setBuilding] = useState(false);
const [handoff, setHandoff] = useState<{ step: "upload" | "share"; packageId: string } | null>(null);
const { data: projects } = useQuery({
queryKey: ["pipeline-projects"],
queryFn: async () => {
const res = await fetch("/api/projects");
if (!res.ok) throw new Error("Failed to load projects");
const body = await res.json();
return (body.projects ?? body) as ProjectOption[];
},
});
const { data: ready, isLoading: readyLoading } = useQuery({
queryKey: ["delivery-ready", projectId],
queryFn: async () => {
const res = await fetch(`/api/deliveries/ready?projectId=${encodeURIComponent(projectId)}`);
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? "Failed to load ready exports");
return res.json() as Promise<ReadyResponse>;
},
enabled: Boolean(projectId),
});
const { data: packages } = useQuery({
queryKey: ["delivery-packages", projectId],
queryFn: async () => {
const res = await fetch(`/api/deliveries${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""}`);
if (!res.ok) throw new Error("Failed to load packages");
return res.json() as Promise<{ packages: PackageRow[] }>;
},
// A build is a queued job like any other — poll while one is in flight.
refetchInterval: 5000,
});
async function build(ep: string) {
if (!projectId) return;
setBuilding(true);
try {
const res = await fetch("/api/deliveries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, episode: ep || null }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: "Build failed", description: body.error ?? res.statusText, variant: "destructive" });
} else {
toast({
title: `Package #${body.package.packageNumber} queued`,
description: `${body.package.itemCount} shots → ${body.package.rootPath}`,
});
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
}
} finally {
setBuilding(false);
}
}
function refreshPackages() {
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
}
async function cancelPackage(id: string) {
const res = await fetch(`/api/deliveries/${id}/cancel`, { method: "POST" });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: "Cancel failed", description: body.error ?? res.statusText, variant: "destructive" });
}
refreshPackages();
}
// Looked up from the live list so the dialog never acts on a stale row.
const handoffPackage = handoff ? packages?.packages.find((p) => p.id === handoff.packageId) : undefined;
const episodes = ready?.episodes ?? [];
const shown = episode ? episodes.filter((e) => e.episode === episode) : episodes;
return (
<div className="p-4 md:p-6 space-y-6 max-w-6xl">
<div className="flex items-center gap-3">
<Link href="/pipeline">
<Button variant="ghost" size="icon-sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<h1 className="text-xl font-semibold text-white">Delivery packages</h1>
</div>
{/* Picker */}
<div className="flex items-center gap-3 flex-wrap">
<select
value={projectId}
onChange={(e) => {
setProjectId(e.target.value);
setEpisode("");
}}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
>
<option value="">Select a project…</option>
{(projects ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.code})
</option>
))}
</select>
{episodes.length > 0 && (
<select
value={episode}
onChange={(e) => setEpisode(e.target.value)}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
>
<option value="">All episodes</option>
{episodes.map((e) => (
<option key={e.episode} value={e.episode}>
{e.episode || "(no episode)"} — {e.count} ready
</option>
))}
</select>
)}
</div>
<RequestsPanel projectId={projectId} canBuild={canBuild} />
{/* Ready to build */}
{projectId && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
Ready for delivery
</div>
{readyLoading && <div className="p-4 text-sm text-zinc-500">Loading…</div>}
{!readyLoading && shown.length === 0 && (
<div className="p-4 text-sm text-zinc-500">
Nothing has passed QC yet. Exports appear here once QC passes them.
</div>
)}
{shown.map((ep) => (
<div key={ep.episode} className="border-b border-zinc-800/60 last:border-0">
<div className="flex items-center justify-between gap-4 px-4 py-3 flex-wrap">
<div>
<span className="text-white text-sm font-medium">
{ep.episode ? `Episode ${ep.episode}` : "No episode"}
</span>
<span className="ml-3 text-sm text-zinc-400">Ready: {ep.count} shots</span>
{ep.exports.some((x) => !x.hasChecksum) && (
<span className="ml-3 text-xs text-amber-400">
{ep.exports.filter((x) => !x.hasChecksum).length} without a validation checksum
</span>
)}
</div>
{canBuild && (
<Button size="sm" onClick={() => build(ep.episode)} disabled={building}>
<Package className="h-4 w-4 mr-2" />
Build delivery
</Button>
)}
</div>
<div className="px-4 pb-3 grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{ep.exports.map((x) => (
<div key={x.exportId} className="flex items-center gap-2 text-xs">
<Link href={`/pipeline/exports/${x.exportId}`} className="text-zinc-300 hover:text-white">
{x.shotCode}
</Link>
<span className="text-zinc-500">{x.versionString}</span>
<span className="text-zinc-600">{x.fileCount ?? "?"} frames</span>
<span className="text-zinc-600">{formatBytes(x.totalBytes)}</span>
{!x.deliveryMovPath && <span className="text-amber-500/80">no MOV</span>}
</div>
))}
</div>
</div>
))}
</div>
)}
{/* History — packages are permanent (§11.5) */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Packages</div>
<div className="divide-y divide-zinc-800/60">
{(packages?.packages ?? []).map((p) => {
const stage = handoffStage(p);
return (
<details key={p.id} className="group">
<summary className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-900/60 flex-wrap">
<span className="text-sm text-white font-medium">#{p.packageNumber}</span>
<span className="text-sm text-zinc-300">{p.name}</span>
{p.kind === "MOV_REVIEW" && (
<span className="rounded-full border border-violet-500/30 bg-violet-500/10 px-2 py-0.5 text-xs text-violet-300">
MOV review
</span>
)}
<span
className={cn(
"rounded-full border px-2 py-0.5 text-xs",
// Built is not delivered: a package still owed to the
// client must not read as a green "READY".
stage ? "bg-amber-500/15 text-amber-400 border-amber-500/30" : (DELIVERY_STATUS_STYLES[p.status] ?? "")
)}
>
{stage === "AWAITING_UPLOAD"
? "BUILT — NOT UPLOADED"
: stage === "AWAITING_SHARE"
? "UPLOADED — LINK NOT SHARED"
: statusLabel(p.status)}
</span>
<span className="text-xs text-zinc-500">
{p.episode ? `episode ${p.episode} · ` : ""}
{p.verifiedCount}/{p.itemCount} shots
</span>
<span className="text-xs text-zinc-600">{new Date(p.createdAt).toLocaleDateString()}</span>
{p.status === "BUILDING" && <RefreshCw className="h-3.5 w-3.5 text-blue-400 animate-spin" />}
<span className="ml-auto flex items-center gap-2">
{canBuild && stage === "AWAITING_UPLOAD" && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
setHandoff({ step: "upload", packageId: p.id });
}}
>
<Upload className="h-4 w-4 mr-2 text-amber-400" />
Record upload
</Button>
)}
{canBuild && stage === "AWAITING_SHARE" && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
setHandoff({ step: "share", packageId: p.id });
}}
>
<CheckCircle2 className="h-4 w-4 mr-2 text-emerald-400" />
Share link
</Button>
)}
{canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
cancelPackage(p.id);
}}
>
<XCircle className="h-4 w-4 mr-2 text-red-400" />
Cancel
</Button>
)}
</span>
</summary>
<div className="px-4 pb-4 space-y-2">
{p.rootPath && <div className="text-xs text-zinc-500 break-all">{p.rootPath}</div>}
{p.errorMessage && <div className="text-xs text-red-400">{p.errorMessage}</div>}
{p.uploadedAt && p.uploadUrl && (
<div className="text-xs text-zinc-400 break-all">
Uploaded {new Date(p.uploadedAt).toLocaleString()}
{p.uploadedByName ? ` by ${p.uploadedByName}` : ""} —{" "}
<a href={p.uploadUrl} target="_blank" rel="noreferrer" className="text-sky-400 hover:underline">
{p.uploadUrl}
</a>
{canBuild && stage === "AWAITING_SHARE" && (
<button
type="button"
className="ml-2 text-zinc-500 hover:text-white underline"
onClick={() => setHandoff({ step: "upload", packageId: p.id })}
>
change
</button>
)}
</div>
)}
{p.deliveredAt && (
<div className="text-xs text-emerald-400">
{p.sharedWith
? `Link shared with ${p.sharedWith} ${new Date(p.deliveredAt).toLocaleString()}`
: `Delivered ${new Date(p.deliveredAt).toLocaleString()} — before uploads and shares were recorded`}
{p.deliveredByName ? ` by ${p.deliveredByName}` : ""}
{p.sharedNote ? ` — ${p.sharedNote}` : ""}
</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{p.items.map((item) => (
<div key={item.id} className="flex items-center gap-2 text-xs">
<span
className={cn(
item.status === "VERIFIED"
? "text-emerald-400"
: item.status === "FAILED"
? "text-red-400"
: "text-zinc-500"
)}
>
{item.status === "VERIFIED" ? "✓" : item.status === "FAILED" ? "✕" : "·"}
</span>
<span className="text-zinc-300">{item.shotCode}</span>
<span className="text-zinc-500">{item.versionString}</span>
{p.kind === "EXR_DELIVERY" && item.fileCount != null && (
<span className="text-zinc-600">{item.fileCount}f</span>
)}
{item.linkMode && <span className="text-zinc-600">{item.linkMode}</span>}
{item.errorMessage && <span className="text-red-400">{item.errorMessage}</span>}
</div>
))}
</div>
</div>
</details>
);
})}
{(packages?.packages ?? []).length === 0 && (
<div className="p-4 text-sm text-zinc-500">No packages built yet</div>
)}
</div>
</div>
{handoff?.step === "upload" && handoffPackage && (
<RecordUploadDialog pkg={handoffPackage} onClose={() => setHandoff(null)} onSaved={refreshPackages} />
)}
{handoff?.step === "share" && handoffPackage?.uploadUrl && (
<RecordShareDialog
pkg={{ ...handoffPackage, uploadUrl: handoffPackage.uploadUrl }}
onClose={() => setHandoff(null)}
onSaved={refreshPackages}
/>
)}
</div>
);
}