Added status tracking & episode due dates
Deploy / deploy (push) Failing after 2m30s

This commit is contained in:
twotalesanimation
2026-06-03 14:49:12 +02:00
parent 15046892d1
commit e75a15132e
12 changed files with 915 additions and 25 deletions
+3
View File
@@ -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"]
+245
View File
@@ -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 <API_SECRET_KEY>
```
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
```
+54
View File
@@ -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<typeof setEpisodeDueDateSchema>) {
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;
}
+25
View File
@@ -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");
@@ -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<Tab>("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<Set<string>>(new Set());
const [editingEpisode, setEditingEpisode] = useState<string | null>(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({
<div className="space-y-4">
{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 (
<div key={episode}>
<div className="flex items-center gap-2 w-full mb-3">
<button
onClick={() => toggleEpisode(episode)}
className="flex items-center gap-2 w-full mb-3 group text-left"
className="flex items-center gap-2 shrink-0"
>
{collapsed
? <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
: <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />}
? <ChevronRight className="h-4 w-4 text-muted-foreground" />
: <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</button>
<button
onClick={() => toggleEpisode(episode)}
className="flex items-center gap-2 text-left"
>
<span className="font-semibold text-sm">
Episode {episode}
</span>
<span className="text-xs text-muted-foreground font-normal">
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
</span>
<div className="flex-1 h-px bg-border ml-1" />
</button>
{/* Due date display / edit */}
{isEditing ? (
<div className="flex items-center gap-1 ml-2">
<Input
type="date"
value={episodeDateInput}
onChange={(e) => setEpisodeDateInput(e.target.value)}
className="h-6 w-36 text-xs px-2 py-0"
/>
<button
onClick={() => handleSaveEpisodeDate(episode)}
disabled={isPendingDate}
className="text-emerald-400 hover:text-emerald-300 p-0.5"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onClick={() => { setEditingEpisode(null); setEpisodeDateInput(""); }}
className="text-zinc-500 hover:text-zinc-300 p-0.5"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex items-center gap-1.5 ml-2">
{dueDate && (
<span className={cn(
"flex items-center gap-1 text-xs",
isOverdue ? "text-red-400" : "text-zinc-400"
)}>
<Calendar className="h-3 w-3" />
{format(dueDate, "d MMM yyyy")}
</span>
)}
{canManage && (
<button
onClick={() => {
setEditingEpisode(episode);
setEpisodeDateInput(
dueDate ? format(dueDate, "yyyy-MM-dd") : ""
);
}}
className="text-zinc-600 hover:text-zinc-300 p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
title="Set due date"
>
<Pencil className="h-3 w-3" />
</button>
)}
</div>
)}
<div className="flex-1 h-px bg-border ml-1" />
{/* Always-visible edit button for managers */}
{canManage && !isEditing && (
<button
onClick={() => {
setEditingEpisode(episode);
setEpisodeDateInput(
dueDate ? format(dueDate, "yyyy-MM-dd") : ""
);
}}
className="text-zinc-600 hover:text-zinc-400 transition-colors shrink-0"
title="Set episode due date"
>
<Pencil className="h-3 w-3" />
</button>
)}
</div>
{!collapsed && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{episodeShots.map((shot) => (
+3 -1
View File
@@ -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}
/>
</div>
@@ -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<string, { label: string; color: string; icon: React.ElementType }> = {
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 (
<span className="text-sm text-zinc-400 whitespace-pre-wrap">
{shot.notes ?? <span className="italic text-zinc-600"></span>}
</span>
);
}
return (
<div className="relative">
<Textarea
value={value}
onChange={(e) => { setValue(e.target.value); setSaved(false); }}
onBlur={handleBlur}
placeholder="Add notes..."
className="min-h-[60px] text-sm resize-none bg-zinc-900 border-zinc-700 focus:border-zinc-500 text-zinc-200 placeholder:text-zinc-600"
rows={2}
/>
{isPending && (
<Loader2 className="absolute bottom-2 right-2 h-3 w-3 animate-spin text-zinc-500" />
)}
{!isPending && !saved && (
<span className="absolute bottom-1.5 right-2 text-[10px] text-zinc-500">unsaved</span>
)}
</div>
);
}
function ShotTable({ shots, canManage }: { shots: ShotRow[]; canManage: boolean }) {
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-14">Thumb</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Shot</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Status</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Due Date</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{shots.map((shot) => {
const cfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
const StatusIcon = cfg.icon;
const dueDate = shot.dueDate ? new Date(shot.dueDate) : null;
const isOverdue = dueDate && dueDate < new Date() && shot.status !== "COMPLETE";
return (
<tr key={shot.id} className="hover:bg-zinc-800/30 transition-colors">
{/* Thumbnail */}
<td className="py-2 px-3">
<div className="w-10 h-10 rounded overflow-hidden bg-zinc-800 flex items-center justify-center shrink-0">
{shot.thumbnailUrl ? (
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
width={40}
height={40}
className="object-cover w-full h-full"
/>
) : (
<Film className="h-4 w-4 text-zinc-600" />
)}
</div>
</td>
{/* Shot name */}
<td className="py-2 px-3">
<span className="font-mono text-sm text-white">{shot.shotCode}</span>
{shot.description && (
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">
{shot.description}
</p>
)}
</td>
{/* Status */}
<td className="py-2 px-3">
<Badge className={cn("gap-1 text-xs", cfg.color)}>
<StatusIcon className="h-3 w-3" />
{cfg.label}
</Badge>
</td>
{/* Due date */}
<td className="py-2 px-3">
{dueDate ? (
<span
className={cn(
"flex items-center gap-1 text-xs",
isOverdue ? "text-red-400" : "text-zinc-400"
)}
>
<Calendar className="h-3 w-3" />
{format(dueDate, "d MMM yyyy")}
</span>
) : (
<span className="text-xs text-zinc-600 italic"></span>
)}
</td>
{/* Notes */}
<td className="py-2 px-3 min-w-[220px]">
<NotesCell shot={shot} canManage={canManage} />
</td>
</tr>
);
})}
</tbody>
</table>
);
}
export function ShotStatusClient({
projects,
selectedProjectId,
selectedProject,
shots,
canManage,
}: ShotStatusClientProps) {
const router = useRouter();
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
const toggleEpisode = (ep: string) =>
setCollapsedEpisodes((prev) => {
const next = new Set(prev);
next.has(ep) ? next.delete(ep) : next.add(ep);
return next;
});
const handleProjectChange = (id: string) => {
router.push(`/shot-status?projectId=${id}`);
};
// Group by episode for episodic projects
const isEpisodic = selectedProject?.projectType === "EPISODIC";
const episodeGroups: [string, ShotRow[]][] = isEpisodic
? (() => {
const map = new Map<string, ShotRow[]>();
for (const shot of shots) {
const key = shot.episode ?? "(No Episode)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
}
return Array.from(map.entries());
})()
: [];
const statusCounts = shots.reduce(
(acc, s) => {
acc[s.status] = (acc[s.status] ?? 0) + 1;
return acc;
},
{} as Record<string, number>
);
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-white">Shot Status</h1>
<p className="text-zinc-400 mt-1">Track shot progress and add production notes</p>
</div>
{/* Project selector */}
<div className="flex items-center gap-3">
<label className="text-sm text-zinc-400 shrink-0">Project:</label>
<Select value={selectedProjectId ?? ""} onValueChange={handleProjectChange}>
<SelectTrigger className="w-72">
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
<span className="font-mono text-xs text-zinc-400 mr-2">{p.code}</span>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* No project selected */}
{!selectedProject && (
<div className="text-center py-20 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Select a project to view shot status</p>
</div>
)}
{/* Shots table */}
{selectedProject && shots.length === 0 && (
<div className="text-center py-20 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No shots in this project yet</p>
</div>
)}
{selectedProject && shots.length > 0 && (
<>
{/* Summary pills */}
<div className="flex flex-wrap gap-2">
<span className="text-xs text-zinc-500 self-center">{shots.length} shots total</span>
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => {
const count = statusCounts[key] ?? 0;
if (!count) return null;
return (
<Badge key={key} className={cn("gap-1 text-xs", cfg.color)}>
{count} {cfg.label}
</Badge>
);
})}
</div>
{/* Table */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 overflow-hidden">
{isEpisodic ? (
<div className="divide-y divide-zinc-800">
{episodeGroups.map(([episode, episodeShots]) => {
const collapsed = collapsedEpisodes.has(episode);
return (
<div key={episode}>
<button
onClick={() => toggleEpisode(episode)}
className="flex items-center gap-2 w-full px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors text-left"
>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<span className="font-semibold text-sm text-white">Episode {episode}</span>
<span className="text-xs text-zinc-500 font-normal">
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
</span>
</button>
{!collapsed && (
<div className="overflow-x-auto">
<ShotTable shots={episodeShots} canManage={canManage} />
</div>
)}
</div>
);
})}
</div>
) : (
<div className="overflow-x-auto">
<ShotTable shots={shots} canManage={canManage} />
</div>
)}
</div>
</>
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { ShotStatusClient } from "./ShotStatusClient";
async function getProjects() {
return db.project.findMany({
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
select: { id: true, name: true, code: true, projectType: true },
orderBy: { name: "asc" },
});
}
async function getShotsForProject(projectId: string) {
return db.shot.findMany({
where: { projectId },
orderBy: [
{ episode: { sort: "asc", nulls: "last" } },
{ scene: "asc" },
{ shotNumber: "asc" },
],
select: {
id: true,
shotCode: true,
scene: true,
episode: true,
shotNumber: true,
status: true,
priority: true,
dueDate: true,
thumbnailUrl: true,
notes: true,
description: true,
artist: { select: { id: true, name: true, image: true, email: true } },
},
});
}
export default async function ShotStatusPage({
searchParams,
}: {
searchParams: Promise<{ projectId?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
const { projectId } = await searchParams;
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const [projects, shots] = await Promise.all([
getProjects(),
projectId ? getShotsForProject(projectId) : Promise.resolve([]),
]);
const selectedProject = projects.find((p) => p.id === projectId) ?? null;
return (
<ShotStatusClient
projects={projects}
selectedProjectId={projectId ?? null}
selectedProject={selectedProject}
shots={shots as any}
canManage={canManage}
/>
);
}
+2
View File
@@ -16,6 +16,7 @@ import {
ChevronRight,
ListTodo,
CalendarRange,
BarChart2,
} from 'lucide-react';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
@@ -24,6 +25,7 @@ import { Button } from '@/components/ui/button';
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/projects', label: 'Projects', icon: FolderOpen },
{ href: '/shot-status', label: 'Shot Status', icon: BarChart2, hideForClient: true },
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
{ href: '/clients', label: 'Clients', icon: Users, adminOnly: true },
@@ -0,0 +1,20 @@
-- AlterTable: add notes field to shots
ALTER TABLE "shots" ADD COLUMN "notes" TEXT;
-- CreateTable: episode_due_dates
CREATE TABLE "episode_due_dates" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"episode" TEXT NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "episode_due_dates_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "episode_due_dates_projectId_episode_key" ON "episode_due_dates"("projectId", "episode");
-- AddForeignKey
ALTER TABLE "episode_due_dates" ADD CONSTRAINT "episode_due_dates_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+16
View File
@@ -247,10 +247,25 @@ model Project {
tasks Task[]
shotGroups ShotGroup[]
reviewSessions ReviewSession[]
episodeDueDates EpisodeDueDate[]
@@map("projects")
}
model EpisodeDueDate {
id String @id @default(cuid())
projectId String
episode String
dueDate DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([projectId, episode])
@@map("episode_due_dates")
}
model Shot {
id String @id @default(cuid())
shotCode String
@@ -259,6 +274,7 @@ model Shot {
shotNumber Int @default(0)
sequence String?
description String? @db.Text
notes String? @db.Text
status ShotStatus @default(WAITING)
priority ShotPriority @default(NORMAL)
artistId String?
+1
View File
@@ -153,6 +153,7 @@ export interface ShotWithDetails {
shotNumber: number;
sequence: string | null;
description: string | null;
notes: string | null;
status: ShotStatus;
priority: ShotPriority;
artistId: string | null;