f30acef101
Deploy / deploy (push) Successful in 5m17s
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>
416 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|