Files
vfxreview/app/(dashboard)/shot-status/ShotStatusClient.tsx
T
twotalesanimation b187e1b16e
Deploy / deploy (push) Failing after 2m53s
Added bulk date changes
2026-06-05 08:01:38 +02:00

628 lines
21 KiB
TypeScript

"use client";
import { useState, useTransition, useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import {
Select,
SelectContent,
SelectItem,
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, bulkUpdateDueDates } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import {
Film,
Clock,
CheckCircle2,
AlertCircle,
Calendar,
CalendarDays,
ChevronDown,
ChevronRight,
Loader2,
X,
} from "lucide-react";
import { format } from "date-fns";
type ShotRow = {
id: string;
shotCode: string;
scene: string;
episode: string | null;
shotNumber: number;
status: string;
priority: string;
dueDate: Date | string | null;
thumbnailUrl: string | null;
notes: string | null;
description: string | null;
artist: { id: string; name: string | null; image: string | null; email: string } | null;
};
interface Project {
id: string;
name: string;
code: string;
projectType: string;
}
interface ShotStatusClientProps {
projects: Project[];
selectedProjectId: string | null;
selectedProject: Project | null;
shots: ShotRow[];
canManage: boolean;
}
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Film },
IN_REVIEW: { label: "In Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle },
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: AlertCircle },
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,
}: {
shot: ShotRow;
canManage: boolean;
}) {
const [value, setValue] = useState(shot.notes ?? "");
const [saved, setSaved] = useState(true);
const [isPending, startTransition] = useTransition();
const { toast } = useToast();
const handleBlur = useCallback(() => {
if (value === (shot.notes ?? "")) return;
startTransition(async () => {
try {
await updateShotNotes(shot.id, value);
setSaved(true);
toast({ title: "Notes saved" });
} catch {
toast({ title: "Failed to save notes", variant: "destructive" });
}
});
}, [value, shot.id, shot.notes, toast]);
if (!canManage) {
return (
<span className="text-sm text-zinc-400 whitespace-pre-wrap">
{shot.notes ?? <span className="italic text-zinc-600"></span>}
</span>
);
}
return (
<div className="relative">
<Textarea
value={value}
onChange={(e) => { setValue(e.target.value); setSaved(false); }}
onBlur={handleBlur}
placeholder="Add notes..."
className="min-h-[60px] text-sm resize-none bg-zinc-900 border-zinc-700 focus:border-zinc-500 text-zinc-200 placeholder:text-zinc-600"
rows={2}
/>
{isPending && (
<Loader2 className="absolute bottom-2 right-2 h-3 w-3 animate-spin text-zinc-500" />
)}
{!isPending && !saved && (
<span className="absolute bottom-1.5 right-2 text-[10px] text-zinc-500">unsaved</span>
)}
</div>
);
}
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>
<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>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{shots.map((shot) => {
const cfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
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={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">
{shot.thumbnailUrl ? (
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
width={96}
height={40}
className="object-cover w-full h-full"
/>
) : (
<Film className="h-4 w-4 text-zinc-600" />
)}
</div>
</td>
{/* Shot name */}
<td className="py-2 px-3">
<Link
href={`/projects/${projectId}/shots/${shot.id}`}
className="font-mono text-sm text-white hover:text-blue-400 transition-colors"
>
{shot.shotCode}
</Link>
{shot.description && (
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">
{shot.description}
</p>
)}
</td>
{/* Status */}
<td className="py-2 px-3">
<Badge className={cn("gap-1 text-xs", cfg.color)}>
<StatusIcon className="h-3 w-3" />
{cfg.label}
</Badge>
</td>
{/* Due date */}
<td className="py-2 px-3">
{dueDate ? (
<span
className={cn(
"flex items-center gap-1 text-xs",
isOverdue ? "text-red-400" : "text-zinc-400"
)}
>
<Calendar className="h-3 w-3" />
{format(dueDate, "d MMM yyyy")}
</span>
) : (
<span className="text-xs text-zinc-600 italic"></span>
)}
</td>
{/* Notes */}
<td className="py-2 px-3 min-w-[220px]">
<NotesCell shot={shot} canManage={canManage} />
</td>
</tr>
);
})}
</tbody>
</table>
);
}
export function ShotStatusClient({
projects,
selectedProjectId,
selectedProject,
shots,
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) => {
const next = new Set(prev);
next.has(ep) ? next.delete(ep) : next.add(ep);
return next;
});
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
? (() => {
const map = new Map<string, ShotRow[]>();
for (const shot of shots) {
const key = shot.episode ?? "(No Episode)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
}
return Array.from(map.entries());
})()
: [];
const statusCounts = shots.reduce(
(acc, s) => {
acc[s.status] = (acc[s.status] ?? 0) + 1;
return acc;
},
{} as Record<string, number>
);
const selectionCount = selectedIds.size;
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-white">Shot Status</h1>
<p className="text-zinc-400 mt-1">Track shot progress and add production notes</p>
</div>
{/* Project selector */}
<div className="flex items-center gap-3">
<label className="text-sm text-zinc-400 shrink-0">Project:</label>
<Select value={selectedProjectId ?? ""} onValueChange={handleProjectChange}>
<SelectTrigger className="w-72">
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
<span className="font-mono text-xs text-zinc-400 mr-2">{p.code}</span>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* No project selected */}
{!selectedProject && (
<div className="text-center py-20 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Select a project to view shot status</p>
</div>
)}
{/* Shots table */}
{selectedProject && shots.length === 0 && (
<div className="text-center py-20 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No shots in this project yet</p>
</div>
)}
{selectedProject && shots.length > 0 && (
<>
{/* 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;
if (!count) return null;
return (
<Badge key={key} className={cn("gap-1 text-xs", cfg.color)}>
{count} {cfg.label}
</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 */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 overflow-hidden">
{isEpisodic ? (
<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 flex-1 text-left"
>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<span className="font-semibold text-sm text-white">Episode {episode}</span>
<span className="text-xs text-zinc-500 font-normal">
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
</span>
</button>
</div>
{!collapsed && (
<div className="overflow-x-auto">
<ShotTable
shots={episodeShots}
canManage={canManage}
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onToggleAll={toggleMany}
/>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="overflow-x-auto">
<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>
);
}