Files
vfxreview/app/(dashboard)/shot-status/ShotStatusClient.tsx
T
twotalesanimation f5c97fd9a7
Deploy / deploy (push) Failing after 2m46s
updated shot status pages
2026-06-05 07:53:24 +02:00

355 lines
12 KiB
TypeScript

"use client";
import { useState, useTransition, useCallback } 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 { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { updateShotNotes } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import {
Film,
Clock,
CheckCircle2,
AlertCircle,
Calendar,
ChevronDown,
ChevronRight,
Loader2,
} 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 },
};
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 }: { shots: ShotRow[]; canManage: boolean; projectId: string }) {
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<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";
return (
<tr key={shot.id} className="hover:bg-zinc-800/30 transition-colors">
{/* 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 [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
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) => {
router.push(`/shot-status?projectId=${id}`);
};
// 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>
);
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 pills */}
<div className="flex flex-wrap gap-2">
<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>
{/* 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);
return (
<div key={episode}>
<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"
>
{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>
{!collapsed && (
<div className="overflow-x-auto">
<ShotTable shots={episodeShots} canManage={canManage} projectId={selectedProjectId!} />
</div>
)}
</div>
);
})}
</div>
) : (
<div className="overflow-x-auto">
<ShotTable shots={shots} canManage={canManage} projectId={selectedProjectId!} />
</div>
)}
</div>
</>
)}
</div>
);
}