Files
vfxreview/app/(dashboard)/shot-status/ShotStatusClient.tsx
T
twotalesanimation f30acef101
Deploy / deploy (push) Successful in 5m17s
feat(deliveries): delivery requests and upload/share evidence
A built package used to become DELIVERED on one click with nothing behind
it, and nothing recorded what a client had asked for.

- Marking delivered is now two recorded steps: Record upload (the link on
  the client's portal, contents confirmed) then Share link (who it was sent
  to). The second is refused until the first is on record.
- Delivery requests log a client's ask by episode, pasted shot codes or a
  Shot Status selection. A line closes only when its shot goes out with
  evidence after the request was logged; a request can otherwise only be
  cancelled with a reason.
- Delivered Outside Pipeline takes an upload link and recipient, required
  for shots on an open request.
- The dashboard gains an Awaiting delivery section listing open requests
  and built packages the client does not have yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:21:24 +02:00

1692 lines
62 KiB
TypeScript

"use client";
import { useState, useTransition, useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useScrollMemory } from "@/hooks/use-scroll-memory";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
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, bulkSetManualDelivery, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
import { useToast } from "@/components/ui/use-toast";
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
import {
Film,
Clock,
CheckCircle2,
AlertCircle,
Calendar,
CalendarDays,
ChevronDown,
ChevronRight,
Loader2,
X,
ShieldCheck,
Eye,
Star,
Trash2,
ChevronsUpDown,
Clapperboard,
Scaling,
UserPlus,
PackageCheck,
ClipboardList,
} from "lucide-react";
import { format } from "date-fns";
import { shotThumbnail } from "@/lib/shot-thumbnail";
type ShotRow = {
id: string;
shotCode: string;
scene: string;
episode: string | null;
shotNumber: number;
status: string;
priority: string;
dueDate: Date | string | null;
thumbnailUrl: string | null;
generatedThumbnailUrl: string | null;
notes: string | null;
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;
};
interface Artist {
id: string;
name: string | null;
email: string;
image: string | null;
}
interface Project {
id: string;
name: string;
code: string;
projectType: string;
}
interface ShotStatusClientProps {
projects: Project[];
selectedProjectId: string | null;
selectedProject: Project | null;
shots: ShotRow[];
episodeDueDates: { episode: string; dueDate: Date | string }[];
artists: Artist[];
canManage: boolean;
}
function readStored(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
/** A saved list of open sections, or null when none was saved (or it is unreadable). */
function readStoredList(key: string): string[] | null {
const raw = readStored(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : null;
} catch {
return null;
}
}
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Film },
INTERNAL_REVIEW: { label: "Internal Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle },
READY_FOR_CLIENT: { label: "Ready for Client", color: "bg-sky-500/10 text-sky-400 border-sky-500/20", icon: ShieldCheck },
CLIENT_REVIEW: { label: "Client Review", color: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", icon: Eye },
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: AlertCircle },
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 },
};
// ── IndeterminateCheckbox ──────────────────────────────────────────────────────
function IndeterminateCheckbox({
checked,
indeterminate,
onChange,
className,
}: {
checked: boolean;
indeterminate?: boolean;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
className?: string;
}) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = !!indeterminate;
}, [indeterminate]);
return (
<input
ref={ref}
type="checkbox"
checked={checked}
onChange={onChange}
className={cn("cursor-pointer accent-blue-500 w-3.5 h-3.5", className)}
/>
);
}
// ── BulkDueDateDialog ─────────────────────────────────────────────────────────
function BulkDueDateDialog({
open,
onClose,
onApply,
count,
}: {
open: boolean;
onClose: () => void;
onApply: (date: string, includeTasks: boolean) => Promise<void>;
count: number;
}) {
const [date, setDate] = useState("");
const [includeTasks, setIncludeTasks] = useState(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (open) {
setDate("");
setIncludeTasks(false);
}
}, [open]);
const handleApply = () => {
if (!date) return;
startTransition(async () => {
await onApply(date, includeTasks);
});
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Change Due Date</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-5 pt-1">
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-400">New due date</label>
<input
type="date"
value={date}
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-2">
<label className="text-xs font-medium text-zinc-400">Apply to</label>
<div className="space-y-2.5">
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
name="bulk-scope"
checked={!includeTasks}
onChange={() => setIncludeTasks(false)}
className="accent-blue-500 cursor-pointer"
/>
<span className="text-sm text-zinc-200">Shots only</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
name="bulk-scope"
checked={includeTasks}
onChange={() => setIncludeTasks(true)}
className="accent-blue-500 cursor-pointer"
/>
<span className="text-sm text-zinc-200">Shots and their tasks</span>
</label>
</div>
</div>
</div>
<DialogFooter className="mt-2">
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleApply} disabled={!date || isPending}>
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── BulkAssignArtistDialog ────────────────────────────────────────────────────
const UNASSIGN = "__unassign__";
/**
* Hand a selection of shots to one artist, or clear the assignment.
*
* Unassigning is offered as an explicit choice rather than a separate menu
* entry — reassignment and hand-back are the same production move, and the
* confirm button changes wording so nobody clears a batch by accident.
*/
function BulkAssignArtistDialog({
open,
onClose,
onApply,
artists,
count,
}: {
open: boolean;
onClose: () => void;
onApply: (artistId: string | null) => Promise<void>;
artists: Artist[];
count: number;
}) {
const [value, setValue] = useState("");
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (open) setValue("");
}, [open]);
const handleApply = () => {
if (!value) return;
startTransition(async () => {
await onApply(value === UNASSIGN ? null : value);
});
};
const unassigning = value === UNASSIGN;
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Assign Artist</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-1.5 pt-1">
<label className="text-xs font-medium text-zinc-400">Artist</label>
<Select value={value} onValueChange={setValue}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an artist..." />
</SelectTrigger>
<SelectContent>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
<span className="flex items-center gap-2">
<Avatar className="h-5 w-5">
<AvatarImage src={a.image ?? undefined} />
<AvatarFallback className="text-[9px] bg-zinc-700 text-zinc-300">
{getInitials(a.name ?? a.email)}
</AvatarFallback>
</Avatar>
{a.name ?? a.email}
</span>
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={UNASSIGN}>
<span className="text-zinc-400">Unassign</span>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-zinc-500 pt-1">
This replaces whoever is currently on {count === 1 ? "the shot" : "these shots"}.
</p>
</div>
<DialogFooter className="mt-2">
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleApply} disabled={!value || isPending}>
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{unassigning ? "Unassign" : "Assign"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── 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,
evidence: { uploadUrl: string; sharedWith: string },
) => Promise<void>;
count: number;
}) {
const [date, setDate] = useState("");
const [note, setNote] = useState("");
const [uploadUrl, setUploadUrl] = useState("");
const [sharedWith, setSharedWith] = useState("");
const [clearing, setClearing] = useState(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (open) {
setDate("");
setNote("");
setUploadUrl("");
setSharedWith("");
setClearing(false);
}
}, [open]);
const handleApply = () => {
if (!clearing && !date) return;
startTransition(async () => {
await onApply(clearing ? null : date, note, { uploadUrl, sharedWith });
});
};
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">Upload link</label>
<input
type="url"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
placeholder="https://…"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 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">Link sent to</label>
<input
type="text"
value={sharedWith}
onChange={(e) => setSharedWith(e.target.value)}
placeholder="Name, and where it was sent"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
/>
<p className="text-xs text-zinc-500">
Both are needed for shots on an open delivery request; a backfill of old deliveries can leave
them blank.
</p>
</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,
onConfirm,
count,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
count: number;
}) {
const [isPending, startTransition] = useTransition();
const handleConfirm = () => {
startTransition(async () => {
await onConfirm();
});
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-red-400">Delete {count} shot{count !== 1 ? "s" : ""}?</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-300">
This will permanently delete <span className="font-semibold text-white">{count}</span>{" "}
shot{count !== 1 ? "s" : ""}, including all tasks, versions, footage, and annotations.
This cannot be undone.
</p>
<p className="text-xs text-zinc-500">
A shot with renders or deliveries already on record is protected and will be skipped
rather than deleted — you'll see which ones and why.
</p>
<DialogFooter className="mt-2">
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirm} disabled={isPending}>
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{isPending ? "Deleting…" : `Yes, delete ${count} shot${count !== 1 ? "s" : ""}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── MovReviewDialog ───────────────────────────────────────────────────────────
type ReviewPreview = Awaited<ReturnType<typeof previewMovReview>>;
/**
* Confirm what a MOV review package would contain before building it
* (RenderPipeline2 §11.6).
*
* The version each shot resolves to is the newest one that actually has a
* preview MOV, which is not always the newest one full stop — so the dialog
* names the version per shot rather than making anyone guess, and lists the
* shots with no MOV at all instead of quietly building a smaller folder.
*/
function MovReviewDialog({
open,
onClose,
onBuilt,
shotIds,
}: {
open: boolean;
onClose: () => void;
onBuilt: () => void;
shotIds: string[];
}) {
const { toast } = useToast();
const [preview, setPreview] = useState<ReviewPreview | null>(null);
const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!open) {
setPreview(null);
return;
}
let cancelled = false;
setLoading(true);
previewMovReview(shotIds)
.then((result) => {
if (!cancelled) setPreview(result);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const ready = preview?.ok ? preview.candidates : [];
const skipped = preview?.ok ? preview.skipped : [];
const handleBuild = () => {
startTransition(async () => {
const result = await buildMovReview(shotIds);
if (!result.ok) {
toast({ title: "Could not build review package", description: result.error, variant: "destructive" });
return;
}
toast({
title: `${result.package.name} queued`,
description: `${result.package.itemCount} MOV${result.package.itemCount !== 1 ? "s" : ""} → ${result.package.rootPath}`,
});
onBuilt();
});
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Build MOV review package</DialogTitle>
</DialogHeader>
{loading && (
<p className="text-sm text-zinc-400 flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Working out what each shot would contribute…
</p>
)}
{!loading && preview && !preview.ok && (
<p className="text-sm text-red-400">{preview.error}</p>
)}
{!loading && preview?.ok && (
<div className="space-y-4">
<div className="rounded-md border border-zinc-800 bg-zinc-900/60 px-3 py-2">
<div className="text-xs text-zinc-500">Folder</div>
<div className="text-sm text-white font-medium break-all">{preview.packageName}</div>
<div className="text-xs text-zinc-500 break-all">{preview.packageDir}</div>
</div>
{ready.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-zinc-400">
{ready.length} MOV{ready.length !== 1 ? "s" : ""}
</div>
<div className="max-h-52 overflow-y-auto rounded-md border border-zinc-800 divide-y divide-zinc-800/60">
{ready.map((c) => (
<div key={c.shotId} className="flex items-center gap-2 px-3 py-1.5 text-xs">
<span className="text-zinc-200">{c.shotCode}</span>
<span className="text-zinc-500">{c.versionString}</span>
<span className="ml-auto text-zinc-600 truncate">{c.deliveredName}</span>
</div>
))}
</div>
</div>
)}
{skipped.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-amber-400">
{skipped.length} shot{skipped.length !== 1 ? "s" : ""} left out
</div>
<div className="max-h-32 overflow-y-auto rounded-md border border-amber-500/20 bg-amber-500/5 divide-y divide-amber-500/10">
{skipped.map((sk) => (
<div key={sk.shotId} className="flex items-center gap-2 px-3 py-1.5 text-xs">
<span className="text-zinc-300">{sk.shotCode}</span>
<span className="text-amber-400/80">{sk.reason}</span>
</div>
))}
</div>
</div>
)}
<p className="text-xs text-zinc-500">
A render machine builds the folder. Nothing about these shots changes — a review is
not a delivery, so the versions stay exactly where they are in the pipeline.
</p>
</div>
)}
<DialogFooter className="mt-2">
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleBuild} disabled={isPending || loading || ready.length === 0}>
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{isPending ? "Queueing…" : `Build with ${ready.length} MOV${ready.length !== 1 ? "s" : ""}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── NotesCell ─────────────────────────────────────────────────────────────────
function NotesCell({
shot,
canManage,
}: {
shot: ShotRow;
canManage: boolean;
}) {
const [value, setValue] = useState(shot.notes ?? "");
const [saved, setSaved] = useState(true);
const [isPending, startTransition] = useTransition();
const { toast } = useToast();
const handleBlur = useCallback(() => {
if (value === (shot.notes ?? "")) return;
startTransition(async () => {
try {
await updateShotNotes(shot.id, value);
setSaved(true);
toast({ title: "Notes saved" });
} catch {
toast({ title: "Failed to save notes", variant: "destructive" });
}
});
}, [value, shot.id, shot.notes, toast]);
if (!canManage) {
return (
<span className="text-sm text-zinc-400 whitespace-pre-wrap">
{shot.notes ?? <span className="italic text-zinc-600">—</span>}
</span>
);
}
return (
<div className="relative">
<Textarea
value={value}
onChange={(e) => { setValue(e.target.value); setSaved(false); }}
onBlur={handleBlur}
placeholder="Add notes..."
className="min-h-[60px] text-sm resize-none bg-zinc-900 border-zinc-700 focus:border-zinc-500 text-zinc-200 placeholder:text-zinc-600"
rows={2}
/>
{isPending && (
<Loader2 className="absolute bottom-2 right-2 h-3 w-3 animate-spin text-zinc-500" />
)}
{!isPending && !saved && (
<span className="absolute bottom-1.5 right-2 text-[10px] text-zinc-500">unsaved</span>
)}
</div>
);
}
// ── Shot flag toggles ─────────────────────────────────────────────────────────
/**
* 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<unknown>;
onLabel: string;
offLabel: string;
failureTitle: string;
tone: { on: string; ring: string };
}) {
const [optimistic, setOptimistic] = useState(value);
const [isPending, startTransition] = useTransition();
const { toast } = useToast();
const handle = () => {
const next = !optimistic;
setOptimistic(next);
startTransition(async () => {
try {
await onSet(next);
} catch {
setOptimistic(!next);
toast({ title: failureTitle, variant: "destructive" });
}
});
};
return (
<button
type="button"
role="switch"
aria-checked={optimistic}
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 disabled:opacity-50",
tone.ring,
optimistic ? tone.on : "bg-zinc-600 hover:bg-zinc-500"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-lg transition-transform",
optimistic ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
);
}
function KeyShotToggle({ shot }: { shot: ShotRow }) {
return (
<FlagToggle
value={shot.isKeyShot}
onSet={(next) => 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 (
<FlagToggle
value={shot.isScaled}
onSet={(next) => 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,
projectId,
selectedIds,
onToggle,
onToggleAll,
}: {
shots: ShotRow[];
canManage: boolean;
projectId: string;
selectedIds: Set<string>;
onToggle: (id: string) => void;
onToggleAll: (ids: string[], checked: boolean) => void;
}) {
const allChecked = shots.length > 0 && shots.every((s) => selectedIds.has(s.id));
const someChecked = shots.some((s) => selectedIds.has(s.id));
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
{canManage && (
<th className="py-2 px-3 w-9">
<IndeterminateCheckbox
checked={allChecked}
indeterminate={someChecked && !allChecked}
onChange={(e) => onToggleAll(shots.map((s) => s.id), e.target.checked)}
/>
</th>
)}
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Thumb</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Shot</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Status</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-40">Artist</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Due Date</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Notes</th>
<th className="py-2 px-3 text-xs font-medium text-zinc-500 w-20 text-center">
<span className="flex items-center gap-1 justify-center">
<Star className="h-3 w-3" />
Key
</span>
</th>
<th className="py-2 px-3 text-xs font-medium text-zinc-500 w-20 text-center">
<span className="flex items-center gap-1 justify-center">
<Scaling className="h-3 w-3" />
Scaled
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{shots.map((shot) => {
const cfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
const StatusIcon = cfg.icon;
const dueDate = shot.dueDate ? new Date(shot.dueDate) : null;
const isOverdue = dueDate && dueDate < new Date() && shot.status !== "COMPLETE";
const isSelected = selectedIds.has(shot.id);
return (
<tr key={shot.id} className={cn(
"transition-colors",
shot.isKeyShot
? "bg-amber-500/5 hover:bg-amber-500/10 border-l-2 border-l-amber-500"
: "hover:bg-zinc-800/30",
isSelected && "bg-blue-500/5 hover:bg-blue-500/10"
)}>
{/* Checkbox */}
{canManage && (
<td className="py-2 px-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggle(shot.id)}
className="cursor-pointer accent-blue-500 w-3.5 h-3.5"
/>
</td>
)}
{/* Thumbnail */}
<td className="py-2 px-3">
<div className="w-24 aspect-[2.39/1] rounded overflow-hidden bg-zinc-800 flex items-center justify-center shrink-0">
{shotThumbnail(shot) ? (
<Image
src={shotThumbnail(shot)!}
alt={shot.shotCode}
width={96}
height={40}
className="object-cover w-full h-full"
/>
) : (
<Film className="h-4 w-4 text-zinc-600" />
)}
</div>
</td>
{/* Shot name */}
<td className="py-2 px-3">
<div className="flex items-center gap-1.5">
{shot.isKeyShot && (
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />
)}
<Link
href={`/projects/${projectId}/shots/${shot.id}`}
className="font-mono text-sm text-white hover:text-blue-400 transition-colors"
>
{shot.shotCode}
</Link>
{shot.shotVersion && (
<span className="font-mono text-xs text-zinc-400 shrink-0">{shot.shotVersion}</span>
)}
{shot.isScaled && (
<span className="flex items-center gap-1 rounded-full border border-cyan-500/30 bg-cyan-500/10 px-1.5 py-0.5 text-[10px] font-medium text-cyan-300 shrink-0">
<Scaling className="h-2.5 w-2.5" />
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]">
{shot.description}
</p>
)}
</td>
{/* Status */}
<td className="py-2 px-3">
<Badge className={cn("gap-1 text-xs", cfg.color)}>
<StatusIcon className="h-3 w-3" />
{cfg.label}
</Badge>
</td>
{/* Artist */}
<td className="py-2 px-3">
{shot.artist ? (
<span className="flex items-center gap-2 min-w-0">
<Avatar className="h-5 w-5 shrink-0">
<AvatarImage src={shot.artist.image ?? undefined} />
<AvatarFallback className="text-[9px] bg-zinc-700 text-zinc-300">
{getInitials(shot.artist.name ?? shot.artist.email)}
</AvatarFallback>
</Avatar>
<span className="text-xs text-zinc-300 truncate">
{shot.artist.name ?? shot.artist.email}
</span>
</span>
) : (
<span className="text-xs text-zinc-600 italic">Unassigned</span>
)}
</td>
{/* Due date */}
<td className="py-2 px-3">
{dueDate ? (
<span
className={cn(
"flex items-center gap-1 text-xs",
isOverdue ? "text-red-400" : "text-zinc-400"
)}
>
<Calendar className="h-3 w-3" />
{format(dueDate, "d MMM yyyy")}
</span>
) : (
<span className="text-xs text-zinc-600 italic">—</span>
)}
</td>
{/* Notes */}
<td className="py-2 px-3 min-w-[220px]">
<NotesCell shot={shot} canManage={canManage} />
</td>
{/* Key shot toggle */}
<td className="py-2 px-3 text-center">
<KeyShotToggle shot={shot} />
</td>
{/* Scaled-in-edit toggle */}
<td className="py-2 px-3 text-center">
<ScaledShotToggle shot={shot} />
</td>
</tr>
);
})}
</tbody>
</table>
);
}
export function ShotStatusClient({
projects,
selectedProjectId,
selectedProject,
shots,
episodeDueDates,
artists,
canManage,
}: ShotStatusClientProps) {
const router = useRouter();
const { toast } = useToast();
// Restore last selected project if no project is currently selected
useEffect(() => {
if (!selectedProjectId) {
const saved = localStorage.getItem("shotStatus:lastProjectId");
if (saved && projects.some((p) => p.id === saved)) {
router.replace(`/shot-status?projectId=${saved}`);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(new Set());
const [expandedScenes, setExpandedScenes] = useState<Set<string>>(new Set());
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [groupMode, setGroupMode] = useState<"flat" | "scene" | "group">("flat");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Which sections were open is restored before the scroll position: until
// they are, the page is shorter and the saved offset would not fit.
const [expandRestored, setExpandRestored] = useState(false);
const pageRef = useRef<HTMLDivElement>(null);
useScrollMemory(selectedProjectId ? `shotStatus:${selectedProjectId}` : null, pageRef, expandRestored);
// Load saved expand state and grouping when the project changes
useEffect(() => {
if (!selectedProjectId) return;
setExpandRestored(false);
const mode = (readStored(`shotStatus:${selectedProjectId}:groupMode`) ?? "flat") as "flat" | "scene" | "group";
setGroupMode(mode);
setExpandedEpisodes(new Set(readStoredList(`shotStatus:${selectedProjectId}:episodeExpand`) ?? []));
// Scenes and groups open fully when their mode is first chosen, so with
// nothing saved yet they come back that way rather than all collapsed.
setExpandedScenes(
new Set(readStoredList(`shotStatus:${selectedProjectId}:sceneExpand`) ?? shots.map((s) => s.scene))
);
setExpandedGroups(
new Set(readStoredList(`shotStatus:${selectedProjectId}:groupExpand`) ?? shots.map((s) => s.shotGroup?.id ?? ""))
);
setExpandRestored(true);
// Shots are read only for the defaults; a refresh after an edit must not
// reset what the user has opened.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProjectId]);
const saveExpanded = (kind: "episodeExpand" | "sceneExpand" | "groupExpand", open: Set<string>) => {
if (!selectedProjectId) return;
try {
localStorage.setItem(`shotStatus:${selectedProjectId}:${kind}`, JSON.stringify([...open]));
} catch {}
};
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false);
const [manualDeliveryDialogOpen, setManualDeliveryDialogOpen] = useState(false);
const [requestDialogOpen, setRequestDialogOpen] = useState(false);
const toggleEpisode = (ep: string) =>
setExpandedEpisodes((prev) => {
const next = new Set(prev);
next.has(ep) ? next.delete(ep) : next.add(ep);
saveExpanded("episodeExpand", next);
return next;
});
const toggleScene = (sc: string) =>
setExpandedScenes((prev) => {
const next = new Set(prev);
next.has(sc) ? next.delete(sc) : next.add(sc);
saveExpanded("sceneExpand", next);
return next;
});
const toggleGroup = (g: string) =>
setExpandedGroups((prev) => {
const next = new Set(prev);
next.has(g) ? next.delete(g) : next.add(g);
saveExpanded("groupExpand", next);
return next;
});
const handleGroupMode = (mode: "flat" | "scene" | "group") => {
setGroupMode(mode);
if (mode === "scene") {
const all = new Set(shots.map((s) => s.scene));
setExpandedScenes(all);
saveExpanded("sceneExpand", all);
}
if (mode === "group") {
const all = new Set(shots.map((s) => s.shotGroup?.id ?? ""));
setExpandedGroups(all);
saveExpanded("groupExpand", all);
}
if (selectedProjectId) {
try { localStorage.setItem(`shotStatus:${selectedProjectId}:groupMode`, mode); } catch {}
}
};
const handleProjectChange = (id: string) => {
localStorage.setItem("shotStatus:lastProjectId", id);
setSelectedIds(new Set());
router.push(`/shot-status?projectId=${id}`);
};
const toggleShot = (id: string) =>
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const toggleMany = (ids: string[], checked: boolean) =>
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) ids.forEach((id) => next.add(id));
else ids.forEach((id) => next.delete(id));
return next;
});
const clearSelection = () => setSelectedIds(new Set());
const handleBulkDueDate = async (date: string, includeTasks: boolean) => {
try {
const result = await bulkUpdateDueDates([...selectedIds], date, includeTasks);
toast({
title: `Due date updated for ${result.updated} shot${result.updated !== 1 ? "s" : ""}`,
});
setDueDateDialogOpen(false);
clearSelection();
router.refresh();
} catch {
toast({ title: "Failed to update due dates", variant: "destructive" });
}
};
const handleBulkAssignArtist = async (artistId: string | null) => {
try {
const result = await bulkAssignArtist([...selectedIds], artistId);
toast({
title: result.artistName
? `${result.updated} shot${result.updated !== 1 ? "s" : ""} assigned to ${result.artistName}`
: `${result.updated} shot${result.updated !== 1 ? "s" : ""} unassigned`,
});
setAssignDialogOpen(false);
clearSelection();
router.refresh();
} catch (e) {
toast({
title: "Failed to assign artist",
description: e instanceof Error ? e.message : undefined,
variant: "destructive",
});
}
};
const handleManualDelivery = async (
deliveredOn: string | null,
note: string,
evidence: { uploadUrl: string; sharedWith: string },
) => {
try {
const result = await bulkSetManualDelivery([...selectedIds], deliveredOn, note, evidence);
if (!result.success) {
// The dialog stays open so the missing link and recipient can be added.
toast({ title: "Not marked delivered", description: result.error, variant: "destructive" });
return;
}
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]);
if (result.failed.length > 0) {
toast({
title: `Deleted ${result.deleted} shot${result.deleted !== 1 ? "s" : ""}, ${result.failed.length} skipped`,
description: result.failed.map((f) => `${f.shotCode}: ${f.reason}`).join("; "),
variant: "destructive",
});
} else {
toast({ title: `Deleted ${result.deleted} shot${result.deleted !== 1 ? "s" : ""}` });
}
setDeleteDialogOpen(false);
clearSelection();
router.refresh();
} catch (e) {
toast({ title: "Failed to delete shots", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
}
};
// Group by episode for episodic projects
const isEpisodic = selectedProject?.projectType === "EPISODIC";
const episodeGroups: [string, ShotRow[]][] = isEpisodic
? (() => {
const map = new Map<string, ShotRow[]>();
for (const shot of shots) {
const key = shot.episode ?? "(No Episode)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
}
return Array.from(map.entries());
})()
: [];
const sceneGroups: [string, ShotRow[]][] = !isEpisodic && groupMode === "scene"
? (() => {
const map = new Map<string, ShotRow[]>();
for (const shot of shots) {
const key = shot.scene || "(No Scene)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
}
return Array.from(map.entries());
})()
: [];
const shotGroupGroups: [string, string, ShotRow[]][] = !isEpisodic && groupMode === "group"
? (() => {
const map = new Map<string, [string, ShotRow[]]>();
for (const shot of shots) {
const id = shot.shotGroup?.id ?? "";
const name = shot.shotGroup?.name ?? "(No Group)";
if (!map.has(id)) map.set(id, [name, []]);
map.get(id)![1].push(shot);
}
return Array.from(map.entries()).map(([id, [name, s]]) => [id, name, s] as [string, string, ShotRow[]]);
})()
: [];
const episodeDueDateMap = new Map(
episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)])
);
const statusCounts = shots.reduce(
(acc, s) => {
acc[s.status] = (acc[s.status] ?? 0) + 1;
return acc;
},
{} as Record<string, number>
);
const selectionCount = selectedIds.size;
return (
<div ref={pageRef} className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-white">Shot Status</h1>
<p className="text-zinc-400 mt-1">Track shot progress and add production notes</p>
</div>
{/* Project selector */}
{/* Phones: the picker fills the row and "Group by" wraps onto its own
line. From md: one row, as on desktop. */}
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm text-zinc-400 shrink-0">Project:</label>
<Select value={selectedProjectId ?? ""} onValueChange={handleProjectChange}>
<SelectTrigger className="flex-1 min-w-0 md:flex-none md:w-72">
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
<span className="font-mono text-xs text-zinc-400 mr-2">{p.code}</span>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedProject && !isEpisodic && (
<div className="flex w-full md:w-auto items-center gap-3">
<span className="text-sm text-zinc-500">Group by:</span>
{(["flat", "scene", "group"] as const).map((mode) => (
<button
key={mode}
onClick={() => handleGroupMode(mode)}
className={cn(
"px-3 py-1 rounded text-xs font-medium transition-colors",
groupMode === mode
? "bg-zinc-700 text-zinc-100"
: "text-zinc-500 hover:text-zinc-300"
)}
>
{mode === "flat" ? "None" : mode === "scene" ? "Scene" : "Group"}
</button>
))}
</div>
)}
</div>
{/* No project selected */}
{!selectedProject && (
<div className="text-center py-20 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Select a project to view shot status</p>
</div>
)}
{/* Shots table */}
{selectedProject && shots.length === 0 && (
<div className="text-center py-20 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No shots in this project yet</p>
</div>
)}
{selectedProject && shots.length > 0 && (
<>
{/* Summary / selection bar */}
<div className="flex flex-wrap items-center gap-2">
{selectionCount === 0 ? (
<>
<span className="text-xs text-zinc-500 self-center">{shots.length} shots total</span>
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => {
const count = statusCounts[key] ?? 0;
if (!count) return null;
return (
<Badge key={key} className={cn("gap-1 text-xs", cfg.color)}>
{count} {cfg.label}
</Badge>
);
})}
</>
) : (
<div className="flex items-center gap-3 rounded-lg border border-blue-500/30 bg-blue-500/10 px-4 py-2">
<span className="text-sm font-medium text-blue-300">
{selectionCount} shot{selectionCount !== 1 ? "s" : ""} selected
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 border-blue-500/40 text-blue-300 hover:bg-blue-500/20 hover:text-blue-200"
>
Bulk Actions
<ChevronsUpDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setDueDateDialogOpen(true)}>
<CalendarDays className="h-3.5 w-3.5 mr-2" />
Change Due Date
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAssignDialogOpen(true)}>
<UserPlus className="h-3.5 w-3.5 mr-2" />
Assign Artist
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMovReviewDialogOpen(true)}>
<Clapperboard className="h-3.5 w-3.5 mr-2" />
Build MOV Review Package
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setRequestDialogOpen(true)}>
<ClipboardList className="h-3.5 w-3.5 mr-2" />
Log Delivery Request
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setManualDeliveryDialogOpen(true)}>
<PackageCheck className="h-3.5 w-3.5 mr-2" />
Delivered Outside Pipeline
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
className="text-red-400 focus:text-red-300"
>
<Trash2 className="h-3.5 w-3.5 mr-2" />
Delete Shots
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
onClick={clearSelection}
className="text-zinc-500 hover:text-zinc-300 transition-colors"
aria-label="Clear selection"
>
<X className="h-4 w-4" />
</button>
</div>
)}
</div>
{/* Table */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 overflow-hidden">
{isEpisodic ? (
<div className="divide-y divide-zinc-800">
{episodeGroups.map(([episode, episodeShots]) => {
const collapsed = !expandedEpisodes.has(episode);
const episodeIds = episodeShots.map((s) => s.id);
const allEpSelected = episodeIds.every((id) => selectedIds.has(id));
const someEpSelected = episodeIds.some((id) => selectedIds.has(id));
const epDueDate = episodeDueDateMap.get(episode);
const epIsOverdue = epDueDate && epDueDate < new Date();
return (
<div key={episode}>
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
{canManage && (
<IndeterminateCheckbox
checked={allEpSelected}
indeterminate={someEpSelected && !allEpSelected}
onChange={(e) => toggleMany(episodeIds, e.target.checked)}
className="shrink-0"
/>
)}
<button
onClick={() => toggleEpisode(episode)}
className="flex items-center gap-2 flex-1 text-left"
>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<span className="font-semibold text-sm text-white">Episode {episode}</span>
<span className="text-xs text-zinc-500 font-normal">
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
</span>
{epDueDate && (
<span className={cn(
"flex items-center gap-1 text-xs",
epIsOverdue ? "text-red-400" : "text-zinc-400"
)}>
<Calendar className="h-3 w-3" />
{format(epDueDate, "d MMM yyyy")}
</span>
)}
</button>
</div>
{!collapsed && (
<div className="overflow-x-auto">
<ShotTable
shots={episodeShots}
canManage={canManage}
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onToggleAll={toggleMany}
/>
</div>
)}
</div>
);
})}
</div>
) : groupMode === "scene" ? (
<div className="divide-y divide-zinc-800">
{sceneGroups.map(([scene, sceneShots]) => {
const collapsed = !expandedScenes.has(scene);
const sceneIds = sceneShots.map((s) => s.id);
const allScSelected = sceneIds.every((id) => selectedIds.has(id));
const someScSelected = sceneIds.some((id) => selectedIds.has(id));
return (
<div key={scene}>
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
{canManage && (
<IndeterminateCheckbox
checked={allScSelected}
indeterminate={someScSelected && !allScSelected}
onChange={(e) => toggleMany(sceneIds, e.target.checked)}
className="shrink-0"
/>
)}
<button
onClick={() => toggleScene(scene)}
className="flex items-center gap-2 flex-1 text-left"
>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<span className="font-semibold text-sm text-white">Scene {scene}</span>
<span className="text-xs text-zinc-500 font-normal">
{sceneShots.length} shot{sceneShots.length !== 1 ? "s" : ""}
</span>
</button>
</div>
{!collapsed && (
<div className="overflow-x-auto">
<ShotTable
shots={sceneShots}
canManage={canManage}
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onToggleAll={toggleMany}
/>
</div>
)}
</div>
);
})}
</div>
) : groupMode === "group" ? (
<div className="divide-y divide-zinc-800">
{shotGroupGroups.map(([groupId, groupName, groupShots]) => {
const collapsed = !expandedGroups.has(groupId);
const groupIds = groupShots.map((s) => s.id);
const allGrSelected = groupIds.every((id) => selectedIds.has(id));
const someGrSelected = groupIds.some((id) => selectedIds.has(id));
return (
<div key={groupId}>
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
{canManage && (
<IndeterminateCheckbox
checked={allGrSelected}
indeterminate={someGrSelected && !allGrSelected}
onChange={(e) => toggleMany(groupIds, e.target.checked)}
className="shrink-0"
/>
)}
<button
onClick={() => toggleGroup(groupId)}
className="flex items-center gap-2 flex-1 text-left"
>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<span className="font-semibold text-sm text-white">{groupName}</span>
<span className="text-xs text-zinc-500 font-normal">
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
</span>
</button>
</div>
{!collapsed && (
<div className="overflow-x-auto">
<ShotTable
shots={groupShots}
canManage={canManage}
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onToggleAll={toggleMany}
/>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="overflow-x-auto">
<ShotTable
shots={shots}
canManage={canManage}
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onToggleAll={toggleMany}
/>
</div>
)}
</div>
</>
)}
{/* Bulk due date dialog */}
<BulkDueDateDialog
open={dueDateDialogOpen}
onClose={() => setDueDateDialogOpen(false)}
onApply={handleBulkDueDate}
count={selectionCount}
/>
{/* Bulk artist assignment dialog */}
<BulkAssignArtistDialog
open={assignDialogOpen}
onClose={() => setAssignDialogOpen(false)}
onApply={handleBulkAssignArtist}
artists={artists}
count={selectionCount}
/>
{/* Manual (pre-pipeline) delivery dialog */}
<ManualDeliveryDialog
open={manualDeliveryDialogOpen}
onClose={() => setManualDeliveryDialogOpen(false)}
onApply={handleManualDelivery}
count={selectionCount}
/>
{/* A client asked for the selected shots */}
{selectedProjectId && (
<LogRequestDialog
open={requestDialogOpen}
onClose={() => setRequestDialogOpen(false)}
onLogged={clearSelection}
preset={{ projectId: selectedProjectId, shotIds: [...selectedIds] }}
/>
)}
{/* Bulk delete dialog */}
<BulkDeleteDialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
onConfirm={handleBulkDelete}
count={selectionCount}
/>
{/* MOV review package (§11.6) */}
<MovReviewDialog
open={movReviewDialogOpen}
onClose={() => setMovReviewDialogOpen(false)}
onBuilt={() => {
setMovReviewDialogOpen(false);
clearSelection();
}}
shotIds={[...selectedIds]}
/>
</div>
);
}