Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { Building2, Mail, User2, Phone, ArrowLeft, ExternalLink, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ShareReviewDialog } from "@/components/clients/ShareReviewDialog";
import { ReviewSessionList } from "@/components/clients/ReviewSessionList";
import { cn } from "@/lib/utils";
export const metadata: Metadata = { title: "Client Detail" };
async function getClient(clientId: string) {
return db.client.findUnique({
where: { id: clientId },
include: {
projects: {
orderBy: { createdAt: "desc" },
include: {
_count: { select: { shots: true } },
},
},
},
});
}
async function getReviewSessions(projectIds: string[]) {
if (projectIds.length === 0) return [];
return db.reviewSession.findMany({
where: { projectId: { in: projectIds }, isActive: true },
orderBy: { createdAt: "desc" },
include: { project: { select: { name: true } } },
});
}
export default async function ClientDetailPage({
params,
}: {
params: Promise<{ clientId: string }>;
}) {
const { clientId } = await params;
const session = await auth();
if (!session || !["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
redirect("/dashboard");
}
const client = await getClient(clientId);
if (!client) notFound();
const projectIds = client.projects.map((p) => p.id);
const reviewSessions = await getReviewSessions(projectIds);
const PROJECT_STATUS_COLORS: Record<string, string> = {
ACTIVE: "text-emerald-400 bg-emerald-500/10",
COMPLETED: "text-zinc-400 bg-zinc-700",
ON_HOLD: "text-amber-400 bg-amber-500/10",
CANCELLED: "text-red-400 bg-red-500/10",
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Back */}
<Link
href="/clients"
className="inline-flex items-center gap-1.5 text-sm text-zinc-400 hover:text-white transition-colors"
>
<ArrowLeft className="h-4 w-4" />
All Clients
</Link>
{/* Client header */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-zinc-800 border border-zinc-700 flex items-center justify-center shrink-0">
<Building2 className="h-6 w-6 text-zinc-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">{client.company}</h1>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full mt-1 inline-block",
client.isActive
? "bg-emerald-500/10 text-emerald-400"
: "bg-zinc-700 text-zinc-500"
)}
>
{client.isActive ? "Active" : "Inactive"}
</span>
</div>
</div>
<ShareReviewDialog
clientId={client.id}
clientEmail={client.email ?? ""}
projects={client.projects}
>
<Button className="gap-2 shrink-0">
<ExternalLink className="h-4 w-4" />
Share Review Link
</Button>
</ShareReviewDialog>
</div>
<div className="mt-5 grid grid-cols-1 sm:grid-cols-3 gap-4">
{client.contactPerson && (
<div className="flex items-center gap-2 text-sm text-zinc-300">
<User2 className="h-4 w-4 text-zinc-600 shrink-0" />
<span>{client.contactPerson}</span>
</div>
)}
{client.email && (
<div className="flex items-center gap-2 text-sm text-zinc-300">
<Mail className="h-4 w-4 text-zinc-600 shrink-0" />
<a href={`mailto:${client.email}`} className="hover:text-amber-400 transition-colors">
{client.email}
</a>
</div>
)}
{client.phone && (
<div className="flex items-center gap-2 text-sm text-zinc-300">
<Phone className="h-4 w-4 text-zinc-600 shrink-0" />
<span>{client.phone}</span>
</div>
)}
</div>
{client.notes && (
<p className="mt-4 text-sm text-zinc-400 border-t border-zinc-800 pt-4">
{client.notes}
</p>
)}
</div>
{/* Projects */}
<div>
<h2 className="text-base font-semibold text-white mb-3">
Projects ({client.projects.length})
</h2>
{client.projects.length === 0 ? (
<p className="text-sm text-zinc-500">No projects assigned to this client.</p>
) : (
<div className="space-y-2">
{client.projects.map((project) => (
<Link
key={project.id}
href={`/projects/${project.id}`}
className="flex items-center gap-4 p-4 rounded-xl border border-zinc-800 bg-zinc-900 hover:border-zinc-600 hover:bg-zinc-800/60 transition-all group"
>
<div className="flex-1 min-w-0">
<p className="font-semibold text-white">{project.name}</p>
<p className="text-xs font-mono text-zinc-500">{project.code}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs text-zinc-500">
{project._count.shots} shots
</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
PROJECT_STATUS_COLORS[project.status] ?? "text-zinc-400 bg-zinc-700"
)}
>
{project.status}
</span>
</div>
</Link>
))}
</div>
)}
</div>
{/* Review sessions */}
<ReviewSessionList sessions={reviewSessions} />
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { Metadata } from "next";
import Link from "next/link";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { PlusCircle, Building2, Mail, User2, FolderOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { NewClientDialog } from "@/components/clients/NewClientDialog";
import { cn } from "@/lib/utils";
export const metadata: Metadata = { title: "Clients" };
async function getClients() {
return db.client.findMany({
orderBy: { company: "asc" },
include: { _count: { select: { projects: true } } },
});
}
export default async function ClientsPage() {
const session = await auth();
if (!session || !["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
redirect("/dashboard");
}
const clients = await getClients();
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Clients</h1>
<p className="text-sm text-zinc-400 mt-0.5">
{clients.length} client{clients.length !== 1 ? "s" : ""} total
</p>
</div>
<NewClientDialog>
<Button className="gap-2">
<PlusCircle className="h-4 w-4" />
New Client
</Button>
</NewClientDialog>
</div>
{/* Grid */}
{clients.length === 0 ? (
<div className="rounded-xl border border-dashed border-zinc-700 bg-zinc-900/40 p-16 text-center">
<Building2 className="h-10 w-10 mx-auto mb-3 text-zinc-600" />
<p className="text-zinc-400 font-medium">No clients yet</p>
<p className="text-zinc-600 text-sm mt-1">
Add your first client to start sharing reviews.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{clients.map((client) => (
<Link
key={client.id}
href={`/clients/${client.id}`}
className={cn(
"rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4",
"hover:border-zinc-600 hover:bg-zinc-800/60 transition-all group"
)}
>
{/* Avatar + company */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-zinc-800 border border-zinc-700 flex items-center justify-center shrink-0 group-hover:bg-zinc-700 transition-colors">
<Building2 className="h-5 w-5 text-zinc-400" />
</div>
<div className="min-w-0">
<p className="font-semibold text-white truncate">{client.company}</p>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
client.isActive
? "bg-emerald-500/10 text-emerald-400"
: "bg-zinc-700 text-zinc-500"
)}
>
{client.isActive ? "Active" : "Inactive"}
</span>
</div>
</div>
{/* Details */}
<div className="space-y-1.5">
{client.contactPerson && (
<div className="flex items-center gap-2 text-xs text-zinc-400">
<User2 className="h-3.5 w-3.5 text-zinc-600 shrink-0" />
<span className="truncate">{client.contactPerson}</span>
</div>
)}
{client.email && (
<div className="flex items-center gap-2 text-xs text-zinc-400">
<Mail className="h-3.5 w-3.5 text-zinc-600 shrink-0" />
<span className="truncate">{client.email}</span>
</div>
)}
</div>
{/* Project count */}
<div className="flex items-center gap-1.5 text-xs text-zinc-500 pt-2 border-t border-zinc-800">
<FolderOpen className="h-3.5 w-3.5" />
<span>{client._count.projects} project{client._count.projects !== 1 ? "s" : ""}</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { StatsCards } from "@/components/dashboard/StatsCards";
import { ShotQueue } from "@/components/dashboard/ShotQueue";
import { RecentActivity } from "@/components/dashboard/RecentActivity";
import { TaskWidgets } from "@/components/dashboard/TaskWidgets";
import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets";
import type { DashboardStats } from "@/types";
export const metadata = { title: "Dashboard" };
async function getDashboardData(userId: string, role: string) {
const isArtist = role === "ARTIST";
const isClient = role === "CLIENT";
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(todayStart.getTime() + 86400000);
const [awaitingReview, needsRevisions, approved, activeProjects,
tasksDueToday, tasksInReview, tasksOverdue, myTasksCount] =
await Promise.all([
db.version.count({ where: { approvalStatus: "PENDING_REVIEW" } }),
db.version.count({ where: { approvalStatus: "NEEDS_CHANGES" } }),
db.version.count({ where: { approvalStatus: "APPROVED" } }),
db.project.count({ where: { status: "ACTIVE" } }),
db.task.count({
where: {
dueDate: { gte: todayStart, lt: todayEnd },
status: { not: "DONE" },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: {
status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: {
dueDate: { lt: todayStart },
status: { not: "DONE" },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: { assignedArtistId: userId, status: { not: "DONE" } },
}),
]);
const shotsWhere = isArtist ? { artistId: userId } : {};
const shots = await db.shot.findMany({
where: { ...shotsWhere, status: { not: "COMPLETE" } },
take: 15,
orderBy: { updatedAt: "desc" },
include: {
artist: { select: { id: true, name: true, image: true, email: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
include: { comments: { select: { id: true, isResolved: true } } },
},
},
});
// My tasks (for widget)
const myTasks = await db.task.findMany({
where: {
assignedArtistId: userId,
status: { not: "DONE" },
},
take: 8,
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
},
});
// Tasks in review (for supervisors/producers)
const reviewTasks = isArtist || isClient ? [] : await db.task.findMany({
where: { status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] } },
take: 8,
orderBy: { updatedAt: "desc" },
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: { select: { id: true, name: true, image: true, email: true } },
},
});
const activity = await db.notification.findMany({
where: isClient ? { userId } : {},
take: 20,
orderBy: { createdAt: "desc" },
include: { user: { select: { name: true, image: true } } },
});
// Schedule data (for producers/supervisors/admins)
const scheduledTasks =
!isArtist && !isClient
? await db.task.findMany({
where: { scheduledStartDate: { not: null }, status: { not: "DONE" } },
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: { scheduledStartDate: "asc" },
})
: [];
const scheduleArtists =
!isArtist && !isClient
? await db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
},
orderBy: { name: "asc" },
})
: [];
const stats: DashboardStats = {
awaitingReview,
needsRevisions,
approved,
overdue: tasksOverdue,
activeProjects,
tasksDueToday,
tasksInReview,
tasksOverdue,
myTasksCount,
};
return { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists };
}
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) return null;
const { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists } =
await getDashboardData(session.user.id, session.user.role);
const isClient = session.user.role === "CLIENT";
const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
session.user.role
);
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="mb-4">
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
<p className="text-zinc-400 mt-1">
Welcome back, {session.user.name ?? session.user.email}
</p>
</div>
<StatsCards stats={stats} />
{!isClient && (
<TaskWidgets
myTasks={myTasks as any}
reviewTasks={reviewTasks as any}
role={session.user.role}
/>
)}
{canSeeSchedule && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Schedule Overview
</h2>
<ScheduleWidgets
scheduledTasks={scheduledTasks as any}
artists={scheduleArtists}
/>
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Shot Queue
</h2>
<div className="rounded-lg border border-border bg-card p-1">
<ShotQueue shots={shots as any} />
</div>
</div>
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Recent Activity
</h2>
<div className="rounded-lg border border-border bg-card h-[400px] overflow-hidden">
<RecentActivity activities={activity as any} />
</div>
</div>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
return (
<div className="flex h-screen bg-zinc-950 overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</div>
);
}
@@ -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>
);
}
+209
View File
@@ -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>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ProjectCard } from "@/components/projects/ProjectCard";
import { NewProjectDialog } from "@/components/projects/NewProjectDialog";
import { Plus, Search, Loader2 } from "lucide-react";
export default function ProjectsPage() {
const [search, setSearch] = useState("");
const [showNew, setShowNew] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["projects", search],
queryFn: async () => {
const res = await fetch(`/api/projects?q=${encodeURIComponent(search)}`);
if (!res.ok) throw new Error("Failed to fetch projects");
return res.json() as Promise<{ projects: any[] }>;
},
staleTime: 30_000,
});
const { data: clientsData } = useQuery({
queryKey: ["clients"],
queryFn: async () => {
const res = await fetch("/api/clients");
if (!res.ok) return { clients: [] };
return res.json() as Promise<{ clients: { id: string; company: string }[] }>;
},
staleTime: 60_000,
});
const projects = data?.projects ?? [];
const clients = clientsData?.clients ?? [];
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="flex items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Projects</h1>
<p className="text-zinc-400 mt-1">
{projects.length} project{projects.length !== 1 ? "s" : ""}
</p>
</div>
<Button onClick={() => setShowNew(true)} className="gap-2">
<Plus className="h-4 w-4" />
New Project
</Button>
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Grid */}
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No projects found.</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setShowNew(true)}
>
Create your first project
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
<NewProjectDialog
open={showNew}
onClose={() => setShowNew(false)}
clients={clients}
/>
</div>
);
}
@@ -0,0 +1,592 @@
"use client";
import { useState, useRef, useCallback, useMemo } from "react";
import {
DndContext,
DragStartEvent,
DragEndEvent,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
addDays,
startOfWeek,
startOfDay,
format,
differenceInDays,
isBefore,
parseISO,
} from "date-fns";
import { scheduleTask, unscheduleTask } from "@/actions/schedule";
import { ScheduleTimeline } from "@/components/schedule/ScheduleTimeline";
import { BacklogPanel } from "@/components/schedule/BacklogPanel";
import { ScheduleFilters } from "@/components/schedule/ScheduleFilters";
import { CalendarDays, PanelRightOpen, PanelRightClose, Columns2, Rows } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { TaskStatus, TaskType } from "@prisma/client";
export const DAY_WIDTH = 216;
export const ROW_HEIGHT = 56;
export const HEADER_HEIGHT = 44;
export const LABEL_WIDTH = 208;
export const NUM_DAYS = 35;
export const SLOT_HEIGHT = 48; // task height = ROW_HEIGHT - 8
export interface ScheduleTask {
id: string;
title: string;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: string | null;
estimatedHours: number | null;
scheduledStartDate: string | null;
scheduledEndDate: string | null;
scheduleNotes: string | null;
assignedArtistId: string | null;
assignedArtist: {
id: string;
name: string | null;
email: string;
image: string | null;
} | null;
shot: { id: string; shotCode: string; thumbnailUrl: string | null } | null;
asset: { id: string; assetCode: string; name: string } | null;
project: { id: string; name: string; code: string };
}
export interface ScheduleArtist {
id: string;
name: string | null;
email: string;
image: string | null;
role: string;
}
export interface ActiveDragData {
type: "scheduled" | "backlog" | "resize";
taskId: string;
duration?: number;
estimatedHours?: number | null;
originalEndDate?: string;
}
interface SchedulePageClientProps {
artists: ScheduleArtist[];
tasks: ScheduleTask[];
backlog: ScheduleTask[];
projects: { id: string; name: string; code: string }[];
canEdit: boolean;
currentUserId: string;
activeProject?: string;
activeArtist?: string;
}
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
function calcDuration(task: ScheduleTask): number {
return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8));
}
export function SchedulePageClient({
artists,
tasks,
backlog,
projects,
canEdit,
currentUserId,
activeProject,
activeArtist,
}: SchedulePageClientProps) {
const { toast } = useToast();
const [localTasks, setLocalTasks] = useState<ScheduleTask[]>(tasks);
const [localBacklog, setLocalBacklog] = useState<ScheduleTask[]>(backlog);
const [viewStart, setViewStart] = useState<Date>(() =>
startOfWeek(new Date(), { weekStartsOn: 1 })
);
const [showBacklog, setShowBacklog] = useState(true);
const [filterProject, setFilterProject] = useState(activeProject ?? "");
const [filterArtist, setFilterArtist] = useState(activeArtist ?? "");
const [filterStatus, setFilterStatus] = useState("");
const [activeDrag, setActiveDrag] = useState<ActiveDragData | null>(null);
const [resizePreview, setResizePreview] = useState<{
taskId: string;
endDate: Date;
estimatedHours: number;
} | null>(null);
const [dayWidth, setDayWidth] = useState(DAY_WIDTH);
const [rowHeight, setRowHeight] = useState(ROW_HEIGHT);
const timelineRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
})
);
const days = useMemo(
() => Array.from({ length: NUM_DAYS }, (_, i) => addDays(viewStart, i)),
[viewStart]
);
const filteredArtists = useMemo(() => {
if (!filterArtist) return artists;
return artists.filter((a) => a.id === filterArtist);
}, [artists, filterArtist]);
const filteredScheduledTasks = useMemo(() => {
return localTasks.filter((t) => {
if (filterProject && t.project.id !== filterProject) return false;
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
if (filterStatus && t.status !== filterStatus) return false;
return true;
});
}, [localTasks, filterProject, filterArtist, filterStatus]);
const filteredBacklog = useMemo(() => {
return localBacklog.filter((t) => {
if (filterProject && t.project.id !== filterProject) return false;
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
if (filterStatus && t.status !== filterStatus) return false;
return true;
});
}, [localBacklog, filterProject, filterArtist, filterStatus]);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const data = event.active.data.current as ActiveDragData;
setActiveDrag(data);
},
[]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveDrag(null);
const { active, delta, over } = event;
if (!active.data.current) return;
const dragData = active.data.current as ActiveDragData;
// Resize handle - only deltaX matters
if (dragData.type === "resize") {
const task = localTasks.find((t) => t.id === dragData.taskId);
if (!task?.scheduledStartDate || !dragData.originalEndDate) return;
const originalEnd = toDate(dragData.originalEndDate)!;
const daysDelta = Math.round(delta.x / dayWidth);
let newEnd = addDays(originalEnd, daysDelta);
const startDate = toDate(task.scheduledStartDate)!;
if (isBefore(newEnd, startDate)) newEnd = startDate;
const prevEnd = task.scheduledEndDate;
setLocalTasks((prev) =>
prev.map((t) =>
t.id === dragData.taskId
? { ...t, scheduledEndDate: newEnd.toISOString() }
: t
)
);
scheduleTask({
taskId: dragData.taskId,
scheduledStartDate: task.scheduledStartDate,
scheduledEndDate: newEnd.toISOString(),
}).catch(() => {
setLocalTasks((prev) =>
prev.map((t) =>
t.id === dragData.taskId
? { ...t, scheduledEndDate: prevEnd }
: t
)
);
toast({ title: "Failed to resize task", variant: "destructive" });
});
return;
}
// Check if dropped over the backlog panel (unschedule)
if (over?.id === "backlog-drop-zone" && dragData.type === "scheduled") {
const task = localTasks.find((t) => t.id === dragData.taskId);
if (!task) return;
setLocalTasks((prev) => prev.filter((t) => t.id !== dragData.taskId));
setLocalBacklog((prev) => [
{
...task,
scheduledStartDate: null,
scheduledEndDate: null,
},
...prev,
]);
unscheduleTask(dragData.taskId).catch(() => {
setLocalTasks((prev) => [...prev, task]);
setLocalBacklog((prev) =>
prev.filter((t) => t.id !== dragData.taskId)
);
toast({ title: "Failed to unschedule task", variant: "destructive" });
});
return;
}
// Drop on timeline - calculate position from translated rect
const translatedRect = active.rect.current.translated;
if (!translatedRect || !timelineRef.current) return;
const timelineBounds = timelineRef.current.getBoundingClientRect();
const scrollLeft = timelineRef.current.scrollLeft;
const centerX = translatedRect.left + translatedRect.width / 2;
const centerY = translatedRect.top + translatedRect.height / 2;
// Check if drop is within timeline bounds
const inTimeline =
centerX >= timelineBounds.left &&
centerX <= timelineBounds.right &&
centerY >= timelineBounds.top + HEADER_HEIGHT &&
centerY <= timelineBounds.bottom;
if (!inTimeline && dragData.type !== "backlog") return;
if (!inTimeline && dragData.type === "backlog") return;
const dayIndex = Math.max(
0,
Math.min(
Math.floor((centerX - timelineBounds.left + scrollLeft) / dayWidth),
NUM_DAYS - 1
)
);
const artistIndex = Math.max(
0,
Math.min(
Math.floor(
(centerY - timelineBounds.top - HEADER_HEIGHT) / rowHeight
),
filteredArtists.length - 1
)
);
const newArtist = filteredArtists[artistIndex];
if (!newArtist) return;
const taskId = dragData.taskId;
// Duration always derived from estimatedHours so width = hours
let duration = 1;
if (dragData.type === "scheduled") {
const task = localTasks.find((t) => t.id === taskId);
if (task) duration = calcDuration(task);
} else if (dragData.type === "backlog") {
const task = localBacklog.find((t) => t.id === taskId);
if (task) duration = calcDuration(task);
}
// Pack task within the dropped day using hour offsets.
// Sum hours already scheduled for this artist on that day,
// and start the new task at that hour (stored in scheduledStartDate's time component).
const existingForArtist = localTasks.filter(
(t) =>
t.assignedArtistId === newArtist.id &&
t.id !== taskId &&
t.scheduledStartDate
);
const droppedDay = startOfDay(addDays(viewStart, dayIndex));
const hoursOnDay = existingForArtist
.filter((t) => {
const tStart = toDate(t.scheduledStartDate!);
return tStart && differenceInDays(startOfDay(tStart), droppedDay) === 0;
})
.reduce((sum, t) => sum + (t.estimatedHours ?? 8), 0);
const newStartDate = new Date(droppedDay);
newStartDate.setHours(hoursOnDay);
const newEndDate = addDays(newStartDate, duration - 1);
if (dragData.type === "backlog") {
const task = localBacklog.find((t) => t.id === taskId);
if (!task) return;
const scheduledTask: ScheduleTask = {
...task,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
assignedArtist: {
id: newArtist.id,
name: newArtist.name,
email: newArtist.email,
image: newArtist.image,
},
};
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
setLocalTasks((prev) => [...prev, scheduledTask]);
scheduleTask({
taskId,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
}).catch(() => {
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
setLocalBacklog((prev) => [task, ...prev]);
toast({ title: "Failed to schedule task", variant: "destructive" });
});
} else if (dragData.type === "scheduled") {
const prevTask = localTasks.find((t) => t.id === taskId);
setLocalTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
assignedArtist: {
id: newArtist.id,
name: newArtist.name,
email: newArtist.email,
image: newArtist.image,
},
}
: t
)
);
scheduleTask({
taskId,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
}).catch(() => {
if (prevTask) {
setLocalTasks((prev) =>
prev.map((t) => (t.id === taskId ? prevTask : t))
);
}
toast({ title: "Failed to move task", variant: "destructive" });
});
}
},
[localTasks, localBacklog, viewStart, filteredArtists, toast, dayWidth, rowHeight]
);
const handleResizeMouseDown = useCallback(
(taskId: string, _currentEndDate: string) =>
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const prevTask = localTasks.find((t) => t.id === taskId);
if (!prevTask?.scheduledStartDate) return;
const startX = e.clientX;
const startDate = toDate(prevTask.scheduledStartDate)!;
const originalHours = prevTask.estimatedHours ?? 8;
const HOUR_WIDTH = dayWidth / 8; // px per 1 hour
let currentHours = originalHours;
const onMouseMove = (me: MouseEvent) => {
const deltaPx = me.clientX - startX;
const hoursDelta = Math.round(deltaPx / HOUR_WIDTH);
currentHours = Math.max(1, originalHours + hoursDelta);
const newDays = Math.max(1, Math.ceil(currentHours / 8));
const newEndDate = addDays(startDate, newDays - 1);
setResizePreview({
taskId,
endDate: newEndDate,
estimatedHours: currentHours,
});
};
const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
const newDays = Math.max(1, Math.ceil(currentHours / 8));
const finalEnd = addDays(startDate, newDays - 1);
setResizePreview(null);
setLocalTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
scheduledEndDate: finalEnd.toISOString(),
estimatedHours: currentHours,
}
: t
)
);
scheduleTask({
taskId,
scheduledStartDate: prevTask.scheduledStartDate,
scheduledEndDate: finalEnd.toISOString(),
estimatedHours: currentHours,
}).catch(() => {
setLocalTasks((prev) =>
prev.map((t) => (t.id === taskId ? prevTask : t))
);
toast({ title: "Failed to resize task", variant: "destructive" });
});
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
},
[localTasks, toast, dayWidth]
);
const activeDragTask = activeDrag
? localTasks.find((t) => t.id === activeDrag.taskId) ??
localBacklog.find((t) => t.id === activeDrag.taskId)
: null;
const handleUnschedule = useCallback(
(taskId: string) => {
const task = localTasks.find((t) => t.id === taskId);
if (!task) return;
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
setLocalBacklog((prev) => [
{ ...task, scheduledStartDate: null, scheduledEndDate: null },
...prev,
]);
unscheduleTask(taskId).catch(() => {
setLocalTasks((prev) => [...prev, task]);
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
toast({ title: "Failed to unschedule task", variant: "destructive" });
});
},
[localTasks, toast]
);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col h-full overflow-hidden bg-zinc-950">
{/* Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-zinc-800 bg-zinc-900 shrink-0">
<div className="flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-amber-400" />
<h1 className="text-lg font-semibold text-white">Schedule</h1>
<span className="text-sm text-zinc-500">
{format(viewStart, "MMM d")} {" "}
{format(addDays(viewStart, NUM_DAYS - 1), "MMM d, yyyy")}
</span>
</div>
<div className="flex items-center gap-2">
{/* Zoom controls */}
<div className="flex items-center gap-3 border border-zinc-700 rounded-md px-2.5 py-1">
<div className="flex items-center gap-1.5">
<Columns2 className="h-3 w-3 text-zinc-500" />
<input
type="range"
min={60}
max={400}
step={8}
value={dayWidth}
onChange={(e) => setDayWidth(Number(e.target.value))}
className="w-20 accent-amber-400 cursor-pointer"
/>
</div>
<div className="flex items-center gap-1.5">
<Rows className="h-3 w-3 text-zinc-500" />
<input
type="range"
min={40}
max={120}
step={4}
value={rowHeight}
onChange={(e) => setRowHeight(Number(e.target.value))}
className="w-20 accent-amber-400 cursor-pointer"
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowBacklog((b) => !b)}
className="text-zinc-400 hover:text-white gap-2"
>
{showBacklog ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRightOpen className="h-4 w-4" />
)}
{showBacklog ? "Hide" : "Backlog"}
</Button>
</div>
</div>
{/* Filters */}
<ScheduleFilters
projects={projects}
artists={artists}
filterProject={filterProject}
filterArtist={filterArtist}
filterStatus={filterStatus}
viewStart={viewStart}
onProjectChange={setFilterProject}
onArtistChange={setFilterArtist}
onStatusChange={setFilterStatus}
onViewStartChange={setViewStart}
/>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
<ScheduleTimeline
artists={filteredArtists}
tasks={filteredScheduledTasks}
days={days}
viewStart={viewStart}
canEdit={canEdit}
timelineRef={timelineRef}
resizePreview={resizePreview}
onResizeMouseDown={handleResizeMouseDown}
activeDragId={activeDrag?.taskId ?? null}
onUnschedule={handleUnschedule}
dayWidth={dayWidth}
rowHeight={rowHeight}
/>
{showBacklog && (
<BacklogPanel
tasks={filteredBacklog}
canEdit={canEdit}
/>
)}
</div>
{/* Drag overlay */}
<DragOverlay dropAnimation={null}>
{activeDragTask && (
<div className="rounded-md border border-amber-500/60 bg-amber-500/20 px-2 py-1.5 text-xs font-medium text-amber-200 shadow-xl opacity-90 max-w-[200px] truncate pointer-events-none">
{activeDragTask.shot?.shotCode ??
activeDragTask.asset?.assetCode ??
activeDragTask.title}
</div>
)}
</DragOverlay>
</div>
</DndContext>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { SchedulePageClient } from "./SchedulePageClient";
export const metadata = { title: "Schedule" };
export default async function SchedulePage({
searchParams,
}: {
searchParams: Promise<{ project?: string; artist?: string; status?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const { project, artist, status } = await searchParams;
const [artists, scheduledTasks, backlogTasks, projects] = await Promise.all([
db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: { id: true, name: true, email: true, image: true, role: true },
orderBy: [{ role: "asc" }, { name: "asc" }],
}),
db.task.findMany({
where: {
scheduledStartDate: { not: null },
status: { not: "DONE" },
...(project ? { projectId: project } : {}),
...(artist ? { assignedArtistId: artist } : {}),
},
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: { scheduledStartDate: "asc" },
}),
db.task.findMany({
where: {
scheduledStartDate: null,
status: { not: "DONE" },
...(project ? { projectId: project } : {}),
...(artist ? { assignedArtistId: artist } : {}),
},
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
}),
db.project.findMany({
where: { status: "ACTIVE" },
select: { id: true, name: true, code: true },
orderBy: { name: "asc" },
}),
]);
const canEdit = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
session.user.role
);
const serializeTask = (t: (typeof scheduledTasks)[number]) => ({
...t,
dueDate: t.dueDate ? t.dueDate.toISOString() : null,
scheduledStartDate: t.scheduledStartDate ? t.scheduledStartDate.toISOString() : null,
scheduledEndDate: t.scheduledEndDate ? t.scheduledEndDate.toISOString() : null,
});
return (
<SchedulePageClient
artists={artists}
tasks={scheduledTasks.map(serializeTask)}
backlog={backlogTasks.map(serializeTask)}
projects={projects}
canEdit={canEdit}
currentUserId={session.user.id}
activeProject={project}
activeArtist={artist}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { auth } from "@/auth";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials } from "@/lib/utils";
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
export const metadata = { title: "Settings" };
export default async function SettingsPage() {
const session = await auth();
if (!session?.user) return null;
return (
<div className="p-6 space-y-6 max-w-2xl mx-auto">
<h1 className="text-2xl font-bold">Account Settings</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">Your Profile</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-4">
<Avatar className="h-14 w-14">
{session.user.image && <AvatarImage src={session.user.image} />}
<AvatarFallback className="text-lg">
{getInitials(session.user.name)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">{session.user.name ?? "—"}</p>
<p className="text-sm text-muted-foreground">{session.user.email}</p>
<p className="text-xs text-muted-foreground mt-0.5 capitalize">
{session.user.role?.toLowerCase()}
</p>
</div>
</CardContent>
</Card>
<ChangePasswordForm mustChangePassword={session.user.mustChangePassword ?? false} />
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn, getInitials } from "@/lib/utils";
import {
CalendarDays,
ListTodo,
AlertTriangle,
Eye,
Clock,
Layers,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
import { TaskStatus, TaskType } from "@prisma/client";
const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-zinc-500",
NORMAL: "bg-blue-500",
HIGH: "bg-amber-500",
URGENT: "bg-red-500",
};
interface Artist {
id: string;
name: string | null;
email: string;
}
interface Task {
id: string;
title: string;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: Date | null;
shot?: { id: string; shotCode: string } | null;
asset?: { id: string; assetCode: string; name: string } | null;
project: { id: string; name: string; code: string };
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
_count?: { versions: number };
versions?: { id: string; versionNumber: number; approvalStatus: string; createdAt: Date }[];
}
interface TasksPageClientProps {
tasks: Task[];
artists: Artist[];
currentUserId: string;
role: string;
counts: { today: number; overdue: number; inReview: number; total: number };
activeStatus?: string;
activeAssignee?: string;
}
const FILTER_TABS = [
{ label: "All", status: undefined, icon: ListTodo, color: "text-zinc-400" },
{ label: "Due Today", status: "today", icon: Clock, color: "text-amber-400" },
{ label: "Overdue", status: "overdue", icon: AlertTriangle, color: "text-red-400" },
{ label: "In Review", status: "INTERNAL_REVIEW", icon: Eye, color: "text-purple-400" },
];
export function TasksPageClient({
tasks,
artists,
currentUserId,
role,
counts,
activeStatus,
activeAssignee,
}: TasksPageClientProps) {
const router = useRouter();
const isArtist = role === "ARTIST";
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Client-side filter
const filtered = tasks.filter((task) => {
if (activeStatus === "today") {
return (
task.dueDate &&
new Date(task.dueDate) >= todayStart &&
new Date(task.dueDate) < new Date(todayStart.getTime() + 86400000) &&
task.status !== "DONE"
);
}
if (activeStatus === "overdue") {
return task.dueDate && new Date(task.dueDate) < todayStart && task.status !== "DONE";
}
if (activeStatus === "INTERNAL_REVIEW") {
return ["INTERNAL_REVIEW", "CLIENT_REVIEW"].includes(task.status);
}
return true;
});
const navigate = (params: { status?: string; assignee?: string }) => {
const sp = new URLSearchParams();
if (params.status) sp.set("status", params.status);
if (params.assignee) sp.set("assignee", params.assignee);
router.push(`/tasks?${sp.toString()}`);
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">
{isArtist ? "My Tasks" : "All Tasks"}
</h1>
<p className="text-sm text-zinc-400 mt-0.5">
{counts.total} total · {counts.overdue > 0 && (
<span className="text-red-400">{counts.overdue} overdue · </span>
)}
{counts.inReview} in review
</p>
</div>
{/* Assignee filter (non-artists) */}
{!isArtist && artists.length > 0 && (
<Select
value={activeAssignee ?? "__all__"}
onValueChange={(v) =>
navigate({ status: activeStatus, assignee: v === "__all__" ? undefined : v })
}
>
<SelectTrigger className="w-44 h-8 text-sm">
<SelectValue placeholder="All artists" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All artists</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name ?? a.email}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Filter tabs */}
<div className="flex gap-1 border-b border-zinc-800 pb-0">
{FILTER_TABS.map((tab) => {
const Icon = tab.icon;
const isActive =
(!activeStatus && !tab.status) || activeStatus === tab.status;
const count =
tab.status === "today"
? counts.today
: tab.status === "overdue"
? counts.overdue
: tab.status === "INTERNAL_REVIEW"
? counts.inReview
: counts.total;
return (
<button
key={tab.label}
onClick={() =>
navigate({ status: tab.status, assignee: activeAssignee })
}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
isActive
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Icon className={cn("h-3.5 w-3.5", isActive ? "text-amber-400" : tab.color)} />
{tab.label}
{count > 0 && (
<span className="text-xs text-zinc-600 bg-zinc-800 rounded-full px-1.5 font-mono">
{count}
</span>
)}
</button>
);
})}
</div>
{/* Task list */}
{filtered.length === 0 ? (
<div className="text-center py-16 border border-dashed border-zinc-800 rounded-xl">
<ListTodo className="h-8 w-8 mx-auto mb-3 text-zinc-700" />
<p className="text-zinc-500 text-sm">No tasks found</p>
</div>
) : (
<div className="space-y-1">
{filtered.map((task) => {
const cfg = TASK_STATUS_CONFIG[task.status];
const Icon = cfg.icon;
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
const isOverdue =
task.dueDate &&
new Date(task.dueDate) < now &&
task.status !== "DONE";
const latestVersion = task.versions?.[0];
return (
<Link
key={task.id}
href={`/tasks/${task.id}`}
className="flex items-center gap-3 px-4 py-3 rounded-lg border border-transparent hover:border-zinc-800 hover:bg-zinc-900/60 transition-all group"
>
{/* Priority dot */}
<span
className={cn(
"w-1.5 h-1.5 rounded-full shrink-0",
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
)}
/>
{/* Title + context */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-200 group-hover:text-amber-400 transition-colors truncate">
{task.title}
</span>
{contextCode && (
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded shrink-0">
{contextCode}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-zinc-500">
<span>{TASK_TYPE_LABELS[task.type]}</span>
<span className="text-zinc-700">·</span>
<span>{task.project.name}</span>
</div>
</div>
{/* Meta */}
<div className="flex items-center gap-3 shrink-0">
{latestVersion && (
<span className="text-xs font-mono text-zinc-500">
v{latestVersion.versionNumber}
</span>
)}
{(task._count?.versions ?? 0) > 0 && (
<span className="flex items-center gap-1 text-[11px] text-zinc-500">
<Layers className="h-3 w-3" />
{task._count!.versions}
</span>
)}
{task.dueDate && (
<span
className={cn(
"flex items-center gap-1 text-[11px]",
isOverdue ? "text-red-400" : "text-zinc-500"
)}
>
<CalendarDays className="h-3 w-3" />
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
</span>
)}
{task.assignedArtist && (
<Avatar className="h-5 w-5">
<AvatarImage src={task.assignedArtist.image ?? undefined} />
<AvatarFallback className="text-[8px] bg-zinc-700 text-zinc-300">
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
</AvatarFallback>
</Avatar>
)}
<Badge
className={cn(
"text-[10px] border px-1.5 py-0 h-5 gap-1",
cfg.color
)}
>
<Icon className="h-2.5 w-2.5" />
{cfg.label}
</Badge>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,485 @@
"use client";
import { useState, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { getInitials, formatRelativeDate, formatFileSize } from "@/lib/utils";
import { updateTask, updateTaskStatus } from "@/actions/tasks";
import { useToast } from "@/components/ui/use-toast";
import { TaskStatus, TaskType } from "@prisma/client";
import { formatDistanceToNow, format } from "date-fns";
import {
ArrowLeft,
CalendarDays,
Clock,
Film,
Layers,
Play,
Upload,
ChevronDown,
CheckCircle2,
Eye,
RefreshCw,
Loader2,
AlertCircle,
User,
ExternalLink,
} from "lucide-react";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
import { VersionUpload } from "@/components/versions/VersionUpload";
import type { CommentWithReplies } from "@/types";
import { CommentPanel } from "@/components/comments/CommentPanel";
const PRIORITY_COLORS: Record<string, string> = {
LOW: "text-zinc-400", NORMAL: "text-blue-400", HIGH: "text-amber-400", URGENT: "text-red-400",
};
const APPROVAL_STYLES: Record<string, string> = {
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
};
interface Artist {
id: string;
name: string | null;
email: string;
image: string | null;
role: string;
}
interface Version {
id: string;
versionNumber: number;
fileName: string;
fileSize: bigint | null;
fps: number;
approvalStatus: string;
isLatest: boolean;
isClientVisible: boolean;
createdAt: Date;
notes: string | null;
artist: { id: string; name: string | null; image: string | null; email: string } | null;
_count: { comments: number };
approvals: { id: string; user: { id: string; name: string | null; role: string } }[];
comments: CommentWithReplies[];
}
interface Task {
id: string;
title: string;
description: string | null;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: Date | null;
estimatedHours: number | null;
projectId: string;
assignedArtistId: string | null;
assignedArtist: Artist | null;
createdBy: { id: string; name: string | null; email: string };
project: { id: string; name: string; code: string };
shot: { id: string; shotCode: string; projectId: string } | null;
asset: { id: string; assetCode: string; name: string; projectId: string } | null;
versions: Version[];
}
interface TaskDetailClientProps {
task: Task;
artists: Artist[];
currentUserId: string;
canManage: boolean;
canUpload: boolean;
}
export function TaskDetailClient({
task,
artists,
currentUserId,
canManage,
canUpload,
}: TaskDetailClientProps) {
const router = useRouter();
const { toast } = useToast();
const [showUpload, setShowUpload] = useState(false);
const [updatingStatus, setUpdatingStatus] = useState(false);
const statusCfg = TASK_STATUS_CONFIG[task.status];
const StatusIcon = statusCfg.icon;
const latestVersion = task.versions[0];
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== "DONE";
const parentLink = task.shot
? { label: task.shot.shotCode, href: `/projects/${task.project.id}` }
: task.asset
? { label: `${task.asset.assetCode}${task.asset.name}`, href: `/projects/${task.project.id}` }
: null;
const handleStatusChange = async (newStatus: TaskStatus) => {
setUpdatingStatus(true);
try {
await updateTaskStatus(task.id, newStatus);
router.refresh();
} catch {
toast({ title: "Failed to update status", variant: "destructive" });
} finally {
setUpdatingStatus(false);
}
};
const handleAssigneeChange = async (artistId: string) => {
try {
await updateTask(task.id, { assignedArtistId: artistId === "__none__" ? null : artistId });
router.refresh();
toast({ title: "Assignment updated" });
} catch {
toast({ title: "Failed to update assignment", variant: "destructive" });
}
};
return (
<div className="min-h-screen bg-background">
<div className="max-w-5xl mx-auto p-6 space-y-6">
{/* 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>
<Link href={`/projects/${task.project.id}`} className="hover:text-white transition-colors">
{task.project.name}
</Link>
{parentLink && (
<>
<span>/</span>
<Link href={parentLink.href} className="hover:text-white transition-colors">
{parentLink.label}
</Link>
</>
)}
<span>/</span>
<span className="text-zinc-300">{task.title}</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main column */}
<div className="lg:col-span-2 space-y-5">
{/* Task header */}
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold text-white">{task.title}</h1>
<div className="flex items-center gap-2 text-sm text-zinc-500">
<span className="font-mono">{TASK_TYPE_LABELS[task.type]}</span>
{parentLink && (
<>
<span>·</span>
<Link href={parentLink.href} className="hover:text-zinc-300">
{parentLink.label}
</Link>
</>
)}
</div>
</div>
{canUpload && (
<Button onClick={() => setShowUpload(true)} className="gap-2 shrink-0">
<Upload className="h-4 w-4" />
Upload Version
</Button>
)}
</div>
{task.description && (
<p className="text-zinc-400 text-sm leading-relaxed">{task.description}</p>
)}
</div>
{/* Version history */}
<div className="space-y-3">
<h2 className="font-semibold text-zinc-300 flex items-center gap-2">
<Layers className="h-4 w-4" />
Versions
{task.versions.length > 0 && (
<span className="text-xs text-zinc-500 font-normal">({task.versions.length})</span>
)}
</h2>
{task.versions.length === 0 ? (
<div className="text-center py-8 border border-dashed border-border rounded-lg">
<Film className="h-8 w-8 mx-auto mb-2 text-zinc-600" />
<p className="text-sm text-muted-foreground">No versions uploaded yet</p>
{canUpload && (
<Button
variant="ghost"
size="sm"
className="mt-3 gap-2"
onClick={() => setShowUpload(true)}
>
<Upload className="h-3.5 w-3.5" />
Upload first version
</Button>
)}
</div>
) : (
<div className="space-y-2">
{task.versions.map((v) => (
<div
key={v.id}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors",
v.isLatest
? "border-amber-500/30 bg-amber-500/5"
: "border-border bg-card"
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-medium text-sm text-white">
v{String(v.versionNumber).padStart(3, "0")}
</span>
{v.isLatest && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-amber-500/15 text-amber-400 border-amber-500/30">
Latest
</Badge>
)}
<Badge className={cn("text-xs border px-1.5 py-0 h-5", APPROVAL_STYLES[v.approvalStatus])}>
{v.approvalStatus.replace(/_/g, " ")}
</Badge>
{v.isClientVisible && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-blue-500/15 text-blue-400 border-blue-500/30">
Client Visible
</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-zinc-500">
<span>{v.fileName}</span>
{v.fileSize != null && (
<span>{formatFileSize(Number(v.fileSize))}</span>
)}
<span>{formatRelativeDate(new Date(v.createdAt))}</span>
{v._count.comments > 0 && (
<span className="flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{v._count.comments} comments
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{v.artist && (
<Avatar className="h-6 w-6">
<AvatarImage src={v.artist.image ?? undefined} />
<AvatarFallback className="text-[10px]">
{getInitials(v.artist.name ?? v.artist.email)}
</AvatarFallback>
</Avatar>
)}
<Link href={`/review/${v.id}`}>
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs">
<Play className="h-3 w-3" />
Review
</Button>
</Link>
</div>
</div>
))}
</div>
)}
</div>
{/* Comments on latest version */}
{latestVersion && (
<div className="space-y-3">
<h2 className="font-semibold text-zinc-300 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Comments
</h2>
<CommentPanel
versionId={latestVersion.id}
fps={latestVersion.fps}
comments={latestVersion.comments}
/>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4">
{/* Status */}
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
<h3 className="text-sm font-medium text-zinc-400">Status</h3>
{canManage ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={updatingStatus}
className={cn(
"w-full flex items-center justify-between gap-2 px-3 py-2 rounded-md border text-sm font-medium transition-colors",
statusCfg.color
)}
>
<span className="flex items-center gap-2">
<StatusIcon className="h-3.5 w-3.5" />
{statusCfg.label}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{(Object.keys(TASK_STATUS_CONFIG) as TaskStatus[]).map((s) => {
const cfg = TASK_STATUS_CONFIG[s];
const Icon = cfg.icon;
return (
<DropdownMenuItem
key={s}
onClick={() => handleStatusChange(s)}
className={cn(s === task.status && "font-medium")}
>
<Icon className="h-3.5 w-3.5 mr-2" />
{cfg.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className={cn("flex items-center gap-2 px-3 py-2 rounded-md border text-sm font-medium", statusCfg.color)}>
<StatusIcon className="h-3.5 w-3.5" />
{statusCfg.label}
</div>
)}
{/* Meta fields */}
<div className="space-y-3 pt-2 border-t border-border">
<div>
<p className="text-xs text-zinc-500 mb-1">Type</p>
<p className="text-sm font-medium">{TASK_TYPE_LABELS[task.type]}</p>
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Priority</p>
<p className={cn("text-sm font-medium capitalize", PRIORITY_COLORS[task.priority])}>
{task.priority}
</p>
</div>
{task.dueDate && (
<div>
<p className="text-xs text-zinc-500 mb-1">Due Date</p>
<p className={cn("text-sm flex items-center gap-1.5", isOverdue ? "text-red-400" : "text-zinc-300")}>
<CalendarDays className="h-3.5 w-3.5" />
{format(new Date(task.dueDate), "MMM d, yyyy")}
{isOverdue && <span className="text-xs">(Overdue)</span>}
</p>
</div>
)}
{task.estimatedHours && (
<div>
<p className="text-xs text-zinc-500 mb-1">Estimated</p>
<p className="text-sm flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-zinc-400" />
{task.estimatedHours}h
</p>
</div>
)}
</div>
</div>
{/* Assignee */}
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<h3 className="text-sm font-medium text-zinc-400">Assigned Artist</h3>
{canManage ? (
<Select
value={task.assignedArtistId ?? "__none__"}
onValueChange={handleAssigneeChange}
>
<SelectTrigger className="text-sm">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Unassigned</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name ?? a.email}
</SelectItem>
))}
</SelectContent>
</Select>
) : task.assignedArtist ? (
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7">
<AvatarImage src={task.assignedArtist.image ?? undefined} />
<AvatarFallback className="text-xs">
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{task.assignedArtist.name ?? task.assignedArtist.email}</p>
<p className="text-xs text-zinc-500">{task.assignedArtist.role}</p>
</div>
</div>
) : (
<p className="text-sm text-zinc-500">Unassigned</p>
)}
</div>
{/* Project context */}
<div className="rounded-lg border border-border bg-card p-4 space-y-2">
<h3 className="text-sm font-medium text-zinc-400">Project</h3>
<Link
href={`/projects/${task.project.id}`}
className="flex items-center gap-1.5 text-sm text-white hover:text-amber-400 transition-colors"
>
<span className="font-mono text-zinc-500">{task.project.code}</span>
{task.project.name}
<ExternalLink className="h-3 w-3 text-zinc-500" />
</Link>
{task.shot && (
<p className="text-xs text-zinc-500">Shot: {task.shot.shotCode}</p>
)}
{task.asset && (
<p className="text-xs text-zinc-500">Asset: {task.asset.assetCode} {task.asset.name}</p>
)}
</div>
</div>
</div>
</div>
{/* Version upload dialog */}
{showUpload && (
<VersionUpload
taskId={task.id}
projectId={task.projectId}
currentVersionNumber={latestVersion?.versionNumber ?? 0}
open={showUpload}
onClose={() => setShowUpload(false)}
onSuccess={() => {
setShowUpload(false);
router.refresh();
}}
/>
)}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { TaskDetailClient } from "./TaskDetailClient";
export async function generateMetadata({ params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const task = await db.task.findUnique({ where: { id: taskId }, select: { title: true } });
return { title: task?.title ?? "Task" };
}
async function getTask(taskId: string) {
return db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { id: true, shotCode: true, projectId: true } },
asset: { select: { id: true, assetCode: true, name: true, projectId: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true, role: true } },
createdBy: { select: { id: true, name: true, email: true } },
project: { select: { id: true, name: true, code: true } },
versions: {
orderBy: { versionNumber: "desc" },
include: {
artist: { select: { id: true, name: true, image: true, email: true } },
_count: { select: { comments: true } },
approvals: {
orderBy: { createdAt: "desc" },
take: 1,
include: { user: { select: { id: true, name: true, role: true } } },
},
comments: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
},
},
},
},
},
},
},
});
}
async function getProjectArtists(projectId: string) {
return db.user.findMany({
where: {
isActive: true,
},
select: { id: true, name: true, email: true, image: true, role: true },
orderBy: { name: "asc" },
});
}
export default async function TaskPage({ params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const session = await auth();
if (!session?.user) redirect("/login");
const task = await getTask(taskId);
if (!task) notFound();
const artists = await getProjectArtists(task.projectId);
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const isAssigned = task.assignedArtistId === session.user.id;
const canUpload = canManage || isAssigned;
return (
<TaskDetailClient
task={task as any}
artists={artists}
currentUserId={session.user.id}
canManage={canManage}
canUpload={canUpload}
/>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { TasksPageClient } from "./TasksPageClient";
export const metadata = { title: "My Tasks" };
export default async function TasksPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; assignee?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const { status, assignee } = await searchParams;
const isArtist = session.user.role === "ARTIST";
// Artists only see their own tasks; others can filter by assignee
const assigneeFilter = isArtist
? session.user.id
: assignee || undefined;
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tasks = await db.task.findMany({
where: {
...(assigneeFilter ? { assignedArtistId: assigneeFilter } : {}),
},
orderBy: [{ dueDate: "asc" }, { priority: "desc" }, { createdAt: "desc" }],
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: 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 },
},
},
});
const artists = isArtist
? []
: await db.user.findMany({
where: { isActive: true },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
});
// Counts for filter tabs
const today = tasks.filter(
(t) => t.dueDate && new Date(t.dueDate) >= todayStart && new Date(t.dueDate) < new Date(todayStart.getTime() + 86400000) && t.status !== "DONE"
).length;
const overdue = tasks.filter(
(t) => t.dueDate && new Date(t.dueDate) < todayStart && t.status !== "DONE"
).length;
const inReview = tasks.filter((t) =>
["INTERNAL_REVIEW", "CLIENT_REVIEW"].includes(t.status)
).length;
return (
<TasksPageClient
tasks={tasks as any}
artists={artists}
currentUserId={session.user.id}
role={session.user.role}
counts={{ today, overdue, inReview, total: tasks.length }}
activeStatus={status}
activeAssignee={assignee}
/>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { Metadata } from "next";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { UsersClient } from "@/components/users/UsersClient";
import { Users } from "lucide-react";
export const metadata: Metadata = { title: "User Management" };
export default async function UsersPage() {
const session = await auth();
if (!session || session.user.role !== "ADMIN") {
redirect("/dashboard");
}
const users = await db.user.findMany({
orderBy: [{ role: "asc" }, { name: "asc" }, { email: "asc" }],
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
createdAt: true,
},
});
// Serialize dates
const serialized = users.map((u) => ({
...u,
createdAt: u.createdAt.toISOString(),
}));
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center shrink-0">
<Users className="h-5 w-5 text-amber-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">User Management</h1>
<p className="text-sm text-zinc-400 mt-0.5">Manage studio accounts, roles, and access</p>
</div>
</div>
<UsersClient users={serialized} currentUserId={session.user.id!} />
</div>
);
}