From e24fd8eda076bbdb0cf95ac8bc260eaa8c6f1c5a Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:06:12 +0200 Subject: [PATCH] Supervisor feature --- VFXSupervisorReports.md | 467 +++++++++++++++ actions/shoot-log.ts | 347 ++++++++++++ app/(dashboard)/shoot-log/page.tsx | 22 + .../shoot-log/days/[dayId]/setups/route.ts | 66 +++ app/api/shoot-log/days/route.ts | 72 +++ app/api/shoot-log/recent-values/route.ts | 72 +++ .../shoot-log/setups/[setupId]/takes/route.ts | 77 +++ .../attachments/[attachmentId]/route.ts | 46 ++ .../takes/[takeId]/attachments/route.ts | 69 +++ app/api/shoot-log/takes/[takeId]/route.ts | 90 +++ components/layout/Sidebar.tsx | 6 +- components/shoot-log/NewSetupDialog.tsx | 98 ++++ components/shoot-log/NewShootDayDialog.tsx | 136 +++++ components/shoot-log/PreviousTakePanel.tsx | 104 ++++ components/shoot-log/ShootLogClient.tsx | 233 ++++++++ components/shoot-log/TakeEditorPanel.tsx | 532 ++++++++++++++++++ components/shoot-log/TakeImageGallery.tsx | 258 +++++++++ components/shoot-log/TakeListPanel.tsx | 319 +++++++++++ prisma/schema.prisma | 163 ++++++ 19 files changed, 3175 insertions(+), 2 deletions(-) create mode 100644 VFXSupervisorReports.md create mode 100644 actions/shoot-log.ts create mode 100644 app/(dashboard)/shoot-log/page.tsx create mode 100644 app/api/shoot-log/days/[dayId]/setups/route.ts create mode 100644 app/api/shoot-log/days/route.ts create mode 100644 app/api/shoot-log/recent-values/route.ts create mode 100644 app/api/shoot-log/setups/[setupId]/takes/route.ts create mode 100644 app/api/shoot-log/takes/[takeId]/attachments/[attachmentId]/route.ts create mode 100644 app/api/shoot-log/takes/[takeId]/attachments/route.ts create mode 100644 app/api/shoot-log/takes/[takeId]/route.ts create mode 100644 components/shoot-log/NewSetupDialog.tsx create mode 100644 components/shoot-log/NewShootDayDialog.tsx create mode 100644 components/shoot-log/PreviousTakePanel.tsx create mode 100644 components/shoot-log/ShootLogClient.tsx create mode 100644 components/shoot-log/TakeEditorPanel.tsx create mode 100644 components/shoot-log/TakeImageGallery.tsx create mode 100644 components/shoot-log/TakeListPanel.tsx diff --git a/VFXSupervisorReports.md b/VFXSupervisorReports.md new file mode 100644 index 0000000..36aed81 --- /dev/null +++ b/VFXSupervisorReports.md @@ -0,0 +1,467 @@ +# Feature: On-Set VFX Supervisor Shot Log + +## Overview + +Add a new module to the existing VFX Review project called **Shot Logging** (working title: **VFX Supervisor Reports**). + +This is **not** simply a spreadsheet of takes. It is a production tool designed for VFX Supervisors to log every setup and take while on set using an iPad or phone. + +The design must fit naturally into the existing VFX Review architecture, coding standards, authentication system, permissions and database structure. + +This feature will later integrate with Shot Reports, Reviews, Assets and the VFX pipeline, so everything must be designed around reusable relationships instead of duplicated data. + +--- + +# Primary Goals + +The application must allow a supervisor to: + +* create a shoot day +* create setups +* create unlimited takes within a setup +* quickly duplicate previous take information +* review previous takes without leaving the current page +* attach unlimited reference images +* capture complete VFX metadata +* work efficiently while standing on set with an iPad + +The workflow should require as few taps as possible. + +--- + +# Future Integration + +Design the database so this module can later connect to: + +* Shot Reports +* Review Sessions +* Final Shots +* Assets +* Camera Reports +* Production Notes +* Editorial +* Deliverables + +For example: + +A Shot Report should later be able to display + +* every logged take +* supervisor notes +* all reference images +* HDRI status +* tracking marker notes +* camera information + +without duplicating data. + +Avoid storing information twice. + +--- + +# UI Requirements + +This page is intended primarily for iPad landscape orientation. + +Requirements: + +* touch friendly controls +* large tap targets +* Apple Pencil friendly +* responsive +* works well on desktop +* works on phones for quick edits + +The page should never feel like filling in a spreadsheet. + +Think of it as a digital clipboard. + +--- + +# Layout + +Use a split layout. + +LEFT SIDE + +A scrollable list of all takes. + +Example + +Setup A + +Take 1 + +Take 2 + +Take 3 + +Take 4 + +Each take should display: + +* take number +* clip name +* quality colour +* notes indicator +* image count +* warning indicators + +Selecting a take loads it instantly. + +No page reload. + +--- + +RIGHT SIDE + +Displays the editable take. + +Changes should autosave. + +--- + +# Fast Navigation + +Allow: + +Previous Take + +Next Take + +Duplicate Previous Take + +New Take + +New Setup + +Keyboard shortcuts on desktop. + +Large touch buttons on iPad. + +--- + +# Smart Autofill + +The application should remember previous values during the current shoot. + +Examples + +Lens + +Cooke S4 50mm + +↓ + +Suggest again + +Camera + +Alexa Mini LF + +↓ + +Suggest again + +FPS + +↓ + +24 + +ISO + +↓ + +800 + +White Balance + +↓ + +5600 + +Filters + +↓ + +reuse previous + +Clip names should intelligently suggest the next sequential clip name where possible. + +Changing only one or two fields between takes should be the normal workflow. + +--- + +# Required Fields + +General + +* Production +* Shoot Day +* Date +* Unit +* Scene +* Shot +* Setup +* Take + +Camera + +* Camera Letter +* Clip Name +* Roll +* Camera Model +* Resolution +* Codec +* FPS +* Shutter +* ISO +* White Balance +* Colour Space + +Lens + +* Lens Set +* Lens +* T Stop +* Filters +* Anamorphic + +Tracking + +Checkboxes for + +* HDRI +* Chrome Ball +* Grey Ball +* Macbeth Chart +* Clean Plate +* Survey +* Lidar +* Witness Camera +* Lens Grid +* Texture Photos +* Photogrammetry + +Lighting + +* Weather +* Sun Direction +* Artificial Lights + +Supervisor + +* Notes +* Continuity +* VFX Requirements +* Quality Rating + +--- + +# Unlimited Images + +Each take can contain unlimited images. + +Support: + +* drag and drop +* upload +* direct camera capture +* iPad camera +* phone camera + +Images should upload in the background. + +Do not block editing while uploads complete. + +Each image should store: + +* filename +* timestamp +* uploaded by +* optional caption +* optional category + +Suggested categories + +* Slate +* Camera Position +* Wide Reference +* Lens +* Lighting +* HDRI +* Tracking +* Texture +* Witness +* Miscellaneous + +Images should appear as thumbnails. + +Selecting a thumbnail opens a gallery viewer. + +Allow deleting, renaming and reordering. + +--- + +# Previous Takes Panel + +One of the most important features. + +The supervisor should always be able to see previous takes while entering the current one. + +Provide: + +* previous take summary +* compare current vs previous +* highlight changed fields + +For example + +Lens + +50mm + +↓ + +85mm + +highlighted + +ISO + +800 + +↓ + +800 + +unchanged + +This makes continuity checking very fast. + +--- + +# Autosave + +Every field should autosave. + +No Save button. + +Unsaved changes should be visually indicated. + +--- + +# Offline Ready + +The architecture should support offline editing later. + +Design APIs and local state with synchronisation in mind. + +--- + +# Attachments + +Each take should later support attaching: + +* videos +* HDRIs +* LiDAR files +* lens grids +* PDFs +* documents + +Design the database with generic attachment support rather than image-only support. + +--- + +# Database Design + +Normalise data appropriately. + +Suggested entities: + +ShootDay + +Setup + +Take + +TakeImage + +TakeAttachment + +Lens + +Camera + +Location + +Weather + +User + +Avoid storing repeated strings where practical. + +--- + +# API Design + +Create REST endpoints consistent with the existing VFX Review API. + +Support: + +Create + +Read + +Update + +Delete + +Autosave + +Bulk image upload + +Image deletion + +Pagination where appropriate. + +--- + +# UX Goals + +The application should feel fast enough that a supervisor can log an entire take in under 20 seconds. + +The interface should minimise typing. + +Prioritise: + +* autocomplete +* remembered values +* one-tap selections +* large touch controls +* quick navigation +* minimal modal dialogs + +The supervisor should never lose context while moving between takes. + +--- + +# Future Enhancements + +Keep the architecture open for: + +* voice notes with speech-to-text +* Apple Pencil annotations +* drawing on reference images +* GPS logging +* barcode/QR scanning +* camera metadata import +* live camera feed integration +* Shot Report generation +* continuity checking +* production analytics +* completeness validation (HDRI missing, no clean plate, etc.) + +Build this as a first-class production module that integrates seamlessly with the existing VFX Review platform rather than as a standalone form. diff --git a/actions/shoot-log.ts b/actions/shoot-log.ts new file mode 100644 index 0000000..72df0e3 --- /dev/null +++ b/actions/shoot-log.ts @@ -0,0 +1,347 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { TakeQuality, AttachmentFileType, AttachmentCategory } from "@prisma/client"; + +// ── Permission guard ───────────────────────────────────────────────────────── + +async function requireShootAccess() { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + throw new Error("Insufficient permissions"); + } + return session.user; +} + +// ── Shoot Day ──────────────────────────────────────────────────────────────── + +const createShootDaySchema = z.object({ + projectId: z.string().cuid(), + date: z.string().min(1), + unit: z.string().max(20).default("A"), + label: z.string().max(120).optional(), + notes: z.string().optional(), +}); + +export async function createShootDay(data: z.infer) { + const user = await requireShootAccess(); + const parsed = createShootDaySchema.parse(data); + + const shootDay = await db.shootDay.create({ + data: { + projectId: parsed.projectId, + date: new Date(parsed.date), + unit: parsed.unit, + label: parsed.label, + notes: parsed.notes, + createdById: user.id, + }, + include: { setups: { include: { takes: { include: { attachments: true } } } } }, + }); + + revalidatePath("/shoot-log"); + return shootDay; +} + +export async function updateShootDay( + id: string, + data: Partial<{ label: string; notes: string; unit: string }> +) { + await requireShootAccess(); + const day = await db.shootDay.update({ + where: { id }, + data, + }); + revalidatePath("/shoot-log"); + return day; +} + +// ── Setup ──────────────────────────────────────────────────────────────────── + +const createSetupSchema = z.object({ + shootDayId: z.string().cuid(), + name: z.string().min(1).max(100), + description: z.string().optional(), +}); + +export async function createSetup(data: z.infer) { + await requireShootAccess(); + const parsed = createSetupSchema.parse(data); + + const lastSetup = await db.setup.findFirst({ + where: { shootDayId: parsed.shootDayId }, + orderBy: { sortOrder: "desc" }, + }); + + const setup = await db.setup.create({ + data: { + shootDayId: parsed.shootDayId, + name: parsed.name, + description: parsed.description, + sortOrder: (lastSetup?.sortOrder ?? -1) + 1, + }, + include: { takes: { include: { attachments: true } } }, + }); + + revalidatePath("/shoot-log"); + return setup; +} + +export async function updateSetup(id: string, data: Partial<{ name: string; description: string }>) { + await requireShootAccess(); + const setup = await db.setup.update({ where: { id }, data }); + revalidatePath("/shoot-log"); + return setup; +} + +export async function deleteSetup(id: string) { + await requireShootAccess(); + await db.setup.delete({ where: { id } }); + revalidatePath("/shoot-log"); +} + +// ── Take ───────────────────────────────────────────────────────────────────── + +const takeFieldsSchema = z.object({ + scene: z.string().max(100).optional(), + shotLabel: z.string().max(100).optional(), + unitLabel: z.string().max(50).optional(), + cameraLetter: z.string().max(10).optional(), + clipName: z.string().max(120).optional(), + roll: z.string().max(50).optional(), + cameraModel: z.string().max(100).optional(), + resolution: z.string().max(50).optional(), + codec: z.string().max(50).optional(), + fps: z.number().positive().optional(), + shutter: z.string().max(30).optional(), + iso: z.number().int().positive().optional(), + whiteBalance: z.number().int().positive().optional(), + colourSpace: z.string().max(80).optional(), + lensSet: z.string().max(100).optional(), + lens: z.string().max(100).optional(), + tStop: z.string().max(20).optional(), + filters: z.string().max(200).optional(), + isAnamorphic: z.boolean().optional(), + hasHdri: z.boolean().optional(), + hasChromeBall: z.boolean().optional(), + hasGreyBall: z.boolean().optional(), + hasMacbeth: z.boolean().optional(), + hasCleanPlate: z.boolean().optional(), + hasSurvey: z.boolean().optional(), + hasLidar: z.boolean().optional(), + hasWitnessCamera: z.boolean().optional(), + hasLensGrid: z.boolean().optional(), + hasTexturePhotos: z.boolean().optional(), + hasPhotogrammetry: z.boolean().optional(), + weather: z.string().max(100).optional(), + sunDirection: z.string().max(100).optional(), + artificialLights: z.string().optional(), + supervisorNotes: z.string().optional(), + continuityNotes: z.string().optional(), + vfxRequirements: z.string().optional(), + quality: z.nativeEnum(TakeQuality).optional(), + pipelineShotId: z.string().cuid().optional().or(z.literal("")), +}); + +export async function createTake( + setupId: string, + initialData?: Partial> +) { + const user = await requireShootAccess(); + + const lastTake = await db.take.findFirst({ + where: { setupId }, + orderBy: { takeNumber: "desc" }, + }); + const takeNumber = (lastTake?.takeNumber ?? 0) + 1; + + const take = await db.take.create({ + data: { + setupId, + takeNumber, + createdById: user.id, + ...sanitizeTakeData(initialData ?? {}), + }, + include: { attachments: true }, + }); + + revalidatePath("/shoot-log"); + return take; +} + +export async function updateTake( + id: string, + data: Partial> +) { + await requireShootAccess(); + const parsed = takeFieldsSchema.partial().parse(data); + + const take = await db.take.update({ + where: { id }, + data: sanitizeTakeData(parsed), + include: { attachments: true }, + }); + + revalidatePath("/shoot-log"); + return take; +} + +export async function duplicateTake(sourceId: string) { + const user = await requireShootAccess(); + + const source = await db.take.findUniqueOrThrow({ + where: { id: sourceId }, + include: { setup: true }, + }); + + const lastTake = await db.take.findFirst({ + where: { setupId: source.setupId }, + orderBy: { takeNumber: "desc" }, + }); + const takeNumber = (lastTake?.takeNumber ?? 0) + 1; + + // Increment clip name suffix if pattern matches e.g. A001_C002 -> A001_C003 + const nextClipName = incrementClipName(source.clipName); + + const newTake = await db.take.create({ + data: { + setupId: source.setupId, + takeNumber, + createdById: user.id, + scene: source.scene, + shotLabel: source.shotLabel, + unitLabel: source.unitLabel, + cameraLetter: source.cameraLetter, + clipName: nextClipName, + roll: source.roll, + cameraModel: source.cameraModel, + resolution: source.resolution, + codec: source.codec, + fps: source.fps, + shutter: source.shutter, + iso: source.iso, + whiteBalance: source.whiteBalance, + colourSpace: source.colourSpace, + lensSet: source.lensSet, + lens: source.lens, + tStop: source.tStop, + filters: source.filters, + isAnamorphic: source.isAnamorphic, + hasHdri: source.hasHdri, + hasChromeBall: source.hasChromeBall, + hasGreyBall: source.hasGreyBall, + hasMacbeth: source.hasMacbeth, + hasCleanPlate: source.hasCleanPlate, + hasSurvey: source.hasSurvey, + hasLidar: source.hasLidar, + hasWitnessCamera: source.hasWitnessCamera, + hasLensGrid: source.hasLensGrid, + hasTexturePhotos: source.hasTexturePhotos, + hasPhotogrammetry: source.hasPhotogrammetry, + weather: source.weather, + sunDirection: source.sunDirection, + artificialLights: source.artificialLights, + quality: source.quality, + // Notes reset for new take + supervisorNotes: null, + continuityNotes: null, + vfxRequirements: null, + }, + include: { attachments: true }, + }); + + revalidatePath("/shoot-log"); + return newTake; +} + +export async function deleteTake(id: string) { + await requireShootAccess(); + await db.take.delete({ where: { id } }); + revalidatePath("/shoot-log"); +} + +// ── Attachments ────────────────────────────────────────────────────────────── + +const addAttachmentSchema = z.object({ + takeId: z.string().cuid(), + fileUrl: z.string().min(1), + fileKey: z.string().default(""), + fileName: z.string().min(1), + fileSize: z.number().optional(), + fileType: z.nativeEnum(AttachmentFileType).default("IMAGE"), + category: z.nativeEnum(AttachmentCategory).default("MISCELLANEOUS"), + caption: z.string().max(200).optional(), +}); + +export async function addTakeAttachment(data: z.infer) { + const user = await requireShootAccess(); + const parsed = addAttachmentSchema.parse(data); + + const lastAttachment = await db.takeAttachment.findFirst({ + where: { takeId: parsed.takeId }, + orderBy: { sortOrder: "desc" }, + }); + + const attachment = await db.takeAttachment.create({ + data: { + takeId: parsed.takeId, + fileUrl: parsed.fileUrl, + fileKey: parsed.fileKey, + fileName: parsed.fileName, + fileSize: parsed.fileSize ? BigInt(parsed.fileSize) : undefined, + fileType: parsed.fileType, + category: parsed.category, + caption: parsed.caption, + sortOrder: (lastAttachment?.sortOrder ?? -1) + 1, + uploadedById: user.id, + }, + }); + + revalidatePath("/shoot-log"); + return attachment; +} + +export async function updateAttachmentCaption(id: string, caption: string) { + await requireShootAccess(); + return db.takeAttachment.update({ where: { id }, data: { caption } }); +} + +export async function updateAttachmentCategory(id: string, category: AttachmentCategory) { + await requireShootAccess(); + return db.takeAttachment.update({ where: { id }, data: { category } }); +} + +export async function deleteTakeAttachment(id: string) { + await requireShootAccess(); + await db.takeAttachment.delete({ where: { id } }); + revalidatePath("/shoot-log"); +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function sanitizeTakeData(data: Record) { + const clean: Record = {}; + for (const [k, v] of Object.entries(data)) { + if (v === undefined) continue; + if (v === "") { + clean[k] = null; + } else { + clean[k] = v; + } + } + return clean; +} + +/** Increment trailing clip number: A001_C002 → A001_C003, ROLL_007 → ROLL_008 */ +function incrementClipName(clipName: string | null | undefined): string | null { + if (!clipName) return null; + const match = clipName.match(/^(.*?)(\d+)$/); + if (!match) return clipName; + const [, prefix, numStr] = match; + const next = String(Number(numStr) + 1).padStart(numStr.length, "0"); + return `${prefix}${next}`; +} diff --git a/app/(dashboard)/shoot-log/page.tsx b/app/(dashboard)/shoot-log/page.tsx new file mode 100644 index 0000000..94629c6 --- /dev/null +++ b/app/(dashboard)/shoot-log/page.tsx @@ -0,0 +1,22 @@ +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import { db } from "@/lib/db"; +import { ShootLogClient } from "@/components/shoot-log/ShootLogClient"; + +export const metadata = { title: "Shot Log" }; + +export default async function ShootLogPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + redirect("/dashboard"); + } + + const projects = await db.project.findMany({ + where: { status: { in: ["ACTIVE", "ON_HOLD"] } }, + select: { id: true, name: true, code: true }, + orderBy: { name: "asc" }, + }); + + return ; +} diff --git a/app/api/shoot-log/days/[dayId]/setups/route.ts b/app/api/shoot-log/days/[dayId]/setups/route.ts new file mode 100644 index 0000000..5b3fe15 --- /dev/null +++ b/app/api/shoot-log/days/[dayId]/setups/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; + +function requireRole(role: string) { + return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role); +} + +// GET /api/shoot-log/days/[dayId]/setups +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ dayId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { dayId } = await params; + + const setups = await db.setup.findMany({ + where: { shootDayId: dayId }, + orderBy: { sortOrder: "asc" }, + include: { + takes: { + orderBy: { takeNumber: "asc" }, + include: { _count: { select: { attachments: true } } }, + }, + }, + }); + + return NextResponse.json({ setups }); +} + +// POST /api/shoot-log/days/[dayId]/setups +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ dayId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { dayId } = await params; + const body = await req.json(); + const { name, description } = body; + if (!name) return NextResponse.json({ error: "name required" }, { status: 400 }); + + const last = await db.setup.findFirst({ + where: { shootDayId: dayId }, + orderBy: { sortOrder: "desc" }, + }); + + const setup = await db.setup.create({ + data: { + shootDayId: dayId, + name, + description: description ?? null, + sortOrder: (last?.sortOrder ?? -1) + 1, + }, + include: { takes: { orderBy: { takeNumber: "asc" }, include: { _count: { select: { attachments: true } } } } }, + }); + + return NextResponse.json({ setup }, { status: 201 }); +} diff --git a/app/api/shoot-log/days/route.ts b/app/api/shoot-log/days/route.ts new file mode 100644 index 0000000..ed525a3 --- /dev/null +++ b/app/api/shoot-log/days/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; + +function requireRole(role: string) { + return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role); +} + +// GET /api/shoot-log/days?projectId=xxx +export async function GET(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const projectId = req.nextUrl.searchParams.get("projectId"); + if (!projectId) return NextResponse.json({ error: "projectId required" }, { status: 400 }); + + const days = await db.shootDay.findMany({ + where: { projectId }, + orderBy: { date: "desc" }, + include: { + setups: { + orderBy: { sortOrder: "asc" }, + include: { + takes: { + orderBy: { takeNumber: "asc" }, + include: { + _count: { select: { attachments: true } }, + }, + }, + }, + }, + createdBy: { select: { id: true, name: true } }, + }, + }); + + return NextResponse.json({ days }); +} + +// POST /api/shoot-log/days +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const body = await req.json(); + const { projectId, date, unit, label, notes } = body; + if (!projectId || !date) { + return NextResponse.json({ error: "projectId and date required" }, { status: 400 }); + } + + const day = await db.shootDay.create({ + data: { + projectId, + date: new Date(date), + unit: unit ?? "A", + label: label ?? null, + notes: notes ?? null, + createdById: session.user.id, + }, + include: { + setups: { + orderBy: { sortOrder: "asc" }, + include: { takes: { orderBy: { takeNumber: "asc" }, include: { _count: { select: { attachments: true } } } } }, + }, + }, + }); + + return NextResponse.json({ day }, { status: 201 }); +} diff --git a/app/api/shoot-log/recent-values/route.ts b/app/api/shoot-log/recent-values/route.ts new file mode 100644 index 0000000..7da7ce9 --- /dev/null +++ b/app/api/shoot-log/recent-values/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; + +function requireRole(role: string) { + return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role); +} + +// GET /api/shoot-log/recent-values?projectId=xxx +// Returns recently used values for smart autofill on new takes +export async function GET(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const projectId = req.nextUrl.searchParams.get("projectId"); + if (!projectId) return NextResponse.json({ error: "projectId required" }, { status: 400 }); + + // Fetch the 5 most recent takes for this project to extract autofill candidates + const recentTakes = await db.take.findMany({ + where: { + setup: { shootDay: { projectId } }, + }, + orderBy: { createdAt: "desc" }, + take: 5, + select: { + cameraModel: true, + cameraLetter: true, + resolution: true, + codec: true, + fps: true, + shutter: true, + iso: true, + whiteBalance: true, + colourSpace: true, + lensSet: true, + lens: true, + filters: true, + isAnamorphic: true, + weather: true, + clipName: true, + }, + }); + + // Return the most recent non-null value for each field + function latest(field: keyof typeof recentTakes[0]): T | null { + for (const take of recentTakes) { + const v = take[field]; + if (v !== null && v !== undefined) return v as T; + } + return null; + } + + return NextResponse.json({ + cameraModel: latest("cameraModel"), + cameraLetter: latest("cameraLetter"), + resolution: latest("resolution"), + codec: latest("codec"), + fps: latest("fps"), + shutter: latest("shutter"), + iso: latest("iso"), + whiteBalance: latest("whiteBalance"), + colourSpace: latest("colourSpace"), + lensSet: latest("lensSet"), + lens: latest("lens"), + filters: latest("filters"), + isAnamorphic: latest("isAnamorphic"), + weather: latest("weather"), + lastClipName: latest("clipName"), + }); +} diff --git a/app/api/shoot-log/setups/[setupId]/takes/route.ts b/app/api/shoot-log/setups/[setupId]/takes/route.ts new file mode 100644 index 0000000..ffea115 --- /dev/null +++ b/app/api/shoot-log/setups/[setupId]/takes/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; + +function requireRole(role: string) { + return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role); +} + +// GET /api/shoot-log/setups/[setupId]/takes +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ setupId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { setupId } = await params; + + const takes = await db.take.findMany({ + where: { setupId }, + orderBy: { takeNumber: "asc" }, + include: { attachments: { orderBy: { sortOrder: "asc" } } }, + }); + + return NextResponse.json({ takes }); +} + +// POST /api/shoot-log/setups/[setupId]/takes +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ setupId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { setupId } = await params; + const body = await req.json(); + + const last = await db.take.findFirst({ + where: { setupId }, + orderBy: { takeNumber: "desc" }, + }); + const takeNumber = (last?.takeNumber ?? 0) + 1; + + const take = await db.take.create({ + data: { + setupId, + takeNumber, + createdById: session.user.id, + ...sanitize(body), + }, + include: { attachments: { orderBy: { sortOrder: "asc" } } }, + }); + + return NextResponse.json({ take }, { status: 201 }); +} + +function sanitize(data: Record) { + const allowed = new Set([ + "scene","shotLabel","unitLabel","cameraLetter","clipName","roll","cameraModel", + "resolution","codec","fps","shutter","iso","whiteBalance","colourSpace", + "lensSet","lens","tStop","filters","isAnamorphic", + "hasHdri","hasChromeBall","hasGreyBall","hasMacbeth","hasCleanPlate", + "hasSurvey","hasLidar","hasWitnessCamera","hasLensGrid","hasTexturePhotos","hasPhotogrammetry", + "weather","sunDirection","artificialLights", + "supervisorNotes","continuityNotes","vfxRequirements","quality", + ]); + return Object.fromEntries( + Object.entries(data) + .filter(([k]) => allowed.has(k)) + .map(([k, v]) => [k, v === "" ? null : v]) + ); +} diff --git a/app/api/shoot-log/takes/[takeId]/attachments/[attachmentId]/route.ts b/app/api/shoot-log/takes/[takeId]/attachments/[attachmentId]/route.ts new file mode 100644 index 0000000..2942c47 --- /dev/null +++ b/app/api/shoot-log/takes/[takeId]/attachments/[attachmentId]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; + +function requireRole(role: string) { + return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role); +} + +// PATCH /api/shoot-log/takes/[takeId]/attachments/[attachmentId] +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ takeId: string; attachmentId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { attachmentId } = await params; + const { caption, category } = await req.json(); + + const attachment = await db.takeAttachment.update({ + where: { id: attachmentId }, + data: { + ...(caption !== undefined ? { caption } : {}), + ...(category !== undefined ? { category } : {}), + }, + }); + + return NextResponse.json({ attachment }); +} + +// DELETE /api/shoot-log/takes/[takeId]/attachments/[attachmentId] +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ takeId: string; attachmentId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { attachmentId } = await params; + await db.takeAttachment.delete({ where: { id: attachmentId } }); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/shoot-log/takes/[takeId]/attachments/route.ts b/app/api/shoot-log/takes/[takeId]/attachments/route.ts new file mode 100644 index 0000000..8ae8997 --- /dev/null +++ b/app/api/shoot-log/takes/[takeId]/attachments/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { AttachmentFileType, AttachmentCategory } from "@prisma/client"; + +function requireRole(role: string) { + return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role); +} + +// GET /api/shoot-log/takes/[takeId]/attachments +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ takeId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { takeId } = await params; + const attachments = await db.takeAttachment.findMany({ + where: { takeId }, + orderBy: { sortOrder: "asc" }, + include: { uploadedBy: { select: { id: true, name: true } } }, + }); + + return NextResponse.json({ attachments }); +} + +// POST /api/shoot-log/takes/[takeId]/attachments +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ takeId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { takeId } = await params; + const body = await req.json(); + const { fileUrl, fileKey, fileName, fileSize, fileType, category, caption } = body; + + if (!fileUrl || !fileName) { + return NextResponse.json({ error: "fileUrl and fileName required" }, { status: 400 }); + } + + const last = await db.takeAttachment.findFirst({ + where: { takeId }, + orderBy: { sortOrder: "desc" }, + }); + + const attachment = await db.takeAttachment.create({ + data: { + takeId, + fileUrl, + fileKey: fileKey ?? "", + fileName, + fileSize: fileSize ? BigInt(fileSize) : undefined, + fileType: (fileType as AttachmentFileType) ?? "IMAGE", + category: (category as AttachmentCategory) ?? "MISCELLANEOUS", + caption: caption ?? null, + sortOrder: (last?.sortOrder ?? -1) + 1, + uploadedById: session.user.id, + }, + }); + + return NextResponse.json({ attachment }, { status: 201 }); +} diff --git a/app/api/shoot-log/takes/[takeId]/route.ts b/app/api/shoot-log/takes/[takeId]/route.ts new file mode 100644 index 0000000..701bfef --- /dev/null +++ b/app/api/shoot-log/takes/[takeId]/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; + +function requireRole(role: string) { + return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role); +} + +function sanitize(data: Record) { + const allowed = new Set([ + "scene","shotLabel","unitLabel","cameraLetter","clipName","roll","cameraModel", + "resolution","codec","fps","shutter","iso","whiteBalance","colourSpace", + "lensSet","lens","tStop","filters","isAnamorphic", + "hasHdri","hasChromeBall","hasGreyBall","hasMacbeth","hasCleanPlate", + "hasSurvey","hasLidar","hasWitnessCamera","hasLensGrid","hasTexturePhotos","hasPhotogrammetry", + "weather","sunDirection","artificialLights", + "supervisorNotes","continuityNotes","vfxRequirements","quality", + ]); + return Object.fromEntries( + Object.entries(data) + .filter(([k]) => allowed.has(k)) + .map(([k, v]) => [k, v === "" ? null : v]) + ); +} + +// GET /api/shoot-log/takes/[takeId] +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ takeId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { takeId } = await params; + + const take = await db.take.findUnique({ + where: { id: takeId }, + include: { + attachments: { orderBy: { sortOrder: "asc" } }, + setup: { + include: { + shootDay: { select: { id: true, date: true, unit: true, label: true, projectId: true } }, + takes: { orderBy: { takeNumber: "asc" }, select: { id: true, takeNumber: true } }, + }, + }, + }, + }); + + if (!take) return NextResponse.json({ error: "Not found" }, { status: 404 }); + return NextResponse.json({ take }); +} + +// PATCH /api/shoot-log/takes/[takeId] +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ takeId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { takeId } = await params; + const body = await req.json(); + + const take = await db.take.update({ + where: { id: takeId }, + data: sanitize(body), + include: { attachments: { orderBy: { sortOrder: "asc" } } }, + }); + + return NextResponse.json({ take }); +} + +// DELETE /api/shoot-log/takes/[takeId] +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ takeId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { takeId } = await params; + await db.take.delete({ where: { id: takeId } }); + return NextResponse.json({ ok: true }); +} diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 6e9a239..c6323d5 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -19,6 +19,7 @@ import { BarChart2, ListVideo, CloudUpload, + Clapperboard, } from 'lucide-react'; import { useState } from 'react'; import { useSession } from 'next-auth/react'; @@ -30,6 +31,7 @@ const navItems = [ { href: '/shot-status', label: 'Shot Status', icon: BarChart2, hideForClient: true }, { href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true }, { href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true }, + { href: '/shoot-log', label: 'Shot Log', icon: Clapperboard, supervisorOnly: true }, { href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true }, { href: '/batch-upload', label: 'Batch Upload', icon: CloudUpload, adminOnly: true }, { href: '/clients', label: 'Clients', icon: Users, adminOnly: true }, @@ -88,8 +90,8 @@ export function Sidebar() { {navItems.map((item) => { if (item.adminOnly && !isAdmin) return null; if ((item as any).adminStrictOnly && session?.user?.role !== 'ADMIN') return null; - if ((item as any).hideForClient && session?.user?.role === 'CLIENT') - return null; + if ((item as any).hideForClient && session?.user?.role === 'CLIENT') return null; + if ((item as any).supervisorOnly && !['ADMIN', 'PRODUCER', 'SUPERVISOR'].includes(session?.user?.role ?? '')) return null; const Icon = item.icon; const isActive = pathname === item.href || pathname.startsWith(item.href + '/'); diff --git a/components/shoot-log/NewSetupDialog.tsx b/components/shoot-log/NewSetupDialog.tsx new file mode 100644 index 0000000..f94a92b --- /dev/null +++ b/components/shoot-log/NewSetupDialog.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +const schema = z.object({ + name: z.string().min(1, "Name required").max(100), + description: z.string().max(300).optional(), +}); +type FormData = z.infer; + +interface Props { + shootDayId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: (setup: { id: string; name: string }) => void; +} + +export function NewSetupDialog({ shootDayId, open, onOpenChange, onCreated }: Props) { + const [loading, setLoading] = useState(false); + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: "" }, + }); + + async function onSubmit(data: FormData) { + setLoading(true); + try { + const res = await fetch(`/api/shoot-log/days/${shootDayId}/setups`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Failed to create setup"); + const { setup } = await res.json(); + onCreated(setup); + reset(); + onOpenChange(false); + } finally { + setLoading(false); + } + } + + return ( + + + + New Setup + + +
+
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ + +
+ + + + + +
+
+
+ ); +} diff --git a/components/shoot-log/NewShootDayDialog.tsx b/components/shoot-log/NewShootDayDialog.tsx new file mode 100644 index 0000000..a90906f --- /dev/null +++ b/components/shoot-log/NewShootDayDialog.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { format } from "date-fns"; +import { CalendarIcon, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +const schema = z.object({ + date: z.string().min(1, "Date is required"), + unit: z.string().max(20).default("A"), + label: z.string().max(120).optional(), + notes: z.string().optional(), +}); + +type FormData = z.infer; + +interface Props { + projectId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onCreate: (day: { id: string; date: string; unit: string; label: string | null }) => void; +} + +export function NewShootDayDialog({ projectId, open, onOpenChange, onCreate }: Props) { + const [loading, setLoading] = useState(false); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + date: format(new Date(), "yyyy-MM-dd"), + unit: "A", + }, + }); + + async function onSubmit(data: FormData) { + setLoading(true); + try { + const res = await fetch("/api/shoot-log/days", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId, ...data }), + }); + if (!res.ok) throw new Error("Failed to create shoot day"); + const { day } = await res.json(); + onCreate(day); + reset({ date: format(new Date(), "yyyy-MM-dd"), unit: "A" }); + onOpenChange(false); + } finally { + setLoading(false); + } + } + + return ( + + + + New Shoot Day + + +
+
+
+ + + {errors.date && ( +

{errors.date.message}

+ )} +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ + + + + +
+
+
+ ); +} diff --git a/components/shoot-log/PreviousTakePanel.tsx b/components/shoot-log/PreviousTakePanel.tsx new file mode 100644 index 0000000..d0e8da8 --- /dev/null +++ b/components/shoot-log/PreviousTakePanel.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { FullTake } from "./TakeEditorPanel"; + +// Fields to compare (label → key) +const COMPARE_FIELDS: { label: string; key: keyof FullTake; format?: (v: unknown) => string }[] = [ + { label: "Scene", key: "scene" }, + { label: "Shot", key: "shotLabel" }, + { label: "Camera", key: "cameraLetter" }, + { label: "Clip Name", key: "clipName" }, + { label: "Camera Model", key: "cameraModel" }, + { label: "Resolution", key: "resolution" }, + { label: "Codec", key: "codec" }, + { label: "FPS", key: "fps" }, + { label: "Shutter", key: "shutter" }, + { label: "ISO", key: "iso" }, + { label: "White Balance", key: "whiteBalance" }, + { label: "Colour Space", key: "colourSpace" }, + { label: "Lens Set", key: "lensSet" }, + { label: "Lens", key: "lens" }, + { label: "T Stop", key: "tStop" }, + { label: "Filters", key: "filters" }, + { label: "Anamorphic", key: "isAnamorphic", format: (v) => (v ? "Yes" : "No") }, + { label: "Weather", key: "weather" }, + { label: "Sun Direction", key: "sunDirection" }, +]; + +function display(value: unknown, format?: (v: unknown) => string): string { + if (value === null || value === undefined || value === "") return "—"; + if (format) return format(value); + return String(value); +} + +function isChanged(a: unknown, b: unknown): boolean { + if (a === null || a === undefined) a = ""; + if (b === null || b === undefined) b = ""; + return String(a) !== String(b); +} + +interface Props { + current: FullTake; + previous: FullTake; +} + +export function PreviousTakePanel({ current, previous }: Props) { + const changedFields = COMPARE_FIELDS.filter(({ key, format }) => + isChanged(display(current[key], format), display(previous[key], format)) + ); + + const unchangedFields = COMPARE_FIELDS.filter(({ key, format }) => + !isChanged(display(current[key], format), display(previous[key], format)) + ); + + return ( +
+
+
Field
+
+ T{previous.takeNumber} (prev) +
+
+ T{current.takeNumber} (current) +
+
+ + {/* Changed fields — highlighted */} + {changedFields.map(({ label, key, format }) => ( +
+
{label}
+
+ {display(previous[key], format)} +
+
+ {display(current[key], format)} +
+
+ ))} + + {/* Unchanged fields */} + {unchangedFields.map(({ label, key, format }) => { + const val = display(current[key], format); + if (val === "—") return null; + return ( +
+
{label}
+
{val}
+
{val}
+
+ ); + })} + + {changedFields.length === 0 && ( +
No changes from previous take.
+ )} +
+ ); +} diff --git a/components/shoot-log/ShootLogClient.tsx b/components/shoot-log/ShootLogClient.tsx new file mode 100644 index 0000000..cbfa8a2 --- /dev/null +++ b/components/shoot-log/ShootLogClient.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Clapperboard, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { TakeListPanel } from "./TakeListPanel"; +import { TakeEditorPanel, type FullTake } from "./TakeEditorPanel"; +import { NewShootDayDialog } from "./NewShootDayDialog"; +import { NewSetupDialog } from "./NewSetupDialog"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface Project { + id: string; + name: string; + code: string; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +interface Props { + projects: Project[]; +} + +export function ShootLogClient({ projects }: Props) { + const [projectId, setProjectId] = useState(projects[0]?.id ?? ""); + const [selectedTakeId, setSelectedTakeId] = useState(null); + const [selectedSetupId, setSelectedSetupId] = useState(null); + + // Dialog state + const [newDayOpen, setNewDayOpen] = useState(false); + const [newSetupDayId, setNewSetupDayId] = useState(null); + + // Refresh token to force TakeListPanel to refetch + const [refreshToken, setRefreshToken] = useState(0); + const refresh = useCallback(() => setRefreshToken((t) => t + 1), []); + + // ── Fetch selected take ──────────────────────────────────────────────────── + const { data: takeData, isLoading: takeLoading } = useQuery<{ take: FullTake }>({ + queryKey: ["take", selectedTakeId], + queryFn: async () => { + const res = await fetch(`/api/shoot-log/takes/${selectedTakeId}`); + if (!res.ok) throw new Error("Failed to load take"); + return res.json(); + }, + enabled: !!selectedTakeId, + staleTime: 0, + }); + + const take = takeData?.take ?? null; + + // ── Fetch previous take (same setup, takeNumber - 1) ────────────────────── + const prevTakeId = take?.setup.takes.find( + (t) => t.takeNumber === (take?.takeNumber ?? 0) - 1 + )?.id; + + const { data: prevTakeData } = useQuery<{ take: FullTake }>({ + queryKey: ["take", prevTakeId], + queryFn: async () => { + const res = await fetch(`/api/shoot-log/takes/${prevTakeId}`); + if (!res.ok) throw new Error("Failed to load previous take"); + return res.json(); + }, + enabled: !!prevTakeId, + staleTime: 60_000, + }); + + const previousTake = prevTakeData?.take ?? null; + + // ── Navigation ───────────────────────────────────────────────────────────── + const allTakesInSetup = take?.setup.takes ?? []; + const currentIdx = allTakesInSetup.findIndex((t) => t.id === selectedTakeId); + const prevTakeNav = currentIdx > 0 ? allTakesInSetup[currentIdx - 1] : null; + const nextTakeNav = currentIdx < allTakesInSetup.length - 1 ? allTakesInSetup[currentIdx + 1] : null; + + // ── Actions ──────────────────────────────────────────────────────────────── + const createTake = useCallback( + async (setupId: string, initialData?: object) => { + const res = await fetch(`/api/shoot-log/setups/${setupId}/takes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(initialData ?? {}), + }); + if (!res.ok) throw new Error("Failed to create take"); + const { take } = await res.json(); + refresh(); + setSelectedTakeId(take.id); + setSelectedSetupId(setupId); + }, + [refresh] + ); + + const handleDuplicate = useCallback(async () => { + if (!take) return; + // Build initial data from current take (omit id, notes, attachments) + const { id, takeNumber, attachments, setup, createdById, createdAt, updatedAt, ...rest } = take as FullTake & Record; + void id; void takeNumber; void attachments; void setup; void createdById; void createdAt; void updatedAt; + + // Increment clip name + const clipName = incrementClipName(take.clipName); + const initial = { ...rest, clipName, supervisorNotes: null, continuityNotes: null, vfxRequirements: null }; + + await createTake(take.setupId, initial); + }, [take, createTake]); + + const handleDelete = useCallback(async () => { + if (!selectedTakeId) return; + if (!confirm("Delete this take? This cannot be undone.")) return; + await fetch(`/api/shoot-log/takes/${selectedTakeId}`, { method: "DELETE" }); + setSelectedTakeId(null); + refresh(); + }, [selectedTakeId, refresh]); + + // ── Render ───────────────────────────────────────────────────────────────── + if (!projectId) { + return ( +
+ +

No active projects found.

+
+ ); + } + + return ( + <> + {/* Project selector (top bar) */} +
+ + Shot Log +
+ +
+ + {/* Keyboard hint */} +
+ ← → navigate + D duplicate + N new take +
+
+ + {/* Split layout */} +
+ {/* Left panel */} +
+ { + setSelectedTakeId(takeId); + setSelectedSetupId(setupId); + }} + onNewDay={() => setNewDayOpen(true)} + onNewSetup={(dayId) => setNewSetupDayId(dayId)} + onNewTake={(setupId) => createTake(setupId)} + /> +
+ + {/* Right panel */} +
+ {takeLoading ? ( +
+ +
+ ) : take ? ( + setSelectedTakeId(prevTakeNav.id) : null} + onNext={nextTakeNav ? () => setSelectedTakeId(nextTakeNav.id) : null} + onDuplicate={handleDuplicate} + onNewTake={() => createTake(take.setupId)} + onDelete={handleDelete} + onAttachmentsChange={refresh} + /> + ) : ( +
+ +

Select a take or create a new shoot day

+
+ )} +
+
+ + {/* Dialogs */} + + {newSetupDayId && ( + { if (!o) setNewSetupDayId(null); }} + onCreated={() => { + setNewSetupDayId(null); + refresh(); + }} + /> + )} + + ); +} + +/** Increment trailing number in clip name: A001_C002 → A001_C003 */ +function incrementClipName(clipName: string | null | undefined): string | null { + if (!clipName) return null; + const match = clipName.match(/^(.*?)(\d+)$/); + if (!match) return clipName; + const [, prefix, numStr] = match; + const next = String(Number(numStr) + 1).padStart(numStr.length, "0"); + return `${prefix}${next}`; +} diff --git a/components/shoot-log/TakeEditorPanel.tsx b/components/shoot-log/TakeEditorPanel.tsx new file mode 100644 index 0000000..e8b85a8 --- /dev/null +++ b/components/shoot-log/TakeEditorPanel.tsx @@ -0,0 +1,532 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { + ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2, + Trash2, AlertCircle, ChevronDown, ChevronUp, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { TakeImageGallery } from "./TakeImageGallery"; +import { PreviousTakePanel } from "./PreviousTakePanel"; +import type { TakeQuality } from "@prisma/client"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface TakeAttachmentData { + id: string; + fileUrl: string; + fileName: string; + fileType: string; + category: string; + caption: string | null; + sortOrder: number; +} + +export interface FullTake { + id: string; + setupId: string; + takeNumber: number; + scene: string | null; + shotLabel: string | null; + unitLabel: string | null; + cameraLetter: string | null; + clipName: string | null; + roll: string | null; + cameraModel: string | null; + resolution: string | null; + codec: string | null; + fps: number | null; + shutter: string | null; + iso: number | null; + whiteBalance: number | null; + colourSpace: string | null; + lensSet: string | null; + lens: string | null; + tStop: string | null; + filters: string | null; + isAnamorphic: boolean; + hasHdri: boolean; + hasChromeBall: boolean; + hasGreyBall: boolean; + hasMacbeth: boolean; + hasCleanPlate: boolean; + hasSurvey: boolean; + hasLidar: boolean; + hasWitnessCamera: boolean; + hasLensGrid: boolean; + hasTexturePhotos: boolean; + hasPhotogrammetry: boolean; + weather: string | null; + sunDirection: string | null; + artificialLights: string | null; + supervisorNotes: string | null; + continuityNotes: string | null; + vfxRequirements: string | null; + quality: TakeQuality; + attachments: TakeAttachmentData[]; + setup: { + takes: { id: string; takeNumber: number }[]; + shootDay: { id: string; date: string; unit: string; label: string | null; projectId: string }; + }; +} + +// ─── Quality config ─────────────────────────────────────────────────────────── + +const QUALITIES: { value: TakeQuality; label: string; color: string; active: string }[] = [ + { value: "FALSE_START", label: "False Start", color: "border-zinc-700 text-zinc-500", active: "bg-zinc-700 text-zinc-200 border-zinc-600" }, + { value: "NO_GOOD", label: "No Good", color: "border-red-900/60 text-red-500", active: "bg-red-900/60 text-red-200 border-red-700" }, + { value: "PRINT", label: "Print", color: "border-amber-800/60 text-amber-500", active: "bg-amber-800/60 text-amber-200 border-amber-600" }, + { value: "GOOD", label: "Good", color: "border-blue-800/60 text-blue-400", active: "bg-blue-900/60 text-blue-200 border-blue-600" }, + { value: "HERO", label: "Hero ★", color: "border-green-800/60 text-green-500", active: "bg-green-900/60 text-green-200 border-green-600" }, +]; + +const TRACKING_ITEMS: { key: keyof FullTake; label: string }[] = [ + { key: "hasHdri", label: "HDRI" }, + { key: "hasChromeBall", label: "Chrome Ball" }, + { key: "hasGreyBall", label: "Grey Ball" }, + { key: "hasMacbeth", label: "Macbeth" }, + { key: "hasCleanPlate", label: "Clean Plate" }, + { key: "hasSurvey", label: "Survey" }, + { key: "hasLidar", label: "LiDAR" }, + { key: "hasWitnessCamera", label: "Witness Cam" }, + { key: "hasLensGrid", label: "Lens Grid" }, + { key: "hasTexturePhotos", label: "Texture Photos" }, + { key: "hasPhotogrammetry", label: "Photogrammetry" }, +]; + +// ─── Autosave hook ──────────────────────────────────────────────────────────── + +type SaveStatus = "clean" | "dirty" | "saving" | "saved" | "error"; + +function useAutosave(takeId: string) { + const [status, setStatus] = useState("clean"); + const timerRef = useRef | null>(null); + const pendingRef = useRef>({}); + + const flush = useCallback(async () => { + if (Object.keys(pendingRef.current).length === 0) return; + const patch = { ...pendingRef.current }; + pendingRef.current = {}; + setStatus("saving"); + try { + const res = await fetch(`/api/shoot-log/takes/${takeId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) throw new Error("save failed"); + setStatus("saved"); + setTimeout(() => setStatus("clean"), 2000); + } catch { + setStatus("error"); + } + }, [takeId]); + + const queue = useCallback( + (patch: Record) => { + pendingRef.current = { ...pendingRef.current, ...patch }; + setStatus("dirty"); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(flush, 800); + }, + [flush] + ); + + // Flush immediately when takeId changes + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [takeId]); + + return { status, queue, flush }; +} + +// ─── Field components ───────────────────────────────────────────────────────── + +function Field({ + label, + children, + className, +}: { + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ + {children} +
+ ); +} + +function TF({ + value, + onChange, + placeholder, + type = "text", + className, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + type?: string; + className?: string; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className={cn("h-9 bg-zinc-900 border-zinc-700 text-sm", className)} + /> + ); +} + +function SectionHeader({ title }: { title: string }) { + return ( +
+ {title} +
+
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface Props { + take: FullTake; + previousTake: FullTake | null; + onPrev: (() => void) | null; + onNext: (() => void) | null; + onDuplicate: () => void; + onNewTake: () => void; + onDelete: () => void; + onAttachmentsChange: () => void; +} + +export function TakeEditorPanel({ + take, + previousTake, + onPrev, + onNext, + onDuplicate, + onNewTake, + onDelete, + onAttachmentsChange, +}: Props) { + const { status, queue } = useAutosave(take.id); + + // Local field state — initialised from take prop, synced when take.id changes + const [fields, setFields] = useState(take); + const prevIdRef = useRef(take.id); + + useEffect(() => { + if (take.id !== prevIdRef.current) { + setFields(take); + prevIdRef.current = take.id; + } + }, [take]); + + const [showPrevPanel, setShowPrevPanel] = useState(false); + + function set(key: K, value: FullTake[K]) { + setFields((f) => ({ ...f, [key]: value })); + queue({ [key]: value }); + } + + const str = (v: string | null) => v ?? ""; + const num = (v: number | null) => (v !== null && v !== undefined ? String(v) : ""); + + // Keyboard shortcuts + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if (e.key === "ArrowLeft" && onPrev) onPrev(); + if (e.key === "ArrowRight" && onNext) onNext(); + if (e.key === "d" && !e.metaKey && !e.ctrlKey) onDuplicate(); + if (e.key === "n" && !e.metaKey && !e.ctrlKey) onNewTake(); + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onPrev, onNext, onDuplicate, onNewTake]); + + return ( +
+ {/* ── Nav bar ── */} +
+
+ + +
+ +
+

+ Setup {take.setup.shootDay.unit} · Take {take.takeNumber} +

+

+ {new Date(take.setup.shootDay.date).toLocaleDateString("en-GB", { + weekday: "short", day: "numeric", month: "short", year: "numeric", + })} + {take.setup.shootDay.label ? ` · ${take.setup.shootDay.label}` : ""} +

+
+ + {/* Save status */} +
+ {status === "saving" && } + {status === "saved" && } + {status === "dirty" && } + {status === "error" && } +
+ +
+ + + +
+
+ + {/* ── Quality bar ── */} +
+ {QUALITIES.map((q) => ( + + ))} +
+ + {/* ── Scrollable form ── */} +
+
+ + {/* General */} + +
+ + set("scene", v || null)} placeholder="e.g. 12" /> + + + set("shotLabel", v || null)} placeholder="e.g. 12A" /> + + + set("unitLabel", v || null)} placeholder="e.g. A" /> + + + {}} placeholder="—" className="opacity-60 cursor-default" /> + +
+ + {/* Camera */} + +
+ + set("cameraLetter", v || null)} placeholder="A" /> + + + set("clipName", v || null)} placeholder="A001_C001" /> + + + set("roll", v || null)} placeholder="A001" /> + + + set("cameraModel", v || null)} placeholder="Alexa Mini LF" /> + + + set("resolution", v || null)} placeholder="4.5K" /> + + + set("codec", v || null)} placeholder="ARRIRAW" /> + + + set("fps", v ? Number(v) : null)} + placeholder="24" + /> + + + set("shutter", v || null)} placeholder="180°" /> + + + set("iso", v ? Number(v) : null)} + placeholder="800" + /> + + + set("whiteBalance", v ? Number(v) : null)} + placeholder="5600" + /> + + + set("colourSpace", v || null)} placeholder="LogC3 AWG3" /> + +
+ + {/* Lens */} + +
+ + set("lensSet", v || null)} placeholder="Cooke S4" /> + + + set("lens", v || null)} placeholder="50mm" /> + + + set("tStop", v || null)} placeholder="T2.8" /> + + + set("filters", v || null)} placeholder="IRND 0.6" /> + + + + +
+ + {/* Tracking */} + +
+ {TRACKING_ITEMS.map(({ key, label }) => { + const checked = fields[key] as boolean; + return ( + + ); + })} +
+ + {/* Environment */} + +
+ + set("weather", v || null)} placeholder="Overcast" /> + + + set("sunDirection", v || null)} placeholder="NW high" /> + + +