feat(delivery): record shots delivered outside the render pipeline
Deploy / deploy (push) Successful in 3m34s

Shots delivered by hand before the pipeline have no Export, so they could
never appear in a DeliveryPackage and the dashboard countdown showed them
as not delivered. Add Shot.manualDeliveredAt/ById/Note, a "Delivered
Outside Pipeline" bulk action on Shot Status (mark with the real date, or
clear), a badge on marked shots, and count them in the countdown alongside
DELIVERED EXR packages. The real date keeps the weekly rate honest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-25 04:03:56 +02:00
parent 27f9f7ac64
commit c2509512ff
6 changed files with 253 additions and 3 deletions
+62
View File
@@ -703,6 +703,68 @@ export async function bulkAssignArtist(shotIds: string[], artistId: string | nul
return { success: true, updated: shots.length, artistName };
}
// ── Bulk Manual Delivery ──────────────────────────────────────────────────────
/**
* Record that a selection of shots was delivered outside the render pipeline
* (by hand, before packages existed), or clear that record when
* `deliveredOn` is null. The delivery countdown counts these alongside
* DELIVERED EXR packages (lib/dashboard/ops.ts).
*
* `deliveredOn` is the day the frames went out ("yyyy-MM-dd"), stored at
* noon UTC so the calendar day survives any timezone it is shown in.
*/
export async function bulkSetManualDelivery(
shotIds: string[],
deliveredOn: string | null,
note?: string,
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const ids = z.array(z.string().cuid()).parse(shotIds);
if (!ids.length) return { success: true, updated: 0 };
let deliveredAt: Date | null = null;
if (deliveredOn) {
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).parse(deliveredOn);
deliveredAt = new Date(`${day}T12:00:00.000Z`);
if (Number.isNaN(deliveredAt.getTime())) throw new Error("Invalid delivery date");
if (deliveredAt.getTime() > Date.now() + 24 * 60 * 60 * 1000) {
throw new Error("A delivery date can't be in the future");
}
}
const shots = await db.shot.findMany({
where: { id: { in: ids } },
select: { id: true, projectId: true },
});
if (shots.length !== ids.length) throw new Error("Some shots not found");
const trimmedNote = note?.trim() || null;
await db.shot.updateMany({
where: { id: { in: ids } },
data: deliveredAt
? {
manualDeliveredAt: deliveredAt,
manualDeliveredById: session.user.id,
manualDeliveryNote: trimmedNote,
}
: { manualDeliveredAt: null, manualDeliveredById: null, manualDeliveryNote: null },
});
for (const pid of [...new Set(shots.map((s) => s.projectId))]) {
revalidatePath(`/projects/${pid}`);
}
revalidatePath(`/shot-status`);
revalidatePath(`/dashboard`);
return { success: true, updated: shots.length };
}
// ── Bulk Delete Shots ─────────────────────────────────────────────────────────
export async function bulkDeleteShots(shotIds: string[]) {
@@ -32,7 +32,7 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, getInitials } from "@/lib/utils";
import { updateShotNotes, bulkUpdateDueDates, bulkAssignArtist, bulkDeleteShots, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
import { updateShotNotes, bulkUpdateDueDates, bulkAssignArtist, bulkDeleteShots, bulkSetManualDelivery, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
import { useToast } from "@/components/ui/use-toast";
import {
@@ -54,6 +54,7 @@ import {
Clapperboard,
Scaling,
UserPlus,
PackageCheck,
} from "lucide-react";
import { format } from "date-fns";
import { shotThumbnail } from "@/lib/shot-thumbnail";
@@ -73,6 +74,8 @@ type ShotRow = {
description: string | null;
isKeyShot: boolean;
isScaled: boolean;
manualDeliveredAt: Date | string | null;
manualDeliveryNote: string | null;
shotVersion: string | null;
artist: { id: string; name: string | null; image: string | null; email: string } | null;
shotGroup: { id: string; name: string } | null;
@@ -352,6 +355,128 @@ function BulkAssignArtistDialog({
);
}
// ── ManualDeliveryDialog ──────────────────────────────────────────────────────
/**
* Record shots as delivered outside the render pipeline — the ones sent by
* hand before packages existed, which have no Export to put in a package.
*
* The date asked for is when the frames went out, not today: the delivery
* countdown's weekly rate reads it, so a backfill stamped with today's date
* would claim a week nobody had.
*/
function ManualDeliveryDialog({
open,
onClose,
onApply,
count,
}: {
open: boolean;
onClose: () => void;
onApply: (deliveredOn: string | null, note: string) => Promise<void>;
count: number;
}) {
const [date, setDate] = useState("");
const [note, setNote] = useState("");
const [clearing, setClearing] = useState(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (open) {
setDate("");
setNote("");
setClearing(false);
}
}, [open]);
const handleApply = () => {
if (!clearing && !date) return;
startTransition(async () => {
await onApply(clearing ? null : date, note);
});
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delivered Outside Pipeline</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400 -mt-1">
Applying to <span className="text-white font-medium">{count}</span>{" "}
shot{count !== 1 ? "s" : ""}
</p>
<div className="space-y-4 pt-1">
<div className="space-y-2.5">
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
name="manual-delivery-mode"
checked={!clearing}
onChange={() => setClearing(false)}
className="accent-blue-500 cursor-pointer"
/>
<span className="text-sm text-zinc-200">Mark as delivered</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
name="manual-delivery-mode"
checked={clearing}
onChange={() => setClearing(true)}
className="accent-blue-500 cursor-pointer"
/>
<span className="text-sm text-zinc-200">Clear manual delivery</span>
</label>
</div>
{!clearing && (
<>
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-400">Date the shots went out</label>
<input
type="date"
value={date}
max={format(new Date(), "yyyy-MM-dd")}
onChange={(e) => setDate(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 [color-scheme:dark] focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-400">Note (optional)</label>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="e.g. Aspera upload, turnover 3"
className="min-h-[56px] text-sm resize-none bg-zinc-900 border-zinc-700 focus:border-zinc-500 text-zinc-200 placeholder:text-zinc-600"
rows={2}
/>
</div>
</>
)}
<p className="text-xs text-zinc-500">
For shots sent by hand before the render pipeline. They count as delivered on the
dashboard; no package is created and nothing is sent.
</p>
</div>
<DialogFooter className="mt-2">
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleApply} disabled={(!clearing && !date) || isPending}>
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{clearing ? "Clear" : "Mark delivered"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function BulkDeleteDialog({
open,
onClose,
@@ -811,6 +936,15 @@ function ShotTable({
Scaled
</span>
)}
{shot.manualDeliveredAt && (
<span
title={shot.manualDeliveryNote ?? "Delivered outside the render pipeline"}
className="flex items-center gap-1 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-300 shrink-0"
>
<PackageCheck className="h-2.5 w-2.5" />
Delivered {format(new Date(shot.manualDeliveredAt), "d MMM")}
</span>
)}
</div>
{shot.description && (
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">
@@ -951,6 +1085,7 @@ export function ShotStatusClient({
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false);
const [manualDeliveryDialogOpen, setManualDeliveryDialogOpen] = useState(false);
const toggleEpisode = (ep: string) =>
setExpandedEpisodes((prev) => {
@@ -1050,6 +1185,23 @@ export function ShotStatusClient({
}
};
const handleManualDelivery = async (deliveredOn: string | null, note: string) => {
try {
const result = await bulkSetManualDelivery([...selectedIds], deliveredOn, note);
const n = `${result.updated} shot${result.updated !== 1 ? "s" : ""}`;
toast({ title: deliveredOn ? `${n} marked delivered` : `Manual delivery cleared on ${n}` });
setManualDeliveryDialogOpen(false);
clearSelection();
router.refresh();
} catch (e) {
toast({
title: "Failed to update delivery",
description: e instanceof Error ? e.message : undefined,
variant: "destructive",
});
}
};
const handleBulkDelete = async () => {
try {
const result = await bulkDeleteShots([...selectedIds]);
@@ -1232,6 +1384,10 @@ export function ShotStatusClient({
<Clapperboard className="h-3.5 w-3.5 mr-2" />
Build MOV Review Package
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setManualDeliveryDialogOpen(true)}>
<PackageCheck className="h-3.5 w-3.5 mr-2" />
Delivered Outside Pipeline
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
@@ -1446,6 +1602,14 @@ export function ShotStatusClient({
count={selectionCount}
/>
{/* Manual (pre-pipeline) delivery dialog */}
<ManualDeliveryDialog
open={manualDeliveryDialogOpen}
onClose={() => setManualDeliveryDialogOpen(false)}
onApply={handleManualDelivery}
count={selectionCount}
/>
{/* Bulk delete dialog */}
<BulkDeleteDialog
open={deleteDialogOpen}
+2
View File
@@ -51,6 +51,8 @@ async function getShotsForProject(projectId: string) {
description: true,
isKeyShot: true,
isScaled: true,
manualDeliveredAt: true,
manualDeliveryNote: true,
// The shot-level version the panel burns into overlays and queueExport
// renders under — not the count of review Versions uploaded.
shotVersion: true,
+10 -2
View File
@@ -267,7 +267,9 @@ export function projectPace(
* Are we going to make the date, per episode?
*
* A shot counts as delivered when it appears in a package that has actually
* gone out — a DeliveryItem in a DELIVERED EXR_DELIVERY package. Shot.status
* gone out — a DeliveryItem in a DELIVERED EXR_DELIVERY package — or when it
* was recorded as delivered by hand before the pipeline existed
* (Shot.manualDeliveredAt), since those shots have no Export to package. Shot.status
* is deliberately not the measure: it says where the work is, not whether the
* client has the frames, and a review package (MOV_REVIEW) ships nothing.
*
@@ -295,7 +297,7 @@ export async function getDeliveryCountdown(limit = 6): Promise<EpisodeCountdown[
const [shots, postQc, deliveredItems] = await Promise.all([
db.shot.findMany({
where: { projectId: { in: projectIds } },
select: { id: true, projectId: true, episode: true, status: true },
select: { id: true, projectId: true, episode: true, status: true, manualDeliveredAt: true },
}),
db.export.findMany({
where: { status: { in: POST_QC_STATUSES }, shot: { projectId: { in: projectIds } } },
@@ -323,6 +325,12 @@ export async function getDeliveryCountdown(limit = 6): Promise<EpisodeCountdown[
const seen = deliveredAt.get(item.shotId);
if (!seen || when < seen) deliveredAt.set(item.shotId, when);
}
for (const shot of shots) {
const when = shot.manualDeliveredAt;
if (!when) continue;
const seen = deliveredAt.get(shot.id);
if (!seen || when < seen) deliveredAt.set(shot.id, when);
}
const now = Date.now();
const rows: EpisodeCountdown[] = [];
@@ -0,0 +1,6 @@
-- Shots delivered by hand before the render pipeline existed have no Export,
-- so they can never appear in a DeliveryPackage. Record the delivery on the
-- shot itself so the delivery countdown can count it.
ALTER TABLE "shots" ADD COLUMN "manualDeliveredAt" TIMESTAMP(3);
ALTER TABLE "shots" ADD COLUMN "manualDeliveredById" TEXT;
ALTER TABLE "shots" ADD COLUMN "manualDeliveryNote" TEXT;
+8
View File
@@ -455,6 +455,14 @@ model Shot {
/// standing property of the shot rather than a note, and it is flagged on
/// the shot-status board the way a key shot is.
isScaled Boolean @default(false)
/// Delivered outside the render pipeline — by hand, before packages
/// existed. There is no Export to put in a DeliveryPackage, so the fact is
/// recorded here instead and counted alongside DELIVERED EXR packages. The
/// date is when the frames actually went out, not when it was recorded, so
/// a backfill does not inflate the weekly delivery rate.
manualDeliveredAt DateTime?
manualDeliveredById String?
manualDeliveryNote String? @db.Text
// Netflix slate fields — managed via the Slate tab, used by the preview pipeline
slateType String?
slateDescription String? @db.Text