feat(shot-status): bulk-assign selected shots to an artist
Deploy / deploy (push) Successful in 6m30s
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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user