349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition, useCallback } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Image from "next/image";
|
|
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 }: { shots: ShotRow[]; canManage: boolean }) {
|
|
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-14">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-10 h-10 rounded overflow-hidden bg-zinc-800 flex items-center justify-center shrink-0">
|
|
{shot.thumbnailUrl ? (
|
|
<Image
|
|
src={shot.thumbnailUrl}
|
|
alt={shot.shotCode}
|
|
width={40}
|
|
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">
|
|
<span className="font-mono text-sm text-white">{shot.shotCode}</span>
|
|
{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} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<ShotTable shots={shots} canManage={canManage} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|