diff --git a/Dockerfile b/Dockerfile index 89e8d8e..0d593d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,4 +44,7 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +# Copy prisma binaries needed for migrate deploy at runtime +COPY --from=builder /app/node_modules/prisma ./node_modules/prisma + CMD ["node", "server.js"] diff --git a/GET_SHOTS.md b/GET_SHOTS.md new file mode 100644 index 0000000..50e0c0a --- /dev/null +++ b/GET_SHOTS.md @@ -0,0 +1,245 @@ +# Shots API — External Integration Reference + +Base URL: `https://review.twotalesvfx.com` + +All requests must include an `Authorization` header with the configured API key. + +--- + +## Authentication + +``` +Authorization: Bearer +``` + +The `API_SECRET_KEY` is set as an environment variable on the server. Contact your +system administrator for the key value. + +--- + +## Endpoints + +### 1. List Shots + +``` +GET /api/ext/shots?projectId={projectId} +``` + +Returns all shots for a project with optional filters. + +#### Query Parameters + +| Parameter | Required | Description | +|-------------|----------|----------------------------------------------------------| +| `projectId` | Yes | The project CUID (e.g. `cmp6l5mzq0001ua0gz07bk72f`) | +| `episode` | No | Filter by episode number (e.g. `103`) | +| `status` | No | Filter by shot status (see status values below) | +| `shotCode` | No | Return only the shot matching this exact code | + +#### Shot Status Values + +| Value | Description | +|---------------|------------------------------------| +| `WAITING` | Not yet started | +| `IN_PROGRESS` | Currently being worked on | +| `IN_REVIEW` | Submitted for review | +| `REVISIONS` | Changes requested | +| `COMPLETE` | Approved and complete | + +#### Example Request + +```http +GET /api/ext/shots?projectId=cmp6l5mzq0001ua0gz07bk72f&episode=103&status=COMPLETE +Authorization: Bearer your-api-key +``` + +#### Example Response + +```json +{ + "shots": [ + { + "id": "clxxxxxxxxxxxxxx", + "shotCode": "UNG_103_010_010", + "scene": "010", + "episode": "103", + "shotNumber": 10, + "description": "Hero wide establishing shot", + "status": "COMPLETE", + "priority": "NORMAL", + "frameStart": 1001, + "frameEnd": 1120, + "fps": 24, + "dueDate": "2026-06-01T00:00:00.000Z", + "createdAt": "2026-05-20T10:00:00.000Z", + "updatedAt": "2026-05-28T14:30:00.000Z", + "artist": { + "id": "clxxxxxxxxxxxxxx", + "name": "Jane Smith", + "email": "jane@studio.com" + }, + "_count": { + "versions": 3, + "tasks": 2 + } + } + ], + "total": 1 +} +``` + +--- + +### 2. Get Single Shot + +``` +GET /api/ext/shots/{id} +``` + +Returns full detail for one shot including all tasks and the latest version status. + +#### By Database ID + +```http +GET /api/ext/shots/clxxxxxxxxxxxxxx +Authorization: Bearer your-api-key +``` + +#### By Shot Code + +Add `byCode=1` and `projectId` to look up by the human-readable shot code instead: + +```http +GET /api/ext/shots/UNG_103_010_010?byCode=1&projectId=cmp6l5mzq0001ua0gz07bk72f +Authorization: Bearer your-api-key +``` + +#### Example Response + +```json +{ + "shot": { + "id": "clxxxxxxxxxxxxxx", + "shotCode": "UNG_103_010_010", + "scene": "010", + "episode": "103", + "shotNumber": 10, + "description": "Hero wide establishing shot", + "status": "COMPLETE", + "priority": "NORMAL", + "frameStart": 1001, + "frameEnd": 1120, + "fps": 24, + "dueDate": "2026-06-01T00:00:00.000Z", + "createdAt": "2026-05-20T10:00:00.000Z", + "updatedAt": "2026-05-28T14:30:00.000Z", + "project": { + "id": "cmp6l5mzq0001ua0gz07bk72f", + "name": "UNG Episode 103", + "code": "UNG103", + "showId": "UNG" + }, + "artist": { + "id": "clxxxxxxxxxxxxxx", + "name": "Jane Smith", + "email": "jane@studio.com" + }, + "tasks": [ + { + "id": "clxxxxxxxxxxxxxx", + "title": "Comp", + "type": "COMP", + "status": "DONE", + "priority": "NORMAL", + "estimatedHours": 8, + "dueDate": "2026-06-01T00:00:00.000Z", + "assignedArtist": { + "id": "clxxxxxxxxxxxxxx", + "name": "Jane Smith", + "email": "jane@studio.com" + }, + "_count": { + "versions": 3 + } + } + ], + "versions": [ + { + "id": "clxxxxxxxxxxxxxx", + "versionNumber": 3, + "approvalStatus": "APPROVED", + "createdAt": "2026-05-28T14:30:00.000Z", + "artist": { + "id": "clxxxxxxxxxxxxxx", + "name": "Jane Smith", + "email": "jane@studio.com" + } + } + ], + "_count": { + "versions": 3, + "tasks": 2 + } + } +} +``` + +--- + +## Error Responses + +| Status | Meaning | +|--------|----------------------------------------------| +| `400` | Missing required parameter (e.g. projectId) | +| `401` | Missing or invalid API key | +| `404` | Shot not found | +| `500` | Internal server error | + +```json +{ "error": "projectId is required" } +``` + +--- + +## Invoicing Use Cases + +### Get all completed shots for billing + +```http +GET /api/ext/shots?projectId={id}&status=COMPLETE +``` + +### Get all shots for a specific episode + +```http +GET /api/ext/shots?projectId={id}&episode=103 +``` + +### Get frame count for a shot (for per-frame billing) + +From the single shot response, calculate: + +``` +frameCount = frameEnd - frameStart + 1 +``` + +### Get estimated hours across all tasks for a shot + +Sum `estimatedHours` from the `tasks` array in the single shot response. + +--- + +## Shot Code Format + +Shot codes follow the convention: + +``` +{showId}_{episode}_{scene}_{shotNumber} + +e.g. UNG_103_010_010 + ^^^ ^^^ ^^^ ^^^ + | | | shot number (padded) + | | scene + | episode + show ID +``` diff --git a/actions/episode-due-dates.ts b/actions/episode-due-dates.ts new file mode 100644 index 0000000..c282db2 --- /dev/null +++ b/actions/episode-due-dates.ts @@ -0,0 +1,54 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +const setEpisodeDueDateSchema = z.object({ + projectId: z.string().cuid(), + episode: z.string().min(1).max(50), + dueDate: z.string().nullable(), +}); + +export async function setEpisodeDueDate(data: z.infer) { + 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 parsed = setEpisodeDueDateSchema.parse(data); + + if (!parsed.dueDate) { + // Remove the due date + await db.episodeDueDate.deleteMany({ + where: { projectId: parsed.projectId, episode: parsed.episode }, + }); + } else { + await db.episodeDueDate.upsert({ + where: { + projectId_episode: { projectId: parsed.projectId, episode: parsed.episode }, + }, + create: { + projectId: parsed.projectId, + episode: parsed.episode, + dueDate: new Date(parsed.dueDate), + }, + update: { + dueDate: new Date(parsed.dueDate), + }, + }); + } + + revalidatePath(`/projects/${parsed.projectId}`); + return { success: true }; +} + +export async function getEpisodeDueDates(projectId: string) { + const rows = await db.episodeDueDate.findMany({ + where: { projectId }, + orderBy: { episode: "asc" }, + }); + return rows; +} diff --git a/actions/shots.ts b/actions/shots.ts index e45ea3d..4d5a557 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -459,6 +459,31 @@ export async function removeFootagePlate(plateId: string) { return { success: true }; } +// ── Update Shot Notes ───────────────────────────────────────────────────────── + +export async function updateShotNotes(shotId: string, notes: 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: { notes: notes.trim() || null }, + }); + + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/shot-status`); + return { success: true }; +} + export async function deleteShot(shotId: string) { const session = await auth(); if (!session?.user) throw new Error("Unauthorized"); diff --git a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx index 7b74e7f..716531a 100644 --- a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx +++ b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useState, useTransition } from "react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { ShotCard } from "@/components/shots/ShotCard"; import { NewShotDialog } from "@/components/shots/NewShotDialog"; import { ImportShotsDialog } from "@/components/shots/ImportShotsDialog"; @@ -11,9 +12,12 @@ 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, ChevronDown, ChevronRight } from "lucide-react"; +import { Film, Package, ListTodo, LayoutDashboard, Plus, Settings, FileUp, ChevronDown, ChevronRight, Calendar, Pencil, Check, X } from "lucide-react"; import type { ShotWithDetails } from "@/types"; import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab"; +import { setEpisodeDueDate } from "@/actions/episode-due-dates"; +import { useToast } from "@/components/ui/use-toast"; +import { format } from "date-fns"; type Tab = "shots" | "assets" | "tasks" | "kanban" | "settings"; @@ -61,6 +65,7 @@ interface ProjectTabsClientProps { tasks: any[]; artists: Artist[]; shotGroups: { id: string; name: string }[]; + episodeDueDates: { episode: string; dueDate: Date | string }[]; canManage: boolean; } @@ -75,14 +80,42 @@ export function ProjectTabsClient({ tasks, artists, shotGroups, + episodeDueDates, canManage, }: ProjectTabsClientProps) { + const { toast } = useToast(); const [activeTab, setActiveTab] = useState("shots"); const [showNewShot, setShowNewShot] = useState(false); const [showImportShots, setShowImportShots] = useState(false); const [showNewAsset, setShowNewAsset] = useState(false); const [showNewTask, setShowNewTask] = useState(false); const [collapsedEpisodes, setCollapsedEpisodes] = useState>(new Set()); + const [editingEpisode, setEditingEpisode] = useState(null); + const [episodeDateInput, setEpisodeDateInput] = useState(""); + const [isPendingDate, startDateTransition] = useTransition(); + + // Build a lookup map for episode due dates + const episodeDueDateMap = new Map( + episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)]) + ); + + const handleSaveEpisodeDate = (episode: string) => { + startDateTransition(async () => { + try { + await setEpisodeDueDate({ + projectId, + episode, + dueDate: episodeDateInput || null, + }); + toast({ title: `Due date ${episodeDateInput ? "set" : "cleared"} for Episode ${episode}` }); + } catch { + toast({ title: "Failed to save due date", variant: "destructive" }); + } finally { + setEditingEpisode(null); + setEpisodeDateInput(""); + } + }); + }; const toggleEpisode = (ep: string) => setCollapsedEpisodes((prev) => { @@ -211,23 +244,98 @@ export function ProjectTabsClient({
{episodeGroups.map(([episode, episodeShots]) => { const collapsed = collapsedEpisodes.has(episode); + const dueDate = episodeDueDateMap.get(episode); + const isOverdue = dueDate && dueDate < new Date(); + const isEditing = editingEpisode === episode; return (
- + + {/* Due date display / edit */} + {isEditing ? ( +
+ setEpisodeDateInput(e.target.value)} + className="h-6 w-36 text-xs px-2 py-0" + /> + + +
+ ) : ( +
+ {dueDate && ( + + + {format(dueDate, "d MMM yyyy")} + + )} + {canManage && ( + + )} +
+ )}
- + {/* Always-visible edit button for managers */} + {canManage && !isEditing && ( + + )} +
{!collapsed && (
{episodeShots.map((shot) => ( diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index ac9c5ed..e76c90a 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -106,11 +106,12 @@ async function getTeamMembers() { 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([ + const [project, artists, clients, teamMembers, episodeDueDates] = await Promise.all([ getProject(id), getProjectArtists(), getClients(), getTeamMembers(), + db.episodeDueDate.findMany({ where: { projectId: id }, orderBy: { episode: "asc" } }), ]); if (!project) notFound(); @@ -208,6 +209,7 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st tasks={project.tasks as any} artists={artists} shotGroups={project.shotGroups} + episodeDueDates={episodeDueDates} canManage={!!canManage} />
diff --git a/app/(dashboard)/shot-status/ShotStatusClient.tsx b/app/(dashboard)/shot-status/ShotStatusClient.tsx new file mode 100644 index 0000000..2486024 --- /dev/null +++ b/app/(dashboard)/shot-status/ShotStatusClient.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { useState, useTransition, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import Image from "next/image"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { updateShotNotes } from "@/actions/shots"; +import { useToast } from "@/components/ui/use-toast"; +import { + Film, + Clock, + CheckCircle2, + AlertCircle, + Calendar, + ChevronDown, + ChevronRight, + Loader2, +} from "lucide-react"; +import { format } from "date-fns"; + +type ShotRow = { + id: string; + shotCode: string; + scene: string; + episode: string | null; + shotNumber: number; + status: string; + priority: string; + dueDate: Date | string | null; + thumbnailUrl: string | null; + notes: string | null; + description: string | null; + artist: { id: string; name: string | null; image: string | null; email: string } | null; +}; + +interface Project { + id: string; + name: string; + code: string; + projectType: string; +} + +interface ShotStatusClientProps { + projects: Project[]; + selectedProjectId: string | null; + selectedProject: Project | null; + shots: ShotRow[]; + canManage: boolean; +} + +const STATUS_CONFIG: Record = { + WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock }, + IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Film }, + IN_REVIEW: { label: "In Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle }, + REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: AlertCircle }, + COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 }, +}; + +function NotesCell({ + shot, + canManage, +}: { + shot: ShotRow; + canManage: boolean; +}) { + const [value, setValue] = useState(shot.notes ?? ""); + const [saved, setSaved] = useState(true); + const [isPending, startTransition] = useTransition(); + const { toast } = useToast(); + + const handleBlur = useCallback(() => { + if (value === (shot.notes ?? "")) return; + startTransition(async () => { + try { + await updateShotNotes(shot.id, value); + setSaved(true); + toast({ title: "Notes saved" }); + } catch { + toast({ title: "Failed to save notes", variant: "destructive" }); + } + }); + }, [value, shot.id, shot.notes, toast]); + + if (!canManage) { + return ( + + {shot.notes ?? } + + ); + } + + return ( +
+