feat(delivery): record shots delivered outside the render pipeline
Deploy / deploy (push) Successful in 3m34s
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:
@@ -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}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user