Initial commit
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { auth } from '@/auth';
|
||||
import Image from 'next/image';
|
||||
import { Montserrat } from 'next/font/google';
|
||||
|
||||
const montserrat = Montserrat({
|
||||
subsets: ['latin'],
|
||||
weight: ['200', '500', '600'],
|
||||
});
|
||||
|
||||
export default async function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (session?.user) redirect('/dashboard');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-950">
|
||||
<div className="w-full max-w-md px-4">
|
||||
{/* Logo / Brand */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-md bg-black border border-zinc-800 shadow-2xl">
|
||||
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
|
||||
</div>
|
||||
|
||||
<div className={`${montserrat.className} mt-4`}>
|
||||
<span className="block text-2xl font-light tracking-wide text-white leading-none">
|
||||
TWO TALES
|
||||
</span>
|
||||
|
||||
<span className="block text-[11px] tracking-[0.28em] italic uppercase text-zinc-500 leading-none mt-1">
|
||||
vfx review
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Eye, EyeOff, LogIn } from "lucide-react";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginFormValues) => {
|
||||
setAuthError(null);
|
||||
const result = await signIn("credentials", {
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setAuthError("Invalid email or password");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-xl align-middle text-center">Sign in</CardTitle>
|
||||
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@studio.com"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-red-400">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
className="pr-10"
|
||||
{...register("password")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-red-400">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<p className="text-sm text-red-400 text-center">{authError}</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full gap-2" disabled={isSubmitting}>
|
||||
<LogIn className="h-4 w-4" />
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { ApprovalStatus } from "@prisma/client";
|
||||
import { recalcShotStatus } from "@/lib/shot-status";
|
||||
|
||||
async function getOrCreateClientUser(email: string, label?: string | null) {
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) return existing;
|
||||
return db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: label ?? email.split("@")[0],
|
||||
role: "CLIENT",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function validateToken(token: string) {
|
||||
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||
if (!session || !session.isActive) return null;
|
||||
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params;
|
||||
const session = await validateToken(token);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { versionId, status, notes } = body;
|
||||
|
||||
const validStatuses: ApprovalStatus[] = ["APPROVED", "REJECTED", "NEEDS_CHANGES"];
|
||||
if (!versionId || !validStatuses.includes(status)) {
|
||||
return NextResponse.json({ error: "versionId and valid status required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure the version belongs to this project via its task
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
task: {
|
||||
include: { shot: true, project: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const projectId = version?.task?.projectId;
|
||||
if (!version || projectId !== session.projectId) {
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
||||
const user = await getOrCreateClientUser(email, session.label);
|
||||
|
||||
// Record approval
|
||||
await db.approval.create({
|
||||
data: { versionId, userId: user.id, status, notes },
|
||||
});
|
||||
|
||||
// Update version approval status
|
||||
await db.version.update({
|
||||
where: { id: versionId },
|
||||
data: { approvalStatus: status },
|
||||
});
|
||||
|
||||
// Update task status based on approval decision
|
||||
if (version.task) {
|
||||
if (status === "APPROVED") {
|
||||
await db.task.update({ where: { id: version.task.id }, data: { status: "DONE" } });
|
||||
} else {
|
||||
await db.task.update({ where: { id: version.task.id }, data: { status: "CHANGES" } });
|
||||
}
|
||||
|
||||
// Recalculate derived shot status
|
||||
if (version.task.shot) {
|
||||
await recalcShotStatus(version.task.shot.id).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Slack notification
|
||||
if (version.task?.project?.slackWebhook) {
|
||||
const { slackNotifyApproval } = await import("@/lib/slack");
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
|
||||
const contextCode = version.task.shot?.shotCode ?? version.task.title;
|
||||
await slackNotifyApproval(version.task.project.slackWebhook, {
|
||||
shotCode: contextCode,
|
||||
versionLabel: `v${String(version.versionNumber).padStart(3, "0")}`,
|
||||
reviewerName: user.name ?? "Client",
|
||||
status,
|
||||
projectName: version.task.project.name,
|
||||
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { slackNotifyNewFeedback } from "@/lib/slack";
|
||||
|
||||
/** Find or create a guest user for the client reviewer based on the session email */
|
||||
async function getOrCreateClientUser(email: string, label?: string | null) {
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) return existing;
|
||||
return db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: label ?? email.split("@")[0],
|
||||
role: "CLIENT",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function validateToken(token: string) {
|
||||
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||
if (!session || !session.isActive) return null;
|
||||
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params;
|
||||
const session = await validateToken(token);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { versionId, frameNumber, timestamp, text } = body;
|
||||
|
||||
if (!versionId || frameNumber == null || timestamp == null || !text?.trim()) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure the version belongs to this project
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
shot: { select: { projectId: true, shotCode: true, project: { select: { slackWebhook: true } } } },
|
||||
task: { select: { projectId: true, title: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
|
||||
},
|
||||
});
|
||||
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
|
||||
if (!version || projectId !== session.projectId) {
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Resolve commenter identity
|
||||
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
||||
const user = await getOrCreateClientUser(email, session.label);
|
||||
|
||||
const comment = await db.comment.create({
|
||||
data: {
|
||||
versionId,
|
||||
authorId: user.id,
|
||||
frameNumber,
|
||||
timestamp,
|
||||
text: text.trim(),
|
||||
},
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
replies: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Slack notification
|
||||
const slackWebhook =
|
||||
version.shot?.project?.slackWebhook ?? version.task?.project?.slackWebhook ?? null;
|
||||
const shotCode =
|
||||
version.shot?.shotCode ?? version.task?.shot?.shotCode ?? version.task?.title ?? "Task";
|
||||
if (slackWebhook) {
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
await slackNotifyNewFeedback(slackWebhook, {
|
||||
shotCode,
|
||||
frameNumber,
|
||||
authorName: user.name ?? user.email,
|
||||
commentText: text.trim(),
|
||||
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ comment }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
async function validateToken(token: string) {
|
||||
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||
if (!session || !session.isActive) return null;
|
||||
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
/** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params;
|
||||
const session = await validateToken(token);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: session.projectId },
|
||||
select: { id: true, name: true, code: true, description: true, status: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Find shots that have at least one task with a client-visible version
|
||||
const shots = await db.shot.findMany({
|
||||
where: {
|
||||
projectId: session.projectId,
|
||||
tasks: {
|
||||
some: {
|
||||
versions: { some: { isClientVisible: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
sequence: true,
|
||||
description: true,
|
||||
status: true,
|
||||
thumbnailUrl: true,
|
||||
tasks: {
|
||||
where: {
|
||||
versions: { some: { isClientVisible: true } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
status: true,
|
||||
versions: {
|
||||
where: { isClientVisible: true, isLatest: true },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
approvalStatus: true,
|
||||
fps: true,
|
||||
duration: true,
|
||||
thumbnailUrl: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Asset tasks with client-visible versions (no shotId)
|
||||
const assetTasks = await db.task.findMany({
|
||||
where: {
|
||||
projectId: session.projectId,
|
||||
shotId: null,
|
||||
versions: { some: { isClientVisible: true } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
status: true,
|
||||
asset: { select: { id: true, assetCode: true, name: true } },
|
||||
versions: {
|
||||
where: { isClientVisible: true, isLatest: true },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
approvalStatus: true,
|
||||
fps: true,
|
||||
duration: true,
|
||||
thumbnailUrl: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Increment access count
|
||||
await db.reviewSession.update({
|
||||
where: { id: session.id },
|
||||
data: { accessCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
project,
|
||||
shots,
|
||||
assetTasks,
|
||||
sessionLabel: session.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
async function validateToken(token: string) {
|
||||
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||
if (!session || !session.isActive) return null;
|
||||
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string; versionId: string }> }
|
||||
) {
|
||||
const { token, versionId } = await params;
|
||||
const session = await validateToken(token);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||
}
|
||||
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
shot: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
versions: {
|
||||
orderBy: { versionNumber: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
approvalStatus: true,
|
||||
isLatest: true,
|
||||
fps: true,
|
||||
duration: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
task: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
shot: { select: { shotCode: true } },
|
||||
asset: { select: { assetCode: true, name: true } },
|
||||
},
|
||||
},
|
||||
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||
approvals: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve project: from shot or from task
|
||||
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
|
||||
|
||||
if (!version || projectId !== session.projectId) {
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// For task-only versions (no shot), require explicit client sharing
|
||||
if (!version.shot && !version.isClientVisible) {
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const comments = await db.comment.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { frameNumber: "asc" },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
replies: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const serializedVersion = {
|
||||
...version,
|
||||
fileSize: version.fileSize?.toString() ?? null,
|
||||
};
|
||||
|
||||
return NextResponse.json({ version: serializedVersion, comments });
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ clientId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { clientId } = await params;
|
||||
|
||||
const client = await db.client.findUnique({
|
||||
where: { id: clientId },
|
||||
include: {
|
||||
projects: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
_count: { select: { shots: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
return NextResponse.json({ error: "Client not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ client });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ clientId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { clientId } = await params;
|
||||
const body = await req.json();
|
||||
const { company, contactPerson, email, phone, notes, isActive } = body;
|
||||
|
||||
const client = await db.client.update({
|
||||
where: { id: clientId },
|
||||
data: { company, contactPerson, email, phone, notes, isActive },
|
||||
});
|
||||
|
||||
return NextResponse.json({ client });
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const clients = await db.client.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
company: true,
|
||||
contactPerson: true,
|
||||
email: true,
|
||||
isActive: true,
|
||||
_count: { select: { projects: true } },
|
||||
},
|
||||
orderBy: { company: "asc" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ clients });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { company, contactPerson, email, phone, notes } = body;
|
||||
|
||||
if (!company || !contactPerson || !email) {
|
||||
return NextResponse.json({ error: "company, contactPerson and email are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await db.client.create({
|
||||
data: { company, contactPerson, email, phone, notes },
|
||||
});
|
||||
|
||||
return NextResponse.json({ client }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ key: string[] }> }
|
||||
) {
|
||||
const { key } = await params;
|
||||
// key is a catch-all segment array, e.g. ["videos", "uuid-filename.mp4"]
|
||||
const relativePath = key.join("/");
|
||||
|
||||
// Sanitize: prevent path traversal
|
||||
const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads");
|
||||
const filePath = path.resolve(path.join(uploadDir, relativePath));
|
||||
|
||||
if (!filePath.startsWith(uploadDir)) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".avi": "video/x-msvideo",
|
||||
".mxf": "application/mxf",
|
||||
".webm": "video/webm",
|
||||
};
|
||||
const contentType = mimeMap[ext] ?? "application/octet-stream";
|
||||
|
||||
// Support range requests so the HTML5 video player can seek
|
||||
const rangeHeader = req.headers.get("range");
|
||||
|
||||
if (rangeHeader) {
|
||||
const [startStr, endStr] = rangeHeader.replace("bytes=", "").split("-");
|
||||
const start = parseInt(startStr, 10);
|
||||
const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
|
||||
const chunkSize = end - start + 1;
|
||||
|
||||
const stream = fs.createReadStream(filePath, { start, end });
|
||||
const nodeStream = stream as unknown as ReadableStream;
|
||||
|
||||
return new NextResponse(nodeStream, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(chunkSize),
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(filePath) as unknown as ReadableStream;
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
"Content-Length": String(stat.size),
|
||||
"Content-Type": contentType,
|
||||
"Accept-Ranges": "bytes",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const notifications = await db.notification.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
const unreadCount = await db.notification.count({
|
||||
where: { userId: session.user.id, isRead: false },
|
||||
});
|
||||
|
||||
return NextResponse.json({ notifications, unreadCount });
|
||||
}
|
||||
|
||||
export async function PATCH() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
await db.notification.updateMany({
|
||||
where: { userId: session.user.id, isRead: false },
|
||||
data: { isRead: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = searchParams.get("q") ?? "";
|
||||
const status = searchParams.get("status");
|
||||
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
{ code: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
status ? { status: status as any } : {},
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
client: { select: { id: true, company: true } },
|
||||
producer: { select: { id: true, name: true, image: true } },
|
||||
_count: { select: { shots: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ projects });
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { addDays } from "date-fns";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const projectId = req.nextUrl.searchParams.get("projectId");
|
||||
|
||||
const sessions = await db.reviewSession.findMany({
|
||||
where: projectId ? { projectId } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ sessions });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { projectId, label, email, expiresInDays = 30 } = body;
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({ where: { id: projectId } });
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const reviewSession = await db.reviewSession.create({
|
||||
data: {
|
||||
projectId,
|
||||
label: label || `Review — ${project.name}`,
|
||||
email: email || null,
|
||||
expiresAt: addDays(new Date(), expiresInDays),
|
||||
},
|
||||
});
|
||||
|
||||
const appUrl =
|
||||
process.env.NEXT_PUBLIC_APP_URL ||
|
||||
`${req.headers.get("x-forwarded-proto") ?? "https"}://${req.headers.get("host")}`;
|
||||
const portalUrl = `${appUrl}/client/${reviewSession.token}`;
|
||||
|
||||
return NextResponse.json({ session: reviewSession, portalUrl }, { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
|
||||
await db.reviewSession.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ shotId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { shotId } = await params;
|
||||
|
||||
const shot = await db.shot.findUnique({
|
||||
where: { id: shotId },
|
||||
include: {
|
||||
artist: { select: { id: true, name: true, email: true, image: true } },
|
||||
versions: {
|
||||
orderBy: { versionNumber: "desc" },
|
||||
include: {
|
||||
artist: { select: { id: true, name: true, email: true, image: true } },
|
||||
_count: { select: { comments: true } },
|
||||
approvals: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { user: { select: { id: true, name: true, image: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!shot) {
|
||||
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: shot.projectId },
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
const [tasks, artists] = await Promise.all([
|
||||
db.task.findMany({
|
||||
where: { shotId },
|
||||
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 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.user.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, name: true, email: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
|
||||
session.user.role as string
|
||||
);
|
||||
|
||||
// Serialize BigInt fields (fileSize) so JSON.stringify doesn't throw
|
||||
const shotSerialized = {
|
||||
...shot,
|
||||
versions: shot.versions.map((v) => ({
|
||||
...v,
|
||||
fileSize: v.fileSize != null ? v.fileSize.toString() : null,
|
||||
})),
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
shot: shotSerialized,
|
||||
projectName: project?.name ?? "",
|
||||
canApprove,
|
||||
tasks,
|
||||
artists,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { taskId } = await params;
|
||||
const task = await db.task.findUnique({
|
||||
where: { id: taskId },
|
||||
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 } },
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
_count: { select: { versions: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json(task);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { taskId } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const task = await db.task.update({
|
||||
where: { id: taskId },
|
||||
data: body,
|
||||
});
|
||||
|
||||
return NextResponse.json(task);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { taskId } = await params;
|
||||
await db.task.delete({ where: { id: taskId } });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get("projectId");
|
||||
const shotId = searchParams.get("shotId");
|
||||
const assetId = searchParams.get("assetId");
|
||||
|
||||
const tasks = await db.task.findMany({
|
||||
where: {
|
||||
...(projectId && { projectId }),
|
||||
...(shotId && { shotId }),
|
||||
...(assetId && { assetId }),
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
include: {
|
||||
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||
_count: { select: { versions: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(tasks);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { uploadFile } from "@/lib/storage";
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
// Max 2 GB
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!file.type.match(/video\//)) {
|
||||
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: "File too large (max 2 GB)" }, { status: 413 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const result = await uploadFile(buffer, file.name, file.type, "videos");
|
||||
|
||||
return NextResponse.json({ url: result.url, key: result.key });
|
||||
} catch (err) {
|
||||
console.error("[local-upload]", err);
|
||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { uploadFile } from "@/lib/storage";
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const type = (formData.get("type") as string) || "videos";
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate file type based on upload type
|
||||
if (type === "image" && !file.type.match(/image\//)) {
|
||||
return NextResponse.json({ error: "Only image files are accepted" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (type === "video" && !file.type.match(/video\//)) {
|
||||
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Size limit: 500MB for images, 2GB for videos
|
||||
const maxSize = type === "image" ? 500 * 1024 * 1024 : 2 * 1024 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
const maxSizeStr = type === "image" ? "500 MB" : "2 GB";
|
||||
return NextResponse.json(
|
||||
{ error: `File too large (max ${maxSizeStr})` },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const result = await uploadFile(buffer, file.name, file.type, type);
|
||||
|
||||
return NextResponse.json({ url: result.url, key: result.key });
|
||||
} catch (err) {
|
||||
console.error("[upload]", err);
|
||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const disabled = () =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"UploadThing is not configured. Add UPLOADTHING_SECRET to .env or use STORAGE_PROVIDER=local.",
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
|
||||
// Lazily resolve the real handlers only when the secret is present.
|
||||
// This prevents the UploadThing SDK from throwing at module-load time
|
||||
// when the env var is missing (e.g. STORAGE_PROVIDER=local).
|
||||
async function getHandlers() {
|
||||
if (!process.env.UPLOADTHING_SECRET) return null;
|
||||
const { createRouteHandler } = await import("uploadthing/next");
|
||||
const { uploadRouter } = await import("@/lib/uploadthing");
|
||||
return createRouteHandler({ router: uploadRouter });
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const h = await getHandlers();
|
||||
return h ? h.GET(req) : disabled();
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const h = await getHandlers();
|
||||
return h ? h.POST(req) : disabled();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ versionId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { versionId } = await params;
|
||||
|
||||
const annotations = await db.annotation.findMany({
|
||||
where: { versionId, isVisible: true },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
orderBy: { frameNumber: "asc" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ annotations });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ versionId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { versionId } = await params;
|
||||
|
||||
const comments = await db.comment.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { frameNumber: "asc" },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
replies: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ comments });
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
Film,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Montserrat } from 'next/font/google';
|
||||
|
||||
const montserrat = Montserrat({
|
||||
subsets: ['latin'],
|
||||
weight: ['200', '500', '600'],
|
||||
});
|
||||
|
||||
interface ClientVersion {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
approvalStatus: string;
|
||||
fps: number;
|
||||
duration: number | null;
|
||||
thumbnailUrl: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ClientTask {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
versions: ClientVersion[];
|
||||
}
|
||||
|
||||
interface ClientShot {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
sequence: string | null;
|
||||
description: string | null;
|
||||
status: string;
|
||||
thumbnailUrl: string | null;
|
||||
tasks: ClientTask[];
|
||||
}
|
||||
|
||||
interface AssetTask extends ClientTask {
|
||||
asset?: { id: string; assetCode: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const APPROVAL_STYLES: Record<
|
||||
string,
|
||||
{ label: string; className: string; Icon: React.ElementType }
|
||||
> = {
|
||||
PENDING_REVIEW: {
|
||||
label: 'Awaiting Review',
|
||||
className: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
Icon: Clock,
|
||||
},
|
||||
APPROVED: {
|
||||
label: 'Approved',
|
||||
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
Icon: CheckCircle2,
|
||||
},
|
||||
REJECTED: {
|
||||
label: 'Rejected',
|
||||
className: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
Icon: XCircle,
|
||||
},
|
||||
NEEDS_CHANGES: {
|
||||
label: 'Needs Changes',
|
||||
className: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
|
||||
Icon: AlertCircle,
|
||||
},
|
||||
};
|
||||
|
||||
const TASK_TYPE_LABELS: Record<string, string> = {
|
||||
TRACK: 'Tracking',
|
||||
ROTO: 'Roto',
|
||||
KEY: 'Keying',
|
||||
COMP: 'Comp',
|
||||
FX: 'FX',
|
||||
LIGHTING: 'Lighting',
|
||||
RENDER: 'Render',
|
||||
ANIMATION: 'Animation',
|
||||
MODEL: 'Model',
|
||||
TEXTURE: 'Texture',
|
||||
RIG: 'Rig',
|
||||
LOOKDEV: 'Lookdev',
|
||||
GENERAL: 'Task',
|
||||
};
|
||||
|
||||
function getLatestVersion(task: ClientTask): ClientVersion | undefined {
|
||||
return task.versions[0];
|
||||
}
|
||||
|
||||
export default function ClientPortalPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const [token, setToken] = useState<string>('');
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [shots, setShots] = useState<ClientShot[]>([]);
|
||||
const [assetTasks, setAssetTasks] = useState<AssetTask[]>([]);
|
||||
const [sessionLabel, setSessionLabel] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
params.then(({ token: t }) => {
|
||||
setToken(t);
|
||||
fetch(`/api/client/${t}/project`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('Invalid or expired review link');
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setProject(data.project);
|
||||
setShots(data.shots ?? []);
|
||||
setAssetTasks(data.assetTasks ?? []);
|
||||
setSessionLabel(data.sessionLabel ?? '');
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
});
|
||||
}, [params]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
||||
<div className="h-8 w-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center gap-4 text-center px-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-500 flex items-center justify-center">
|
||||
<Film className="h-6 w-6 text-black" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-white">
|
||||
Review link unavailable
|
||||
</h1>
|
||||
<p className="text-zinc-400 text-sm max-w-sm">
|
||||
{error ??
|
||||
'This review link has expired or is no longer active. Please request a new link from your studio contact.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allTasks = [...shots.flatMap((s) => s.tasks), ...assetTasks];
|
||||
const totalTasks = allTasks.length;
|
||||
const approved = allTasks.filter(
|
||||
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
|
||||
).length;
|
||||
const needsChanges = allTasks.filter((t) =>
|
||||
['REJECTED', 'NEEDS_CHANGES'].includes(
|
||||
getLatestVersion(t)?.approvalStatus ?? '',
|
||||
),
|
||||
).length;
|
||||
const pending = totalTasks - approved - needsChanges;
|
||||
|
||||
const shotsBySequence = shots.reduce<Record<string, ClientShot[]>>(
|
||||
(acc, shot) => {
|
||||
const seq = shot.sequence ?? 'Shots';
|
||||
if (!acc[seq]) acc[seq] = [];
|
||||
acc[seq].push(shot);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-white">
|
||||
<header className="border-b border-zinc-800 bg-zinc-900">
|
||||
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-black">
|
||||
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
|
||||
</div>
|
||||
|
||||
<div className={montserrat.className}>
|
||||
<span className="block text-2xl font-light text-white leading-none">
|
||||
TWO TALES
|
||||
</span>
|
||||
|
||||
<span className="block text-[11px] tracking-[0.18em] italic text-zinc-400 leading-none -mt-0.25">
|
||||
vfx review
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{sessionLabel && (
|
||||
<span className="text-sm text-zinc-400">{sessionLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="border-b border-zinc-800 bg-zinc-900/50">
|
||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider font-medium mb-1">
|
||||
{project.code}
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{project.name}</h1>
|
||||
{project.description && (
|
||||
<p className="text-zinc-400 text-sm max-w-xl">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 mt-6">
|
||||
<div className="bg-zinc-800 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||
<p className="text-2xl font-bold text-white">{totalTasks}</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">Items</p>
|
||||
</div>
|
||||
<div className="bg-emerald-900/30 border border-emerald-800/30 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||
<p className="text-2xl font-bold text-emerald-400">{approved}</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">Approved</p>
|
||||
</div>
|
||||
<div className="bg-amber-900/20 border border-amber-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||
<p className="text-2xl font-bold text-amber-400">{pending}</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">Awaiting Review</p>
|
||||
</div>
|
||||
{needsChanges > 0 && (
|
||||
<div className="bg-red-900/20 border border-red-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||
<p className="text-2xl font-bold text-red-400">
|
||||
{needsChanges}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">Needs Changes</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-6 py-8 space-y-10">
|
||||
{Object.entries(shotsBySequence).map(([sequence, seqShots]) => (
|
||||
<div key={sequence}>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
|
||||
{sequence}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{seqShots.map((shot) => (
|
||||
<div key={shot.id} className="space-y-1">
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
{shot.thumbnailUrl && (
|
||||
<div className="relative flex-shrink-0 w-40 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
|
||||
<Image
|
||||
src={shot.thumbnailUrl}
|
||||
alt={shot.shotCode}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="font-mono text-sm font-semibold text-zinc-300">
|
||||
{shot.shotCode}
|
||||
{shot.description && (
|
||||
<span className="font-sans font-normal text-zinc-500 ml-2">
|
||||
{shot.description}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5 pl-3 border-l border-zinc-800">
|
||||
{shot.tasks.map((task) => {
|
||||
const ver = getLatestVersion(task);
|
||||
const approvalKey =
|
||||
ver?.approvalStatus ?? 'PENDING_REVIEW';
|
||||
const approval =
|
||||
APPROVAL_STYLES[approvalKey] ??
|
||||
APPROVAL_STYLES.PENDING_REVIEW;
|
||||
const ApprovalIcon = approval.Icon;
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
|
||||
className={cn(
|
||||
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
|
||||
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
|
||||
!ver && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white">
|
||||
{task.title}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
||||
</p>
|
||||
{ver?.notes && (
|
||||
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
|
||||
“{ver.notes}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{ver ? (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs font-mono text-zinc-500">
|
||||
v{String(ver.versionNumber).padStart(3, '0')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
|
||||
approval.className,
|
||||
)}
|
||||
>
|
||||
<ApprovalIcon className="h-3 w-3" />
|
||||
{approval.label}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 shrink-0">
|
||||
No versions yet
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{assetTasks.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
|
||||
Assets
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{assetTasks.map((task) => {
|
||||
const ver = getLatestVersion(task);
|
||||
const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW';
|
||||
const approval =
|
||||
APPROVAL_STYLES[approvalKey] ??
|
||||
APPROVAL_STYLES.PENDING_REVIEW;
|
||||
const ApprovalIcon = approval.Icon;
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
|
||||
className={cn(
|
||||
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
|
||||
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
|
||||
!ver && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
>
|
||||
<Package className="h-4 w-4 text-zinc-500 shrink-0" />
|
||||
<div className="min-w-[90px]">
|
||||
<p className="font-mono font-semibold text-white text-sm">
|
||||
{task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-300 truncate">
|
||||
{task.title}
|
||||
</p>
|
||||
{ver?.notes && (
|
||||
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
|
||||
“{ver.notes}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{ver ? (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs font-mono text-zinc-500">
|
||||
v{String(ver.versionNumber).padStart(3, '0')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
|
||||
approval.className,
|
||||
)}
|
||||
>
|
||||
<ApprovalIcon className="h-3 w-3" />
|
||||
{approval.label}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 shrink-0">
|
||||
No versions yet
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalTasks === 0 && (
|
||||
<div className="text-center py-16 text-zinc-500">
|
||||
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>No items have been shared for review yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-zinc-800 py-6 text-center text-xs text-zinc-600">
|
||||
Powered by <span className="text-zinc-400">TTDEV</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn, frameToTimecode, getInitials } from "@/lib/utils";
|
||||
import {
|
||||
Film,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
frameNumber: number;
|
||||
timestamp: number;
|
||||
text: string;
|
||||
isResolved: boolean;
|
||||
createdAt: string;
|
||||
author: { id: string; name: string | null; image: string | null; email: string };
|
||||
replies: { id: string; text: string; createdAt: string; author: { name: string | null } }[];
|
||||
}
|
||||
|
||||
interface Version {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
fileUrl: string;
|
||||
fps: number;
|
||||
duration: number | null;
|
||||
approvalStatus: string;
|
||||
notes: string | null;
|
||||
shot?: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
project: { id: string; name: string; code: string };
|
||||
} | null;
|
||||
task?: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
project: { id: string; name: string; code: string };
|
||||
shot?: { shotCode: string } | null;
|
||||
asset?: { assetCode: string; name: string } | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const APPROVAL_STATUS_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",
|
||||
};
|
||||
|
||||
export default function ClientReviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string; versionId: string }>;
|
||||
}) {
|
||||
const [token, setToken] = useState("");
|
||||
const [version, setVersion] = useState<Version | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const currentFrame = useReviewStore((s) => s.currentFrame);
|
||||
|
||||
// Comment form state
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [commentFrame, setCommentFrame] = useState<number | null>(null);
|
||||
const [submittingComment, setSubmittingComment] = useState(false);
|
||||
|
||||
// Approval dialog
|
||||
const [approvalDialog, setApprovalDialog] = useState<{
|
||||
open: boolean;
|
||||
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null;
|
||||
}>({ open: false, status: null });
|
||||
const [approvalNotes, setApprovalNotes] = useState("");
|
||||
const [submittingApproval, setSubmittingApproval] = useState(false);
|
||||
const [currentApprovalStatus, setCurrentApprovalStatus] = useState("PENDING_REVIEW");
|
||||
|
||||
const playerRef = useRef<ReviewPlayerRef>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
params.then(({ token: t, versionId }) => {
|
||||
setToken(t);
|
||||
fetch(`/api/client/${t}/versions/${versionId}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("Invalid or expired review link");
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setVersion(data.version);
|
||||
setComments(data.comments);
|
||||
setCurrentApprovalStatus(data.version.approvalStatus);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
});
|
||||
}, [params]);
|
||||
|
||||
const refreshComments = useCallback(async (t: string, vId: string) => {
|
||||
const res = await fetch(`/api/client/${t}/versions/${vId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setComments(data.comments);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePlayerAddComment = useCallback((frameNumber: number, _timestamp: number) => {
|
||||
playerRef.current?.pause();
|
||||
setCommentFrame(frameNumber);
|
||||
}, []);
|
||||
|
||||
const handleSubmitComment = async () => {
|
||||
if (!commentText.trim() || commentFrame === null || !version) return;
|
||||
setSubmittingComment(true);
|
||||
try {
|
||||
const res = await fetch(`/api/client/${token}/comment`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
versionId: version.id,
|
||||
frameNumber: commentFrame,
|
||||
timestamp: commentFrame / version.fps,
|
||||
text: commentText.trim(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to post comment");
|
||||
setCommentText("");
|
||||
setCommentFrame(null);
|
||||
await refreshComments(token, version.id);
|
||||
toast({ title: "Comment added" });
|
||||
} catch {
|
||||
toast({ title: "Failed to add comment", variant: "destructive" });
|
||||
} finally {
|
||||
setSubmittingComment(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitApproval = async () => {
|
||||
if (!approvalDialog.status || !version) return;
|
||||
setSubmittingApproval(true);
|
||||
try {
|
||||
const res = await fetch(`/api/client/${token}/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
versionId: version.id,
|
||||
status: approvalDialog.status,
|
||||
notes: approvalNotes,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to submit decision");
|
||||
setCurrentApprovalStatus(approvalDialog.status);
|
||||
setApprovalDialog({ open: false, status: null });
|
||||
setApprovalNotes("");
|
||||
toast({
|
||||
title:
|
||||
approvalDialog.status === "APPROVED"
|
||||
? "Version approved!"
|
||||
: approvalDialog.status === "REJECTED"
|
||||
? "Version rejected"
|
||||
: "Changes requested",
|
||||
});
|
||||
} catch {
|
||||
toast({ title: "Failed to submit decision", variant: "destructive" });
|
||||
} finally {
|
||||
setSubmittingApproval(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
||||
<div className="h-8 w-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !version) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center gap-4 text-center px-4">
|
||||
<Film className="h-10 w-10 text-zinc-600" />
|
||||
<h1 className="text-xl font-semibold text-white">Version unavailable</h1>
|
||||
<p className="text-zinc-400 text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const approvalStyle = APPROVAL_STATUS_STYLES[currentApprovalStatus] ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-zinc-950 text-white overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-3 px-4 py-2.5 border-b border-zinc-800 bg-zinc-900 shrink-0">
|
||||
<Link
|
||||
href={`/client/${token}`}
|
||||
className="text-zinc-400 hover:text-white transition-colors flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">All Shots</span>
|
||||
</Link>
|
||||
|
||||
<div className="h-4 w-px bg-zinc-700" />
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="w-5 h-5 rounded bg-amber-500 flex items-center justify-center shrink-0">
|
||||
<Film className="h-3 w-3 text-black" />
|
||||
</div>
|
||||
{(() => {
|
||||
const project = version.shot?.project ?? version.task?.project;
|
||||
const contextCode =
|
||||
version.shot?.shotCode ??
|
||||
version.task?.shot?.shotCode ??
|
||||
version.task?.asset?.assetCode ??
|
||||
null;
|
||||
return (
|
||||
<>
|
||||
<span className="text-xs text-zinc-500 hidden sm:block">{project?.code}</span>
|
||||
<span className="text-zinc-600">/</span>
|
||||
{contextCode && (
|
||||
<>
|
||||
<span className="font-mono font-semibold">{contextCode}</span>
|
||||
<span className="text-zinc-600">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className="font-mono text-sm text-zinc-300">
|
||||
v{String(version.versionNumber).padStart(3, "0")}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2.5 py-1 rounded-full border hidden sm:inline-flex items-center gap-1",
|
||||
approvalStyle
|
||||
)}
|
||||
>
|
||||
{currentApprovalStatus === "PENDING_REVIEW" ? (
|
||||
<Clock className="h-3 w-3" />
|
||||
) : currentApprovalStatus === "APPROVED" ? (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
) : currentApprovalStatus === "NEEDS_CHANGES" ? (
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
{currentApprovalStatus.replace(/_/g, " ")}
|
||||
</span>
|
||||
|
||||
{/* Decision buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
|
||||
onClick={() => setApprovalDialog({ open: true, status: "NEEDS_CHANGES" })}
|
||||
>
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Needs Changes</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
onClick={() => setApprovalDialog({ open: true, status: "APPROVED" })}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Approve</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main: player + comments */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Player */}
|
||||
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
|
||||
<ReviewPlayer
|
||||
ref={playerRef}
|
||||
videoUrl={version.fileUrl}
|
||||
fps={version.fps}
|
||||
comments={comments as any}
|
||||
versionId={version.id}
|
||||
onAddComment={handlePlayerAddComment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comment panel */}
|
||||
<div className="w-72 xl:w-80 shrink-0 flex flex-col border-l border-zinc-800 bg-zinc-900">
|
||||
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-zinc-400" />
|
||||
Notes
|
||||
{comments.length > 0 && (
|
||||
<span className="text-xs text-zinc-500">({comments.length})</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 px-4 py-3">
|
||||
{comments.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-500 text-sm">
|
||||
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||
<p>No notes yet.</p>
|
||||
<p className="text-xs mt-1">Pause the video and add a note at any frame.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="space-y-2">
|
||||
<div
|
||||
className="rounded-lg bg-zinc-800/70 p-3 space-y-2 cursor-pointer hover:bg-zinc-700/70 transition-colors"
|
||||
onClick={() => playerRef.current?.seekToFrame(comment.frameNumber)}
|
||||
title={`Jump to frame ${comment.frameNumber}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarFallback className="text-[9px] bg-zinc-700">
|
||||
{getInitials(comment.author.name ?? comment.author.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-zinc-300 font-medium">
|
||||
{comment.author.name ?? comment.author.email.split("@")[0]}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-xs text-amber-400/80">
|
||||
{frameToTimecode(comment.frameNumber, version.fps)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
|
||||
</div>
|
||||
{comment.replies.map((reply) => (
|
||||
<div key={reply.id} className="ml-4 rounded-lg bg-zinc-800/40 border border-zinc-700/50 p-2.5">
|
||||
<span className="text-xs text-zinc-400 font-medium">
|
||||
{reply.author.name ?? "Reply"}
|
||||
</span>
|
||||
<p className="text-xs text-zinc-300 mt-1">{reply.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add note */}
|
||||
{commentFrame !== null ? (
|
||||
<div className="border-t border-zinc-800 p-3 space-y-2">
|
||||
<p className="text-xs text-zinc-500 font-mono">
|
||||
Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)}
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder="Add your note..."
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
className="min-h-[80px] text-sm resize-none"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmitComment();
|
||||
if (e.key === "Escape") setCommentFrame(null);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs"
|
||||
onClick={() => { setCommentFrame(null); setCommentText(""); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 h-8 text-xs gap-1"
|
||||
onClick={handleSubmitComment}
|
||||
disabled={!commentText.trim() || submittingComment}
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-zinc-800 p-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-sm gap-2 h-9"
|
||||
onClick={() => {
|
||||
playerRef.current?.pause();
|
||||
setCommentFrame(currentFrame);
|
||||
}}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Add Note at Current Frame
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approval dialog */}
|
||||
<Dialog
|
||||
open={approvalDialog.open}
|
||||
onOpenChange={(o) => !o && setApprovalDialog({ open: false, status: null })}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{approvalDialog.status === "APPROVED"
|
||||
? "✅ Approve this version"
|
||||
: approvalDialog.status === "NEEDS_CHANGES"
|
||||
? "✏️ Request changes"
|
||||
: "❌ Reject this version"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{approvalDialog.status === "APPROVED"
|
||||
? "Confirm you're happy with this version. You can add an optional note below."
|
||||
: "Describe what needs to change so the team can action it quickly."}
|
||||
</p>
|
||||
<Textarea
|
||||
value={approvalNotes}
|
||||
onChange={(e) => setApprovalNotes(e.target.value)}
|
||||
placeholder={
|
||||
approvalDialog.status === "APPROVED"
|
||||
? "Looks great! (optional)"
|
||||
: "Please describe the changes needed..."
|
||||
}
|
||||
className="min-h-[100px]"
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setApprovalDialog({ open: false, status: null })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmitApproval}
|
||||
disabled={submittingApproval}
|
||||
className={cn(
|
||||
approvalDialog.status === "APPROVED"
|
||||
? "bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
: approvalDialog.status === "NEEDS_CHANGES"
|
||||
? "bg-orange-600 hover:bg-orange-500 text-white"
|
||||
: "bg-red-700 hover:bg-red-600 text-white"
|
||||
)}
|
||||
>
|
||||
{submittingApproval
|
||||
? "Submitting..."
|
||||
: approvalDialog.status === "APPROVED"
|
||||
? "Approve"
|
||||
: approvalDialog.status === "NEEDS_CHANGES"
|
||||
? "Request Changes"
|
||||
: "Reject"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── Dark-first CSS variables (shadcn/ui compatible) ─────────────────────── */
|
||||
@layer base {
|
||||
:root {
|
||||
/* Zinc dark palette — always dark */
|
||||
--background: 0 0% 3.9%; /* zinc-950 */
|
||||
--foreground: 0 0% 98%; /* white */
|
||||
|
||||
--card: 0 0% 9%; /* zinc-900 */
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 0 0% 9%; /* zinc-900 */
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
/* Amber accent — brand colour */
|
||||
--primary: 38 92% 50%; /* amber-500 */
|
||||
--primary-foreground: 38 100% 6%;
|
||||
|
||||
--secondary: 0 0% 14.9%; /* zinc-800 */
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%; /* zinc-800 */
|
||||
--muted-foreground: 0 0% 63.9%; /* zinc-400 */
|
||||
|
||||
--accent: 0 0% 14.9%; /* zinc-800 */
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 14.9%; /* zinc-800 */
|
||||
--input: 0 0% 9%; /* zinc-900 */
|
||||
--ring: 38 92% 50%; /* amber-500 */
|
||||
|
||||
--radius: 0.625rem; /* 10px */
|
||||
|
||||
/* Status colors */
|
||||
--status-approved: 142 71% 45%;
|
||||
--status-rejected: 0 72% 51%;
|
||||
--status-pending: 38 92% 50%;
|
||||
--status-changes: 271 76% 53%;
|
||||
}
|
||||
|
||||
/* Force dark mode globally */
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-border rounded-full;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-muted-foreground/50;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Custom utilities ────────────────────────────────────────────────────── */
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Cinematic glow effect for review player */
|
||||
.player-glow {
|
||||
box-shadow: 0 0 60px rgba(0, 0, 0, 0.8), inset 0 0 120px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Annotation canvas cursor crosshair */
|
||||
.cursor-crosshair {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* Timeline scrub cursor */
|
||||
.cursor-ew-resize {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
/* Status colors */
|
||||
.status-approved {
|
||||
color: hsl(var(--status-approved));
|
||||
}
|
||||
.status-rejected {
|
||||
color: hsl(var(--status-rejected));
|
||||
}
|
||||
.status-pending {
|
||||
color: hsl(var(--status-pending));
|
||||
}
|
||||
.status-changes {
|
||||
color: hsl(var(--status-changes));
|
||||
}
|
||||
|
||||
.bg-status-approved {
|
||||
background-color: hsl(var(--status-approved) / 0.15);
|
||||
}
|
||||
.bg-status-rejected {
|
||||
background-color: hsl(var(--status-rejected) / 0.15);
|
||||
}
|
||||
.bg-status-pending {
|
||||
background-color: hsl(var(--status-pending) / 0.15);
|
||||
}
|
||||
.bg-status-changes {
|
||||
background-color: hsl(var(--status-changes) / 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Review player specific styles ──────────────────────────────────────── */
|
||||
.review-player-container {
|
||||
position: relative;
|
||||
background: #000;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.review-player-container video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Annotation canvas overlay */
|
||||
.annotation-canvas-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.annotation-canvas-overlay.is-annotating {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Frame timeline */
|
||||
.frame-timeline {
|
||||
position: relative;
|
||||
height: 48px;
|
||||
background: hsl(0 0% 6%);
|
||||
border-top: 1px solid hsl(0 0% 14%);
|
||||
cursor: ew-resize;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Comment badge on timeline */
|
||||
.timeline-comment-marker {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: hsl(213 94% 68%);
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Keyframe indicator */
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: hsl(0 72% 51%);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Player controls glassmorphism */
|
||||
.player-controls {
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/layout/Providers";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | FeedBack",
|
||||
default: "FeedBack — VFX Review Platform",
|
||||
},
|
||||
description: "Frame-accurate review and approval for VFX and animation studios.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<Providers>
|
||||
{children}
|
||||
<Toaster />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export default async function RootPage() {
|
||||
const session = await auth();
|
||||
if (session?.user) {
|
||||
redirect("/dashboard");
|
||||
} else {
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
|
||||
import { CommentPanel } from "@/components/comments/CommentPanel";
|
||||
import { VersionList } from "@/components/versions/VersionList";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { submitApproval } from "@/actions/approvals";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { ShareWithClientButton } from "@/components/versions/ShareWithClientButton";
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ArrowLeft,
|
||||
Film,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ReviewPageClientProps {
|
||||
version: {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
fileUrl: string;
|
||||
fps: number;
|
||||
duration: number | null;
|
||||
frameCount: number | null;
|
||||
approvalStatus: string;
|
||||
notes: string | null;
|
||||
isClientVisible: boolean;
|
||||
shot?: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
project: { id: string; name: string; code: string };
|
||||
versions: {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
approvalStatus: string;
|
||||
isLatest: boolean;
|
||||
fps: number;
|
||||
duration: number | null;
|
||||
notes: string | null;
|
||||
fileSize: number | null;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
} | null;
|
||||
task?: {
|
||||
id: string;
|
||||
title: string;
|
||||
project: { id: string; name: string; code: string };
|
||||
shot?: { id: string; shotCode: string } | null;
|
||||
asset?: { id: string; assetCode: string; name: string } | null;
|
||||
versions: {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
approvalStatus: string;
|
||||
isLatest: boolean;
|
||||
fps: number;
|
||||
duration: number | null;
|
||||
notes: string | null;
|
||||
fileSize: number | null;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
} | null;
|
||||
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
||||
};
|
||||
comments: any[];
|
||||
annotations: any[];
|
||||
canApprove: boolean;
|
||||
canShare: boolean;
|
||||
currentUserId: string;
|
||||
}
|
||||
|
||||
const APPROVAL_STATUS_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",
|
||||
};
|
||||
|
||||
export function ReviewPageClient({
|
||||
version,
|
||||
comments: initialComments,
|
||||
annotations: initialAnnotations,
|
||||
canApprove,
|
||||
canShare,
|
||||
currentUserId,
|
||||
}: ReviewPageClientProps) {
|
||||
const [comments, setComments] = useState(initialComments);
|
||||
const [annotations, setAnnotations] = useState(initialAnnotations);
|
||||
const [pendingFrame, setPendingFrame] = useState<number | null>(null);
|
||||
const [approvalDialog, setApprovalDialog] = useState<{
|
||||
open: boolean;
|
||||
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null;
|
||||
}>({ open: false, status: null });
|
||||
const [approvalNotes, setApprovalNotes] = useState("");
|
||||
const [isSubmittingApproval, setIsSubmittingApproval] = useState(false);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
|
||||
const playerRef = useRef<ReviewPlayerRef>(null);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const handleAddComment = useCallback(() => {
|
||||
// Pause player first
|
||||
playerRef.current?.pause();
|
||||
const frame = playerRef.current ? undefined : undefined;
|
||||
// Frame is tracked in ReviewPlayer store — CommentPanel reads from there
|
||||
setPendingFrame(Date.now()); // trigger effect with timestamp trick
|
||||
}, []);
|
||||
|
||||
const handleCommentsChange = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${version.id}/comments`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setComments(data.comments);
|
||||
}
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}, [version.id]);
|
||||
|
||||
const handleAnnotationSaved = useCallback(async () => {
|
||||
// Refresh both annotations (for timeline markers) and comments (for the auto-comment)
|
||||
try {
|
||||
const [annRes, cmtRes] = await Promise.all([
|
||||
fetch(`/api/versions/${version.id}/annotations`),
|
||||
fetch(`/api/versions/${version.id}/comments`),
|
||||
]);
|
||||
if (annRes.ok) setAnnotations((await annRes.json()).annotations);
|
||||
if (cmtRes.ok) setComments((await cmtRes.json()).comments);
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}, [version.id]);
|
||||
|
||||
const handleApprovalSubmit = async () => {
|
||||
if (!approvalDialog.status) return;
|
||||
setIsSubmittingApproval(true);
|
||||
try {
|
||||
await submitApproval({
|
||||
versionId: version.id,
|
||||
status: approvalDialog.status,
|
||||
notes: approvalNotes,
|
||||
});
|
||||
toast({
|
||||
title:
|
||||
approvalDialog.status === "APPROVED"
|
||||
? "Version approved!"
|
||||
: approvalDialog.status === "REJECTED"
|
||||
? "Version rejected"
|
||||
: "Changes requested",
|
||||
});
|
||||
setApprovalDialog({ open: false, status: null });
|
||||
setApprovalNotes("");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to submit approval",
|
||||
description: (err as Error).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmittingApproval(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openApproval = (status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES") => {
|
||||
setApprovalDialog({ open: true, status });
|
||||
};
|
||||
|
||||
// Resolve context: shot or task
|
||||
const project = version.shot?.project ?? version.task?.project;
|
||||
const contextCode =
|
||||
version.shot?.shotCode ??
|
||||
version.task?.shot?.shotCode ??
|
||||
version.task?.asset?.assetCode ??
|
||||
null;
|
||||
const backHref = version.shot
|
||||
? `/projects/${project?.id}/shots/${version.shot.id}`
|
||||
: version.task
|
||||
? `/tasks/${version.task.id}`
|
||||
: `/dashboard`;
|
||||
const versionList = version.shot?.versions ?? version.task?.versions ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background overflow-hidden">
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-card shrink-0">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="text-xs text-muted-foreground hidden sm:block">
|
||||
{project?.code}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
{contextCode && (
|
||||
<>
|
||||
<span className="font-mono text-sm font-semibold">{contextCode}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Version selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1 font-mono text-sm">
|
||||
<Film className="h-3.5 w-3.5" />
|
||||
v{String(version.versionNumber).padStart(3, "0")}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{versionList.map((v) => (
|
||||
<DropdownMenuItem key={v.id} asChild>
|
||||
<Link href={`/review/${v.id}`}>
|
||||
v{String(v.versionNumber).padStart(3, "0")}
|
||||
{v.isLatest && " (latest)"}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Approval status */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full border hidden sm:inline-flex items-center gap-1",
|
||||
APPROVAL_STATUS_STYLES[version.approvalStatus] ?? ""
|
||||
)}
|
||||
>
|
||||
{version.approvalStatus.replace("_", " ")}
|
||||
</span>
|
||||
|
||||
{/* Share with Client */}
|
||||
{canShare && (
|
||||
<ShareWithClientButton
|
||||
versionId={version.id}
|
||||
isAlreadyShared={version.isClientVisible}
|
||||
onShared={() => {/* router.refresh() handled inside component */}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Approval actions */}
|
||||
{canApprove && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
|
||||
onClick={() => openApproval("NEEDS_CHANGES")}
|
||||
>
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Needs Changes</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs gap-1 text-red-400 border-red-500/30 hover:bg-red-500/10"
|
||||
onClick={() => openApproval("REJECTED")}
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Reject</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
onClick={() => openApproval("APPROVED")}
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Approve</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content — player + comments */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Player — 70% */}
|
||||
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
|
||||
<ReviewPlayer
|
||||
ref={playerRef}
|
||||
videoUrl={version.fileUrl}
|
||||
fps={version.fps}
|
||||
comments={comments}
|
||||
annotations={annotations}
|
||||
versionId={version.id}
|
||||
onAddComment={handleAddComment}
|
||||
onAnnotationSaved={handleAnnotationSaved}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comment panel — 30%, min 280px */}
|
||||
<div className="w-80 xl:w-96 shrink-0 flex flex-col border-l border-border">
|
||||
<CommentPanel
|
||||
versionId={version.id}
|
||||
fps={version.fps}
|
||||
comments={comments}
|
||||
onCommentsChange={handleCommentsChange}
|
||||
pendingFrame={pendingFrame}
|
||||
onPendingFrameCleared={() => setPendingFrame(null)}
|
||||
onSeekToFrame={(frame) => playerRef.current?.seekToFrame(frame)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approval Dialog */}
|
||||
<Dialog
|
||||
open={approvalDialog.open}
|
||||
onOpenChange={(o) => !o && setApprovalDialog({ open: false, status: null })}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{approvalDialog.status === "APPROVED"
|
||||
? "Approve Version"
|
||||
: approvalDialog.status === "REJECTED"
|
||||
? "Reject Version"
|
||||
: "Request Changes"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Notes (optional)</Label>
|
||||
<Textarea
|
||||
value={approvalNotes}
|
||||
onChange={(e) => setApprovalNotes(e.target.value)}
|
||||
placeholder={
|
||||
approvalDialog.status === "APPROVED"
|
||||
? "Any final comments..."
|
||||
: "Describe the required changes..."
|
||||
}
|
||||
className="min-h-[100px]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setApprovalDialog({ open: false, status: null })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApprovalSubmit}
|
||||
disabled={isSubmittingApproval}
|
||||
className={
|
||||
approvalDialog.status === "APPROVED"
|
||||
? "bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
: approvalDialog.status === "REJECTED"
|
||||
? "bg-red-600 hover:bg-red-500 text-white"
|
||||
: "bg-orange-600 hover:bg-orange-500 text-white"
|
||||
}
|
||||
>
|
||||
{isSubmittingApproval ? "Submitting..." : "Confirm"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { ReviewPageClient } from "./ReviewPageClient";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ versionId: string }> }) {
|
||||
const { versionId } = await params;
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
shot: { select: { shotCode: true } },
|
||||
task: { select: { title: true } },
|
||||
},
|
||||
});
|
||||
if (!version) return { title: "Not Found" };
|
||||
const label = version.shot?.shotCode ?? version.task?.title ?? "Version";
|
||||
return { title: `Review — ${label} v${version.versionNumber}` };
|
||||
}
|
||||
|
||||
async function getReviewData(versionId: string) {
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
shot: {
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, name: true, code: true },
|
||||
},
|
||||
versions: {
|
||||
orderBy: { versionNumber: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
approvalStatus: true,
|
||||
isLatest: true,
|
||||
fps: true,
|
||||
duration: true,
|
||||
notes: true,
|
||||
fileSize: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
task: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
shot: { select: { id: true, shotCode: true } },
|
||||
asset: { select: { id: true, assetCode: true, name: true } },
|
||||
versions: {
|
||||
orderBy: { versionNumber: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
approvalStatus: true,
|
||||
isLatest: true,
|
||||
fps: true,
|
||||
duration: true,
|
||||
notes: true,
|
||||
fileSize: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
artist: {
|
||||
select: { id: true, name: true, image: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!version) return null;
|
||||
|
||||
const comments = await db.comment.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { frameNumber: "asc" },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
replies: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const annotations = await db.annotation.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { frameNumber: "asc" },
|
||||
});
|
||||
|
||||
return { version, comments, annotations };
|
||||
}
|
||||
|
||||
export default async function ReviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ versionId: string }>;
|
||||
}) {
|
||||
const { versionId } = await params;
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
// Already handled by middleware but be safe
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await getReviewData(versionId);
|
||||
if (!data) notFound();
|
||||
|
||||
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(
|
||||
session.user.role
|
||||
);
|
||||
const canShare = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
|
||||
|
||||
return (
|
||||
<ReviewPageClient
|
||||
version={data.version as any}
|
||||
comments={data.comments as any}
|
||||
annotations={data.annotations as any}
|
||||
canApprove={canApprove}
|
||||
canShare={canShare}
|
||||
currentUserId={session.user.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user