851 lines
30 KiB
TypeScript
851 lines
30 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, toggleKeyShot } from "@/actions/shots";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import {
|
|
Film,
|
|
Clock,
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
Calendar,
|
|
CalendarDays,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Loader2,
|
|
X,
|
|
ShieldCheck,
|
|
Eye,
|
|
Star,
|
|
} 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;
|
|
isKeyShot: boolean;
|
|
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[];
|
|
episodeDueDates: { episode: string; dueDate: Date | string }[];
|
|
canManage: boolean;
|
|
}
|
|
|
|
const SCROLL_KEY = 'shot-status-scroll';
|
|
|
|
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 },
|
|
INTERNAL_REVIEW: { label: "Internal Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle },
|
|
READY_FOR_CLIENT: { label: "Ready for Client", color: "bg-sky-500/10 text-sky-400 border-sky-500/20", icon: ShieldCheck },
|
|
CLIENT_REVIEW: { label: "Client Review", color: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", icon: Eye },
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ── KeyShotToggle ─────────────────────────────────────────────────────────────
|
|
|
|
function KeyShotToggle({ shot }: { shot: ShotRow }) {
|
|
const [optimistic, setOptimistic] = useState(shot.isKeyShot);
|
|
const [isPending, startTransition] = useTransition();
|
|
const { toast } = useToast();
|
|
|
|
const handle = () => {
|
|
const next = !optimistic;
|
|
setOptimistic(next);
|
|
startTransition(async () => {
|
|
try {
|
|
await toggleKeyShot(shot.id, next);
|
|
} catch {
|
|
setOptimistic(!next);
|
|
toast({ title: "Failed to update key shot", variant: "destructive" });
|
|
}
|
|
});
|
|
};
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={optimistic}
|
|
aria-label={optimistic ? "Remove key shot" : "Mark as key shot"}
|
|
disabled={isPending}
|
|
onClick={handle}
|
|
className={cn(
|
|
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 disabled:opacity-50",
|
|
optimistic ? "bg-amber-500" : "bg-zinc-600 hover:bg-zinc-500"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-lg transition-transform",
|
|
optimistic ? "translate-x-4" : "translate-x-0"
|
|
)}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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>
|
|
<th className="py-2 px-3 text-xs font-medium text-zinc-500 w-20 text-center">
|
|
<span className="flex items-center gap-1 justify-center">
|
|
<Star className="h-3 w-3" />
|
|
Key
|
|
</span>
|
|
</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(
|
|
"transition-colors",
|
|
shot.isKeyShot
|
|
? "bg-amber-500/5 hover:bg-amber-500/10 border-l-2 border-l-amber-500"
|
|
: "hover:bg-zinc-800/30",
|
|
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">
|
|
<div className="flex items-center gap-1.5">
|
|
{shot.isKeyShot && (
|
|
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />
|
|
)}
|
|
<Link
|
|
href={`/projects/${projectId}/shots/${shot.id}`}
|
|
className="font-mono text-sm text-white hover:text-blue-400 transition-colors"
|
|
onClick={() => sessionStorage.setItem(SCROLL_KEY, String(window.scrollY))}
|
|
>
|
|
{shot.shotCode}
|
|
</Link>
|
|
</div>
|
|
{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>
|
|
|
|
{/* Key shot toggle */}
|
|
<td className="py-2 px-3 text-center">
|
|
<KeyShotToggle shot={shot} />
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
|
|
export function ShotStatusClient({
|
|
projects,
|
|
selectedProjectId,
|
|
selectedProject,
|
|
shots,
|
|
episodeDueDates,
|
|
canManage,
|
|
}: ShotStatusClientProps) {
|
|
const router = useRouter();
|
|
const { toast } = useToast();
|
|
|
|
// Restore scroll position when shots load
|
|
useEffect(() => {
|
|
const saved = sessionStorage.getItem(SCROLL_KEY);
|
|
if (saved) {
|
|
sessionStorage.removeItem(SCROLL_KEY);
|
|
requestAnimationFrame(() => window.scrollTo(0, parseInt(saved, 10)));
|
|
}
|
|
}, [shots]);
|
|
|
|
// Restore last selected project if no project is currently selected
|
|
useEffect(() => {
|
|
if (!selectedProjectId) {
|
|
const saved = localStorage.getItem("shotStatus:lastProjectId");
|
|
if (saved && projects.some((p) => p.id === saved)) {
|
|
router.replace(`/shot-status?projectId=${saved}`);
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(new Set());
|
|
const [expandedScenes, setExpandedScenes] = useState<Set<string>>(new Set());
|
|
const [groupByScene, setGroupByScene] = useState(false);
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
|
|
// Load saved episode expand state when project changes
|
|
useEffect(() => {
|
|
if (!selectedProjectId) return;
|
|
try {
|
|
const saved = localStorage.getItem(`shotStatus:${selectedProjectId}:episodeExpand`);
|
|
setExpandedEpisodes(saved ? new Set(JSON.parse(saved) as string[]) : new Set());
|
|
} catch {
|
|
setExpandedEpisodes(new Set());
|
|
}
|
|
try {
|
|
const savedGroup = localStorage.getItem(`shotStatus:${selectedProjectId}:groupByScene`);
|
|
setGroupByScene(savedGroup === "1");
|
|
} catch {
|
|
setGroupByScene(false);
|
|
}
|
|
}, [selectedProjectId]);
|
|
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
|
|
|
|
const toggleEpisode = (ep: string) =>
|
|
setExpandedEpisodes((prev) => {
|
|
const next = new Set(prev);
|
|
next.has(ep) ? next.delete(ep) : next.add(ep);
|
|
if (selectedProjectId) {
|
|
try {
|
|
localStorage.setItem(`shotStatus:${selectedProjectId}:episodeExpand`, JSON.stringify([...next]));
|
|
} catch {}
|
|
}
|
|
return next;
|
|
});
|
|
|
|
const toggleScene = (sc: string) =>
|
|
setExpandedScenes((prev) => {
|
|
const next = new Set(prev);
|
|
next.has(sc) ? next.delete(sc) : next.add(sc);
|
|
return next;
|
|
});
|
|
|
|
const handleGroupByScene = (checked: boolean) => {
|
|
setGroupByScene(checked);
|
|
if (checked) setExpandedScenes(new Set(shots.map((s) => s.scene)));
|
|
if (selectedProjectId) {
|
|
try { localStorage.setItem(`shotStatus:${selectedProjectId}:groupByScene`, checked ? "1" : "0"); } catch {}
|
|
}
|
|
};
|
|
|
|
const handleProjectChange = (id: string) => {
|
|
localStorage.setItem("shotStatus:lastProjectId", id);
|
|
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 sceneGroups: [string, ShotRow[]][] = !isEpisodic && groupByScene
|
|
? (() => {
|
|
const map = new Map<string, ShotRow[]>();
|
|
for (const shot of shots) {
|
|
const key = shot.scene || "(No Scene)";
|
|
if (!map.has(key)) map.set(key, []);
|
|
map.get(key)!.push(shot);
|
|
}
|
|
return Array.from(map.entries());
|
|
})()
|
|
: [];
|
|
|
|
const episodeDueDateMap = new Map(
|
|
episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)])
|
|
);
|
|
|
|
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>
|
|
{selectedProject && !isEpisodic && (
|
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={groupByScene}
|
|
onChange={(e) => handleGroupByScene(e.target.checked)}
|
|
className="cursor-pointer accent-blue-500 w-3.5 h-3.5"
|
|
/>
|
|
<span className="text-sm text-zinc-400">Group by scene</span>
|
|
</label>
|
|
)}
|
|
</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 = !expandedEpisodes.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));
|
|
const epDueDate = episodeDueDateMap.get(episode);
|
|
const epIsOverdue = epDueDate && epDueDate < new Date();
|
|
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>
|
|
{epDueDate && (
|
|
<span className={cn(
|
|
"flex items-center gap-1 text-xs",
|
|
epIsOverdue ? "text-red-400" : "text-zinc-400"
|
|
)}>
|
|
<Calendar className="h-3 w-3" />
|
|
{format(epDueDate, "d MMM yyyy")}
|
|
</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>
|
|
) : groupByScene ? (
|
|
<div className="divide-y divide-zinc-800">
|
|
{sceneGroups.map(([scene, sceneShots]) => {
|
|
const collapsed = !expandedScenes.has(scene);
|
|
const sceneIds = sceneShots.map((s) => s.id);
|
|
const allScSelected = sceneIds.every((id) => selectedIds.has(id));
|
|
const someScSelected = sceneIds.some((id) => selectedIds.has(id));
|
|
return (
|
|
<div key={scene}>
|
|
<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={allScSelected}
|
|
indeterminate={someScSelected && !allScSelected}
|
|
onChange={(e) => toggleMany(sceneIds, e.target.checked)}
|
|
className="shrink-0"
|
|
/>
|
|
)}
|
|
<button
|
|
onClick={() => toggleScene(scene)}
|
|
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">Scene {scene}</span>
|
|
<span className="text-xs text-zinc-500 font-normal">
|
|
{sceneShots.length} shot{sceneShots.length !== 1 ? "s" : ""}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{!collapsed && (
|
|
<div className="overflow-x-auto">
|
|
<ShotTable
|
|
shots={sceneShots}
|
|
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>
|
|
);
|
|
}
|