Added bulk date changes
Deploy / deploy (push) Failing after 2m53s

This commit is contained in:
twotalesanimation
2026-06-05 08:01:38 +02:00
parent f5c97fd9a7
commit b187e1b16e
2 changed files with 349 additions and 31 deletions
+45
View File
@@ -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");
@@ -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<string, { label: string; color: string; icon: React.
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>
);
}
// ── 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<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>
@@ -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 (
<tr key={shot.id} className="hover:bg-zinc-800/30 transition-colors">
<tr key={shot.id} className={cn("hover:bg-zinc-800/30 transition-colors", 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">
@@ -217,7 +391,11 @@ export function ShotStatusClient({
canManage,
}: ShotStatusClientProps) {
const router = useRouter();
const { toast } = useToast();
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<string, number>
);
const selectionCount = selectedIds.size;
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Header */}
@@ -296,8 +508,10 @@ export function ShotStatusClient({
{selectedProject && shots.length > 0 && (
<>
{/* Summary pills */}
<div className="flex flex-wrap gap-2">
{/* 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;
@@ -308,6 +522,30 @@ export function ShotStatusClient({
</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>
<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"
onClick={() => setDueDateDialogOpen(true)}
>
<CalendarDays className="h-3.5 w-3.5" />
Change Due Date
</Button>
<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 */}
@@ -316,11 +554,23 @@ export function ShotStatusClient({
<div className="divide-y divide-zinc-800">
{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 (
<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 w-full px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors text-left"
className="flex items-center gap-2 flex-1 text-left"
>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
@@ -332,9 +582,17 @@ export function ShotStatusClient({
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
</span>
</button>
</div>
{!collapsed && (
<div className="overflow-x-auto">
<ShotTable shots={episodeShots} canManage={canManage} projectId={selectedProjectId!} />
<ShotTable
shots={episodeShots}
canManage={canManage}
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onToggleAll={toggleMany}
/>
</div>
)}
</div>
@@ -343,12 +601,27 @@ export function ShotStatusClient({
</div>
) : (
<div className="overflow-x-auto">
<ShotTable shots={shots} canManage={canManage} projectId={selectedProjectId!} />
<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}
/>
</div>
);
}