This commit is contained in:
@@ -44,4 +44,7 @@ EXPOSE 3000
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
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"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
+245
@@ -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
|
||||||
|
```
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -459,6 +459,31 @@ export async function removeFootagePlate(plateId: string) {
|
|||||||
return { success: true };
|
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) {
|
export async function deleteShot(shotId: string) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) throw new Error("Unauthorized");
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { ShotCard } from "@/components/shots/ShotCard";
|
import { ShotCard } from "@/components/shots/ShotCard";
|
||||||
import { NewShotDialog } from "@/components/shots/NewShotDialog";
|
import { NewShotDialog } from "@/components/shots/NewShotDialog";
|
||||||
import { ImportShotsDialog } from "@/components/shots/ImportShotsDialog";
|
import { ImportShotsDialog } from "@/components/shots/ImportShotsDialog";
|
||||||
@@ -11,9 +12,12 @@ import { TaskCard } from "@/components/tasks/TaskCard";
|
|||||||
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
|
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
|
||||||
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
|
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
|
||||||
import { cn } from "@/lib/utils";
|
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 type { ShotWithDetails } from "@/types";
|
||||||
import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab";
|
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";
|
type Tab = "shots" | "assets" | "tasks" | "kanban" | "settings";
|
||||||
|
|
||||||
@@ -61,6 +65,7 @@ interface ProjectTabsClientProps {
|
|||||||
tasks: any[];
|
tasks: any[];
|
||||||
artists: Artist[];
|
artists: Artist[];
|
||||||
shotGroups: { id: string; name: string }[];
|
shotGroups: { id: string; name: string }[];
|
||||||
|
episodeDueDates: { episode: string; dueDate: Date | string }[];
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,14 +80,42 @@ export function ProjectTabsClient({
|
|||||||
tasks,
|
tasks,
|
||||||
artists,
|
artists,
|
||||||
shotGroups,
|
shotGroups,
|
||||||
|
episodeDueDates,
|
||||||
canManage,
|
canManage,
|
||||||
}: ProjectTabsClientProps) {
|
}: ProjectTabsClientProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("shots");
|
const [activeTab, setActiveTab] = useState<Tab>("shots");
|
||||||
const [showNewShot, setShowNewShot] = useState(false);
|
const [showNewShot, setShowNewShot] = useState(false);
|
||||||
const [showImportShots, setShowImportShots] = useState(false);
|
const [showImportShots, setShowImportShots] = useState(false);
|
||||||
const [showNewAsset, setShowNewAsset] = useState(false);
|
const [showNewAsset, setShowNewAsset] = useState(false);
|
||||||
const [showNewTask, setShowNewTask] = useState(false);
|
const [showNewTask, setShowNewTask] = useState(false);
|
||||||
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
|
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) =>
|
const toggleEpisode = (ep: string) =>
|
||||||
setCollapsedEpisodes((prev) => {
|
setCollapsedEpisodes((prev) => {
|
||||||
@@ -211,23 +244,98 @@ export function ProjectTabsClient({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{episodeGroups.map(([episode, episodeShots]) => {
|
{episodeGroups.map(([episode, episodeShots]) => {
|
||||||
const collapsed = collapsedEpisodes.has(episode);
|
const collapsed = collapsedEpisodes.has(episode);
|
||||||
|
const dueDate = episodeDueDateMap.get(episode);
|
||||||
|
const isOverdue = dueDate && dueDate < new Date();
|
||||||
|
const isEditing = editingEpisode === episode;
|
||||||
return (
|
return (
|
||||||
<div key={episode}>
|
<div key={episode}>
|
||||||
<button
|
<div className="flex items-center gap-2 w-full mb-3">
|
||||||
onClick={() => toggleEpisode(episode)}
|
<button
|
||||||
className="flex items-center gap-2 w-full mb-3 group text-left"
|
onClick={() => toggleEpisode(episode)}
|
||||||
>
|
className="flex items-center gap-2 shrink-0"
|
||||||
{collapsed
|
>
|
||||||
? <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
{collapsed
|
||||||
: <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />}
|
? <ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="font-semibold text-sm">
|
: <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||||
Episode {episode}
|
</button>
|
||||||
</span>
|
<button
|
||||||
<span className="text-xs text-muted-foreground font-normal">
|
onClick={() => toggleEpisode(episode)}
|
||||||
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
|
className="flex items-center gap-2 text-left"
|
||||||
</span>
|
>
|
||||||
|
<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>
|
||||||
|
</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" />
|
<div className="flex-1 h-px bg-border ml-1" />
|
||||||
</button>
|
{/* 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 && (
|
{!collapsed && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
{episodeShots.map((shot) => (
|
{episodeShots.map((shot) => (
|
||||||
|
|||||||
@@ -106,11 +106,12 @@ async function getTeamMembers() {
|
|||||||
export default async function ProjectPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function ProjectPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const [project, artists, clients, teamMembers] = await Promise.all([
|
const [project, artists, clients, teamMembers, episodeDueDates] = await Promise.all([
|
||||||
getProject(id),
|
getProject(id),
|
||||||
getProjectArtists(),
|
getProjectArtists(),
|
||||||
getClients(),
|
getClients(),
|
||||||
getTeamMembers(),
|
getTeamMembers(),
|
||||||
|
db.episodeDueDate.findMany({ where: { projectId: id }, orderBy: { episode: "asc" } }),
|
||||||
]);
|
]);
|
||||||
if (!project) notFound();
|
if (!project) notFound();
|
||||||
|
|
||||||
@@ -208,6 +209,7 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
|||||||
tasks={project.tasks as any}
|
tasks={project.tasks as any}
|
||||||
artists={artists}
|
artists={artists}
|
||||||
shotGroups={project.shotGroups}
|
shotGroups={project.shotGroups}
|
||||||
|
episodeDueDates={episodeDueDates}
|
||||||
canManage={!!canManage}
|
canManage={!!canManage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
CalendarRange,
|
CalendarRange,
|
||||||
|
BarChart2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
@@ -24,6 +25,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: '/projects', label: 'Projects', icon: FolderOpen },
|
{ 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: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
|
||||||
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
|
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
|
||||||
{ href: '/clients', label: 'Clients', icon: Users, 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;
|
||||||
+24
-8
@@ -239,18 +239,33 @@ model Project {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
client Client? @relation(fields: [clientId], references: [id])
|
client Client? @relation(fields: [clientId], references: [id])
|
||||||
producer User? @relation("ProducerProjects", fields: [producerId], references: [id])
|
producer User? @relation("ProducerProjects", fields: [producerId], references: [id])
|
||||||
supervisor User? @relation("SupervisorProjects", fields: [supervisorId], references: [id])
|
supervisor User? @relation("SupervisorProjects", fields: [supervisorId], references: [id])
|
||||||
shots Shot[]
|
shots Shot[]
|
||||||
assets Asset[]
|
assets Asset[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
shotGroups ShotGroup[]
|
shotGroups ShotGroup[]
|
||||||
reviewSessions ReviewSession[]
|
reviewSessions ReviewSession[]
|
||||||
|
episodeDueDates EpisodeDueDate[]
|
||||||
|
|
||||||
@@map("projects")
|
@@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 {
|
model Shot {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
shotCode String
|
shotCode String
|
||||||
@@ -259,6 +274,7 @@ model Shot {
|
|||||||
shotNumber Int @default(0)
|
shotNumber Int @default(0)
|
||||||
sequence String?
|
sequence String?
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
|
notes String? @db.Text
|
||||||
status ShotStatus @default(WAITING)
|
status ShotStatus @default(WAITING)
|
||||||
priority ShotPriority @default(NORMAL)
|
priority ShotPriority @default(NORMAL)
|
||||||
artistId String?
|
artistId String?
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export interface ShotWithDetails {
|
|||||||
shotNumber: number;
|
shotNumber: number;
|
||||||
sequence: string | null;
|
sequence: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
notes: string | null;
|
||||||
status: ShotStatus;
|
status: ShotStatus;
|
||||||
priority: ShotPriority;
|
priority: ShotPriority;
|
||||||
artistId: string | null;
|
artistId: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user