feat(shot-status): bulk-assign selected shots to an artist
Deploy / deploy (push) Successful in 6m30s

Adds an "Assign Artist" entry to the bulk actions menu, backed by a
bulkAssignArtist server action. The action re-checks the target user
server-side — a deactivated account or a CLIENT is rejected rather than
trusted from the picker — and passing null clears the assignment, so
reassignment and hand-back are the same flow.

Like single-shot assignment in updateShot this writes the column and
sends no notification, so reshuffling a batch during planning stays quiet.

Also renders the Artist column the page was already querying but never
showing; without it there was no way to see the result of an assignment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-03 09:51:13 +02:00
parent 75f9258b69
commit 04e5eb123a
3 changed files with 228 additions and 3 deletions
+51
View File
@@ -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[]) {
@@ -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<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>
);
}
function BulkDeleteDialog({
open,
onClose,
@@ -601,6 +709,7 @@ function ShotTable({
<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">
@@ -700,6 +809,25 @@ function ShotTable({
</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 ? (
@@ -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({
<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
@@ -1249,6 +1403,15 @@ export function ShotStatusClient({
count={selectionCount}
/>
{/* Bulk artist assignment dialog */}
<BulkAssignArtistDialog
open={assignDialogOpen}
onClose={() => setAssignDialogOpen(false)}
onApply={handleBulkAssignArtist}
artists={artists}
count={selectionCount}
/>
{/* Bulk delete dialog */}
<BulkDeleteDialog
open={deleteDialogOpen}
+12 -1
View File
@@ -19,6 +19,15 @@ async function getEpisodeDueDatesForProject(projectId: string) {
});
}
/** Everyone a shot can be handed to — clients and deactivated accounts excluded. */
async function getAssignableArtists() {
return db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: { id: true, name: true, email: true, image: true },
orderBy: { name: "asc" },
});
}
async function getShotsForProject(projectId: string) {
return db.shot.findMany({
where: { projectId },
@@ -62,10 +71,11 @@ export default async function ShotStatusPage({
const { projectId } = await searchParams;
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const [projects, shots, episodeDueDates] = await Promise.all([
const [projects, shots, episodeDueDates, artists] = await Promise.all([
getProjects(),
projectId ? getShotsForProject(projectId) : Promise.resolve([]),
projectId ? getEpisodeDueDatesForProject(projectId) : Promise.resolve([]),
canManage ? getAssignableArtists() : Promise.resolve([]),
]);
const selectedProject = projects.find((p) => 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}
/>
);