From b187e1b16eeb9a8026de7738868c7ea4f2f4ba40 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:01:38 +0200 Subject: [PATCH] Added bulk date changes --- actions/shots.ts | 45 +++ .../shot-status/ShotStatusClient.tsx | 335 ++++++++++++++++-- 2 files changed, 349 insertions(+), 31 deletions(-) diff --git a/actions/shots.ts b/actions/shots.ts index 4d5a557..eaa401e 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -504,6 +504,51 @@ export async function deleteShot(shotId: string) { return { success: true, projectId: shot.projectId }; } +// ── Bulk Update Due Dates ───────────────────────────────────────────────────── + +export async function bulkUpdateDueDates( + shotIds: string[], + dueDate: string, + includeTasks: 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"); + } + + if (!shotIds.length) return { success: true, updated: 0 }; + + const date = new Date(dueDate); + + const shots = await db.shot.findMany({ + where: { id: { in: shotIds } }, + select: { id: true, projectId: true }, + }); + if (shots.length !== shotIds.length) throw new Error("Some shots not found"); + + const projectIds = [...new Set(shots.map((s) => s.projectId))]; + + await db.shot.updateMany({ + where: { id: { in: shotIds } }, + data: { dueDate: date }, + }); + + if (includeTasks) { + await db.task.updateMany({ + where: { shotId: { in: shotIds } }, + data: { dueDate: date }, + }); + } + + for (const pid of projectIds) { + revalidatePath(`/projects/${pid}`); + } + revalidatePath(`/shot-status`); + + return { success: true, updated: shots.length }; +} + export async function renameFootagePlate(plateId: string, label: 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 7b61723..700c730 100644 --- a/app/(dashboard)/shot-status/ShotStatusClient.tsx +++ b/app/(dashboard)/shot-status/ShotStatusClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useTransition, useCallback } from "react"; +import { useState, useTransition, useCallback, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; @@ -11,10 +11,18 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; 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 } from "@/actions/shots"; +import { updateShotNotes, bulkUpdateDueDates } from "@/actions/shots"; import { useToast } from "@/components/ui/use-toast"; import { Film, @@ -22,9 +30,11 @@ import { CheckCircle2, AlertCircle, Calendar, + CalendarDays, ChevronDown, ChevronRight, Loader2, + X, } from "lucide-react"; import { format } from "date-fns"; @@ -66,6 +76,131 @@ const STATUS_CONFIG: Record) => void; + className?: string; +}) { + const ref = useRef(null); + useEffect(() => { + if (ref.current) ref.current.indeterminate = !!indeterminate; + }, [indeterminate]); + return ( + + ); +} + +// ── BulkDueDateDialog ───────────────────────────────────────────────────────── + +function BulkDueDateDialog({ + open, + onClose, + onApply, + count, +}: { + open: boolean; + onClose: () => void; + onApply: (date: string, includeTasks: boolean) => Promise; + 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 ( + { if (!o && !isPending) onClose(); }}> + + + Change Due Date + + +

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

+ +
+
+ + 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" + /> +
+ +
+ +
+ + +
+
+
+ + + + + +
+
+ ); +} + +// ── NotesCell ───────────────────────────────────────────────────────────────── + function NotesCell({ shot, canManage, @@ -119,11 +254,37 @@ function NotesCell({ ); } -function ShotTable({ shots, canManage, projectId }: { shots: ShotRow[]; canManage: boolean; projectId: string }) { +function ShotTable({ + shots, + canManage, + projectId, + selectedIds, + onToggle, + onToggleAll, +}: { + shots: ShotRow[]; + canManage: boolean; + projectId: string; + selectedIds: Set; + 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 ( + {canManage && ( + + )} @@ -137,9 +298,22 @@ function ShotTable({ shots, canManage, projectId }: { shots: ShotRow[]; canManag 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 ( - + + {/* Checkbox */} + {canManage && ( + + )} + {/* Thumbnail */}
+ onToggleAll(shots.map((s) => s.id), e.target.checked)} + /> + Thumb Shot Status
+ onToggle(shot.id)} + className="cursor-pointer accent-blue-500 w-3.5 h-3.5" + /> +
@@ -217,7 +391,11 @@ export function ShotStatusClient({ canManage, }: ShotStatusClientProps) { const router = useRouter(); + const { toast } = useToast(); + const [collapsedEpisodes, setCollapsedEpisodes] = useState>(new Set()); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false); const toggleEpisode = (ep: string) => setCollapsedEpisodes((prev) => { @@ -227,9 +405,41 @@ export function ShotStatusClient({ }); const handleProjectChange = (id: string) => { + 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" }); + } + }; + // Group by episode for episodic projects const isEpisodic = selectedProject?.projectType === "EPISODIC"; const episodeGroups: [string, ShotRow[]][] = isEpisodic @@ -252,6 +462,8 @@ export function ShotStatusClient({ {} as Record ); + const selectionCount = selectedIds.size; + return (
{/* Header */} @@ -296,18 +508,44 @@ export function ShotStatusClient({ {selectedProject && shots.length > 0 && ( <> - {/* Summary pills */} -
- {shots.length} shots total - {Object.entries(STATUS_CONFIG).map(([key, cfg]) => { - const count = statusCounts[key] ?? 0; - if (!count) return null; - return ( - - {count} {cfg.label} - - ); - })} + {/* Summary / selection bar */} +
+ {selectionCount === 0 ? ( + <> + {shots.length} shots total + {Object.entries(STATUS_CONFIG).map(([key, cfg]) => { + const count = statusCounts[key] ?? 0; + if (!count) return null; + return ( + + {count} {cfg.label} + + ); + })} + + ) : ( +
+ + {selectionCount} shot{selectionCount !== 1 ? "s" : ""} selected + + + +
+ )}
{/* Table */} @@ -316,25 +554,45 @@ export function ShotStatusClient({
{episodeGroups.map(([episode, episodeShots]) => { const collapsed = collapsedEpisodes.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)); return (
- + +
{!collapsed && (
- +
)}
@@ -343,12 +601,27 @@ export function ShotStatusClient({
) : (
- +
)}
)} + + {/* Bulk due date dialog */} + setDueDateDialogOpen(false)} + onApply={handleBulkDueDate} + count={selectionCount} + />
); }