Initial commit
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShotCard } from "@/components/shots/ShotCard";
|
||||
import { NewShotDialog } from "@/components/shots/NewShotDialog";
|
||||
import { Film, Plus } from "lucide-react";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
|
||||
interface ProjectShotsClientProps {
|
||||
projectId: string;
|
||||
shots: ShotWithDetails[];
|
||||
}
|
||||
|
||||
export function ProjectShotsClient({ projectId, shots }: ProjectShotsClientProps) {
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">Shots</h2>
|
||||
<Button size="sm" className="gap-2" onClick={() => setShowNew(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Shot
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{shots.length === 0 ? (
|
||||
<div className="text-center py-10 text-muted-foreground border border-dashed border-border rounded-lg">
|
||||
<Film className="h-8 w-8 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No shots yet</p>
|
||||
</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} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NewShotDialog
|
||||
projectId={projectId}
|
||||
open={showNew}
|
||||
onClose={() => setShowNew(false)}
|
||||
onSuccess={() => setShowNew(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 } from "lucide-react";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab";
|
||||
|
||||
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[];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export function ProjectTabsClient({
|
||||
projectId,
|
||||
projectType,
|
||||
projectSettings,
|
||||
clients,
|
||||
teamMembers,
|
||||
shots,
|
||||
assets,
|
||||
tasks,
|
||||
artists,
|
||||
canManage,
|
||||
}: ProjectTabsClientProps) {
|
||||
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 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" />
|
||||
) : (
|
||||
<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} />
|
||||
))}
|
||||
</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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { auth } from "@/auth";
|
||||
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
|
||||
import { ArrowLeft, LayoutDashboard } from "lucide-react";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const project = await db.project.findUnique({
|
||||
where: { id },
|
||||
select: { name: true },
|
||||
});
|
||||
return { title: project ? `${project.name} — Kanban` : "Kanban" };
|
||||
}
|
||||
|
||||
export default async function KanbanPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
// Clients cannot access kanban
|
||||
if (session.user.role === "CLIENT") redirect(`/projects/${id}`);
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, name: true, code: true },
|
||||
});
|
||||
if (!project) notFound();
|
||||
|
||||
const [tasks, artists] = await Promise.all([
|
||||
db.task.findMany({
|
||||
where: { projectId: id },
|
||||
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
|
||||
include: {
|
||||
shot: { select: { id: true, shotCode: true } },
|
||||
asset: { select: { id: true, assetCode: true, name: true } },
|
||||
assignedArtist: {
|
||||
select: { id: true, name: true, email: true, image: true },
|
||||
},
|
||||
_count: { select: { versions: true } },
|
||||
versions: {
|
||||
take: 1,
|
||||
orderBy: { versionNumber: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
approvalStatus: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.user.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, name: true, email: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-[1800px] 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/${id}`}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Kanban</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/projects/${id}`}
|
||||
className="text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<LayoutDashboard className="h-5 w-5 text-amber-400" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{project.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">Kanban Board — {tasks.length} tasks</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board */}
|
||||
<KanbanBoard tasks={tasks} projectId={id} artists={artists} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { auth } from "@/auth";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ProjectTabsClient } from "./ProjectTabsClient";
|
||||
import {
|
||||
Film,
|
||||
Layers,
|
||||
CheckCircle2,
|
||||
ListTodo,
|
||||
} from "lucide-react";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const project = await db.project.findUnique({ where: { id }, select: { name: true } });
|
||||
return { title: project?.name ?? "Project" };
|
||||
}
|
||||
|
||||
async function getProject(id: string) {
|
||||
return db.project.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
client: true,
|
||||
producer: { select: { id: true, name: true, image: true, email: true } },
|
||||
supervisor: { select: { id: true, name: true, image: true, email: true } },
|
||||
shots: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||
versions: {
|
||||
take: 1,
|
||||
orderBy: { versionNumber: "desc" },
|
||||
include: {
|
||||
comments: { select: { id: true, isResolved: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
orderBy: { assetCode: "asc" },
|
||||
include: {
|
||||
lead: { select: { id: true, name: true, email: true, image: true } },
|
||||
_count: { select: { tasks: true } },
|
||||
tasks: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||
_count: { select: { versions: true } },
|
||||
versions: {
|
||||
take: 1,
|
||||
orderBy: { versionNumber: "desc" },
|
||||
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
|
||||
include: {
|
||||
shot: { select: { id: true, shotCode: true } },
|
||||
asset: { select: { id: true, assetCode: true, name: true } },
|
||||
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||
_count: { select: { versions: true } },
|
||||
versions: {
|
||||
take: 1,
|
||||
orderBy: { versionNumber: "desc" },
|
||||
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getProjectArtists() {
|
||||
return db.user.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, name: true, email: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async function getClients() {
|
||||
return db.client.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, company: true },
|
||||
orderBy: { company: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async function getTeamMembers() {
|
||||
return db.user.findMany({
|
||||
where: { isActive: true, role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] } },
|
||||
select: { id: true, name: true, email: true, role: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ProjectPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
const [project, artists, clients, teamMembers] = await Promise.all([
|
||||
getProject(id),
|
||||
getProjectArtists(),
|
||||
getClients(),
|
||||
getTeamMembers(),
|
||||
]);
|
||||
if (!project) notFound();
|
||||
|
||||
const canManage = session?.user && ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
|
||||
|
||||
const totalShots = project.shots.length;
|
||||
const approvedShots = project.shots.filter((s) => s.status === "COMPLETE").length;
|
||||
const totalTasks = project.tasks.length;
|
||||
const doneTasks = project.tasks.filter((t) => t.status === "DONE").length;
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||
<Link href="/projects" className="hover:text-white transition-colors">
|
||||
Projects
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-white">{project.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="font-mono text-sm text-muted-foreground">{project.code}</span>
|
||||
{project.client && (
|
||||
<span className="text-sm text-muted-foreground">• {project.client.company}</span>
|
||||
)}
|
||||
<Badge
|
||||
className={
|
||||
project.status === "ACTIVE"
|
||||
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
|
||||
: "bg-zinc-500/10 text-zinc-400"
|
||||
}
|
||||
>
|
||||
{project.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white">{project.name}</h1>
|
||||
{project.description && (
|
||||
<p className="text-zinc-400 mt-1">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-4 gap-3 max-w-md">
|
||||
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
|
||||
<Layers className="h-4 w-4 text-zinc-400 mb-1" />
|
||||
<span className="text-xl font-bold text-white">{totalShots}</span>
|
||||
<span className="text-xs text-zinc-400">shots</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400 mb-1" />
|
||||
<span className="text-xl font-bold text-white">{approvedShots}</span>
|
||||
<span className="text-xs text-zinc-400">approved</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
|
||||
<ListTodo className="h-4 w-4 text-amber-400 mb-1" />
|
||||
<span className="text-xl font-bold text-white">{totalTasks}</span>
|
||||
<span className="text-xs text-zinc-400">tasks</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
|
||||
<Film className="h-4 w-4 text-blue-400 mb-1" />
|
||||
<span className="text-xl font-bold text-white">{doneTasks}</span>
|
||||
<span className="text-xs text-zinc-400">done</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<ProjectTabsClient
|
||||
projectId={id}
|
||||
projectType={project.projectType}
|
||||
projectSettings={{
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
code: project.code,
|
||||
showId: project.showId,
|
||||
projectType: project.projectType,
|
||||
description: project.description,
|
||||
status: project.status,
|
||||
clientId: project.clientId,
|
||||
producerId: project.producerId,
|
||||
supervisorId: project.supervisorId,
|
||||
dueDate: project.dueDate,
|
||||
startDate: project.startDate,
|
||||
slackWebhook: project.slackWebhook,
|
||||
slackChannel: project.slackChannel,
|
||||
}}
|
||||
clients={clients}
|
||||
teamMembers={teamMembers}
|
||||
shots={project.shots as any}
|
||||
assets={project.assets as any}
|
||||
tasks={project.tasks as any}
|
||||
artists={artists}
|
||||
canManage={!!canManage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"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,
|
||||
} from "lucide-react";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
||||
|
||||
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 },
|
||||
IN_REVIEW: { label: "In Review", className: "bg-amber-500/10 text-amber-400 border-amber-500/20", Icon: AlertCircle },
|
||||
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" },
|
||||
};
|
||||
|
||||
export default function ShotDetailPage() {
|
||||
const params = useParams<{ id: string; shotId: string }>();
|
||||
const router = useRouter();
|
||||
const [shot, setShot] = useState<ShotWithDetails | null>(null);
|
||||
const [projectName, setProjectName] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [canApprove, setCanApprove] = useState(false);
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [artists, setArtists] = useState<any[]>([]);
|
||||
const [canManage, setCanManage] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"tasks" | "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);
|
||||
setTasks(data.tasks ?? []);
|
||||
setArtists(data.artists ?? []);
|
||||
setCanManage(data.canApprove ?? false);
|
||||
} catch {
|
||||
router.push("/projects");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchShot();
|
||||
}, [params.shotId]);
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
</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>
|
||||
{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 === "settings" && canManage && (
|
||||
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user