Files
vfxreview/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx
T
twotalesanimation b640fa472c
Deploy / deploy (push) Successful in 2m37s
added unapprove
2026-06-11 15:19:12 +02:00

434 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { TaskList } from "@/components/tasks/TaskList";
import { Separator } from "@/components/ui/separator";
import { getInitials } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
Film,
ArrowLeft,
Clock,
AlertCircle,
CheckCircle2,
Settings,
ListTodo,
Video,
Copy,
ShieldCheck,
Share2,
Eye,
EyeOff,
} from "lucide-react";
import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
import { FootageViewer } from "@/components/shots/FootageViewer";
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot } from "@/actions/shots";
const STATUS_CONFIG: Record<
string,
{ label: string; className: string; Icon: React.ElementType }
> = {
WAITING: { label: "Waiting", className: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", Icon: Clock },
IN_PROGRESS: { label: "In Progress", className: "bg-blue-500/10 text-blue-400 border-blue-500/20", Icon: Film },
INTERNAL_REVIEW: { label: "Internal Review", className: "bg-purple-500/10 text-purple-400 border-purple-500/20", Icon: AlertCircle },
READY_FOR_CLIENT: { label: "Ready for Client", className: "bg-sky-500/10 text-sky-400 border-sky-500/20", Icon: ShieldCheck },
CLIENT_REVIEW: { label: "Client Review", className: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", Icon: Eye },
REVISIONS: { label: "Revisions", className: "bg-orange-500/10 text-orange-400 border-orange-500/20", Icon: AlertCircle },
COMPLETE: { label: "Complete", className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", Icon: CheckCircle2 },
};
const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
LOW: { label: "Low", dot: "bg-zinc-400" },
NORMAL: { label: "Normal", dot: "bg-blue-400" },
HIGH: { label: "High", dot: "bg-amber-400" },
CRITICAL: { label: "Critical", dot: "bg-red-500" },
};
import { useToast } from "@/components/ui/use-toast";
export default function ShotDetailPage() {
const params = useParams<{ id: string; shotId: string }>();
const router = useRouter();
const { toast } = useToast();
const [shot, setShot] = useState<ShotWithDetails | null>(null);
const [projectName, setProjectName] = useState<string>("");
const [loading, setLoading] = useState(true);
const [canApprove, setCanApprove] = useState(false);
const [canInternallyApprove, setCanInternallyApprove] = useState(false);
const [tasks, setTasks] = useState<any[]>([]);
const [artists, setArtists] = useState<any[]>([]);
const [canManage, setCanManage] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isActioning, setIsActioning] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks");
const fetchShot = async () => {
try {
const res = await fetch(`/api/shots/${params.shotId}?projectId=${params.id}`);
if (res.status === 404) {
router.push("/projects");
return;
}
const data = await res.json();
setShot(data.shot);
setProjectName(data.projectName ?? "");
setCanApprove(data.canApprove ?? false);
setCanInternallyApprove(data.canInternallyApprove ?? false);
setTasks(data.tasks ?? []);
setArtists(data.artists ?? []);
setCanManage(data.canApprove ?? false);
} catch {
router.push("/projects");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchShot();
}, [params.shotId]);
const handleInternalApprove = async () => {
if (!shot) return;
setIsActioning(true);
try {
await internallyApproveShot(shot.id);
toast({ title: "Shot internally approved", description: "Status: Ready for Client" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleShareWithClient = async () => {
if (!shot) return;
setIsActioning(true);
try {
await shareWithClient(shot.id);
toast({ title: "Shared with client", description: "Status: Client Review" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleUnshare = async () => {
if (!shot) return;
setIsActioning(true);
try {
await unshareFromClient(shot.id);
toast({ title: "Removed from client review", description: "Status: Ready for Client" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleUnapproveShot = async () => {
if (!shot) return;
if (!confirm("Undo client approval? The shot will return to Internally Approved.")) return;
setIsActioning(true);
try {
await unapproveShot(shot.id);
toast({ title: "Client approval undone", description: "Status: Ready for Client" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleDuplicate = async () => {
if (!shot) return;
setIsDuplicating(true);
try {
const { shot: newShot } = await duplicateShot(shot.id);
toast({ title: "Shot duplicated", description: `Created ${newShot.shotCode}` });
router.push(`/projects/${params.id}/shots/${newShot.id}`);
} catch (e) {
toast({ title: "Duplicate failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsDuplicating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Film className="h-6 w-6 animate-pulse text-muted-foreground" />
</div>
);
}
if (!shot) return null;
const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
const priorityCfg = PRIORITY_CONFIG[shot.priority] ?? PRIORITY_CONFIG.NORMAL;
const { Icon: StatusIcon } = statusCfg;
return (
<div className="p-6 space-y-6 max-w-[1400px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground transition-colors">
Projects
</Link>
<span>/</span>
<Link href={`/projects/${params.id}`} className="hover:text-foreground transition-colors">
{projectName}
</Link>
<span>/</span>
<span className="text-foreground font-mono">{shot.shotCode}</span>
</div>
{/* Header */}
<div className="flex items-start gap-6">
{/* Thumbnail cinema scope 2.39:1 */}
<Button variant="ghost" size="icon" asChild className="-ml-2 h-8 w-8">
<Link href={`/projects/${params.id}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
{shot.sequence && (
<span className="text-sm text-muted-foreground">Seq: {shot.sequence}</span>
)}
</div>
{shot.description && (
<p className="text-muted-foreground ">{shot.description}</p>
)}
<div className="flex items-center gap-3 flex-wrap">
<Badge className={statusCfg.className} variant="outline">
<StatusIcon className="h-3 w-3 mr-1" />
{statusCfg.label}
</Badge>
{/* Approval status badges */}
{shot.shotApprovalStatus === "PENDING" && (
<Badge variant="outline" className="bg-zinc-500/10 text-zinc-400 border-zinc-500/20 text-[11px]">
Pending Internal Approval
</Badge>
)}
{shot.shotApprovalStatus === "INTERNALLY_APPROVED" && (
<Badge variant="outline" className="bg-sky-500/10 text-sky-400 border-sky-500/20 text-[11px]">
<ShieldCheck className="h-3 w-3 mr-1" />
Internally Approved
</Badge>
)}
{shot.shotApprovalStatus === "CLIENT_APPROVED" && (
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 text-[11px]">
<CheckCircle2 className="h-3 w-3 mr-1" />
Client Approved
</Badge>
)}
{/* Client sharing badge */}
{shot.shotApprovalStatus === "INTERNALLY_APPROVED" && (
<Badge variant="outline" className={cn(
"text-[11px]",
shot.sharedWithClient
? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20"
: "bg-zinc-500/10 text-zinc-500 border-zinc-600/20"
)}>
{shot.sharedWithClient ? "Shared With Client" : "Not Shared"}
</Badge>
)}
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span
className={`h-2 w-2 rounded-full ${priorityCfg.dot}`}
/>
{priorityCfg.label} Priority
</div>
<span className="text-sm text-muted-foreground">{shot.fps} fps</span>
{shot.artist && (
<div className="flex items-center gap-1.5">
<Avatar className="h-5 w-5">
<AvatarImage src={shot.artist.image ?? undefined} />
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(shot.artist.name ?? shot.artist.email)}
</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">
{shot.artist.name ?? shot.artist.email}
</span>
</div>
)}
</div>
</div>
{canManage && (
<div className="ml-auto shrink-0 flex items-center gap-2">
{/* Internal approval action */}
{canInternallyApprove && shot.shotApprovalStatus === "PENDING" && (
<Button
variant="outline"
size="sm"
onClick={handleInternalApprove}
disabled={isActioning}
className="gap-2 border-sky-500/40 text-sky-400 hover:bg-sky-500/10"
>
<ShieldCheck className="h-3.5 w-3.5" />
Approve Internally
</Button>
)}
{/* Share / Unshare with client */}
{canInternallyApprove && shot.shotApprovalStatus === "INTERNALLY_APPROVED" && !shot.sharedWithClient && (
<Button
variant="outline"
size="sm"
onClick={handleShareWithClient}
disabled={isActioning}
className="gap-2 border-indigo-500/40 text-indigo-400 hover:bg-indigo-500/10"
>
<Share2 className="h-3.5 w-3.5" />
Share With Client
</Button>
)}
{canInternallyApprove && shot.sharedWithClient && (
<Button
variant="outline"
size="sm"
onClick={handleUnshare}
disabled={isActioning}
className="gap-2 border-zinc-500/40 text-zinc-400 hover:bg-zinc-500/10"
>
<EyeOff className="h-3.5 w-3.5" />
Remove From Client Review
</Button>
)}
{/* Undo client approval */}
{canInternallyApprove && shot.shotApprovalStatus === "CLIENT_APPROVED" && (
<Button
variant="outline"
size="sm"
onClick={handleUnapproveShot}
disabled={isActioning}
className="gap-2 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
>
<AlertCircle className="h-3.5 w-3.5" />
Undo Client Approval
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleDuplicate}
disabled={isDuplicating}
className="gap-2"
>
<Copy className="h-3.5 w-3.5" />
{isDuplicating ? "Duplicating…" : "Duplicate Shot"}
</Button>
</div>
)}
</div>
<Separator />
{/* Tabs */}
<div>
<div className="flex border-b border-border mb-5">
<button
onClick={() => setActiveTab("tasks")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "tasks"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<ListTodo className="h-4 w-4" />
Tasks
</button>
<button
onClick={() => setActiveTab("footage")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "footage"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Video className="h-4 w-4" />
Footage
</button>
{canManage && (
<button
onClick={() => setActiveTab("settings")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "settings"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Settings className="h-4 w-4" />
Settings
</button>
)}
</div>
{activeTab === "tasks" && (
<TaskList
tasks={tasks}
projectId={params.id}
shotId={shot.id}
artists={artists}
canManage={canManage}
onTaskCreated={fetchShot}
/>
)}
{activeTab === "footage" && (
<FootageViewer
shot={shot}
canManage={canManage}
onSaved={fetchShot}
/>
)}
{activeTab === "settings" && canManage && (
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
)}
</div>
</div>
);
}