feat(deliveries): choose which ready shots go into a package
Deploy / deploy (push) Successful in 3m35s
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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user