484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition, useEffect } from "react";
|
|
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 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>
|
|
);
|
|
}
|