@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user