diff --git a/actions/shots.ts b/actions/shots.ts index eaa401e..0627ccd 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -4,7 +4,8 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { ShotStatus, ShotPriority } from "@prisma/client"; +import { ShotStatus, ShotPriority, ShotApprovalStatus } from "@prisma/client"; +import { recalcShotStatus } from "@/lib/shot-status"; const createShotSchema = z.object({ scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"), @@ -201,6 +202,8 @@ const updateShotSchema = z.object({ description: z.string().optional(), status: z.nativeEnum(ShotStatus).optional(), priority: z.nativeEnum(ShotPriority).optional(), + shotApprovalStatus: z.nativeEnum(ShotApprovalStatus).optional(), + sharedWithClient: z.boolean().optional(), fps: z.number().optional(), frameStart: z.number().int().optional().nullable(), frameEnd: z.number().int().optional().nullable(), @@ -569,3 +572,177 @@ export async function renameFootagePlate(plateId: string, label: string) { return { success: true }; } + +// ── Internal Approval ───────────────────────────────────────────────────────── + +/** + * Supervisor/Producer/Admin: mark a shot as internally approved. + * Sets shotApprovalStatus = INTERNALLY_APPROVED, sharedWithClient = false. + * Shot status becomes READY_FOR_CLIENT. + */ +export async function internallyApproveShot(shotId: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + throw new Error("Insufficient permissions"); + } + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { projectId: true }, + }); + if (!shot) throw new Error("Shot not found"); + + await db.shot.update({ + where: { id: shotId }, + data: { + shotApprovalStatus: "INTERNALLY_APPROVED", + sharedWithClient: false, + status: "READY_FOR_CLIENT", + }, + }); + + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`); + revalidatePath(`/shot-status`); + return { success: true }; +} + +// ── Share / Unshare With Client ─────────────────────────────────────────────── + +/** + * Producer/Supervisor/Admin: share an internally-approved shot with the client. + * Sets sharedWithClient = true → status becomes CLIENT_REVIEW. + */ +export async function shareWithClient(shotId: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + throw new Error("Insufficient permissions"); + } + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { projectId: true, shotApprovalStatus: true }, + }); + if (!shot) throw new Error("Shot not found"); + if (shot.shotApprovalStatus !== "INTERNALLY_APPROVED") { + throw new Error("Shot must be internally approved before sharing with client"); + } + + await db.shot.update({ + where: { id: shotId }, + data: { sharedWithClient: true, status: "CLIENT_REVIEW" }, + }); + + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`); + revalidatePath(`/shot-status`); + return { success: true }; +} + +/** + * Producer/Supervisor/Admin: remove a shot from client review. + * Sets sharedWithClient = false → status becomes READY_FOR_CLIENT. + */ +export async function unshareFromClient(shotId: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + throw new Error("Insufficient permissions"); + } + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { projectId: true }, + }); + if (!shot) throw new Error("Shot not found"); + + await db.shot.update({ + where: { id: shotId }, + data: { sharedWithClient: false, status: "READY_FOR_CLIENT" }, + }); + + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`); + revalidatePath(`/shot-status`); + return { success: true }; +} + +// ── Shot-Level Client Approval ──────────────────────────────────────────────── + +/** + * Internal: handle client approving the shot. + * Sets shotApprovalStatus = CLIENT_APPROVED → status becomes COMPLETE. + */ +export async function clientApproveShot(shotId: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (!["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(session.user.role)) { + throw new Error("Insufficient permissions"); + } + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { projectId: true, sharedWithClient: true }, + }); + if (!shot) throw new Error("Shot not found"); + if (!shot.sharedWithClient) throw new Error("Shot is not shared with client"); + + await db.shot.update({ + where: { id: shotId }, + data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" }, + }); + + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`); + revalidatePath(`/shot-status`); + return { success: true }; +} + +/** + * Internal: handle client requesting changes on a shot. + * Resets shotApprovalStatus = PENDING, sharedWithClient = false. + * Recalculates shot status (will become REVISIONS if tasks set to CHANGES). + */ +export async function clientRequestShotChanges(shotId: string, taskId?: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (!["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(session.user.role)) { + throw new Error("Insufficient permissions"); + } + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { projectId: true }, + }); + if (!shot) throw new Error("Shot not found"); + + await db.$transaction(async (tx) => { + // Reset shot approval + await tx.shot.update({ + where: { id: shotId }, + data: { shotApprovalStatus: "PENDING", sharedWithClient: false }, + }); + + // If a specific task is called out, mark it CHANGES; otherwise mark all non-DONE tasks + if (taskId) { + await tx.task.update({ + where: { id: taskId }, + data: { status: "CHANGES" }, + }); + } else { + await tx.task.updateMany({ + where: { shotId, status: { not: "DONE" } }, + data: { status: "CHANGES" }, + }); + } + + await recalcShotStatus(shotId, tx); + }); + + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`); + revalidatePath(`/shot-status`); + return { success: true }; +} diff --git a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx index 7bfba97..0cef147 100644 --- a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx +++ b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx @@ -21,21 +21,27 @@ import { ListTodo, Video, Copy, + ShieldCheck, + Share2, + Eye, + EyeOff, } from "lucide-react"; import type { ShotWithDetails } from "@/types"; import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab"; import { FootageViewer } from "@/components/shots/FootageViewer"; -import { duplicateShot } from "@/actions/shots"; +import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient } from "@/actions/shots"; 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 }, + 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 }, + INTERNAL_REVIEW: { label: "Internal Review", className: "bg-purple-500/10 text-purple-400 border-purple-500/20", Icon: AlertCircle }, + READY_FOR_CLIENT: { label: "Ready for Client", className: "bg-sky-500/10 text-sky-400 border-sky-500/20", Icon: ShieldCheck }, + CLIENT_REVIEW: { label: "Client Review", className: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", Icon: Eye }, + 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 = { @@ -55,10 +61,12 @@ export default function ShotDetailPage() { const [projectName, setProjectName] = useState(""); const [loading, setLoading] = useState(true); const [canApprove, setCanApprove] = useState(false); + const [canInternallyApprove, setCanInternallyApprove] = useState(false); const [tasks, setTasks] = useState([]); const [artists, setArtists] = useState([]); const [canManage, setCanManage] = useState(false); const [isDuplicating, setIsDuplicating] = useState(false); + const [isActioning, setIsActioning] = useState(false); const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks"); const fetchShot = async () => { @@ -72,6 +80,7 @@ export default function ShotDetailPage() { setShot(data.shot); setProjectName(data.projectName ?? ""); setCanApprove(data.canApprove ?? false); + setCanInternallyApprove(data.canInternallyApprove ?? false); setTasks(data.tasks ?? []); setArtists(data.artists ?? []); setCanManage(data.canApprove ?? false); @@ -86,6 +95,48 @@ export default function ShotDetailPage() { fetchShot(); }, [params.shotId]); + const handleInternalApprove = async () => { + if (!shot) return; + setIsActioning(true); + try { + await internallyApproveShot(shot.id); + toast({ title: "Shot internally approved", description: "Status: Ready for Client" }); + fetchShot(); + } catch (e) { + toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" }); + } finally { + setIsActioning(false); + } + }; + + const handleShareWithClient = async () => { + if (!shot) return; + setIsActioning(true); + try { + await shareWithClient(shot.id); + toast({ title: "Shared with client", description: "Status: Client Review" }); + fetchShot(); + } catch (e) { + toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" }); + } finally { + setIsActioning(false); + } + }; + + const handleUnshare = async () => { + if (!shot) return; + setIsActioning(true); + try { + await unshareFromClient(shot.id); + toast({ title: "Removed from client review", description: "Status: Ready for Client" }); + fetchShot(); + } catch (e) { + toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" }); + } finally { + setIsActioning(false); + } + }; + const handleDuplicate = async () => { if (!shot) return; setIsDuplicating(true); @@ -167,6 +218,37 @@ export default function ShotDetailPage() { {statusCfg.label} + {/* Approval status badges */} + {shot.shotApprovalStatus === "PENDING" && ( + + Pending Internal Approval + + )} + {shot.shotApprovalStatus === "INTERNALLY_APPROVED" && ( + + + Internally Approved + + )} + {shot.shotApprovalStatus === "CLIENT_APPROVED" && ( + + + Client Approved + + )} + + {/* Client sharing badge */} + {shot.shotApprovalStatus === "INTERNALLY_APPROVED" && ( + + {shot.sharedWithClient ? "Shared With Client" : "Not Shared"} + + )} +
{canManage && ( -
+
+ {/* Internal approval action */} + {canInternallyApprove && shot.shotApprovalStatus === "PENDING" && ( + + )} + + {/* Share / Unshare with client */} + {canInternallyApprove && shot.shotApprovalStatus === "INTERNALLY_APPROVED" && !shot.sharedWithClient && ( + + )} + + {canInternallyApprove && shot.sharedWithClient && ( + + )} +