caaa333f02
Deploy / deploy (push) Failing after 19m11s
Two independent features landing together: Generated shot thumbnails — a shot can now carry a thumbnail promoted from a render export (Shot.generatedThumbnailUrl/generatedThumbnailFrom) alongside the existing brief-supplied thumbnailUrl, which always takes display precedence (lib/shot-thumbnail.ts) so a promoted frame can only ever fill a gap, never overwrite the brief. Shows with pipelineConfig.autoShotThumbnail promote automatically on every finished export (lib/render-pipeline/preview.ts); promoteExportThumbnail/ clearGeneratedThumbnail (actions/shots.ts) are the manual equivalent for everyone else. Worker-side seek logic for picking the promoted frame is covered by RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs. Bulk shot delete — the shot-status page's selection bar gets a "Bulk Actions" dropdown (alongside the existing Change Due Date) with Delete Shots. bulkDeleteShots() (actions/shots.ts) deletes one at a time rather than in a single statement: exports_shotId_fkey is ON DELETE RESTRICT by design, so a shot with renders or deliveries on record is protected and gets skipped and reported by name rather than aborting the whole selection or being silently destroyed along with its history. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
498 lines
19 KiB
TypeScript
498 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition, useEffect } from "react";
|
|
import Link from "next/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ShotCard } from "@/components/shots/ShotCard";
|
|
import { NewShotDialog } from "@/components/shots/NewShotDialog";
|
|
import { ImportShotsDialog } from "@/components/shots/ImportShotsDialog";
|
|
import { AssetCard } from "@/components/assets/AssetCard";
|
|
import { NewAssetDialog } from "@/components/assets/NewAssetDialog";
|
|
import { TaskCard } from "@/components/tasks/TaskCard";
|
|
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
|
|
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
|
|
import { cn } from "@/lib/utils";
|
|
import { Film, Package, ListTodo, LayoutDashboard, Plus, Settings, FileUp, ChevronDown, ChevronRight, Calendar, Pencil, Check, X } from "lucide-react";
|
|
import type { ShotWithDetails } from "@/types";
|
|
import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab";
|
|
import { setEpisodeDueDate } from "@/actions/episode-due-dates";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { format } from "date-fns";
|
|
|
|
type Tab = "shots" | "assets" | "tasks" | "kanban" | "settings";
|
|
|
|
interface Artist {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
}
|
|
|
|
interface Client {
|
|
id: string;
|
|
company: string;
|
|
}
|
|
|
|
interface TeamMember {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
role: string;
|
|
}
|
|
|
|
interface ProjectTabsClientProps {
|
|
projectId: string;
|
|
projectType: "STANDARD" | "EPISODIC";
|
|
projectSettings: {
|
|
id: string;
|
|
name: string;
|
|
code: string;
|
|
showId: string;
|
|
projectType: "STANDARD" | "EPISODIC";
|
|
description: string | null;
|
|
status: string;
|
|
clientId: string | null;
|
|
producerId: string | null;
|
|
supervisorId: string | null;
|
|
dueDate: Date | null;
|
|
startDate: Date | null;
|
|
slackWebhook: string | null;
|
|
slackChannel: string | null;
|
|
pipelineConfig: {
|
|
templateAep?: string | null;
|
|
rendersRoot?: string | null;
|
|
overlayComp?: string | null;
|
|
previewComp?: string | null;
|
|
lutComp?: string | null;
|
|
autoShotThumbnail?: boolean | null;
|
|
} | null;
|
|
};
|
|
clients: Client[];
|
|
teamMembers: TeamMember[];
|
|
shots: ShotWithDetails[];
|
|
assets: any[];
|
|
tasks: any[];
|
|
artists: Artist[];
|
|
shotGroups: { id: string; name: string }[];
|
|
episodeDueDates: { episode: string; dueDate: Date | string }[];
|
|
canManage: boolean;
|
|
}
|
|
|
|
export function ProjectTabsClient({
|
|
projectId,
|
|
projectType,
|
|
projectSettings,
|
|
clients,
|
|
teamMembers,
|
|
shots,
|
|
assets,
|
|
tasks,
|
|
artists,
|
|
shotGroups,
|
|
episodeDueDates,
|
|
canManage,
|
|
}: ProjectTabsClientProps) {
|
|
const { toast } = useToast();
|
|
const [activeTab, setActiveTab] = useState<Tab>("shots");
|
|
const [showNewShot, setShowNewShot] = useState(false);
|
|
const [showImportShots, setShowImportShots] = useState(false);
|
|
const [showNewAsset, setShowNewAsset] = useState(false);
|
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(new Set());
|
|
const [editingEpisode, setEditingEpisode] = useState<string | null>(null);
|
|
|
|
// Load saved episode expand state from localStorage on mount
|
|
useEffect(() => {
|
|
try {
|
|
const saved = localStorage.getItem(`project:${projectId}:episodeExpand`);
|
|
if (saved) setExpandedEpisodes(new Set(JSON.parse(saved) as string[]));
|
|
} catch {}
|
|
}, [projectId]);
|
|
const [episodeDateInput, setEpisodeDateInput] = useState("");
|
|
const [isPendingDate, startDateTransition] = useTransition();
|
|
|
|
// Build a lookup map for episode due dates
|
|
const episodeDueDateMap = new Map(
|
|
episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)])
|
|
);
|
|
|
|
const handleSaveEpisodeDate = (episode: string) => {
|
|
startDateTransition(async () => {
|
|
try {
|
|
await setEpisodeDueDate({
|
|
projectId,
|
|
episode,
|
|
dueDate: episodeDateInput || null,
|
|
});
|
|
toast({ title: `Due date ${episodeDateInput ? "set" : "cleared"} for Episode ${episode}` });
|
|
} catch {
|
|
toast({ title: "Failed to save due date", variant: "destructive" });
|
|
} finally {
|
|
setEditingEpisode(null);
|
|
setEpisodeDateInput("");
|
|
}
|
|
});
|
|
};
|
|
|
|
const toggleEpisode = (ep: string) =>
|
|
setExpandedEpisodes((prev) => {
|
|
const next = new Set(prev);
|
|
next.has(ep) ? next.delete(ep) : next.add(ep);
|
|
try {
|
|
localStorage.setItem(`project:${projectId}:episodeExpand`, JSON.stringify([...next]));
|
|
} catch {}
|
|
return next;
|
|
});
|
|
|
|
// For episodic projects: group and sort shots by episode → scene → shotNumber
|
|
const episodeGroups: [string, ShotWithDetails[]][] = projectType === "EPISODIC"
|
|
? (() => {
|
|
const sorted = [...shots].sort((a, b) => {
|
|
const ea = a.episode ?? "";
|
|
const eb = b.episode ?? "";
|
|
if (ea !== eb) return ea.localeCompare(eb, undefined, { numeric: true });
|
|
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
|
|
return a.shotNumber - b.shotNumber;
|
|
});
|
|
const map = new Map<string, ShotWithDetails[]>();
|
|
for (const shot of sorted) {
|
|
const key = shot.episode ?? "(No Episode)";
|
|
if (!map.has(key)) map.set(key, []);
|
|
map.get(key)!.push(shot);
|
|
}
|
|
return Array.from(map.entries());
|
|
})()
|
|
: [];
|
|
|
|
// For standard projects: group by custom shot group when any exist
|
|
const standardGroups: [string, ShotWithDetails[]][] = projectType === "STANDARD" && shots.some((s) => s.shotGroup)
|
|
? (() => {
|
|
const sorted = [...shots].sort((a, b) => {
|
|
const ga = a.shotGroup?.name ?? "\uFFFF";
|
|
const gb = b.shotGroup?.name ?? "\uFFFF";
|
|
if (ga !== gb) return ga.localeCompare(gb, undefined, { numeric: true });
|
|
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
|
|
return a.shotNumber - b.shotNumber;
|
|
});
|
|
const map = new Map<string, ShotWithDetails[]>();
|
|
for (const shot of sorted) {
|
|
const key = shot.shotGroup?.name ?? "(Ungrouped)";
|
|
if (!map.has(key)) map.set(key, []);
|
|
map.get(key)!.push(shot);
|
|
}
|
|
// Move ungrouped to the end
|
|
const ungrouped = map.get("(Ungrouped)");
|
|
if (ungrouped) {
|
|
map.delete("(Ungrouped)");
|
|
map.set("(Ungrouped)", ungrouped);
|
|
}
|
|
return Array.from(map.entries());
|
|
})()
|
|
: [];
|
|
|
|
const tabs: { id: Tab; label: string; icon: React.ElementType; count: number; managerOnly?: boolean }[] = [
|
|
{ id: "shots", label: "Shots", icon: Film, count: shots.length },
|
|
{ id: "assets", label: "Assets", icon: Package, count: assets.length },
|
|
{ id: "tasks", label: "All Tasks", icon: ListTodo, count: tasks.length },
|
|
{ id: "kanban", label: "Kanban", icon: LayoutDashboard, count: 0 },
|
|
{ id: "settings", label: "Settings", icon: Settings, count: 0, managerOnly: true },
|
|
];
|
|
|
|
const visibleTabs = tabs.filter((t) => !t.managerOnly || canManage);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Tab bar */}
|
|
<div className="flex items-center justify-between border-b border-border pb-0">
|
|
<div className="flex">
|
|
{visibleTabs.map((tab) => {
|
|
const Icon = tab.icon;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={cn(
|
|
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
|
|
activeTab === tab.id
|
|
? "border-amber-500 text-amber-400"
|
|
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
{tab.label}
|
|
{tab.count > 0 && (
|
|
<span className="text-xs text-zinc-500">{tab.count}</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Context-sensitive add button */}
|
|
{canManage && (
|
|
<div className="pb-1">
|
|
{activeTab === "shots" && (
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" className="gap-2 h-8" onClick={() => setShowImportShots(true)}>
|
|
<FileUp className="h-3.5 w-3.5" /> Import CSV
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="gap-2 h-8" asChild>
|
|
<Link href={`/projects/${projectId}/import-edl`}>
|
|
<FileUp className="h-3.5 w-3.5" /> VFX Pull
|
|
</Link>
|
|
</Button>
|
|
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewShot(true)}>
|
|
<Plus className="h-3.5 w-3.5" /> New Shot
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{activeTab === "assets" && (
|
|
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewAsset(true)}>
|
|
<Plus className="h-3.5 w-3.5" /> New Asset
|
|
</Button>
|
|
)}
|
|
{(activeTab === "tasks" || activeTab === "kanban") && (
|
|
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewTask(true)}>
|
|
<Plus className="h-3.5 w-3.5" /> New Task
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
{activeTab === "shots" && (
|
|
<div>
|
|
{shots.length === 0 ? (
|
|
<EmptyState icon={Film} label="No shots yet" />
|
|
) : projectType === "EPISODIC" ? (
|
|
<div className="space-y-4">
|
|
{episodeGroups.map(([episode, episodeShots]) => {
|
|
const collapsed = !expandedEpisodes.has(episode);
|
|
const dueDate = episodeDueDateMap.get(episode);
|
|
const isOverdue = dueDate && dueDate < new Date();
|
|
const isEditing = editingEpisode === episode;
|
|
return (
|
|
<div key={episode}>
|
|
<div className="flex items-center gap-2 w-full mb-3">
|
|
<button
|
|
onClick={() => toggleEpisode(episode)}
|
|
className="flex items-center gap-2 shrink-0"
|
|
>
|
|
{collapsed
|
|
? <ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
: <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
|
</button>
|
|
<button
|
|
onClick={() => toggleEpisode(episode)}
|
|
className="flex items-center gap-2 text-left"
|
|
>
|
|
<span className="font-semibold text-sm">
|
|
Episode {episode}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground font-normal">
|
|
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
|
|
</span>
|
|
</button>
|
|
{/* Due date display / edit */}
|
|
{isEditing ? (
|
|
<div className="flex items-center gap-1 ml-2">
|
|
<Input
|
|
type="date"
|
|
value={episodeDateInput}
|
|
onChange={(e) => setEpisodeDateInput(e.target.value)}
|
|
className="h-6 w-36 text-xs px-2 py-0"
|
|
/>
|
|
<button
|
|
onClick={() => handleSaveEpisodeDate(episode)}
|
|
disabled={isPendingDate}
|
|
className="text-emerald-400 hover:text-emerald-300 p-0.5"
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => { setEditingEpisode(null); setEpisodeDateInput(""); }}
|
|
className="text-zinc-500 hover:text-zinc-300 p-0.5"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1.5 ml-2">
|
|
{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>
|
|
)}
|
|
{canManage && (
|
|
<button
|
|
onClick={() => {
|
|
setEditingEpisode(episode);
|
|
setEpisodeDateInput(
|
|
dueDate ? format(dueDate, "yyyy-MM-dd") : ""
|
|
);
|
|
}}
|
|
className="text-zinc-600 hover:text-zinc-300 p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
title="Set due date"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex-1 h-px bg-border ml-1" />
|
|
{/* Always-visible edit button for managers */}
|
|
{canManage && !isEditing && (
|
|
<button
|
|
onClick={() => {
|
|
setEditingEpisode(episode);
|
|
setEpisodeDateInput(
|
|
dueDate ? format(dueDate, "yyyy-MM-dd") : ""
|
|
);
|
|
}}
|
|
className="text-zinc-600 hover:text-zinc-400 transition-colors shrink-0"
|
|
title="Set episode due date"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{!collapsed && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
|
{episodeShots.map((shot) => (
|
|
<ShotCard key={shot.id} shot={shot} projectId={projectId} canManage={canManage} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : standardGroups.length > 0 ? (
|
|
<div className="space-y-4">
|
|
{standardGroups.map(([groupName, groupShots]) => {
|
|
const collapsed = !expandedEpisodes.has(groupName);
|
|
return (
|
|
<div key={groupName}>
|
|
<button
|
|
onClick={() => toggleEpisode(groupName)}
|
|
className="flex items-center gap-2 w-full mb-3 group text-left"
|
|
>
|
|
{collapsed
|
|
? <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
: <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />}
|
|
<span className="font-semibold text-sm">{groupName}</span>
|
|
<span className="text-xs text-muted-foreground font-normal">
|
|
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
|
|
</span>
|
|
<div className="flex-1 h-px bg-border ml-1" />
|
|
</button>
|
|
{!collapsed && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
|
{groupShots.map((shot) => (
|
|
<ShotCard key={shot.id} shot={shot} projectId={projectId} canManage={canManage} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
|
{shots.map((shot) => (
|
|
<ShotCard key={shot.id} shot={shot} projectId={projectId} canManage={canManage} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === "assets" && (
|
|
<div className="space-y-2">
|
|
{assets.length === 0 ? (
|
|
<EmptyState icon={Package} label="No assets yet" />
|
|
) : (
|
|
assets.map((asset) => (
|
|
<AssetCard
|
|
key={asset.id}
|
|
asset={asset}
|
|
projectId={projectId}
|
|
artists={artists}
|
|
canManage={canManage}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === "tasks" && (
|
|
<div className="space-y-1">
|
|
{tasks.length === 0 ? (
|
|
<EmptyState icon={ListTodo} label="No tasks yet" />
|
|
) : (
|
|
tasks.map((task) => (
|
|
<TaskCard key={task.id} task={task} projectId={projectId} canManage={canManage} />
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === "kanban" && (
|
|
<KanbanBoard tasks={tasks} projectId={projectId} artists={artists} />
|
|
)}
|
|
|
|
{activeTab === "settings" && canManage && (
|
|
<ProjectSettingsTab
|
|
project={projectSettings}
|
|
clients={clients}
|
|
teamMembers={teamMembers}
|
|
/>
|
|
)}
|
|
|
|
{/* Dialogs */}
|
|
<NewShotDialog
|
|
projectId={projectId}
|
|
projectType={projectType}
|
|
shotGroups={shotGroups}
|
|
open={showNewShot}
|
|
onClose={() => setShowNewShot(false)}
|
|
onSuccess={() => setShowNewShot(false)}
|
|
/>
|
|
<ImportShotsDialog
|
|
projectId={projectId}
|
|
projectType={projectType}
|
|
open={showImportShots}
|
|
onClose={() => setShowImportShots(false)}
|
|
onSuccess={() => setShowImportShots(false)}
|
|
/>
|
|
<NewAssetDialog
|
|
projectId={projectId}
|
|
open={showNewAsset}
|
|
onClose={() => setShowNewAsset(false)}
|
|
onSuccess={() => setShowNewAsset(false)}
|
|
/>
|
|
<NewTaskDialog
|
|
projectId={projectId}
|
|
artists={artists}
|
|
open={showNewTask}
|
|
onClose={() => setShowNewTask(false)}
|
|
onSuccess={() => setShowNewTask(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ icon: Icon, label }: { icon: React.ElementType; label: string }) {
|
|
return (
|
|
<div className="text-center py-10 text-muted-foreground border border-dashed border-border rounded-lg">
|
|
<Icon className="h-8 w-8 mx-auto mb-3 opacity-30" />
|
|
<p className="text-sm">{label}</p>
|
|
</div>
|
|
);
|
|
}
|