feat(deliveries): choose which ready shots go into a package
Deploy / deploy (push) Successful in 3m35s

Each QC-passed shot in "Ready for delivery" gets a checkbox; all start
selected, with a per-episode Select/Deselect all. Build sends the explicit
exportIds ticked on screen, so a shot that turns ready after the page
loaded never slips into a package unseen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-11 18:10:51 +02:00
parent 06a4122ebb
commit d7e37de18f
@@ -83,6 +83,9 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
const [projectId, setProjectId] = useState<string>("");
const [episode, setEpisode] = useState<string>("");
const [building, setBuilding] = useState(false);
// Tracked as exclusions so every ready shot — including one that passes QC
// while the page is open — starts selected.
const [deselected, setDeselected] = useState<Set<string>>(() => new Set());
const [handoff, setHandoff] = useState<{ step: "upload" | "share"; packageId: string } | null>(null);
const { data: projects } = useQuery({
@@ -116,14 +119,36 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
refetchInterval: 5000,
});
async function build(ep: string) {
if (!projectId) return;
function toggleExport(exportId: string, selected: boolean) {
setDeselected((prev) => {
const next = new Set(prev);
if (selected) next.delete(exportId);
else next.add(exportId);
return next;
});
}
function setEpisodeSelected(exportIds: string[], selected: boolean) {
setDeselected((prev) => {
const next = new Set(prev);
for (const id of exportIds) {
if (selected) next.delete(id);
else next.add(id);
}
return next;
});
}
async function build(ep: string, exportIds: string[]) {
if (!projectId || exportIds.length === 0) return;
setBuilding(true);
try {
// Always send the explicit list: the package holds exactly the shots
// ticked on screen, never one that turned ready since the page loaded.
const res = await fetch("/api/deliveries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, episode: ep || null }),
body: JSON.stringify({ projectId, episode: ep || null, exportIds }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
@@ -179,6 +204,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
onChange={(e) => {
setProjectId(e.target.value);
setEpisode("");
setDeselected(new Set());
}}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
>
@@ -219,30 +245,69 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
Nothing has passed QC yet. Exports appear here once QC passes them.
</div>
)}
{shown.map((ep) => (
{shown.map((ep) => {
const allIds = ep.exports.map((x) => x.exportId);
const selectedIds = allIds.filter((id) => !deselected.has(id));
const allSelected = selectedIds.length === allIds.length;
const selectedExports = ep.exports.filter((x) => !deselected.has(x.exportId));
const missingChecksum = selectedExports.filter((x) => !x.hasChecksum).length;
return (
<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-sm text-zinc-400">
{canBuild ? `Selected: ${selectedIds.length} of ${ep.count} shots` : `Ready: ${ep.count} shots`}
</span>
{canBuild && selectedIds.length > 0 && (
<span className="ml-3 text-xs text-zinc-500">
{formatBytes(selectedExports.reduce((sum, x) => sum + (x.totalBytes ?? 0), 0))}
</span>
)}
{missingChecksum > 0 && (
<span className="ml-3 text-xs text-amber-400">
{ep.exports.filter((x) => !x.hasChecksum).length} without a validation checksum
{missingChecksum} 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>
<span className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setEpisodeSelected(allIds, !allSelected)}
>
{allSelected ? "Deselect all" : "Select all"}
</Button>
<Button
size="sm"
onClick={() => build(ep.episode, selectedIds)}
disabled={building || selectedIds.length === 0}
>
<Package className="h-4 w-4 mr-2" />
Build delivery ({selectedIds.length})
</Button>
</span>
)}
</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">
{ep.exports.map((x) => {
const selected = !deselected.has(x.exportId);
return (
<div
key={x.exportId}
className={cn("flex items-center gap-2 text-xs", canBuild && !selected && "opacity-50")}
>
{canBuild && (
<input
type="checkbox"
checked={selected}
onChange={(e) => toggleExport(x.exportId, e.target.checked)}
aria-label={`Include ${x.shotCode} in the package`}
/>
)}
<Link href={`/pipeline/exports/${x.exportId}`} className="text-zinc-300 hover:text-white">
{x.shotCode}
</Link>
@@ -251,10 +316,12 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
<span className="text-zinc-600">{formatBytes(x.totalBytes)}</span>
{!x.deliveryMovPath && <span className="text-amber-500/80">no MOV</span>}
</div>
))}
);
})}
</div>
</div>
))}
);
})}
</div>
)}