diff --git a/actions/shots.ts b/actions/shots.ts index f675338..31d4884 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -550,6 +550,34 @@ export async function toggleKeyShot(shotId: string, isKeyShot: boolean) { return { success: true }; } +/** + * Scaled in the edit: the plate is not used at its native size, which changes + * how the shot is comped and reviewed. Same shape as toggleKeyShot — a standing + * property of the shot, flipped from the shot-status board. + */ +export async function toggleScaledShot(shotId: string, isScaled: boolean) { + 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 shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { projectId: true }, + }); + if (!shot) throw new Error("Shot not found"); + + await db.shot.update({ + where: { id: shotId }, + data: { isScaled }, + }); + + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/shot-status`); + return { success: true }; +} + export async function deleteShot(shotId: string) { const session = await auth(); if (!session?.user) throw new Error("Unauthorized"); diff --git a/app/(dashboard)/shot-status/ShotStatusClient.tsx b/app/(dashboard)/shot-status/ShotStatusClient.tsx index 0794f74..375d685 100644 --- a/app/(dashboard)/shot-status/ShotStatusClient.tsx +++ b/app/(dashboard)/shot-status/ShotStatusClient.tsx @@ -29,7 +29,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; -import { updateShotNotes, bulkUpdateDueDates, bulkDeleteShots, toggleKeyShot } from "@/actions/shots"; +import { updateShotNotes, bulkUpdateDueDates, bulkDeleteShots, toggleKeyShot, toggleScaledShot } from "@/actions/shots"; import { previewMovReview, buildMovReview } from "@/actions/deliveries"; import { useToast } from "@/components/ui/use-toast"; import { @@ -49,6 +49,7 @@ import { Trash2, ChevronsUpDown, Clapperboard, + Scaling, } from "lucide-react"; import { format } from "date-fns"; import { shotThumbnail } from "@/lib/shot-thumbnail"; @@ -67,6 +68,8 @@ type ShotRow = { notes: string | null; description: string | null; isKeyShot: boolean; + isScaled: boolean; + shotVersion: string | null; artist: { id: string; name: string | null; image: string | null; email: string } | null; shotGroup: { id: string; name: string } | null; }; @@ -474,10 +477,30 @@ function NotesCell({ ); } -// ── KeyShotToggle ───────────────────────────────────────────────────────────── +// ── Shot flag toggles ───────────────────────────────────────────────────────── -function KeyShotToggle({ shot }: { shot: ShotRow }) { - const [optimistic, setOptimistic] = useState(shot.isKeyShot); +/** + * A standing yes/no property of a shot, flipped straight from the board. + * Optimistic: the switch moves immediately and rolls back if the save fails, + * because a producer ticking twenty shots should not wait on a round trip each + * time. + */ +function FlagToggle({ + value, + onSet, + onLabel, + offLabel, + failureTitle, + tone, +}: { + value: boolean; + onSet: (next: boolean) => Promise; + onLabel: string; + offLabel: string; + failureTitle: string; + tone: { on: string; ring: string }; +}) { + const [optimistic, setOptimistic] = useState(value); const [isPending, startTransition] = useTransition(); const { toast } = useToast(); @@ -486,10 +509,10 @@ function KeyShotToggle({ shot }: { shot: ShotRow }) { setOptimistic(next); startTransition(async () => { try { - await toggleKeyShot(shot.id, next); + await onSet(next); } catch { setOptimistic(!next); - toast({ title: "Failed to update key shot", variant: "destructive" }); + toast({ title: failureTitle, variant: "destructive" }); } }); }; @@ -499,12 +522,13 @@ function KeyShotToggle({ shot }: { shot: ShotRow }) { type="button" role="switch" aria-checked={optimistic} - aria-label={optimistic ? "Remove key shot" : "Mark as key shot"} + aria-label={optimistic ? offLabel : onLabel} disabled={isPending} onClick={handle} className={cn( - "relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 disabled:opacity-50", - optimistic ? "bg-amber-500" : "bg-zinc-600 hover:bg-zinc-500" + "relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus-visible:ring-2 disabled:opacity-50", + tone.ring, + optimistic ? tone.on : "bg-zinc-600 hover:bg-zinc-500" )} > toggleKeyShot(shot.id, next)} + onLabel="Mark as key shot" + offLabel="Remove key shot" + failureTitle="Failed to update key shot" + tone={{ on: "bg-amber-500", ring: "focus-visible:ring-amber-400" }} + /> + ); +} + +function ScaledShotToggle({ shot }: { shot: ShotRow }) { + return ( + toggleScaledShot(shot.id, next)} + onLabel="Mark as scaled in the edit" + offLabel="Remove scaled flag" + failureTitle="Failed to update scaled flag" + tone={{ on: "bg-cyan-500", ring: "focus-visible:ring-cyan-400" }} + /> + ); +} + function ShotTable({ shots, canManage, @@ -559,6 +609,12 @@ function ShotTable({ Key + + + + Scaled + + @@ -619,6 +675,15 @@ function ShotTable({ > {shot.shotCode} + {shot.shotVersion && ( + {shot.shotVersion} + )} + {shot.isScaled && ( + + + Scaled + + )} {shot.description && (

@@ -661,6 +726,11 @@ function ShotTable({ + + {/* Scaled-in-edit toggle */} + + + ); })} diff --git a/app/(dashboard)/shot-status/page.tsx b/app/(dashboard)/shot-status/page.tsx index 5670eaf..bc99dc8 100644 --- a/app/(dashboard)/shot-status/page.tsx +++ b/app/(dashboard)/shot-status/page.tsx @@ -41,6 +41,10 @@ async function getShotsForProject(projectId: string) { notes: true, description: true, isKeyShot: true, + isScaled: true, + // The shot-level version the panel burns into overlays and queueExport + // renders under — not the count of review Versions uploaded. + shotVersion: true, artist: { select: { id: true, name: true, image: true, email: true } }, shotGroup: { select: { id: true, name: true } }, }, diff --git a/prisma/migrations/20260828010000_shot_is_scaled/migration.sql b/prisma/migrations/20260828010000_shot_is_scaled/migration.sql new file mode 100644 index 0000000..4be80ef --- /dev/null +++ b/prisma/migrations/20260828010000_shot_is_scaled/migration.sql @@ -0,0 +1,4 @@ +-- Scaled-in-edit flag. The plate is not used at its native size, which changes +-- how the shot is comped and reviewed — a standing property of the shot, like +-- isKeyShot, rather than a note someone has to read. +ALTER TABLE "shots" ADD COLUMN "isScaled" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5c83c54..0fe172d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -450,6 +450,11 @@ model Shot { shotVersion String @default("v001") // Key shot flag — highlighted for prioritisation isKeyShot Boolean @default(false) + /// The shot is scaled in the edit — the plate is not used at its native + /// size. That changes how the shot is comped and reviewed, so it is a + /// 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) // Netflix slate fields — managed via the Slate tab, used by the preview pipeline slateType String? slateDescription String? @db.Text