diff --git a/actions/shots.ts b/actions/shots.ts index e4fb331..a4ed77c 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -652,6 +652,57 @@ export async function bulkUpdateDueDates( return { success: true, updated: shots.length }; } +// ── Bulk Assign Artist ──────────────────────────────────────────────────────── + +/** + * Point a selection of shots at one artist, or clear the assignment when + * `artistId` is null. Mirrors the single-shot artist field in updateShot — + * the assignment is a plain column write with no notification, so reassigning + * a batch during planning stays quiet. + */ +export async function bulkAssignArtist(shotIds: string[], artistId: string | null) { + 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, artistName: null as string | null }; + + let artistName: string | null = null; + if (artistId) { + const artist = await db.user.findUnique({ + where: { id: z.string().cuid().parse(artistId) }, + select: { id: true, name: true, email: true, role: true, isActive: true }, + }); + // A deactivated user or a client account must never end up owning a shot, + // so this is checked here rather than trusting whatever the picker sent. + if (!artist || !artist.isActive || artist.role === "CLIENT") { + throw new Error("That user cannot be assigned shots"); + } + artistName = artist.name ?? artist.email; + } + + 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"); + + await db.shot.updateMany({ + where: { id: { in: ids } }, + data: { artistId }, + }); + + for (const pid of [...new Set(shots.map((s) => s.projectId))]) { + revalidatePath(`/projects/${pid}`); + } + revalidatePath(`/shot-status`); + + return { success: true, updated: shots.length, artistName }; +} + // ── 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 375d685..5709f5c 100644 --- a/app/(dashboard)/shot-status/ShotStatusClient.tsx +++ b/app/(dashboard)/shot-status/ShotStatusClient.tsx @@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, + SelectSeparator, SelectTrigger, SelectValue, } from "@/components/ui/select"; @@ -28,8 +29,9 @@ import { 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, toggleScaledShot } from "@/actions/shots"; +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 { previewMovReview, buildMovReview } from "@/actions/deliveries"; import { useToast } from "@/components/ui/use-toast"; import { @@ -50,6 +52,7 @@ import { ChevronsUpDown, Clapperboard, Scaling, + UserPlus, } from "lucide-react"; import { format } from "date-fns"; import { shotThumbnail } from "@/lib/shot-thumbnail"; @@ -74,6 +77,13 @@ type ShotRow = { shotGroup: { id: string; name: string } | null; }; +interface Artist { + id: string; + name: string | null; + email: string; + image: string | null; +} + interface Project { id: string; name: string; @@ -87,6 +97,7 @@ interface ShotStatusClientProps { selectedProject: Project | null; shots: ShotRow[]; episodeDueDates: { episode: string; dueDate: Date | string }[]; + artists: Artist[]; canManage: boolean; } @@ -225,6 +236,103 @@ function BulkDueDateDialog({ ); } +// ── 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; + 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 ( + { if (!o && !isPending) onClose(); }}> + + + Assign Artist + + +

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

+ +
+ + +

+ This replaces whoever is currently on {count === 1 ? "the shot" : "these shots"}. +

+
+ + + + + +
+
+ ); +} + function BulkDeleteDialog({ open, onClose, @@ -601,6 +709,7 @@ function ShotTable({ Thumb Shot Status + Artist Due Date Notes @@ -700,6 +809,25 @@ function ShotTable({ + {/* Artist */} + + {shot.artist ? ( + + + + + {getInitials(shot.artist.name ?? shot.artist.email)} + + + + {shot.artist.name ?? shot.artist.email} + + + ) : ( + Unassigned + )} + + {/* Due date */} {dueDate ? ( @@ -745,6 +873,7 @@ export function ShotStatusClient({ selectedProject, shots, episodeDueDates, + artists, canManage, }: ShotStatusClientProps) { const router = useRouter(); @@ -793,6 +922,7 @@ export function ShotStatusClient({ } }, [selectedProjectId]); const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false); + const [assignDialogOpen, setAssignDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false); @@ -868,6 +998,26 @@ export function ShotStatusClient({ } }; + 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 handleBulkDelete = async () => { try { const result = await bulkDeleteShots([...selectedIds]); @@ -1040,6 +1190,10 @@ export function ShotStatusClient({ Change Due Date + setAssignDialogOpen(true)}> + + Assign Artist + setMovReviewDialogOpen(true)}> Build MOV Review Package @@ -1249,6 +1403,15 @@ export function ShotStatusClient({ count={selectionCount} /> + {/* Bulk artist assignment dialog */} + setAssignDialogOpen(false)} + onApply={handleBulkAssignArtist} + artists={artists} + count={selectionCount} + /> + {/* Bulk delete dialog */} p.id === projectId) ?? null; @@ -77,6 +87,7 @@ export default async function ShotStatusPage({ selectedProject={selectedProject} shots={shots as any} episodeDueDates={episodeDueDates} + artists={artists} canManage={canManage} /> );