From c2509512ff2baccb57eeb36a99991852bdb91a86 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:03:56 +0200 Subject: [PATCH] feat(delivery): record shots delivered outside the render pipeline 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 --- actions/shots.ts | 62 +++++++ .../shot-status/ShotStatusClient.tsx | 166 +++++++++++++++++- app/(dashboard)/shot-status/page.tsx | 2 + lib/dashboard/ops.ts | 12 +- .../migration.sql | 6 + prisma/schema.prisma | 8 + 6 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260925000000_shot_manual_delivery/migration.sql diff --git a/actions/shots.ts b/actions/shots.ts index a4ed77c..053f6f9 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -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[]) { diff --git a/app/(dashboard)/shot-status/ShotStatusClient.tsx b/app/(dashboard)/shot-status/ShotStatusClient.tsx index a34a42f..af68930 100644 --- a/app/(dashboard)/shot-status/ShotStatusClient.tsx +++ b/app/(dashboard)/shot-status/ShotStatusClient.tsx @@ -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; + 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 ( + { if (!o && !isPending) onClose(); }}> + + + Delivered Outside Pipeline + + +

+ Applying to {count}{" "} + shot{count !== 1 ? "s" : ""} +

+ +
+
+ + +
+ + {!clearing && ( + <> +
+ + 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" + /> +
+
+ +