Files
twotalesanimation 4da80d29a9
Deploy / deploy (push) Failing after 46s
CSV shot implementation
2026-06-12 15:06:50 +02:00

490 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;
};
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>
);
}