Initial commit
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.next
|
||||||
|
.swc
|
||||||
|
.turbo
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.iml
|
||||||
|
.eslintignore
|
||||||
|
.prettier*
|
||||||
|
.editorconfig
|
||||||
|
docker-compose*.yml
|
||||||
|
Dockerfile*
|
||||||
|
.github
|
||||||
|
migrations
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# VFX Review – Environment Configuration
|
||||||
|
# Copy to .env.local and fill in values
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── DATABASE ──────────────────────────────────
|
||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/vfxreview"
|
||||||
|
|
||||||
|
# ── AUTH ──────────────────────────────────────
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
NEXTAUTH_SECRET="your-secret-here-change-in-production"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# ── UPLOADTHING (simplest upload option) ──────
|
||||||
|
# Get from https://uploadthing.com
|
||||||
|
UPLOADTHING_SECRET=""
|
||||||
|
UPLOADTHING_APP_ID=""
|
||||||
|
|
||||||
|
# ── S3-COMPATIBLE STORAGE (optional) ──────────
|
||||||
|
# Set STORAGE_PROVIDER to: local | uploadthing | s3 | r2 | b2 | minio
|
||||||
|
STORAGE_PROVIDER="uploadthing"
|
||||||
|
|
||||||
|
# AWS S3
|
||||||
|
AWS_ACCESS_KEY_ID=""
|
||||||
|
AWS_SECRET_ACCESS_KEY=""
|
||||||
|
AWS_REGION="us-east-1"
|
||||||
|
AWS_BUCKET_NAME=""
|
||||||
|
|
||||||
|
# Cloudflare R2
|
||||||
|
R2_ACCESS_KEY_ID=""
|
||||||
|
R2_SECRET_ACCESS_KEY=""
|
||||||
|
R2_ACCOUNT_ID=""
|
||||||
|
R2_BUCKET_NAME=""
|
||||||
|
R2_PUBLIC_URL=""
|
||||||
|
|
||||||
|
# Backblaze B2
|
||||||
|
B2_APPLICATION_KEY_ID=""
|
||||||
|
B2_APPLICATION_KEY=""
|
||||||
|
B2_BUCKET_NAME=""
|
||||||
|
B2_ENDPOINT=""
|
||||||
|
|
||||||
|
# MinIO (self-hosted)
|
||||||
|
MINIO_ENDPOINT="http://localhost:9000"
|
||||||
|
MINIO_ACCESS_KEY=""
|
||||||
|
MINIO_SECRET_KEY=""
|
||||||
|
MINIO_BUCKET_NAME="vfx-review"
|
||||||
|
|
||||||
|
# Local storage (dev only)
|
||||||
|
LOCAL_UPLOAD_DIR="./uploads"
|
||||||
|
|
||||||
|
# ── EMAIL (nodemailer) ─────────────────────────
|
||||||
|
EMAIL_FROM="noreply@yourcompany.com"
|
||||||
|
EMAIL_SERVER_HOST="smtp.gmail.com"
|
||||||
|
EMAIL_SERVER_PORT=587
|
||||||
|
EMAIL_SERVER_USER=""
|
||||||
|
EMAIL_SERVER_PASSWORD=""
|
||||||
|
|
||||||
|
# ── SLACK ─────────────────────────────────────
|
||||||
|
# Default webhook (can be overridden per-project)
|
||||||
|
SLACK_DEFAULT_WEBHOOK=""
|
||||||
|
|
||||||
|
# ── APP ───────────────────────────────────────
|
||||||
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
NEXT_PUBLIC_APP_NAME="VFX Review"
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
prisma/migrations/*.sql
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Uploads (local dev)
|
||||||
|
uploads/
|
||||||
|
public/uploads/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.vercel
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
ARG NODE_VERSION=20
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS base
|
||||||
|
|
||||||
|
# ── Dependencies ────────────────────────────────────────
|
||||||
|
FROM --platform=$BUILDPLATFORM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ── Builder ─────────────────────────────────────────────
|
||||||
|
FROM --platform=$BUILDPLATFORM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Runner ──────────────────────────────────────────────
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
We need to update how shotcodes are generated and stored.
|
||||||
|
|
||||||
|
to do this:
|
||||||
|
|
||||||
|
projects need a new field called
|
||||||
|
- showId (1–10 chars, `[A-Z0-9_]`, uppercase)
|
||||||
|
- projectType (`STANDARD` or `EPISODIC`)
|
||||||
|
|
||||||
|
# Shot Naming Conventions
|
||||||
|
|
||||||
|
## Shot Code Format
|
||||||
|
|
||||||
|
Shot codes are auto-generated on creation
|
||||||
|
|
||||||
|
### Standard Project
|
||||||
|
|
||||||
|
```
|
||||||
|
{SHOW_ID}_{SCENE}_{SHOT_NUMBER}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `PRJX_10_0010`
|
||||||
|
|
||||||
|
### Episodic Project
|
||||||
|
|
||||||
|
```
|
||||||
|
{SHOW_ID}_{EPISODE}_{SCENE}_{SHOT_NUMBER}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `PRJX_101_10_0010`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segments
|
||||||
|
|
||||||
|
| Segment | Source | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `SHOW_ID` | `Project.showId` | 1–10 chars, letters/numbers/underscores, stored uppercase. Set once on the project. |
|
||||||
|
| `EPISODE` | `Shot.episode` | Only present on `EPISODIC` projects. Free-text, stored uppercase (e.g. `EP01`). |
|
||||||
|
| `SCENE` | `Shot.scene` | Required on every shot. Stored uppercase (e.g. `SC010`). |
|
||||||
|
| `SHOT_NUMBER` | Auto-incremented | 4-digit zero-padded integer, increments by 10 (e.g. `0010`, `0020`). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-Incrementing Shot Numbers
|
||||||
|
|
||||||
|
The shot number is scoped to the **scene within a project** (and additionally to the **episode** for episodic projects):
|
||||||
|
|
||||||
|
- On create, the system queries `MAX(shotNumber)` for all shots sharing the same `projectId + scene` (+ `episode` for episodic).
|
||||||
|
- The new shot number = `MAX + 10`, starting at `0010` when no prior shots exist in that scene.
|
||||||
|
- The number is zero-padded to 4 digits.
|
||||||
|
|
||||||
|
This means shot numbers are **non-contiguous by design** — gaps allow shots to be inserted between existing ones without renumbering.
|
||||||
|
|
||||||
|
When a shot is **duplicated**, it inherits the scene/episode of the original and receives the next available shot number in that scope. Only the trailing 4-digit segment in the code is replaced; the rest of the code is preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where Fields Come From
|
||||||
|
|
||||||
|
| Field | Model | Required? |
|
||||||
|
|---|---|---|
|
||||||
|
| `showId` | `Project` | Yes — validated 1–10 chars, `[A-Z0-9_]` |
|
||||||
|
| `projectCode` | `Project` | Yes — unique across all projects, used for billing/reference (not in shot code) |
|
||||||
|
| `projectType` | `Project` | Yes — `STANDARD` or `EPISODIC`; controls whether episode segment is included |
|
||||||
|
| `scene` | `Shot` | Yes — required by `shotSchema` |
|
||||||
|
| `episode` | `Shot` | Optional — only meaningful (and included in code) when `projectType === EPISODIC` |
|
||||||
|
| `shotGroup` | `Shot` | Optional — organisational grouping only, not part of the shot code |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `projectCode` is a separate billing/admin identifier on the Project and does **not** appear in shot codes. Shot codes use `showId` instead.
|
||||||
|
- Shot codes are not regenerated on edit — if scene or episode values change after creation, the stored `shotCode` will not reflect those changes.
|
||||||
|
- All string segments are uppercased before being written into the code.
|
||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
# FeedBack — App Overview & Status
|
||||||
|
|
||||||
|
> Last updated: May 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What It Is
|
||||||
|
|
||||||
|
FeedBack is a self-hosted VFX review and approval platform for boutique studios. Internal teams (admins, producers, supervisors, artists) manage projects and shots, upload versioned video files, draw frame-accurate annotations, and leave timestamped comments. External clients receive token-gated review links and can approve, reject, or request changes — no login required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
All core features are built and functional:
|
||||||
|
|
||||||
|
| Area | Status |
|
||||||
|
|------|--------|
|
||||||
|
| Auth (login, roles, sessions) | ✅ Complete |
|
||||||
|
| Projects & shots (CRUD) | ✅ Complete |
|
||||||
|
| Version upload (local storage) | ✅ Complete |
|
||||||
|
| Review player + frame timeline | ✅ Complete |
|
||||||
|
| Annotation drawing (canvas) | ✅ Complete — persists across frame navigation |
|
||||||
|
| Frame-anchored comments + replies | ✅ Complete |
|
||||||
|
| Approval workflow | ✅ Complete |
|
||||||
|
| Client management (internal) | ✅ Complete |
|
||||||
|
| Client portal (external, token-gated) | ✅ Complete |
|
||||||
|
| Tokenized review links | ✅ Complete |
|
||||||
|
| Slack notifications | ✅ Complete (optional webhook) |
|
||||||
|
| In-app notifications | ✅ Complete |
|
||||||
|
| Email (nodemailer) | ⚙️ Wired, requires SMTP env vars |
|
||||||
|
| S3 / R2 / MinIO / B2 storage | ⚙️ Wired, requires env vars |
|
||||||
|
| Docker Compose deployment | ✅ Complete |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route Map
|
||||||
|
|
||||||
|
### Public / Unauthenticated
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `/login` | Credentials sign-in page |
|
||||||
|
| `/client/[token]` | External client project overview — lists shots visible to client |
|
||||||
|
| `/client/[token]/review/[versionId]` | External client review page — video player, comments, approve/reject |
|
||||||
|
|
||||||
|
### Dashboard (requires login)
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `/dashboard` | Home — stats cards, shot queue, recent activity |
|
||||||
|
| `/projects` | Project list |
|
||||||
|
| `/projects/[id]` | Project detail — shot grid, stats |
|
||||||
|
| `/projects/[id]/shots/[shotId]` | Shot detail — version list, approval history |
|
||||||
|
| `/review/[versionId]` | Full-screen review player (internal) |
|
||||||
|
| `/clients` | Client list — ADMIN/PRODUCER only |
|
||||||
|
| `/clients/[clientId]` | Client detail — linked projects, active review sessions |
|
||||||
|
| `/settings` | User profile settings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
### Internal (authenticated)
|
||||||
|
|
||||||
|
| Method | Route | Purpose |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| GET/POST | `/api/projects` | List / create projects |
|
||||||
|
| GET/POST/DELETE | `/api/shots` | Shot CRUD |
|
||||||
|
| GET | `/api/versions/[versionId]/comments` | Fetch comments for a version |
|
||||||
|
| GET | `/api/versions/[versionId]/annotations` | Fetch annotations for a version |
|
||||||
|
| GET/POST | `/api/clients` | List / create clients |
|
||||||
|
| GET/POST/DELETE | `/api/review-sessions` | Manage tokenized review links |
|
||||||
|
| GET/POST | `/api/notifications` | In-app notification CRUD |
|
||||||
|
| GET | `/api/files/[...key]` | Serve locally-stored files (no auth) |
|
||||||
|
| POST | `/api/upload/local` | Local file upload handler |
|
||||||
|
|
||||||
|
### Client portal (no auth — token-gated)
|
||||||
|
|
||||||
|
| Method | Route | Purpose |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| GET | `/api/client/[token]/project` | Project + shots (CLIENT_REVIEW / REVISIONS / APPROVED / FINAL only) |
|
||||||
|
| GET | `/api/client/[token]/versions/[versionId]` | Version detail + comments |
|
||||||
|
| POST | `/api/client/[token]/approve` | Submit approval decision |
|
||||||
|
| POST | `/api/client/[token]/comment` | Post a comment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Actions (`/actions`)
|
||||||
|
|
||||||
|
| File | Exports |
|
||||||
|
|------|---------|
|
||||||
|
| `projects.ts` | `createProject`, `updateProject` |
|
||||||
|
| `shots.ts` | `createShot`, `updateShotStatus`, `getShotById` |
|
||||||
|
| `versions.ts` | `createVersion`, `setLatestVersion` |
|
||||||
|
| `comments.ts` | `addComment`, `addReply`, `resolveComment` |
|
||||||
|
| `annotations.ts` | `saveAnnotation`, `getAnnotationsForVersion`, `getAnnotationsForFrame` |
|
||||||
|
| `approvals.ts` | `submitApproval` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Enums
|
||||||
|
|
||||||
|
| Enum | Values |
|
||||||
|
|------|--------|
|
||||||
|
| `Role` | `ADMIN` · `PRODUCER` · `SUPERVISOR` · `ARTIST` · `CLIENT` |
|
||||||
|
| `ProjectStatus` | `ACTIVE` · `ON_HOLD` · `COMPLETED` · `ARCHIVED` |
|
||||||
|
| `ShotStatus` | `WAITING` · `IN_PROGRESS` · `INTERNAL_REVIEW` · `CLIENT_REVIEW` · `REVISIONS` · `APPROVED` · `FINAL` |
|
||||||
|
| `ShotPriority` | `LOW` · `NORMAL` · `HIGH` · `URGENT` |
|
||||||
|
| `ApprovalStatus` | `PENDING_REVIEW` · `APPROVED` · `REJECTED` · `NEEDS_CHANGES` |
|
||||||
|
| `NotificationType` | `VERSION_UPLOADED` · `FEEDBACK_ADDED` · `SHOT_APPROVED` · `SHOT_REJECTED` · `COMMENT_REPLY` · `MENTION` · `REVISION_REQUESTED` |
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
#### `User`
|
||||||
|
Core identity. Has a `role` (see enum). `passwordHash` used for credentials login. NextAuth `Account` and `Session` models hang off this.
|
||||||
|
|
||||||
|
| Key field | Type | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `id` | cuid | PK |
|
||||||
|
| `email` | String | unique |
|
||||||
|
| `role` | Role | default `ARTIST` |
|
||||||
|
| `passwordHash` | String? | bcrypt hash |
|
||||||
|
| `isActive` | Boolean | soft-disable |
|
||||||
|
|
||||||
|
#### `Client`
|
||||||
|
Represents an external studio or client company.
|
||||||
|
|
||||||
|
| Key field | Type | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `company` | String | |
|
||||||
|
| `contactPerson` | String | |
|
||||||
|
| `email` | String | unique |
|
||||||
|
| `phone`, `notes`, `logoUrl` | optional | |
|
||||||
|
|
||||||
|
#### `ClientAccess`
|
||||||
|
Junction between a `User` (with `CLIENT` role) and a `Client` record. Allows a portal user account to be linked to a specific client company.
|
||||||
|
|
||||||
|
#### `Project`
|
||||||
|
Top-level container. Linked to an optional `Client`, `producer` (User), and `supervisor` (User).
|
||||||
|
|
||||||
|
| Key field | Type | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `code` | String | unique, used as short label |
|
||||||
|
| `status` | ProjectStatus | default `ACTIVE` |
|
||||||
|
| `clientId` | String? | nullable |
|
||||||
|
| `slackWebhook` | String? | per-project Slack alerts |
|
||||||
|
|
||||||
|
#### `Shot`
|
||||||
|
Belongs to a `Project`. Tracks pipeline status and priority.
|
||||||
|
|
||||||
|
| Key field | Type | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `shotCode` | String | unique per project |
|
||||||
|
| `sequence` | String? | grouping label |
|
||||||
|
| `status` | ShotStatus | default `WAITING` |
|
||||||
|
| `priority` | ShotPriority | default `NORMAL` |
|
||||||
|
| `fps` | Float | default 24 |
|
||||||
|
| `frameStart`, `frameEnd` | Int? | optional frame range |
|
||||||
|
|
||||||
|
> **Client visibility rule**: only shots with status `CLIENT_REVIEW`, `REVISIONS`, `APPROVED`, or `FINAL` are returned by the client portal API.
|
||||||
|
|
||||||
|
#### `Version`
|
||||||
|
A specific upload for a shot. Multiple versions per shot; only one has `isLatest = true`.
|
||||||
|
|
||||||
|
| Key field | Type | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `versionNumber` | Int | unique per shot |
|
||||||
|
| `fileUrl` | String | path/URL to video |
|
||||||
|
| `fileSize` | BigInt? | serialized to string in API responses |
|
||||||
|
| `fps` | Float | |
|
||||||
|
| `approvalStatus` | ApprovalStatus | default `PENDING_REVIEW` |
|
||||||
|
| `isLatest` | Boolean | |
|
||||||
|
|
||||||
|
#### `Comment`
|
||||||
|
Frame-anchored comment on a version. Supports replies via `CommentReply`.
|
||||||
|
|
||||||
|
| Key field | Type | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `frameNumber` | Int | exact frame |
|
||||||
|
| `timestamp` | Float | seconds |
|
||||||
|
| `text` | Text | |
|
||||||
|
| `isResolved` | Boolean | |
|
||||||
|
|
||||||
|
#### `CommentReply`
|
||||||
|
Flat child of `Comment`. No threading beyond one level.
|
||||||
|
|
||||||
|
#### `Annotation`
|
||||||
|
Canvas drawing saved against a version + frame. `drawingData` is JSON containing an array of `AnnotationShape` objects with normalized (0–1) coordinates.
|
||||||
|
|
||||||
|
| Key field | Type | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `frameNumber` | Int | |
|
||||||
|
| `drawingData` | Json | `{ shapes, canvasWidth, canvasHeight, version }` |
|
||||||
|
| `color` | String | hex, default `#ef4444` |
|
||||||
|
| `isVisible` | Boolean | soft-hide |
|
||||||
|
| `commentId` | String? | optional companion comment link |
|
||||||
|
|
||||||
|
#### `Approval`
|
||||||
|
Immutable approval record created each time a user submits a decision. The version's `approvalStatus` is updated separately.
|
||||||
|
|
||||||
|
| Key field | Type | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `status` | ApprovalStatus | |
|
||||||
|
| `notes` | Text? | |
|
||||||
|
|
||||||
|
#### `Notification`
|
||||||
|
In-app notification for a user.
|
||||||
|
|
||||||
|
| Key field | Type | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `type` | NotificationType | |
|
||||||
|
| `data` | Json? | extra context (versionId, shotCode, etc.) |
|
||||||
|
| `isRead` | Boolean | |
|
||||||
|
|
||||||
|
#### `ReviewSession`
|
||||||
|
A tokenized, time-limited review link for external clients. Tied to a `Project`.
|
||||||
|
|
||||||
|
| Key field | Type | Notes |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| `token` | String | unique cuid, used in client portal URLs |
|
||||||
|
| `label` | String? | friendly name shown to client |
|
||||||
|
| `email` | String? | recipient email |
|
||||||
|
| `expiresAt` | DateTime | |
|
||||||
|
| `isActive` | Boolean | can be deactivated manually |
|
||||||
|
| `accessCount` | Int | incremented on each portal visit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Component Map
|
||||||
|
|
||||||
|
```
|
||||||
|
components/
|
||||||
|
player/
|
||||||
|
ReviewPlayer.tsx — Forwardref player, keyboard shortcuts, fullscreen
|
||||||
|
FrameTimeline.tsx — Canvas ruler with comment/annotation markers
|
||||||
|
PlaybackControls.tsx — Transport bar, speed selector, annotation toggle
|
||||||
|
annotations/
|
||||||
|
AnnotationCanvas.tsx — Canvas overlay; per-frame shape map persists navigation
|
||||||
|
AnnotationTools.tsx — Tool/colour/stroke picker (shown when annotating)
|
||||||
|
comments/
|
||||||
|
CommentPanel.tsx — Comment list, filter, reply, resolve; seekToFrame on click
|
||||||
|
versions/
|
||||||
|
VersionUpload.tsx — Drag-and-drop uploader
|
||||||
|
VersionList.tsx — Version history with approval badges
|
||||||
|
shots/
|
||||||
|
ShotCard.tsx — Shot grid card with status dropdown
|
||||||
|
NewShotDialog.tsx — Create shot form
|
||||||
|
projects/
|
||||||
|
ProjectCard.tsx — Project list card
|
||||||
|
NewProjectDialog.tsx — Create project form
|
||||||
|
clients/
|
||||||
|
NewClientDialog.tsx — Create client form
|
||||||
|
ShareReviewDialog.tsx — Generate tokenized review link
|
||||||
|
ReviewSessionList.tsx — Active sessions with copy/deactivate actions
|
||||||
|
dashboard/
|
||||||
|
StatsCards.tsx — Top-level counts
|
||||||
|
ShotQueue.tsx — Shots needing attention
|
||||||
|
RecentActivity.tsx — Latest version/comment events
|
||||||
|
layout/
|
||||||
|
Sidebar.tsx — Nav + role-based link visibility
|
||||||
|
Header.tsx — Breadcrumb + notification bell
|
||||||
|
NotificationBell.tsx — Dropdown of unread notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth & Roles
|
||||||
|
|
||||||
|
Authentication uses **NextAuth v5** with a Credentials provider (email + bcrypt password). Sessions are JWT-based.
|
||||||
|
|
||||||
|
| Role | Access |
|
||||||
|
|------|--------|
|
||||||
|
| `ADMIN` | Full access including user management, client management |
|
||||||
|
| `PRODUCER` | Full project/shot/version access, client management |
|
||||||
|
| `SUPERVISOR` | Full project/shot/version access, no client management |
|
||||||
|
| `ARTIST` | Own shots and versions; can comment |
|
||||||
|
| `CLIENT` | Dashboard login only (legacy); primary access via portal token links |
|
||||||
|
|
||||||
|
The **client portal** routes (`/client/[token]/*` and `/api/client/[token]/*`) bypass auth entirely — access is controlled by token validity, `isActive`, and `expiresAt`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Controlled by `STORAGE_PROVIDER` env var. Currently defaults to `local` (files saved in `/public/uploads/`, served via `/api/files/[...key]`). Switching to S3/R2/B2/MinIO/UploadThing requires only env var changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables (minimum for local dev)
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://user:pass@localhost:5432/feedback"
|
||||||
|
AUTH_SECRET="<32-char random string>"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
STORAGE_PROVIDER="local"
|
||||||
|
```
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
Build a modern web-based VFX review and approval platform prototype focused on simplicity, speed, and reliability for boutique VFX/animation studios.
|
||||||
|
|
||||||
|
The goal is NOT to compete with enterprise systems immediately.
|
||||||
|
|
||||||
|
The goal is:
|
||||||
|
|
||||||
|
* dead simple uploads
|
||||||
|
* fast review cycles
|
||||||
|
* frame-accurate feedback
|
||||||
|
* artist/client collaboration
|
||||||
|
* approval tracking
|
||||||
|
* production-ready architecture
|
||||||
|
|
||||||
|
The UX should feel modern, cinematic, lightweight, and extremely responsive.
|
||||||
|
|
||||||
|
Tech stack requirements:
|
||||||
|
|
||||||
|
* Next.js latest App Router
|
||||||
|
* TypeScript
|
||||||
|
* TailwindCSS
|
||||||
|
* shadcn/ui
|
||||||
|
* PostgreSQL
|
||||||
|
* Prisma ORM
|
||||||
|
* React Query / TanStack Query
|
||||||
|
* UploadThing or S3-compatible uploads
|
||||||
|
* Server Actions where appropriate
|
||||||
|
* Docker support
|
||||||
|
* Self-hostable architecture
|
||||||
|
* Dark mode first
|
||||||
|
|
||||||
|
The system should support:
|
||||||
|
|
||||||
|
* clients
|
||||||
|
* projects
|
||||||
|
* shots
|
||||||
|
* versions
|
||||||
|
* comments
|
||||||
|
* annotations
|
||||||
|
* approvals
|
||||||
|
|
||||||
|
# CORE GOAL
|
||||||
|
|
||||||
|
Artists upload shot versions.
|
||||||
|
|
||||||
|
Clients and supervisors can:
|
||||||
|
|
||||||
|
* watch versions
|
||||||
|
* pause on frames
|
||||||
|
* leave timestamped comments
|
||||||
|
* draw annotations directly on frames
|
||||||
|
* approve/reject versions
|
||||||
|
|
||||||
|
Everything should feel frictionless.
|
||||||
|
|
||||||
|
# DATA MODEL
|
||||||
|
|
||||||
|
## CLIENTS
|
||||||
|
|
||||||
|
* company
|
||||||
|
* contact person
|
||||||
|
* email
|
||||||
|
|
||||||
|
## PROJECTS
|
||||||
|
|
||||||
|
* project name
|
||||||
|
* code
|
||||||
|
* linked client
|
||||||
|
* status
|
||||||
|
* due dates
|
||||||
|
|
||||||
|
## SHOTS
|
||||||
|
|
||||||
|
* shot code
|
||||||
|
* sequence
|
||||||
|
* description
|
||||||
|
* status
|
||||||
|
* assigned artist
|
||||||
|
* priority
|
||||||
|
|
||||||
|
Statuses:
|
||||||
|
|
||||||
|
* Waiting
|
||||||
|
* In Progress
|
||||||
|
* Internal Review
|
||||||
|
* Client Review
|
||||||
|
* Revisions
|
||||||
|
* Approved
|
||||||
|
* Final
|
||||||
|
|
||||||
|
## VERSIONS
|
||||||
|
|
||||||
|
Each shot can have many versions.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
* version number
|
||||||
|
* upload file
|
||||||
|
* thumbnail
|
||||||
|
* artist
|
||||||
|
* upload date
|
||||||
|
* notes
|
||||||
|
* fps
|
||||||
|
* duration
|
||||||
|
* frame count
|
||||||
|
* approval status
|
||||||
|
|
||||||
|
Approval statuses:
|
||||||
|
|
||||||
|
* Pending Review
|
||||||
|
* Approved
|
||||||
|
* Rejected
|
||||||
|
* Needs Changes
|
||||||
|
|
||||||
|
Support:
|
||||||
|
|
||||||
|
* mp4
|
||||||
|
* mov
|
||||||
|
* jpg sequences
|
||||||
|
* png sequences
|
||||||
|
|
||||||
|
# VIDEO REVIEW PLAYER
|
||||||
|
|
||||||
|
Build a custom review player optimized for frame review.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
* frame stepping
|
||||||
|
* JKL playback
|
||||||
|
* timeline scrubbing
|
||||||
|
* timestamp display
|
||||||
|
* frame number display
|
||||||
|
* fullscreen
|
||||||
|
* playback speed controls
|
||||||
|
|
||||||
|
VERY IMPORTANT:
|
||||||
|
Comments must attach to:
|
||||||
|
|
||||||
|
* exact frame number
|
||||||
|
* timestamp
|
||||||
|
* version
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Frame 115:
|
||||||
|
"Tracking slips here."
|
||||||
|
|
||||||
|
# FRAME-ACCURATE COMMENTS
|
||||||
|
|
||||||
|
Users can:
|
||||||
|
|
||||||
|
* pause playback
|
||||||
|
* click "Add Comment"
|
||||||
|
* comment attaches to exact frame
|
||||||
|
|
||||||
|
Comment structure:
|
||||||
|
|
||||||
|
* author
|
||||||
|
* frame number
|
||||||
|
* timestamp
|
||||||
|
* text
|
||||||
|
* resolved/unresolved
|
||||||
|
* replies
|
||||||
|
* created date
|
||||||
|
|
||||||
|
Allow threaded replies.
|
||||||
|
|
||||||
|
# DRAW-OVER ANNOTATIONS
|
||||||
|
|
||||||
|
Users should be able to:
|
||||||
|
|
||||||
|
* draw directly on paused frame
|
||||||
|
* arrows
|
||||||
|
* circles
|
||||||
|
* freehand lines
|
||||||
|
* rectangles
|
||||||
|
|
||||||
|
Annotations must save:
|
||||||
|
|
||||||
|
* frame number
|
||||||
|
* vector drawing data
|
||||||
|
* author
|
||||||
|
* linked comment
|
||||||
|
|
||||||
|
Allow:
|
||||||
|
|
||||||
|
* toggling annotations
|
||||||
|
* multiple annotations per frame
|
||||||
|
|
||||||
|
Use HTML canvas or Konva.js.
|
||||||
|
|
||||||
|
# REVIEW WORKFLOW
|
||||||
|
|
||||||
|
Implement a lightweight approval pipeline.
|
||||||
|
|
||||||
|
Review statuses:
|
||||||
|
|
||||||
|
* Pending
|
||||||
|
* Internal Approved
|
||||||
|
* Client Approved
|
||||||
|
* Needs Changes
|
||||||
|
* Final Approved
|
||||||
|
|
||||||
|
Clients should be able to:
|
||||||
|
|
||||||
|
* approve version
|
||||||
|
* reject version
|
||||||
|
* request revisions
|
||||||
|
|
||||||
|
Artists should:
|
||||||
|
|
||||||
|
* receive notifications
|
||||||
|
* upload new versions
|
||||||
|
* compare against previous versions
|
||||||
|
|
||||||
|
# SIDE-BY-SIDE VERSION COMPARISON
|
||||||
|
|
||||||
|
Allow:
|
||||||
|
|
||||||
|
* A/B version comparison
|
||||||
|
* wipe comparison slider
|
||||||
|
* previous version overlay
|
||||||
|
|
||||||
|
Useful for:
|
||||||
|
|
||||||
|
* comp revisions
|
||||||
|
* cleanup changes
|
||||||
|
* color tweaks
|
||||||
|
|
||||||
|
# NOTIFICATIONS
|
||||||
|
|
||||||
|
Add notification system.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
* New version uploaded
|
||||||
|
* New feedback added
|
||||||
|
* Shot approved
|
||||||
|
* Shot rejected
|
||||||
|
* Comment reply added
|
||||||
|
|
||||||
|
Support:
|
||||||
|
|
||||||
|
* in-app notifications
|
||||||
|
* email notifications
|
||||||
|
* Slack integration
|
||||||
|
|
||||||
|
# SLACK INTEGRATION
|
||||||
|
|
||||||
|
Integrate with Slack webhooks.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```plaintext id="6j6t2g"
|
||||||
|
SH020 v004 approved by client
|
||||||
|
```
|
||||||
|
|
||||||
|
```plaintext id="p5v8rz"
|
||||||
|
New feedback added on SH035 frame 122
|
||||||
|
```
|
||||||
|
|
||||||
|
Allow configurable channels per project.
|
||||||
|
|
||||||
|
# FILE STORAGE
|
||||||
|
|
||||||
|
Use S3-compatible architecture.
|
||||||
|
|
||||||
|
Support:
|
||||||
|
|
||||||
|
* local storage
|
||||||
|
* Backblaze B2
|
||||||
|
* AWS S3
|
||||||
|
* Cloudflare R2
|
||||||
|
* self-hosted MinIO
|
||||||
|
|
||||||
|
Generate:
|
||||||
|
|
||||||
|
* thumbnails
|
||||||
|
* proxies
|
||||||
|
* poster frames
|
||||||
|
|
||||||
|
# SECURITY
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
* signed upload URLs
|
||||||
|
* secure client review links
|
||||||
|
* expiring review sessions
|
||||||
|
* role permissions
|
||||||
|
|
||||||
|
Roles:
|
||||||
|
|
||||||
|
* Admin
|
||||||
|
* Producer
|
||||||
|
* Supervisor
|
||||||
|
* Artist
|
||||||
|
* Client
|
||||||
|
|
||||||
|
Clients should ONLY see:
|
||||||
|
|
||||||
|
* assigned projects
|
||||||
|
* approved review material
|
||||||
|
|
||||||
|
# CLIENT REVIEW PORTAL
|
||||||
|
|
||||||
|
Clients should have a very simple portal:
|
||||||
|
|
||||||
|
* open project
|
||||||
|
* review shots
|
||||||
|
* leave notes
|
||||||
|
* approve/reject
|
||||||
|
|
||||||
|
No production complexity exposed.
|
||||||
|
|
||||||
|
Keep it clean and minimal.
|
||||||
|
|
||||||
|
# DASHBOARD
|
||||||
|
|
||||||
|
Internal dashboard should show:
|
||||||
|
|
||||||
|
* shots awaiting review
|
||||||
|
* shots needing revisions
|
||||||
|
* approved shots
|
||||||
|
* overdue shots
|
||||||
|
* recent comments
|
||||||
|
* artist assignments
|
||||||
|
|
||||||
|
# UI REQUIREMENTS
|
||||||
|
|
||||||
|
The UI should feel:
|
||||||
|
|
||||||
|
* modern
|
||||||
|
* film-industry oriented
|
||||||
|
* dark themed
|
||||||
|
* fast
|
||||||
|
* minimal clutter
|
||||||
|
|
||||||
|
Inspiration:
|
||||||
|
|
||||||
|
* Frame.io
|
||||||
|
* SyncSketch
|
||||||
|
* ShotGrid Review
|
||||||
|
* Vimeo Review
|
||||||
|
|
||||||
|
But simpler and cleaner.
|
||||||
|
|
||||||
|
# IMPORTANT PERFORMANCE REQUIREMENTS
|
||||||
|
|
||||||
|
Optimize for:
|
||||||
|
|
||||||
|
* fast scrubbing
|
||||||
|
* lightweight annotation rendering
|
||||||
|
* low-latency comments
|
||||||
|
* proxy playback
|
||||||
|
|
||||||
|
Avoid bloated enterprise complexity.
|
||||||
|
|
||||||
|
# BONUS FEATURES
|
||||||
|
|
||||||
|
If possible implement:
|
||||||
|
|
||||||
|
* LUT viewing
|
||||||
|
* EXR sequence support
|
||||||
|
* burn-ins
|
||||||
|
* watermarking
|
||||||
|
* presentation playlists
|
||||||
|
* review sessions
|
||||||
|
* audio comments
|
||||||
|
* webhook API
|
||||||
|
* task linking
|
||||||
|
* AI feedback summaries
|
||||||
|
|
||||||
|
# ARCHITECTURE
|
||||||
|
|
||||||
|
Design the system so it can later integrate into a larger VFX pipeline platform including:
|
||||||
|
|
||||||
|
* bidding
|
||||||
|
* production tracking
|
||||||
|
* artist scheduling
|
||||||
|
* invoicing
|
||||||
|
* asset management
|
||||||
|
* render tracking
|
||||||
|
|
||||||
|
Avoid hardcoding review-specific assumptions.
|
||||||
|
|
||||||
|
# MVP PRIORITY
|
||||||
|
|
||||||
|
Prioritize these features FIRST:
|
||||||
|
|
||||||
|
1. Upload video
|
||||||
|
2. Scrub by frame
|
||||||
|
3. Add timestamp comments
|
||||||
|
4. Draw annotations
|
||||||
|
5. Approve/reject versions
|
||||||
|
6. View feedback history
|
||||||
|
|
||||||
|
Those six features are the core product.
|
||||||
|
|
||||||
|
Everything else is secondary.
|
||||||
|
|
||||||
|
The app should feel like:
|
||||||
|
“A modern lightweight review system that actually respects artists’ time.”
|
||||||
+916
@@ -0,0 +1,916 @@
|
|||||||
|
Update the existing FeedBack platform to add a lightweight production tracking system directly into the current architecture.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
Do NOT redesign the app from scratch.
|
||||||
|
Do NOT build enterprise production management.
|
||||||
|
Do NOT attempt full ShotGrid/ftrack complexity.
|
||||||
|
|
||||||
|
The goal is:
|
||||||
|
|
||||||
|
* simple
|
||||||
|
* fast
|
||||||
|
* usable
|
||||||
|
* production-focused
|
||||||
|
* minimal friction
|
||||||
|
|
||||||
|
FeedBack already has:
|
||||||
|
|
||||||
|
* projects
|
||||||
|
* shots
|
||||||
|
* versions
|
||||||
|
* reviews
|
||||||
|
* comments
|
||||||
|
* approvals
|
||||||
|
* client portal
|
||||||
|
|
||||||
|
We are now extending it into a lightweight VFX production tracker.
|
||||||
|
|
||||||
|
The philosophy should be:
|
||||||
|
“A clean operational layer for boutique VFX studios.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# HIGH LEVEL GOAL
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
* production tracking
|
||||||
|
* tasks
|
||||||
|
* artist assignment
|
||||||
|
* deadlines
|
||||||
|
* kanban boards
|
||||||
|
* simple scheduling
|
||||||
|
* upload/review workflow integration
|
||||||
|
|
||||||
|
The existing review/version system should become directly attached to production tasks.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
The TASK should become the core production object.
|
||||||
|
|
||||||
|
Architecture should evolve toward:
|
||||||
|
|
||||||
|
```plaintext id="c2o8kh"
|
||||||
|
Project
|
||||||
|
├── Shots
|
||||||
|
│ ├── Tasks
|
||||||
|
│ │ ├── Versions
|
||||||
|
│ │ ├── Comments
|
||||||
|
│ │ └── Approvals
|
||||||
|
│
|
||||||
|
├── Assets
|
||||||
|
│ ├── Tasks
|
||||||
|
│ │ ├── Versions
|
||||||
|
│ │ ├── Comments
|
||||||
|
│ │ └── Approvals
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# NEW CORE CONCEPTS
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
* Assets
|
||||||
|
* Tasks
|
||||||
|
* Artist Assignment
|
||||||
|
* Kanban Views
|
||||||
|
* Deadlines
|
||||||
|
* Task Statuses
|
||||||
|
|
||||||
|
DO NOT add:
|
||||||
|
|
||||||
|
* timesheets
|
||||||
|
* complex dependencies
|
||||||
|
* render management
|
||||||
|
* resource planning
|
||||||
|
* advanced scheduling
|
||||||
|
* Gantt charts
|
||||||
|
|
||||||
|
Keep the system intentionally lightweight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DATABASE CHANGES
|
||||||
|
|
||||||
|
## NEW ENUM: `TaskStatus`
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
TODO
|
||||||
|
IN_PROGRESS
|
||||||
|
REVIEW
|
||||||
|
CHANGES
|
||||||
|
DONE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NEW ENUM: `TaskType`
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
TRACK
|
||||||
|
ROTO
|
||||||
|
KEY
|
||||||
|
COMP
|
||||||
|
FX
|
||||||
|
LIGHTING
|
||||||
|
RENDER
|
||||||
|
ANIMATION
|
||||||
|
MODEL
|
||||||
|
TEXTURE
|
||||||
|
RIG
|
||||||
|
LOOKDEV
|
||||||
|
GENERAL
|
||||||
|
```
|
||||||
|
|
||||||
|
Allow custom values later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# NEW MODEL: `Asset`
|
||||||
|
|
||||||
|
Assets should behave similarly to shots.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
* id
|
||||||
|
* projectId
|
||||||
|
* assetCode
|
||||||
|
* name
|
||||||
|
* description
|
||||||
|
* status
|
||||||
|
* priority
|
||||||
|
* assignedLeadId
|
||||||
|
* dueDate
|
||||||
|
* createdAt
|
||||||
|
* updatedAt
|
||||||
|
|
||||||
|
Assets belong to:
|
||||||
|
|
||||||
|
* Project
|
||||||
|
|
||||||
|
Assets contain:
|
||||||
|
|
||||||
|
* Tasks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# NEW MODEL: `Task`
|
||||||
|
|
||||||
|
This becomes the core operational object.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
* id
|
||||||
|
* title
|
||||||
|
* description
|
||||||
|
* type
|
||||||
|
* status
|
||||||
|
* priority
|
||||||
|
* dueDate
|
||||||
|
* estimatedHours
|
||||||
|
* sortOrder
|
||||||
|
* shotId (nullable)
|
||||||
|
* assetId (nullable)
|
||||||
|
* assignedArtistId (nullable)
|
||||||
|
* createdById
|
||||||
|
* createdAt
|
||||||
|
* updatedAt
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* task belongs to either:
|
||||||
|
|
||||||
|
* a Shot
|
||||||
|
* or an Asset
|
||||||
|
* never both
|
||||||
|
|
||||||
|
Tasks can contain:
|
||||||
|
|
||||||
|
* Versions
|
||||||
|
* Comments
|
||||||
|
* Approvals
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
Refactor existing Version model:
|
||||||
|
|
||||||
|
* versions should optionally belong to a Task
|
||||||
|
* NOT only a Shot
|
||||||
|
|
||||||
|
This is critical.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# VERSION REFACTOR
|
||||||
|
|
||||||
|
Currently:
|
||||||
|
|
||||||
|
```plaintext id="vw7z0h"
|
||||||
|
Shot -> Versions
|
||||||
|
```
|
||||||
|
|
||||||
|
Refactor to:
|
||||||
|
|
||||||
|
```plaintext id="7wfp9e"
|
||||||
|
Task -> Versions
|
||||||
|
```
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
The review process is task-specific.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```plaintext id="x4fq6o"
|
||||||
|
SH010
|
||||||
|
├── Track Task
|
||||||
|
│ └── Versions
|
||||||
|
│
|
||||||
|
├── Roto Task
|
||||||
|
│ └── Versions
|
||||||
|
│
|
||||||
|
└── Comp Task
|
||||||
|
└── Versions
|
||||||
|
```
|
||||||
|
|
||||||
|
This massively improves production clarity.
|
||||||
|
|
||||||
|
Still allow:
|
||||||
|
|
||||||
|
* shot-level overview
|
||||||
|
* latest task version summary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK WORKFLOW
|
||||||
|
|
||||||
|
Tasks should support:
|
||||||
|
|
||||||
|
* assignment
|
||||||
|
* deadlines
|
||||||
|
* uploads
|
||||||
|
* review
|
||||||
|
* approvals
|
||||||
|
|
||||||
|
Simple lifecycle:
|
||||||
|
|
||||||
|
```plaintext id="y8y95u"
|
||||||
|
TODO
|
||||||
|
→ IN_PROGRESS
|
||||||
|
→ REVIEW
|
||||||
|
→ CHANGES
|
||||||
|
→ DONE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# KANBAN BOARD
|
||||||
|
|
||||||
|
Implement lightweight kanban production views.
|
||||||
|
|
||||||
|
Views:
|
||||||
|
|
||||||
|
* By Project
|
||||||
|
* By Shot
|
||||||
|
* By Asset
|
||||||
|
* By Artist
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
|
||||||
|
* Todo
|
||||||
|
* In Progress
|
||||||
|
* Review
|
||||||
|
* Changes
|
||||||
|
* Done
|
||||||
|
|
||||||
|
Cards should display:
|
||||||
|
|
||||||
|
* task title
|
||||||
|
* shot/asset code
|
||||||
|
* assigned artist
|
||||||
|
* due date
|
||||||
|
* latest version
|
||||||
|
* comment count
|
||||||
|
* approval state
|
||||||
|
|
||||||
|
Support:
|
||||||
|
|
||||||
|
* drag and drop
|
||||||
|
* optimistic updates
|
||||||
|
* instant status changes
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
* dnd-kit
|
||||||
|
|
||||||
|
NOT:
|
||||||
|
|
||||||
|
* giant enterprise scheduling systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# NEW ROUTES
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```plaintext id="pwttmf"
|
||||||
|
/tasks
|
||||||
|
/tasks/[taskId]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Views
|
||||||
|
|
||||||
|
Extend:
|
||||||
|
|
||||||
|
```plaintext id="6yq0o5"
|
||||||
|
/projects/[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
Add tabs:
|
||||||
|
|
||||||
|
* Shots
|
||||||
|
* Assets
|
||||||
|
* Tasks
|
||||||
|
* Kanban
|
||||||
|
* Activity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Asset Views
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```plaintext id="y5xj4q"
|
||||||
|
/projects/[id]/assets/[assetId]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Views
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```plaintext id="g3fcdj"
|
||||||
|
/tasks/[taskId]
|
||||||
|
```
|
||||||
|
|
||||||
|
Task page should contain:
|
||||||
|
|
||||||
|
* task details
|
||||||
|
* status
|
||||||
|
* assignment
|
||||||
|
* uploads
|
||||||
|
* comments
|
||||||
|
* review player
|
||||||
|
* annotations
|
||||||
|
* approval history
|
||||||
|
|
||||||
|
Essentially:
|
||||||
|
task page = review page + production metadata
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# REVIEW SYSTEM INTEGRATION
|
||||||
|
|
||||||
|
DO NOT duplicate review systems.
|
||||||
|
|
||||||
|
Existing:
|
||||||
|
|
||||||
|
* review player
|
||||||
|
* comments
|
||||||
|
* annotations
|
||||||
|
* approvals
|
||||||
|
|
||||||
|
should now attach to:
|
||||||
|
|
||||||
|
* Task Versions
|
||||||
|
|
||||||
|
The review player remains mostly unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# VERSION UPLOAD FLOW
|
||||||
|
|
||||||
|
New upload flow:
|
||||||
|
|
||||||
|
```plaintext id="v3r0xy"
|
||||||
|
Project
|
||||||
|
→ Shot / Asset
|
||||||
|
→ Task
|
||||||
|
→ Upload Version
|
||||||
|
```
|
||||||
|
|
||||||
|
Version uploads should:
|
||||||
|
|
||||||
|
* automatically increment version number
|
||||||
|
* optionally notify assigned reviewers
|
||||||
|
* optionally move task to REVIEW
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK CREATION UX
|
||||||
|
|
||||||
|
Allow:
|
||||||
|
|
||||||
|
* manual task creation
|
||||||
|
* templates
|
||||||
|
* quick-add buttons
|
||||||
|
|
||||||
|
Example:
|
||||||
|
For shots:
|
||||||
|
|
||||||
|
* Add Track
|
||||||
|
* Add Roto
|
||||||
|
* Add Comp
|
||||||
|
|
||||||
|
For assets:
|
||||||
|
|
||||||
|
* Add Model
|
||||||
|
* Add Texture
|
||||||
|
* Add Rig
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ARTIST ASSIGNMENTS
|
||||||
|
|
||||||
|
Tasks should support:
|
||||||
|
|
||||||
|
* assigned artist
|
||||||
|
* assigned supervisor
|
||||||
|
* watcher/follower users
|
||||||
|
|
||||||
|
Add lightweight workload views:
|
||||||
|
|
||||||
|
* my tasks
|
||||||
|
* due this week
|
||||||
|
* overdue
|
||||||
|
* awaiting review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DASHBOARD IMPROVEMENTS
|
||||||
|
|
||||||
|
Add widgets:
|
||||||
|
|
||||||
|
* Tasks Due Today
|
||||||
|
* Tasks In Review
|
||||||
|
* Overdue Tasks
|
||||||
|
* Latest Uploads
|
||||||
|
* Recent Feedback
|
||||||
|
* Artist Workload
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# NOTIFICATIONS
|
||||||
|
|
||||||
|
Extend notification system.
|
||||||
|
|
||||||
|
New notification types:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
TASK_ASSIGNED
|
||||||
|
TASK_OVERDUE
|
||||||
|
TASK_APPROVED
|
||||||
|
TASK_CHANGES_REQUESTED
|
||||||
|
TASK_READY_FOR_REVIEW
|
||||||
|
```
|
||||||
|
|
||||||
|
Slack examples:
|
||||||
|
|
||||||
|
```plaintext id="hjlwmz"
|
||||||
|
SH010 COMP task ready for review
|
||||||
|
```
|
||||||
|
|
||||||
|
```plaintext id="64q9vx"
|
||||||
|
ASSET_CAR01 MODEL task approved
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PERMISSIONS
|
||||||
|
|
||||||
|
Maintain current auth architecture.
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
|
||||||
|
* ADMIN/PRODUCER/SUPERVISOR:
|
||||||
|
full task control
|
||||||
|
* ARTIST:
|
||||||
|
view assigned tasks
|
||||||
|
upload versions
|
||||||
|
comment
|
||||||
|
* CLIENT:
|
||||||
|
review approved review sessions only
|
||||||
|
|
||||||
|
Clients should NEVER see:
|
||||||
|
|
||||||
|
* kanban
|
||||||
|
* production internals
|
||||||
|
* task assignments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI REQUIREMENTS
|
||||||
|
|
||||||
|
The UI should feel:
|
||||||
|
|
||||||
|
* modern
|
||||||
|
* dark-mode first
|
||||||
|
* fast
|
||||||
|
* clean
|
||||||
|
* film-production oriented
|
||||||
|
|
||||||
|
Inspiration:
|
||||||
|
|
||||||
|
* Linear
|
||||||
|
* Trello
|
||||||
|
* Frame.io
|
||||||
|
* ShotGrid (but MUCH simpler)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# IMPORTANT PRODUCT DIRECTION
|
||||||
|
|
||||||
|
This should feel like:
|
||||||
|
“Trello + Frame.io specifically for boutique VFX studios.”
|
||||||
|
|
||||||
|
NOT:
|
||||||
|
“Enterprise production software.”
|
||||||
|
|
||||||
|
Keep:
|
||||||
|
|
||||||
|
* interactions fast
|
||||||
|
* layouts uncluttered
|
||||||
|
* onboarding minimal
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
# CLIENT REVIEW SHARING FLOW
|
||||||
|
|
||||||
|
Add a lightweight “Share With Client” workflow directly into the production/review system.
|
||||||
|
|
||||||
|
The goal is:
|
||||||
|
|
||||||
|
* one-click client sharing
|
||||||
|
* minimal friction
|
||||||
|
* no complicated publishing workflows
|
||||||
|
* tightly integrated into task review
|
||||||
|
|
||||||
|
This should feel extremely simple for producers/supervisors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# CORE CONCEPT
|
||||||
|
|
||||||
|
A task version can move through two review stages:
|
||||||
|
|
||||||
|
```plaintext id="y5ibk0"
|
||||||
|
Internal Review
|
||||||
|
→ Client Review
|
||||||
|
→ Approved / Changes
|
||||||
|
```
|
||||||
|
|
||||||
|
When a user clicks:
|
||||||
|
|
||||||
|
```plaintext id="l5l90p"
|
||||||
|
Share With Client
|
||||||
|
```
|
||||||
|
|
||||||
|
the system should:
|
||||||
|
|
||||||
|
* mark the task/version as ready for client review
|
||||||
|
* expose the version in the client portal
|
||||||
|
* optionally notify the client
|
||||||
|
* generate/share a review link if needed
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
Do NOT require duplicate uploads or separate publish steps.
|
||||||
|
|
||||||
|
The uploaded task version itself becomes the client review version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# NEW TASK STATUS
|
||||||
|
|
||||||
|
Extend `TaskStatus` enum:
|
||||||
|
|
||||||
|
```ts id="z1vc6r"
|
||||||
|
TODO
|
||||||
|
IN_PROGRESS
|
||||||
|
INTERNAL_REVIEW
|
||||||
|
CLIENT_REVIEW
|
||||||
|
CHANGES
|
||||||
|
DONE
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors actual VFX review flow more accurately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# SHARE WITH CLIENT BUTTON
|
||||||
|
|
||||||
|
Add a prominent:
|
||||||
|
|
||||||
|
```plaintext id="mqkn6v"
|
||||||
|
Share With Client
|
||||||
|
```
|
||||||
|
|
||||||
|
button in multiple locations.
|
||||||
|
|
||||||
|
## PRIMARY LOCATION
|
||||||
|
|
||||||
|
Inside the internal review player page:
|
||||||
|
|
||||||
|
```plaintext id="m1sjc9"
|
||||||
|
/review/[versionId]
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the most important placement.
|
||||||
|
|
||||||
|
Supervisors reviewing a version internally should be able to immediately push it to client review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDITIONAL LOCATIONS
|
||||||
|
|
||||||
|
Add button/action in:
|
||||||
|
|
||||||
|
* task detail page
|
||||||
|
* version list rows
|
||||||
|
* kanban task cards
|
||||||
|
* project shot overview
|
||||||
|
* latest version cards
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
* dropdown action menu
|
||||||
|
* quick action button
|
||||||
|
* contextual right-click menu where appropriate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# SHARE FLOW
|
||||||
|
|
||||||
|
When clicked:
|
||||||
|
|
||||||
|
## IF NO ACTIVE CLIENT REVIEW SESSION EXISTS
|
||||||
|
|
||||||
|
Prompt:
|
||||||
|
|
||||||
|
```plaintext id="4v0j9h"
|
||||||
|
Create review link for client?
|
||||||
|
```
|
||||||
|
|
||||||
|
Allow:
|
||||||
|
|
||||||
|
* create new tokenized review session
|
||||||
|
* select existing review session
|
||||||
|
* choose expiry date
|
||||||
|
* optional password later
|
||||||
|
* optional email send
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IF REVIEW SESSION EXISTS
|
||||||
|
|
||||||
|
Immediately:
|
||||||
|
|
||||||
|
* mark version as client-visible
|
||||||
|
* move task to CLIENT_REVIEW
|
||||||
|
* notify client if enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# VERSION VISIBILITY
|
||||||
|
|
||||||
|
Add fields to `Version`:
|
||||||
|
|
||||||
|
```ts id="e88z4o"
|
||||||
|
isClientVisible Boolean @default(false)
|
||||||
|
sharedAt DateTime?
|
||||||
|
sharedById String?
|
||||||
|
```
|
||||||
|
|
||||||
|
Only versions with:
|
||||||
|
|
||||||
|
```plaintext id="vlfvhy"
|
||||||
|
isClientVisible = true
|
||||||
|
```
|
||||||
|
|
||||||
|
appear in client portal APIs.
|
||||||
|
|
||||||
|
This is MUCH cleaner than relying only on shot status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# CLIENT REVIEW RULES
|
||||||
|
|
||||||
|
Clients should ONLY see:
|
||||||
|
|
||||||
|
* explicitly shared versions
|
||||||
|
* latest shared version by default
|
||||||
|
|
||||||
|
Clients should NEVER see:
|
||||||
|
|
||||||
|
* internal versions
|
||||||
|
* WIP uploads
|
||||||
|
* rejected internal passes
|
||||||
|
* production notes
|
||||||
|
* kanban/task internals
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# CLIENT REVIEW UX
|
||||||
|
|
||||||
|
When shared:
|
||||||
|
|
||||||
|
* client sees version immediately in portal
|
||||||
|
* can comment
|
||||||
|
* approve
|
||||||
|
* request changes
|
||||||
|
|
||||||
|
Client actions should:
|
||||||
|
|
||||||
|
* update task status automatically
|
||||||
|
* create notifications
|
||||||
|
* appear in activity feed
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```plaintext id="yv2hfe"
|
||||||
|
Client approved SH010 COMP v004
|
||||||
|
```
|
||||||
|
|
||||||
|
Or:
|
||||||
|
|
||||||
|
```plaintext id="z61rq5"
|
||||||
|
Client requested changes on SH020 TRACK v002
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# OPTIONAL EMAIL FLOW
|
||||||
|
|
||||||
|
Allow optional:
|
||||||
|
|
||||||
|
```plaintext id="jk3o5i"
|
||||||
|
Send Review Email
|
||||||
|
```
|
||||||
|
|
||||||
|
Email contains:
|
||||||
|
|
||||||
|
* project name
|
||||||
|
* shot/task name
|
||||||
|
* review link
|
||||||
|
* optional message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# SMART STATUS AUTOMATION
|
||||||
|
|
||||||
|
Recommended automatic transitions:
|
||||||
|
|
||||||
|
```plaintext id="mxti1g"
|
||||||
|
Upload Version
|
||||||
|
→ INTERNAL_REVIEW
|
||||||
|
|
||||||
|
Click "Share With Client"
|
||||||
|
→ CLIENT_REVIEW
|
||||||
|
|
||||||
|
Client Approves
|
||||||
|
→ DONE
|
||||||
|
|
||||||
|
Client Requests Changes
|
||||||
|
→ CHANGES
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps production flow extremely simple.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI REQUIREMENTS
|
||||||
|
|
||||||
|
The "Share With Client" action should feel:
|
||||||
|
|
||||||
|
* fast
|
||||||
|
* obvious
|
||||||
|
* production-friendly
|
||||||
|
|
||||||
|
Suggested styling:
|
||||||
|
|
||||||
|
* accent button
|
||||||
|
* paper-plane/share icon
|
||||||
|
* visible near approval controls
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
* buried menus
|
||||||
|
* multi-step publish systems
|
||||||
|
* complicated permission dialogs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# IMPORTANT PRODUCT DIRECTION
|
||||||
|
|
||||||
|
This feature is one of the biggest differentiators of the platform.
|
||||||
|
|
||||||
|
The ideal workflow should feel like:
|
||||||
|
|
||||||
|
```plaintext id="v5z9eo"
|
||||||
|
Artist uploads →
|
||||||
|
Supervisor reviews →
|
||||||
|
Clicks "Share With Client" →
|
||||||
|
Client reviews instantly
|
||||||
|
```
|
||||||
|
|
||||||
|
No exporting.
|
||||||
|
No re-uploading.
|
||||||
|
No external tools.
|
||||||
|
No confusion.
|
||||||
|
|
||||||
|
That simplicity is the product advantage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# IMPLEMENTATION PLAN
|
||||||
|
|
||||||
|
Implement in phases.
|
||||||
|
|
||||||
|
## PHASE 1 — DATABASE & CORE MODELS
|
||||||
|
|
||||||
|
1. Add Asset model
|
||||||
|
2. Add Task model
|
||||||
|
3. Add Task enums
|
||||||
|
4. Refactor Version relationships
|
||||||
|
5. Add Prisma migrations
|
||||||
|
6. Seed example tasks/assets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 2 — TASK CRUD
|
||||||
|
|
||||||
|
1. Task creation dialogs
|
||||||
|
2. Task detail page
|
||||||
|
3. Task assignment
|
||||||
|
4. Status updates
|
||||||
|
5. Due dates
|
||||||
|
6. Quick-add task templates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 3 — VERSION INTEGRATION
|
||||||
|
|
||||||
|
1. Attach uploads to tasks
|
||||||
|
2. Refactor review routes
|
||||||
|
3. Task-based version history
|
||||||
|
4. Latest version indicators
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 4 — KANBAN
|
||||||
|
|
||||||
|
1. Build kanban board
|
||||||
|
2. Drag/drop task movement
|
||||||
|
3. Real-time updates
|
||||||
|
4. Artist filtering
|
||||||
|
5. Project filtering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 5 — DASHBOARD & NOTIFICATIONS
|
||||||
|
|
||||||
|
1. Task widgets
|
||||||
|
2. Workload summaries
|
||||||
|
3. Slack updates
|
||||||
|
4. In-app notifications
|
||||||
|
5. Overdue indicators
|
||||||
|
6. Client Review button implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# IMPORTANT TECHNICAL REQUIREMENTS
|
||||||
|
|
||||||
|
Maintain:
|
||||||
|
|
||||||
|
* current auth system
|
||||||
|
* current storage abstraction
|
||||||
|
* current review functionality
|
||||||
|
* current client portal
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
* breaking existing APIs
|
||||||
|
* rewriting review logic
|
||||||
|
* giant refactors
|
||||||
|
|
||||||
|
This should be an additive evolution of the existing FeedBack architecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
+523
@@ -0,0 +1,523 @@
|
|||||||
|
The current architecture has a conceptual problem around:
|
||||||
|
|
||||||
|
* shots
|
||||||
|
* tasks
|
||||||
|
* versions
|
||||||
|
* approvals
|
||||||
|
* client review visibility
|
||||||
|
|
||||||
|
We need to refactor the production/review workflow so that TASKS become the true operational and review unit.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
Do NOT redesign the whole app.
|
||||||
|
This is a workflow/data relationship correction and cleanup.
|
||||||
|
|
||||||
|
The goal is:
|
||||||
|
|
||||||
|
* clearer production flow
|
||||||
|
* correct review behavior
|
||||||
|
* correct client review visibility
|
||||||
|
* correct kanban behavior
|
||||||
|
* proper task-based versioning
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# CORE ARCHITECTURAL DECISION
|
||||||
|
|
||||||
|
A SHOT is:
|
||||||
|
|
||||||
|
* a container/grouping
|
||||||
|
* a production entity
|
||||||
|
* NOT directly reviewable
|
||||||
|
|
||||||
|
A TASK is:
|
||||||
|
|
||||||
|
* the actual work unit
|
||||||
|
* the review unit
|
||||||
|
* the approval unit
|
||||||
|
* the kanban unit
|
||||||
|
* the upload/version unit
|
||||||
|
|
||||||
|
This means:
|
||||||
|
|
||||||
|
```plaintext id="b89ah6"
|
||||||
|
Shot
|
||||||
|
├── Track Task
|
||||||
|
│ └── Versions
|
||||||
|
│
|
||||||
|
├── Roto Task
|
||||||
|
│ └── Versions
|
||||||
|
│
|
||||||
|
└── Comp Task
|
||||||
|
└── Versions
|
||||||
|
```
|
||||||
|
|
||||||
|
NOT:
|
||||||
|
|
||||||
|
```plaintext id="6fjlwm"
|
||||||
|
Shot
|
||||||
|
└── Versions
|
||||||
|
```
|
||||||
|
|
||||||
|
That old structure is incorrect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# REQUIRED REFACTOR
|
||||||
|
|
||||||
|
## 1. VERSIONS MUST BELONG TO TASKS
|
||||||
|
|
||||||
|
Currently:
|
||||||
|
|
||||||
|
```plaintext id="y7txg8"
|
||||||
|
Shot -> Versions
|
||||||
|
```
|
||||||
|
|
||||||
|
Refactor to:
|
||||||
|
|
||||||
|
```plaintext id="dh4mf6"
|
||||||
|
Task -> Versions
|
||||||
|
```
|
||||||
|
|
||||||
|
A version upload must ALWAYS belong to:
|
||||||
|
|
||||||
|
* exactly one task
|
||||||
|
|
||||||
|
Remove the concept of:
|
||||||
|
|
||||||
|
* "shot latest version"
|
||||||
|
|
||||||
|
Instead:
|
||||||
|
|
||||||
|
* each task has its own latest version
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```plaintext id="hskx5e"
|
||||||
|
SH010
|
||||||
|
├── Track Task
|
||||||
|
│ └── latest: v003
|
||||||
|
│
|
||||||
|
├── Roto Task
|
||||||
|
│ └── latest: v005
|
||||||
|
│
|
||||||
|
└── Comp Task
|
||||||
|
└── latest: v008
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the correct production model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. SHOTS SHOULD NOT DIRECTLY DRIVE CLIENT REVIEW
|
||||||
|
|
||||||
|
Currently:
|
||||||
|
|
||||||
|
* shot status = CLIENT_REVIEW
|
||||||
|
* portal shows latest uploaded version
|
||||||
|
* task context lost
|
||||||
|
|
||||||
|
This is incorrect.
|
||||||
|
|
||||||
|
Client review should be TASK-BASED.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# NEW CLIENT REVIEW RULE
|
||||||
|
|
||||||
|
The client portal should display:
|
||||||
|
|
||||||
|
```plaintext id="rlazoz"
|
||||||
|
Tasks
|
||||||
|
where latest version is marked:
|
||||||
|
isClientVisible = true
|
||||||
|
```
|
||||||
|
|
||||||
|
NOT:
|
||||||
|
|
||||||
|
```plaintext id="cr7j27"
|
||||||
|
Shots where shot.status = CLIENT_REVIEW
|
||||||
|
```
|
||||||
|
|
||||||
|
This fixes:
|
||||||
|
|
||||||
|
* incorrect versions
|
||||||
|
* missing task context
|
||||||
|
* review ambiguity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# CLIENT PORTAL SHOULD SHOW
|
||||||
|
|
||||||
|
Instead of:
|
||||||
|
|
||||||
|
```plaintext id="l3l0xr"
|
||||||
|
SH010
|
||||||
|
```
|
||||||
|
|
||||||
|
Show:
|
||||||
|
|
||||||
|
```plaintext id="1e2dn4"
|
||||||
|
SH010 — COMP
|
||||||
|
v008
|
||||||
|
```
|
||||||
|
|
||||||
|
Or:
|
||||||
|
|
||||||
|
```plaintext id="nyc0h4"
|
||||||
|
SH010 — ROTO
|
||||||
|
v005
|
||||||
|
```
|
||||||
|
|
||||||
|
Clients review TASK DELIVERABLES.
|
||||||
|
|
||||||
|
Not abstract shots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. SHOT STATUS SHOULD BE DERIVED, NOT MANUALLY DRIVEN
|
||||||
|
|
||||||
|
Current shot statuses:
|
||||||
|
|
||||||
|
```plaintext id="5f9n1d"
|
||||||
|
WAITING
|
||||||
|
IN_PROGRESS
|
||||||
|
INTERNAL_REVIEW
|
||||||
|
CLIENT_REVIEW
|
||||||
|
REVISIONS
|
||||||
|
APPROVED
|
||||||
|
FINAL
|
||||||
|
```
|
||||||
|
|
||||||
|
This is creating confusion because:
|
||||||
|
|
||||||
|
* shots are containers
|
||||||
|
* tasks are actual workflow units
|
||||||
|
|
||||||
|
Refactor shot statuses to be:
|
||||||
|
|
||||||
|
* simplified
|
||||||
|
* mostly derived automatically from task states
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# NEW SHOT STATUS MODEL
|
||||||
|
|
||||||
|
Reduce shot statuses to:
|
||||||
|
|
||||||
|
```ts id="8km4hh"
|
||||||
|
WAITING
|
||||||
|
IN_PROGRESS
|
||||||
|
IN_REVIEW
|
||||||
|
REVISIONS
|
||||||
|
COMPLETE
|
||||||
|
```
|
||||||
|
|
||||||
|
Definitions:
|
||||||
|
|
||||||
|
## WAITING
|
||||||
|
|
||||||
|
No active tasks started.
|
||||||
|
|
||||||
|
## IN_PROGRESS
|
||||||
|
|
||||||
|
At least one task is:
|
||||||
|
|
||||||
|
* TODO
|
||||||
|
* IN_PROGRESS
|
||||||
|
|
||||||
|
## IN_REVIEW
|
||||||
|
|
||||||
|
At least one task is:
|
||||||
|
|
||||||
|
* INTERNAL_REVIEW
|
||||||
|
* CLIENT_REVIEW
|
||||||
|
|
||||||
|
## REVISIONS
|
||||||
|
|
||||||
|
At least one task is:
|
||||||
|
|
||||||
|
* CHANGES
|
||||||
|
|
||||||
|
## COMPLETE
|
||||||
|
|
||||||
|
All tasks are:
|
||||||
|
|
||||||
|
* DONE
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
Shot status should mostly be AUTO-CALCULATED from tasks.
|
||||||
|
|
||||||
|
NOT manually set constantly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. REMOVE "FINAL"
|
||||||
|
|
||||||
|
The current:
|
||||||
|
|
||||||
|
```plaintext id="qmv5ps"
|
||||||
|
APPROVED
|
||||||
|
FINAL
|
||||||
|
```
|
||||||
|
|
||||||
|
is redundant/confusing.
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
REMOVE `FINAL`.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```plaintext id="tyf56q"
|
||||||
|
DONE
|
||||||
|
```
|
||||||
|
|
||||||
|
at task level.
|
||||||
|
|
||||||
|
And:
|
||||||
|
|
||||||
|
```plaintext id="7u6wvp"
|
||||||
|
COMPLETE
|
||||||
|
```
|
||||||
|
|
||||||
|
at shot level.
|
||||||
|
|
||||||
|
Cleaner.
|
||||||
|
Much easier mentally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. TASK STATUS IS THE TRUE KANBAN STATUS
|
||||||
|
|
||||||
|
Task statuses should drive:
|
||||||
|
|
||||||
|
* kanban columns
|
||||||
|
* review state
|
||||||
|
* client review state
|
||||||
|
|
||||||
|
Task statuses:
|
||||||
|
|
||||||
|
```ts id="l0m39n"
|
||||||
|
TODO
|
||||||
|
IN_PROGRESS
|
||||||
|
INTERNAL_REVIEW
|
||||||
|
CLIENT_REVIEW
|
||||||
|
CHANGES
|
||||||
|
DONE
|
||||||
|
```
|
||||||
|
|
||||||
|
These statuses represent:
|
||||||
|
|
||||||
|
* actual workflow state
|
||||||
|
* actual review state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. REVIEW FLOW RULES
|
||||||
|
|
||||||
|
## Upload Version
|
||||||
|
|
||||||
|
When artist uploads new version:
|
||||||
|
|
||||||
|
```plaintext id="0q6qzx"
|
||||||
|
Task → INTERNAL_REVIEW
|
||||||
|
```
|
||||||
|
|
||||||
|
Kanban card moves automatically:
|
||||||
|
|
||||||
|
```plaintext id="ym9x1s"
|
||||||
|
IN_PROGRESS
|
||||||
|
→ INTERNAL_REVIEW
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supervisor Shares With Client
|
||||||
|
|
||||||
|
When clicking:
|
||||||
|
|
||||||
|
```plaintext id="rxry5f"
|
||||||
|
Share With Client
|
||||||
|
```
|
||||||
|
|
||||||
|
System should:
|
||||||
|
|
||||||
|
* mark latest version:
|
||||||
|
isClientVisible = true
|
||||||
|
* set task status:
|
||||||
|
CLIENT_REVIEW
|
||||||
|
|
||||||
|
Kanban updates automatically:
|
||||||
|
|
||||||
|
```plaintext id="7j69vz"
|
||||||
|
INTERNAL_REVIEW
|
||||||
|
→ CLIENT_REVIEW
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client Approves
|
||||||
|
|
||||||
|
Client approval should:
|
||||||
|
|
||||||
|
* approve ONLY the task
|
||||||
|
* NOT the entire shot
|
||||||
|
|
||||||
|
System:
|
||||||
|
|
||||||
|
```plaintext id="xcl9sy"
|
||||||
|
Task.status = DONE
|
||||||
|
Version.approvalStatus = APPROVED
|
||||||
|
```
|
||||||
|
|
||||||
|
Kanban:
|
||||||
|
|
||||||
|
```plaintext id="p1m31i"
|
||||||
|
CLIENT_REVIEW
|
||||||
|
→ DONE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client Requests Changes
|
||||||
|
|
||||||
|
System:
|
||||||
|
|
||||||
|
```plaintext id="z5fprm"
|
||||||
|
Task.status = CHANGES
|
||||||
|
Version.approvalStatus = NEEDS_CHANGES
|
||||||
|
```
|
||||||
|
|
||||||
|
Kanban:
|
||||||
|
|
||||||
|
```plaintext id="r6phfx"
|
||||||
|
CLIENT_REVIEW
|
||||||
|
→ CHANGES
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. SHOT COMPLETION RULE
|
||||||
|
|
||||||
|
A shot becomes:
|
||||||
|
|
||||||
|
```plaintext id="l16lq6"
|
||||||
|
COMPLETE
|
||||||
|
```
|
||||||
|
|
||||||
|
ONLY when:
|
||||||
|
|
||||||
|
```plaintext id="t62l6n"
|
||||||
|
ALL TASKS == DONE
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```plaintext id="0b2b7h"
|
||||||
|
SH010
|
||||||
|
├── Track → DONE
|
||||||
|
├── Roto → DONE
|
||||||
|
└── Comp → DONE
|
||||||
|
|
||||||
|
=> Shot COMPLETE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. CLIENT PORTAL STRUCTURE
|
||||||
|
|
||||||
|
Refactor client portal to show:
|
||||||
|
|
||||||
|
```plaintext id="t1qn79"
|
||||||
|
Project
|
||||||
|
├── Shot
|
||||||
|
│ ├── Task
|
||||||
|
│ │ └── Latest Shared Version
|
||||||
|
```
|
||||||
|
|
||||||
|
Example UI:
|
||||||
|
|
||||||
|
```plaintext id="mjgr1k"
|
||||||
|
SH010
|
||||||
|
COMP — v008
|
||||||
|
ROTO — v005
|
||||||
|
|
||||||
|
SH020
|
||||||
|
COMP — v003
|
||||||
|
```
|
||||||
|
|
||||||
|
This is MUCH clearer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. TASK DETAIL PAGE SHOULD BECOME PRIMARY REVIEW HUB
|
||||||
|
|
||||||
|
Task page should become:
|
||||||
|
|
||||||
|
* upload page
|
||||||
|
* review page
|
||||||
|
* approval page
|
||||||
|
* history page
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
|
||||||
|
```plaintext id="e2bd4f"
|
||||||
|
/tasks/[taskId]
|
||||||
|
```
|
||||||
|
|
||||||
|
Contains:
|
||||||
|
|
||||||
|
* task info
|
||||||
|
* latest version
|
||||||
|
* version history
|
||||||
|
* comments
|
||||||
|
* annotations
|
||||||
|
* approvals
|
||||||
|
* review actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. IMPORTANT IMPLEMENTATION NOTES
|
||||||
|
|
||||||
|
DO NOT:
|
||||||
|
|
||||||
|
* rewrite comment system
|
||||||
|
* rewrite annotation system
|
||||||
|
* rewrite player
|
||||||
|
|
||||||
|
JUST:
|
||||||
|
|
||||||
|
* move relationships from Shot → Task
|
||||||
|
* update logic
|
||||||
|
* update queries
|
||||||
|
* update review flow
|
||||||
|
|
||||||
|
This is mostly:
|
||||||
|
|
||||||
|
* schema cleanup
|
||||||
|
* workflow cleanup
|
||||||
|
* state cleanup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# IMPORTANT PRODUCT PRINCIPLE
|
||||||
|
|
||||||
|
The production hierarchy should now be:
|
||||||
|
|
||||||
|
```plaintext id="crkxfm"
|
||||||
|
Project
|
||||||
|
→ Shot
|
||||||
|
→ Task
|
||||||
|
→ Version
|
||||||
|
→ Comments / Annotations / Approvals
|
||||||
|
```
|
||||||
|
|
||||||
|
NOT:
|
||||||
|
|
||||||
|
```plaintext id="zebvlz"
|
||||||
|
Project
|
||||||
|
→ Shot
|
||||||
|
→ Version
|
||||||
|
```
|
||||||
|
|
||||||
|
That distinction fixes nearly all current workflow inconsistencies.
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
Add a lightweight scheduling system to the existing FeedBack production tracking platform.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
This is NOT enterprise scheduling.
|
||||||
|
Do NOT build Microsoft Project.
|
||||||
|
Do NOT build ShotGrid resource planning.
|
||||||
|
|
||||||
|
The goal is:
|
||||||
|
|
||||||
|
* simple visual scheduling
|
||||||
|
* artist workload visibility
|
||||||
|
* drag-and-drop planning
|
||||||
|
* lightweight production coordination
|
||||||
|
|
||||||
|
Think:
|
||||||
|
“Trello timeline view for VFX tasks.”
|
||||||
|
|
||||||
|
The complexity level should match the existing kanban implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# CORE GOAL
|
||||||
|
|
||||||
|
Allow producers/supervisors to:
|
||||||
|
|
||||||
|
* assign tasks to artists
|
||||||
|
* schedule when tasks should be worked on
|
||||||
|
* visualize artist workload
|
||||||
|
* see upcoming deadlines
|
||||||
|
* drag tasks around visually
|
||||||
|
|
||||||
|
The scheduling system should integrate directly with:
|
||||||
|
|
||||||
|
* existing Tasks
|
||||||
|
* existing Artists/Users
|
||||||
|
* existing Due Dates
|
||||||
|
* existing Kanban workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# IMPORTANT PRODUCT PRINCIPLE
|
||||||
|
|
||||||
|
Scheduling is:
|
||||||
|
|
||||||
|
* lightweight planning
|
||||||
|
* not time tracking
|
||||||
|
* not timesheets
|
||||||
|
* not attendance management
|
||||||
|
|
||||||
|
We are ONLY planning:
|
||||||
|
|
||||||
|
* who works on what
|
||||||
|
* when
|
||||||
|
* for roughly how long
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# NEW PAGE
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```plaintext id="9m0ynj"
|
||||||
|
/schedule
|
||||||
|
```
|
||||||
|
|
||||||
|
Add navigation item:
|
||||||
|
|
||||||
|
```plaintext id="9cxij0"
|
||||||
|
Schedule
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# SCHEDULING MODEL
|
||||||
|
|
||||||
|
Each task already has:
|
||||||
|
|
||||||
|
* assignedArtistId
|
||||||
|
* estimatedHours
|
||||||
|
* dueDate
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
* scheduledStartDate
|
||||||
|
* scheduledEndDate
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
* scheduleNotes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ARTIST WORKLOAD ASSUMPTION
|
||||||
|
|
||||||
|
Assume:
|
||||||
|
|
||||||
|
```plaintext id="sls7sz"
|
||||||
|
8 working hours per day
|
||||||
|
```
|
||||||
|
|
||||||
|
Use estimatedHours to determine task duration visually.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```plaintext id="1o9r76"
|
||||||
|
16 estimated hours
|
||||||
|
= 2 scheduled work days
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep calculations simple.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# MAIN UI
|
||||||
|
|
||||||
|
The scheduling page should be a hybrid:
|
||||||
|
|
||||||
|
* kanban
|
||||||
|
* timeline/calendar
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```plaintext id="8fys2r"
|
||||||
|
Artists vertically
|
||||||
|
Dates horizontally
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```plaintext id="ltm7d0"
|
||||||
|
Mon Tue Wed Thu Fri
|
||||||
|
|
||||||
|
Chris [SH010 Comp------]
|
||||||
|
|
||||||
|
Sarah [SH020 Roto--]
|
||||||
|
|
||||||
|
Mike [Asset Car Model----]
|
||||||
|
```
|
||||||
|
|
||||||
|
This should feel:
|
||||||
|
|
||||||
|
* visual
|
||||||
|
* fast
|
||||||
|
* draggable
|
||||||
|
* uncluttered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK CARDS
|
||||||
|
|
||||||
|
Each scheduled task block should show:
|
||||||
|
|
||||||
|
* shot/asset code
|
||||||
|
* task type
|
||||||
|
* task status
|
||||||
|
* due date
|
||||||
|
* estimated hours
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
* latest version indicator
|
||||||
|
* review status badge
|
||||||
|
|
||||||
|
Color coding:
|
||||||
|
|
||||||
|
* TODO
|
||||||
|
* IN_PROGRESS
|
||||||
|
* INTERNAL_REVIEW
|
||||||
|
* CLIENT_REVIEW
|
||||||
|
* CHANGES
|
||||||
|
* DONE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DRAG AND DROP
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
* dnd-kit
|
||||||
|
|
||||||
|
Allow:
|
||||||
|
|
||||||
|
* drag tasks between artists
|
||||||
|
* resize tasks across days
|
||||||
|
* move tasks along timeline
|
||||||
|
|
||||||
|
When dragged:
|
||||||
|
|
||||||
|
* update scheduledStartDate
|
||||||
|
* update scheduledEndDate
|
||||||
|
* update assignedArtistId if moved to another artist row
|
||||||
|
|
||||||
|
Use optimistic updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# SCHEDULING RULES
|
||||||
|
|
||||||
|
Simple rules only.
|
||||||
|
|
||||||
|
## WARNING STATES
|
||||||
|
|
||||||
|
Highlight:
|
||||||
|
|
||||||
|
* overdue tasks
|
||||||
|
* tasks ending after due date
|
||||||
|
* artist overload
|
||||||
|
|
||||||
|
Example overload:
|
||||||
|
|
||||||
|
```plaintext id="qff2t0"
|
||||||
|
Artist scheduled > 8h/day
|
||||||
|
```
|
||||||
|
|
||||||
|
Use visual indicators only.
|
||||||
|
DO NOT block scheduling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# FILTERS
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
* project filter
|
||||||
|
* artist filter
|
||||||
|
* task status filter
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
* department filter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK SOURCE
|
||||||
|
|
||||||
|
The schedule should ONLY display:
|
||||||
|
|
||||||
|
* tasks with assigned artists
|
||||||
|
OR
|
||||||
|
* tasks with scheduled dates
|
||||||
|
|
||||||
|
Unscheduled tasks remain in kanban/backlog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# BACKLOG PANEL
|
||||||
|
|
||||||
|
Add optional side panel:
|
||||||
|
|
||||||
|
```plaintext id="o0lm45"
|
||||||
|
Unscheduled Tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
Allow dragging tasks:
|
||||||
|
|
||||||
|
* from backlog
|
||||||
|
* onto artist schedule
|
||||||
|
|
||||||
|
This is VERY useful.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# REVIEW INTEGRATION
|
||||||
|
|
||||||
|
Scheduling should integrate with review flow.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```plaintext id="hr4lxg"
|
||||||
|
Task enters CLIENT_REVIEW
|
||||||
|
→ visually marked on schedule
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```plaintext id="4f8pl5"
|
||||||
|
Task enters CHANGES
|
||||||
|
→ warning badge appears
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates production awareness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# KANBAN INTEGRATION
|
||||||
|
|
||||||
|
The schedule and kanban should share:
|
||||||
|
|
||||||
|
* same task objects
|
||||||
|
* same statuses
|
||||||
|
* same assignments
|
||||||
|
|
||||||
|
Changing status in:
|
||||||
|
|
||||||
|
* kanban
|
||||||
|
* task page
|
||||||
|
* review flow
|
||||||
|
|
||||||
|
should instantly reflect in schedule.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```plaintext id="rmy3ud"
|
||||||
|
CLIENT_REVIEW
|
||||||
|
→ DONE
|
||||||
|
```
|
||||||
|
|
||||||
|
Task visually updates in schedule immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DASHBOARD WIDGETS
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
* Artist Utilization
|
||||||
|
* Tasks Due This Week
|
||||||
|
* Overloaded Artists
|
||||||
|
* Upcoming Reviews
|
||||||
|
|
||||||
|
Simple summaries only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI REQUIREMENTS
|
||||||
|
|
||||||
|
The scheduler should feel:
|
||||||
|
|
||||||
|
* modern
|
||||||
|
* clean
|
||||||
|
* cinematic
|
||||||
|
* dark mode first
|
||||||
|
|
||||||
|
Inspiration:
|
||||||
|
|
||||||
|
* Linear roadmap
|
||||||
|
* Trello timeline
|
||||||
|
* Notion timeline
|
||||||
|
* Monday.com timeline
|
||||||
|
|
||||||
|
BUT:
|
||||||
|
simpler and faster.
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
* giant grids
|
||||||
|
* enterprise clutter
|
||||||
|
* complex dependencies
|
||||||
|
* gantt-chart nightmares
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# IMPLEMENTATION PLAN
|
||||||
|
|
||||||
|
## PHASE 1 — DATABASE
|
||||||
|
|
||||||
|
1. Add scheduling fields to Task
|
||||||
|
2. Add indexes for schedule queries
|
||||||
|
3. Add task duration helpers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 2 — BASIC TIMELINE
|
||||||
|
|
||||||
|
1. Build schedule page
|
||||||
|
2. Artist rows
|
||||||
|
3. Date columns
|
||||||
|
4. Render scheduled tasks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 3 — DRAG/DROP
|
||||||
|
|
||||||
|
1. Move tasks
|
||||||
|
2. Resize durations
|
||||||
|
3. Reassign artists
|
||||||
|
4. Optimistic updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 4 — BACKLOG
|
||||||
|
|
||||||
|
1. Unscheduled task panel
|
||||||
|
2. Drag from backlog to schedule
|
||||||
|
3. Quick scheduling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 5 — POLISH
|
||||||
|
|
||||||
|
1. Filters
|
||||||
|
2. Overload indicators
|
||||||
|
3. Review status badges
|
||||||
|
4. Utilization summaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# IMPORTANT
|
||||||
|
|
||||||
|
Keep this system intentionally lightweight.
|
||||||
|
|
||||||
|
The ideal workflow should feel like:
|
||||||
|
|
||||||
|
```plaintext id="xmv8lx"
|
||||||
|
Create task
|
||||||
|
→ Assign artist
|
||||||
|
→ Drag onto schedule
|
||||||
|
→ Artist uploads version
|
||||||
|
→ Review
|
||||||
|
→ Approve
|
||||||
|
```
|
||||||
|
|
||||||
|
That simplicity is the product advantage.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# FeedBack — VFX Review & Approval Platform
|
||||||
|
|
||||||
|
Frame-accurate video review, annotation drawing, and approval tracking for boutique VFX and animation studios.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Dead-simple video upload** — drag & drop MP4/MOV, up to 2 GB, version-tracked
|
||||||
|
- **Frame-accurate scrubbing** — canvas-based timeline ruler with click-and-drag seek
|
||||||
|
- **Timestamp comments** — comments anchored to exact frame numbers, threaded replies, resolve/unresolve
|
||||||
|
- **Annotation drawing** — freehand, arrow, rectangle, circle tools with color + stroke width picker
|
||||||
|
- **Approval workflow** — Approve / Reject / Needs Changes with notes, full history
|
||||||
|
- **Client review links** — shareable token-based review URLs for external clients (no login required)
|
||||||
|
- **Role-based access** — Admin / Producer / Supervisor / Artist / Client roles
|
||||||
|
- **Slack notifications** — webhook alerts for uploads and approvals
|
||||||
|
- **Self-hostable** — Docker Compose with PostgreSQL + MinIO (S3-compatible) included
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Framework | Next.js 15 App Router (Turbopack) |
|
||||||
|
| Language | TypeScript 5 strict |
|
||||||
|
| Styling | TailwindCSS 3 + shadcn/ui (zinc, dark-only) |
|
||||||
|
| Auth | NextAuth.js v5 (Credentials + JWT) |
|
||||||
|
| Database | PostgreSQL 16 via Prisma 6 |
|
||||||
|
| File Storage | UploadThing / S3 / R2 / B2 / MinIO / local |
|
||||||
|
| State | Zustand 5 (player) + TanStack Query 5 (server) |
|
||||||
|
| Annotations | HTML5 Canvas API (normalized coordinates) |
|
||||||
|
| Video Playback | HTML5 `<video>` with custom controls |
|
||||||
|
| Container | Docker multi-stage build |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and configure
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env — set AUTH_SECRET, DATABASE_URL, storage provider
|
||||||
|
|
||||||
|
# 2. Start services (PostgreSQL + MinIO + App)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 3. Run migrations and seed
|
||||||
|
docker compose exec app npx prisma migrate deploy
|
||||||
|
docker compose exec app npx tsx prisma/seed.ts
|
||||||
|
|
||||||
|
# 4. Open http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Demo accounts (after seed):**
|
||||||
|
|
||||||
|
| Email | Password | Role |
|
||||||
|
|-------|----------|------|
|
||||||
|
| admin@vfxreview.local | admin123 | Admin |
|
||||||
|
| producer@vfxreview.local | producer123 | Producer |
|
||||||
|
| supervisor@vfxreview.local | supervisor123 | Supervisor |
|
||||||
|
| artist@vfxreview.local | artist123 | Artist |
|
||||||
|
| client@studio.com | client123 | Client |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Set up database (Postgres must be running)
|
||||||
|
npx prisma migrate dev
|
||||||
|
npx tsx prisma/seed.ts
|
||||||
|
|
||||||
|
# Start dev server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
See `.env.example` for full reference. Key variables:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Required
|
||||||
|
DATABASE_URL="postgresql://..."
|
||||||
|
AUTH_SECRET="your-32-char-secret"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Storage (choose one)
|
||||||
|
STORAGE_PROVIDER="local" # local | uploadthing | s3 | r2 | b2 | minio
|
||||||
|
|
||||||
|
# UploadThing (if STORAGE_PROVIDER=uploadthing)
|
||||||
|
UPLOADTHING_SECRET="sk_live_..."
|
||||||
|
UPLOADTHING_APP_ID="..."
|
||||||
|
|
||||||
|
# AWS S3 / R2 / B2 compatible
|
||||||
|
S3_ACCESS_KEY_ID=""
|
||||||
|
S3_SECRET_ACCESS_KEY=""
|
||||||
|
S3_BUCKET_NAME=""
|
||||||
|
S3_REGION=""
|
||||||
|
S3_ENDPOINT="" # for R2/B2/MinIO
|
||||||
|
|
||||||
|
# Slack (optional)
|
||||||
|
SLACK_BOT_TOKEN=""
|
||||||
|
|
||||||
|
# Email (optional, nodemailer)
|
||||||
|
SMTP_HOST=""
|
||||||
|
SMTP_PORT="587"
|
||||||
|
SMTP_USER=""
|
||||||
|
SMTP_PASS=""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
(auth)/login/ — Sign-in page
|
||||||
|
(dashboard)/ — Protected dashboard layout
|
||||||
|
dashboard/ — Home stats + shot queue
|
||||||
|
projects/ — Project list + detail pages
|
||||||
|
settings/ — User profile
|
||||||
|
review/[versionId]/ — Full-screen review player
|
||||||
|
client/[token]/ — Unauthenticated client review link
|
||||||
|
api/
|
||||||
|
auth/[...nextauth]/ — NextAuth route handler
|
||||||
|
uploadthing/ — UploadThing route handler
|
||||||
|
notifications/ — Notification CRUD
|
||||||
|
projects/ — Projects REST endpoint
|
||||||
|
|
||||||
|
components/
|
||||||
|
player/ — ReviewPlayer, FrameTimeline, PlaybackControls
|
||||||
|
annotations/ — AnnotationCanvas, AnnotationTools
|
||||||
|
comments/ — CommentPanel (threads + replies)
|
||||||
|
versions/ — VersionUpload, VersionList
|
||||||
|
shots/ — ShotCard, NewShotDialog
|
||||||
|
projects/ — ProjectCard, NewProjectDialog
|
||||||
|
dashboard/ — StatsCards, ShotQueue, RecentActivity
|
||||||
|
layout/ — Sidebar, Header, NotificationBell, Providers
|
||||||
|
ui/ — shadcn/ui base components
|
||||||
|
|
||||||
|
actions/ — Server Actions (projects, shots, versions, comments, annotations, approvals)
|
||||||
|
lib/ — db, auth, utils, frame-utils, storage, slack, notifications, uploadthing
|
||||||
|
hooks/ — use-review-player (Zustand), use-annotations, use-frame-comments
|
||||||
|
prisma/ — schema.prisma, seed.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review Player Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `Space` / `K` | Play / Pause |
|
||||||
|
| `J` | Reverse playback |
|
||||||
|
| `L` | Forward playback |
|
||||||
|
| `←` / `→` | Step one frame |
|
||||||
|
| `F` | Toggle fullscreen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
+378
@@ -0,0 +1,378 @@
|
|||||||
|
# xBid Design System & Style Guide
|
||||||
|
|
||||||
|
A reference for the visual language, component patterns, and design principles used in xBid so that future apps can maintain the same look and feel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Core Principles
|
||||||
|
|
||||||
|
- **Dark-first**: The app is permanently dark mode (`className="dark"` on `<html>`). There is no light/dark toggle.
|
||||||
|
- **Minimal chrome**: Surfaces are dark zinc grays with amber as the single accent colour. No gradients, no heavy decoration.
|
||||||
|
- **Dense but readable**: Data-heavy UI uses compact sizing (`text-xs`, `text-sm`) with generous whitespace in page-level padding.
|
||||||
|
- **Amber accent, zinc foundation**: Brand colour is amber (`#f59e0b` / `amber-500`). All structural surfaces use the zinc palette.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Typography
|
||||||
|
|
||||||
|
### Font Family
|
||||||
|
- **Primary (body & UI)**: **Inter** — loaded via `next/font/google`, subset `latin`.
|
||||||
|
- **Mono**: Geist Mono (via `--font-geist-mono` CSS variable from shadcn).
|
||||||
|
- Applied globally: `font-sans` on `<html>`.
|
||||||
|
|
||||||
|
### Scale (Tailwind defaults, used in practice)
|
||||||
|
|
||||||
|
| Usage | Class(es) | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Page title | `text-3xl font-bold text-white` | e.g. Dashboard h1 |
|
||||||
|
| Section title | `text-xl font-semibold text-white` | e.g. "Recent Jobs" h2 |
|
||||||
|
| Detail page title | `text-xl font-bold text-white` | Truncated with `truncate` |
|
||||||
|
| Card title | `text-base font-medium` (via `CardTitle`) | |
|
||||||
|
| Body / default | `text-sm` | |
|
||||||
|
| Captions / meta | `text-sm text-zinc-400` or `text-zinc-500` | |
|
||||||
|
| Table headers | `text-xs uppercase tracking-wider text-zinc-500` | |
|
||||||
|
| Table cells | `text-xs` or `text-sm` | |
|
||||||
|
| Error messages | `text-xs text-red-400` | Form validation |
|
||||||
|
| Stat value | `text-3xl font-bold text-white` | Dashboard stat cards |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Colour Palette
|
||||||
|
|
||||||
|
The app uses **Tailwind CSS v4 with OKLCH CSS variables**. All semantic tokens map to zinc grays in dark mode.
|
||||||
|
|
||||||
|
### Brand / Accent
|
||||||
|
|
||||||
|
| Token | Tailwind class | Hex approx | Usage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Brand amber | `amber-500` | `#f59e0b` | Logo bg, icon colour |
|
||||||
|
| Active nav highlight | `amber-500/10` bg + `amber-400` text | — | Active nav item |
|
||||||
|
| Amber text | `text-amber-400` | `#fbbf24` | Active nav, stat icons |
|
||||||
|
| Amber text bold | `text-amber-500` | `#f59e0b` | Logo "Bid" wordmark |
|
||||||
|
|
||||||
|
### Surface / Layout
|
||||||
|
|
||||||
|
| Purpose | Tailwind class | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Page background | `bg-zinc-950` | Top bar, outermost bg |
|
||||||
|
| Sidebar & card bg | `bg-zinc-900` | Primary surface |
|
||||||
|
| Hover / secondary surface | `bg-zinc-800` | Hover states, secondary card |
|
||||||
|
| Input fields | `bg-zinc-900 border-zinc-700` | Form inputs override |
|
||||||
|
| Borders | `border-zinc-800` | Dividers, card outlines |
|
||||||
|
| Input borders | `border-zinc-700` | Slightly lighter than surface borders |
|
||||||
|
|
||||||
|
### Text
|
||||||
|
|
||||||
|
| Purpose | Tailwind class | Hex approx |
|
||||||
|
|---|---|---|
|
||||||
|
| Primary / headings | `text-white` | `#ffffff` |
|
||||||
|
| Secondary / labels | `text-zinc-400` | `#a1a1aa` |
|
||||||
|
| Tertiary / placeholders | `text-zinc-500` | `#71717a` |
|
||||||
|
| Disabled / subtle | `text-zinc-600` | `#52525b` |
|
||||||
|
| Bright secondary | `text-zinc-300` | `#d4d4d8` |
|
||||||
|
|
||||||
|
### Semantic / Status Badges
|
||||||
|
|
||||||
|
These appear on `Job` and `Invoice` status badges. Pattern: coloured bg at low opacity + matching light text.
|
||||||
|
|
||||||
|
| Status | Classes |
|
||||||
|
|---|---|
|
||||||
|
| DRAFT | `bg-zinc-700 text-zinc-300` |
|
||||||
|
| SENT | `bg-blue-900/60 text-blue-300` |
|
||||||
|
| APPROVED / PAID | `bg-green-900/60 text-green-300` |
|
||||||
|
| REJECTED / OVERDUE | `bg-red-900/60 text-red-300` |
|
||||||
|
| ARCHIVED | `bg-yellow-900/60 text-yellow-300` |
|
||||||
|
| VIEWED | `bg-purple-900/60 text-purple-300` |
|
||||||
|
| PARTIALLY_PAID | `bg-orange-900/60 text-orange-300` |
|
||||||
|
| CANCELLED | `bg-zinc-800 text-zinc-500` |
|
||||||
|
|
||||||
|
### Icon Colours (Dashboard Stats)
|
||||||
|
|
||||||
|
| Metric | Icon colour |
|
||||||
|
|---|---|
|
||||||
|
| Jobs / Total | `text-amber-400` |
|
||||||
|
| Clients | `text-blue-400` |
|
||||||
|
| Drafts | `text-zinc-400` |
|
||||||
|
| Sent / Pending | `text-yellow-400` |
|
||||||
|
| Approved | `text-green-400` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Spacing & Layout
|
||||||
|
|
||||||
|
### App Shell
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Sidebar (w-60 / w-16 collapsed) │ Main content (flex-1 overflow-y-auto)
|
||||||
|
│ bg-zinc-900 │ p-8 on page content
|
||||||
|
│ border-r border-zinc-800 │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Root: `flex h-full overflow-hidden`
|
||||||
|
- Sidebar: `flex flex-col bg-zinc-900 border-r border-zinc-800 transition-all duration-200`
|
||||||
|
- Main: `flex-1 overflow-y-auto`
|
||||||
|
- Page padding: `p-8`
|
||||||
|
|
||||||
|
### Page Header pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white">Page Title</h1>
|
||||||
|
<p className="text-zinc-400 mt-1">Subtitle / description.</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detail Page Top Bar
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="border-b border-zinc-800 px-8 py-4 flex items-center gap-4 bg-zinc-950">
|
||||||
|
{/* back arrow + title + meta + actions */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid layouts
|
||||||
|
|
||||||
|
| Pattern | Class |
|
||||||
|
|---|---|
|
||||||
|
| Stat cards | `grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5` |
|
||||||
|
| Form fields | `grid grid-cols-1 sm:grid-cols-2 gap-5` |
|
||||||
|
| Form field spacing | `space-y-1.5` (label + input) |
|
||||||
|
| Form section spacing | `space-y-6` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Border Radius
|
||||||
|
|
||||||
|
Defined by CSS variable `--radius: 0.625rem` (10px).
|
||||||
|
|
||||||
|
| Token | Size | Tailwind class |
|
||||||
|
|---|---|---|
|
||||||
|
| sm | ~6px | `rounded-sm` / `rounded` |
|
||||||
|
| md | ~8px | `rounded-md` |
|
||||||
|
| lg | 10px (base) | `rounded-lg` |
|
||||||
|
| xl | ~14px | `rounded-xl` |
|
||||||
|
| 4xl (badges) | ~26px | `rounded-4xl` |
|
||||||
|
|
||||||
|
**In practice**: `rounded-lg` is the default for buttons, inputs, cards, nav items. Cards use `rounded-xl`. Badges use `rounded-4xl` (pill shape).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Component Patterns
|
||||||
|
|
||||||
|
### Sidebar Navigation
|
||||||
|
|
||||||
|
- Container: `bg-zinc-900 border-r border-zinc-800`
|
||||||
|
- Logo area: `px-4 py-5 border-b border-zinc-800`
|
||||||
|
- Icon: `w-8 h-8 rounded-lg bg-amber-500 flex items-center justify-center` with black icon inside
|
||||||
|
- Wordmark: `font-bold text-lg tracking-tight text-white` — "x" white, "Bid" amber
|
||||||
|
- Nav item inactive: `text-zinc-400 hover:text-white hover:bg-zinc-800 px-3 py-2.5 rounded-lg text-sm font-medium`
|
||||||
|
- Nav item active: `bg-amber-500/10 text-amber-400`
|
||||||
|
- Icon size: `h-4 w-4`
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="bg-zinc-900 border-zinc-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>…</CardTitle>
|
||||||
|
<CardDescription>…</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>…</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Card has `ring-1 ring-foreground/10` and `rounded-xl` by default via shadcn base-nova.
|
||||||
|
- Override bg/border on dark surfaces: `bg-zinc-900 border-zinc-800`.
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
Variants from shadcn `buttonVariants`:
|
||||||
|
|
||||||
|
| Variant | Appearance |
|
||||||
|
|---|---|
|
||||||
|
| `default` | Dark bg (`primary`), light text — effectively `bg-zinc-900 text-white` in dark mode |
|
||||||
|
| `outline` | Border, transparent bg, hover muted |
|
||||||
|
| `secondary` | Muted bg |
|
||||||
|
| `ghost` | No border, hover muted |
|
||||||
|
| `destructive` | Red tinted bg, red text |
|
||||||
|
| `link` | Underline on hover |
|
||||||
|
|
||||||
|
Sizes: `xs`, `sm`, `default` (h-8), `lg`, `icon`, `icon-xs`, `icon-sm`, `icon-lg`.
|
||||||
|
|
||||||
|
### Badges / Status Pills
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge className="bg-green-900/60 text-green-300">Approved</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Custom status colours applied via `className` override (see §3 Semantic/Status Badges).
|
||||||
|
- Default shape: pill (`rounded-4xl`), `h-5`, `text-xs`.
|
||||||
|
|
||||||
|
### Form Fields
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Field Label *</Label>
|
||||||
|
<Input className="bg-zinc-900 border-zinc-700" placeholder="…" />
|
||||||
|
{error && <p className="text-red-400 text-xs">{error.message}</p>}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Input base: `h-8 rounded-lg border border-input bg-transparent` — always override with `bg-zinc-900 border-zinc-700` for dark surface visibility.
|
||||||
|
- Textarea, Select follow the same pattern.
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
Line-item tables are custom `<table>` elements styled with:
|
||||||
|
- `text-xs w-full`
|
||||||
|
- Header row: `border-b border-zinc-800 text-zinc-500 uppercase tracking-wider`
|
||||||
|
- Cells: `px-4 py-2 text-left`
|
||||||
|
- Alternating/hover rows: use `hover:bg-zinc-800/50`
|
||||||
|
|
||||||
|
shadcn `<Table>` component uses `text-sm` and standard `border-b` between rows.
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tabs defaultValue="…">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="…">Label</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="…">…</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `TabsList` bg: `bg-muted` (zinc-800 in dark), `rounded-lg`.
|
||||||
|
- `TabsTrigger` active: white text, slight bg lift.
|
||||||
|
|
||||||
|
### Dialogs / Sheets
|
||||||
|
|
||||||
|
Standard shadcn `Dialog` / `Sheet`. Content uses `bg-zinc-900` / card surface. Destructive actions use red confirm buttons.
|
||||||
|
|
||||||
|
### Toasts / Notifications
|
||||||
|
|
||||||
|
- Library: **Sonner** (`<Toaster richColors position="top-right" />`)
|
||||||
|
- `toast.success(…)` — green
|
||||||
|
- `toast.error(…)` — red
|
||||||
|
- Always called after async actions complete.
|
||||||
|
|
||||||
|
### Dropdown Menus
|
||||||
|
|
||||||
|
Standard shadcn `DropdownMenu`. Destructive actions get `text-destructive` styling on the `DropdownMenuItem`.
|
||||||
|
|
||||||
|
### Collapsible / Expandable Rows
|
||||||
|
|
||||||
|
Shot and asset cards use a chevron toggle (`ChevronDown` / `ChevronRight`) to expand inline line-item tables. Pattern:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button onClick={() => setExpanded(!expanded)}>
|
||||||
|
{expanded ? <ChevronDown /> : <ChevronRight />}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Icons
|
||||||
|
|
||||||
|
Library: **Lucide React** (`lucide-react`)
|
||||||
|
|
||||||
|
Standard icon size: `h-4 w-4` (16px). Larger decorative: `h-5 w-5`.
|
||||||
|
|
||||||
|
Common icons used:
|
||||||
|
|
||||||
|
| Purpose | Icon |
|
||||||
|
|---|---|
|
||||||
|
| Dashboard | `LayoutDashboard` |
|
||||||
|
| Clients | `Users` |
|
||||||
|
| Jobs / Quotes | `Briefcase` |
|
||||||
|
| Invoices | `FileText` |
|
||||||
|
| Rate Cards | `CreditCard` |
|
||||||
|
| Settings | `Settings` |
|
||||||
|
| Back | `ArrowLeft` |
|
||||||
|
| Download PDF | `Download` |
|
||||||
|
| Duplicate | `Copy` |
|
||||||
|
| Delete | `Trash2` |
|
||||||
|
| Edit | `Pencil` |
|
||||||
|
| Add | `Plus` |
|
||||||
|
| More options | `MoreHorizontal` |
|
||||||
|
| Search | `Search` |
|
||||||
|
| Loading | `Loader2` (with `animate-spin`) |
|
||||||
|
| Drag handle | `GripVertical` |
|
||||||
|
| App logo | `Film` |
|
||||||
|
| Send | `Send` |
|
||||||
|
| Approve / Mark paid | `Check` / `CheckCircle` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Motion & Transitions
|
||||||
|
|
||||||
|
- Sidebar collapse: `transition-all duration-200`
|
||||||
|
- Nav item colour: `transition-colors`
|
||||||
|
- Buttons: `transition-all`
|
||||||
|
- Drag-and-drop (DnD Kit): opacity `0.5` while dragging.
|
||||||
|
- Animation library: `tw-animate-css` (imported in globals.css).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Framework | Next.js 15 (App Router, React Server Components) |
|
||||||
|
| Styling | Tailwind CSS v4 |
|
||||||
|
| Component system | shadcn/ui (`base-nova` style) + `@base-ui/react` primitives |
|
||||||
|
| Icon library | Lucide React |
|
||||||
|
| Font | Inter (Google Fonts via `next/font`) |
|
||||||
|
| Notifications | Sonner |
|
||||||
|
| Forms | React Hook Form + Zod |
|
||||||
|
| Drag & Drop | `@dnd-kit/core` + `@dnd-kit/sortable` |
|
||||||
|
| ORM | Prisma |
|
||||||
|
| DB | PostgreSQL |
|
||||||
|
| Color space | OKLCH (CSS variables) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Quick Reference — Recurring Class Combinations
|
||||||
|
|
||||||
|
```
|
||||||
|
# Page wrapper
|
||||||
|
p-8
|
||||||
|
|
||||||
|
# Page title
|
||||||
|
text-3xl font-bold text-white
|
||||||
|
|
||||||
|
# Page subtitle
|
||||||
|
text-zinc-400 mt-1
|
||||||
|
|
||||||
|
# Section heading
|
||||||
|
text-xl font-semibold text-white mb-4
|
||||||
|
|
||||||
|
# Top bar (detail pages)
|
||||||
|
border-b border-zinc-800 px-8 py-4 flex items-center gap-4 bg-zinc-950
|
||||||
|
|
||||||
|
# Card (dark surface)
|
||||||
|
bg-zinc-900 border-zinc-800
|
||||||
|
|
||||||
|
# Card text hierarchy
|
||||||
|
text-white (value) | text-zinc-400 (label) | text-zinc-500 (meta)
|
||||||
|
|
||||||
|
# Form input (dark)
|
||||||
|
bg-zinc-900 border-zinc-700
|
||||||
|
|
||||||
|
# Form error
|
||||||
|
text-red-400 text-xs
|
||||||
|
|
||||||
|
# Table header
|
||||||
|
border-b border-zinc-800 text-zinc-500 uppercase tracking-wider text-xs
|
||||||
|
|
||||||
|
# Empty state text
|
||||||
|
text-center text-sm text-zinc-500
|
||||||
|
|
||||||
|
# Active nav
|
||||||
|
bg-amber-500/10 text-amber-400
|
||||||
|
|
||||||
|
# Inactive nav
|
||||||
|
text-zinc-400 hover:text-white hover:bg-zinc-800
|
||||||
|
```
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { AnnotationDrawingData } from "@/types";
|
||||||
|
|
||||||
|
const saveAnnotationSchema = z.object({
|
||||||
|
versionId: z.string().cuid(),
|
||||||
|
commentId: z.string().cuid().optional(),
|
||||||
|
frameNumber: z.number().int().min(0),
|
||||||
|
drawingData: z.object({
|
||||||
|
shapes: z.array(z.any()),
|
||||||
|
canvasWidth: z.number(),
|
||||||
|
canvasHeight: z.number(),
|
||||||
|
version: z.literal("1.0"),
|
||||||
|
}),
|
||||||
|
color: z.string().default("#ef4444"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function saveAnnotation(data: z.infer<typeof saveAnnotationSchema>) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const parsed = saveAnnotationSchema.parse(data);
|
||||||
|
|
||||||
|
const annotation = await db.annotation.create({
|
||||||
|
data: {
|
||||||
|
versionId: parsed.versionId,
|
||||||
|
commentId: parsed.commentId,
|
||||||
|
authorId: session.user.id,
|
||||||
|
frameNumber: parsed.frameNumber,
|
||||||
|
drawingData: parsed.drawingData as any,
|
||||||
|
color: parsed.color,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/review/${parsed.versionId}`);
|
||||||
|
return { success: true, annotation };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnnotationsForVersion(versionId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.annotation.findMany({
|
||||||
|
where: { versionId, isVisible: true },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
},
|
||||||
|
orderBy: { frameNumber: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnnotationsForFrame(
|
||||||
|
versionId: string,
|
||||||
|
frameNumber: number
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.annotation.findMany({
|
||||||
|
where: { versionId, frameNumber, isVisible: true },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleAnnotationVisibility(
|
||||||
|
annotationId: string,
|
||||||
|
visible: boolean
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
await db.annotation.update({
|
||||||
|
where: { id: annotationId },
|
||||||
|
data: { isVisible: visible },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAnnotation(annotationId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const annotation = await db.annotation.findUnique({
|
||||||
|
where: { id: annotationId },
|
||||||
|
});
|
||||||
|
if (!annotation) throw new Error("Annotation not found");
|
||||||
|
|
||||||
|
if (annotation.authorId !== session.user.id && session.user.role !== "ADMIN") {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.annotation.delete({ where: { id: annotationId } });
|
||||||
|
revalidatePath(`/review/${annotation.versionId}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ApprovalStatus } from "@prisma/client";
|
||||||
|
import { recalcShotStatus } from "@/lib/shot-status";
|
||||||
|
import { notifyApprovalChange } from "@/lib/notifications";
|
||||||
|
import { slackNotifyApproval } from "@/lib/slack";
|
||||||
|
import { versionLabel } from "@/lib/utils";
|
||||||
|
|
||||||
|
const approvalSchema = z.object({
|
||||||
|
versionId: z.string().cuid(),
|
||||||
|
status: z.nativeEnum(ApprovalStatus),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function submitApproval(data: z.infer<typeof approvalSchema>) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const parsed = approvalSchema.parse(data);
|
||||||
|
|
||||||
|
// Only supervisors, producers, admins, and clients can approve
|
||||||
|
const allowedRoles = ["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"];
|
||||||
|
if (!allowedRoles.includes(session.user.role)) {
|
||||||
|
throw new Error("Insufficient permissions to review versions");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the approval record
|
||||||
|
const approval = await db.approval.create({
|
||||||
|
data: {
|
||||||
|
versionId: parsed.versionId,
|
||||||
|
userId: session.user.id,
|
||||||
|
status: parsed.status,
|
||||||
|
notes: parsed.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update version approval status
|
||||||
|
await db.version.update({
|
||||||
|
where: { id: parsed.versionId },
|
||||||
|
data: { approvalStatus: parsed.status },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load version + task + shot + project for downstream effects
|
||||||
|
const version = await db.version.findUnique({
|
||||||
|
where: { id: parsed.versionId },
|
||||||
|
include: {
|
||||||
|
task: {
|
||||||
|
include: {
|
||||||
|
shot: true,
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!version) throw new Error("Version not found");
|
||||||
|
|
||||||
|
// Update task status based on approval result
|
||||||
|
if (version.task && parsed.status !== "PENDING_REVIEW") {
|
||||||
|
if (parsed.status === "APPROVED") {
|
||||||
|
await db.task.update({
|
||||||
|
where: { id: version.task.id },
|
||||||
|
data: { status: "DONE" },
|
||||||
|
});
|
||||||
|
} else if (parsed.status === "REJECTED" || parsed.status === "NEEDS_CHANGES") {
|
||||||
|
await db.task.update({
|
||||||
|
where: { id: version.task.id },
|
||||||
|
data: { status: "CHANGES" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate shot status from updated task states
|
||||||
|
if (version.task.shot) {
|
||||||
|
await recalcShotStatus(version.task.shot.id).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
const reviewer = await db.user.findUnique({ where: { id: session.user.id } });
|
||||||
|
const reviewerName = reviewer?.name ?? "Reviewer";
|
||||||
|
|
||||||
|
if (version.task && parsed.status !== "PENDING_REVIEW") {
|
||||||
|
const contextCode = version.task.shot?.shotCode ?? null;
|
||||||
|
await notifyApprovalChange({
|
||||||
|
artistId: version.task.assignedArtistId ?? version.artistId,
|
||||||
|
shotCode: contextCode ?? version.task.title,
|
||||||
|
versionId: parsed.versionId,
|
||||||
|
status: parsed.status as "APPROVED" | "REJECTED" | "NEEDS_CHANGES",
|
||||||
|
reviewerName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Slack
|
||||||
|
if (version.task.project.slackWebhook) {
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||||
|
await slackNotifyApproval(version.task.project.slackWebhook, {
|
||||||
|
shotCode: contextCode ?? version.task.title,
|
||||||
|
versionLabel: versionLabel(version.versionNumber),
|
||||||
|
status: parsed.status as "APPROVED" | "REJECTED" | "NEEDS_CHANGES",
|
||||||
|
reviewerName,
|
||||||
|
projectName: version.task.project.name,
|
||||||
|
reviewUrl: `${appUrl}/review/${parsed.versionId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/review/${parsed.versionId}`);
|
||||||
|
if (version.task) {
|
||||||
|
revalidatePath(`/tasks/${version.task.id}`);
|
||||||
|
revalidatePath(`/projects/${version.task.projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, approval };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApprovalHistory(versionId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.approval.findMany({
|
||||||
|
where: { versionId },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, image: true, role: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ShotStatus, ShotPriority } from "@prisma/client";
|
||||||
|
|
||||||
|
const createAssetSchema = z.object({
|
||||||
|
assetCode: z.string().min(1, "Asset code is required").max(30).regex(/^[A-Z0-9_\-]+$/i),
|
||||||
|
name: z.string().min(1, "Name is required").max(200),
|
||||||
|
description: z.string().optional(),
|
||||||
|
status: z.nativeEnum(ShotStatus).default("WAITING"),
|
||||||
|
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
|
||||||
|
leadId: z.string().cuid().optional().or(z.literal("")),
|
||||||
|
dueDate: z.string().optional(),
|
||||||
|
projectId: z.string().cuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createAsset(data: z.infer<typeof createAssetSchema>) {
|
||||||
|
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 = createAssetSchema.parse(data);
|
||||||
|
|
||||||
|
const asset = await db.asset.create({
|
||||||
|
data: {
|
||||||
|
assetCode: parsed.assetCode.toUpperCase(),
|
||||||
|
name: parsed.name,
|
||||||
|
description: parsed.description,
|
||||||
|
status: parsed.status,
|
||||||
|
priority: parsed.priority,
|
||||||
|
leadId: parsed.leadId || undefined,
|
||||||
|
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
|
||||||
|
projectId: parsed.projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${parsed.projectId}`);
|
||||||
|
return { success: true, asset };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAssetSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
status: z.nativeEnum(ShotStatus).optional(),
|
||||||
|
priority: z.nativeEnum(ShotPriority).optional(),
|
||||||
|
leadId: z.string().cuid().nullable().optional(),
|
||||||
|
dueDate: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateAsset(assetId: string, data: z.infer<typeof updateAssetSchema>) {
|
||||||
|
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 = updateAssetSchema.parse(data);
|
||||||
|
|
||||||
|
const asset = await db.asset.findUnique({ where: { id: assetId } });
|
||||||
|
if (!asset) throw new Error("Asset not found");
|
||||||
|
|
||||||
|
const updated = await db.asset.update({
|
||||||
|
where: { id: assetId },
|
||||||
|
data: {
|
||||||
|
...(parsed.name !== undefined && { name: parsed.name }),
|
||||||
|
...(parsed.description !== undefined && { description: parsed.description }),
|
||||||
|
...(parsed.status !== undefined && { status: parsed.status }),
|
||||||
|
...(parsed.priority !== undefined && { priority: parsed.priority }),
|
||||||
|
...(parsed.leadId !== undefined && { leadId: parsed.leadId }),
|
||||||
|
...(parsed.dueDate !== undefined && {
|
||||||
|
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${asset.projectId}`);
|
||||||
|
return { success: true, asset: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAsset(assetId: 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 asset = await db.asset.findUnique({ where: { id: assetId } });
|
||||||
|
if (!asset) throw new Error("Asset not found");
|
||||||
|
|
||||||
|
await db.asset.delete({ where: { id: assetId } });
|
||||||
|
revalidatePath(`/projects/${asset.projectId}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectAssets(projectId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.asset.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: { assetCode: "asc" },
|
||||||
|
include: {
|
||||||
|
lead: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
_count: { select: { tasks: true } },
|
||||||
|
tasks: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: { id: true, status: true, title: true, type: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { notifyFeedbackAdded, notifyCommentReply } from "@/lib/notifications";
|
||||||
|
import { slackNotifyNewFeedback } from "@/lib/slack";
|
||||||
|
|
||||||
|
const addCommentSchema = z.object({
|
||||||
|
versionId: z.string().cuid(),
|
||||||
|
frameNumber: z.number().int().min(0),
|
||||||
|
timestamp: z.number().min(0),
|
||||||
|
text: z.string().min(1).max(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function addComment(data: z.infer<typeof addCommentSchema>) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const parsed = addCommentSchema.parse(data);
|
||||||
|
|
||||||
|
const comment = await db.comment.create({
|
||||||
|
data: {
|
||||||
|
versionId: parsed.versionId,
|
||||||
|
authorId: session.user.id,
|
||||||
|
frameNumber: parsed.frameNumber,
|
||||||
|
timestamp: parsed.timestamp,
|
||||||
|
text: parsed.text,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, email: true, image: true, role: true } },
|
||||||
|
replies: true,
|
||||||
|
annotations: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify the shot artist if commenter is not the artist
|
||||||
|
const version = await db.version.findUnique({
|
||||||
|
where: { id: parsed.versionId },
|
||||||
|
include: {
|
||||||
|
shot: { include: { project: true } },
|
||||||
|
task: { include: { project: true, shot: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const slackWebhook =
|
||||||
|
version?.shot?.project?.slackWebhook ??
|
||||||
|
version?.task?.project?.slackWebhook ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const shotCode =
|
||||||
|
version?.shot?.shotCode ??
|
||||||
|
version?.task?.shot?.shotCode ??
|
||||||
|
version?.task?.title ??
|
||||||
|
"Task";
|
||||||
|
|
||||||
|
if (version?.shot?.artistId && version.shot.artistId !== session.user.id) {
|
||||||
|
const user = await db.user.findUnique({ where: { id: session.user.id } });
|
||||||
|
await notifyFeedbackAdded({
|
||||||
|
artistId: version.shot.artistId,
|
||||||
|
shotCode: version.shot.shotCode,
|
||||||
|
frameNumber: parsed.frameNumber,
|
||||||
|
versionId: parsed.versionId,
|
||||||
|
authorName: user?.name ?? session.user.email ?? "Someone",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slack notification
|
||||||
|
if (slackWebhook) {
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||||
|
const user = await db.user.findUnique({ where: { id: session.user.id } });
|
||||||
|
await slackNotifyNewFeedback(slackWebhook, {
|
||||||
|
shotCode,
|
||||||
|
frameNumber: parsed.frameNumber,
|
||||||
|
authorName: user?.name ?? "Someone",
|
||||||
|
commentText: parsed.text,
|
||||||
|
reviewUrl: `${appUrl}/review/${parsed.versionId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/review/${parsed.versionId}`);
|
||||||
|
return { success: true, comment };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addReply(commentId: string, text: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
if (!text?.trim()) throw new Error("Reply text is required");
|
||||||
|
|
||||||
|
const reply = await db.commentReply.create({
|
||||||
|
data: {
|
||||||
|
commentId,
|
||||||
|
authorId: session.user.id,
|
||||||
|
text: text.trim(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, email: true, image: true, role: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify the original comment author
|
||||||
|
const comment = await db.comment.findUnique({
|
||||||
|
where: { id: commentId },
|
||||||
|
include: {
|
||||||
|
version: { include: { shot: true, task: { select: { title: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (comment && comment.authorId && comment.authorId !== session.user.id) {
|
||||||
|
const user = await db.user.findUnique({ where: { id: session.user.id } });
|
||||||
|
await notifyCommentReply({
|
||||||
|
commentAuthorId: comment.authorId,
|
||||||
|
replierName: user?.name ?? "Someone",
|
||||||
|
shotCode: comment.version.shot?.shotCode ?? comment.version.task?.title ?? "Task",
|
||||||
|
frameNumber: comment.frameNumber,
|
||||||
|
versionId: comment.versionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/review/${comment?.versionId}`);
|
||||||
|
return { success: true, reply };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveComment(commentId: string, resolved: boolean) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const comment = await db.comment.update({
|
||||||
|
where: { id: commentId },
|
||||||
|
data: { isResolved: resolved },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/review/${comment.versionId}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteComment(commentId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const comment = await db.comment.findUnique({ where: { id: commentId } });
|
||||||
|
if (!comment) throw new Error("Comment not found");
|
||||||
|
|
||||||
|
// Only the author or admin can delete
|
||||||
|
if (comment.authorId !== session.user.id && session.user.role !== "ADMIN") {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.comment.delete({ where: { id: commentId } });
|
||||||
|
revalidatePath(`/review/${comment.versionId}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ProjectStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
const createProjectSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
code: z.string().min(1).max(20).toUpperCase(),
|
||||||
|
showId: z.string().min(1).max(10).regex(/^[A-Z0-9_]+$/i, "1–10 chars, letters/numbers/underscores").toUpperCase(),
|
||||||
|
projectType: z.enum(["STANDARD", "EPISODIC"]).default("STANDARD"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
clientId: z.string().cuid().optional(),
|
||||||
|
dueDate: z.string().optional(),
|
||||||
|
deadline: z.union([z.string(), z.date()]).optional(),
|
||||||
|
startDate: z.string().optional(),
|
||||||
|
slackWebhook: z.string().url().optional().or(z.literal("")),
|
||||||
|
slackChannel: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createProject(data: z.infer<typeof createProjectSchema>) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
if (!["ADMIN", "PRODUCER"].includes(session.user.role)) {
|
||||||
|
throw new Error("Insufficient permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = createProjectSchema.parse(data);
|
||||||
|
|
||||||
|
// Verify the session user still exists in the DB (guards against stale JWT after a DB reset)
|
||||||
|
const dbUser = await db.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!dbUser) throw new Error("Session expired — please sign out and sign back in.");
|
||||||
|
|
||||||
|
const project = await db.project.create({
|
||||||
|
data: {
|
||||||
|
name: parsed.name,
|
||||||
|
code: parsed.code,
|
||||||
|
showId: parsed.showId,
|
||||||
|
projectType: parsed.projectType,
|
||||||
|
description: parsed.description,
|
||||||
|
clientId: parsed.clientId || undefined,
|
||||||
|
producerId: session.user.id,
|
||||||
|
dueDate: parsed.dueDate
|
||||||
|
? new Date(parsed.dueDate)
|
||||||
|
: parsed.deadline
|
||||||
|
? new Date(parsed.deadline)
|
||||||
|
: undefined,
|
||||||
|
startDate: parsed.startDate ? new Date(parsed.startDate) : undefined,
|
||||||
|
slackWebhook: parsed.slackWebhook || undefined,
|
||||||
|
slackChannel: parsed.slackChannel || undefined,
|
||||||
|
},
|
||||||
|
include: { client: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/projects");
|
||||||
|
return { success: true, project };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateProjectSchema = z.object({
|
||||||
|
id: z.string().cuid(),
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
code: z.string().min(1).max(20).optional(),
|
||||||
|
showId: z.string().min(1).max(10).regex(/^[A-Z0-9_]+$/i).toUpperCase().optional(),
|
||||||
|
projectType: z.enum(["STANDARD", "EPISODIC"]).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
status: z.nativeEnum(ProjectStatus).optional(),
|
||||||
|
clientId: z.string().cuid().optional().nullable(),
|
||||||
|
producerId: z.string().cuid().optional().nullable(),
|
||||||
|
supervisorId: z.string().cuid().optional().nullable(),
|
||||||
|
dueDate: z.string().optional().nullable(),
|
||||||
|
startDate: z.string().optional().nullable(),
|
||||||
|
slackWebhook: z.string().url().optional().or(z.literal("")).nullable(),
|
||||||
|
slackChannel: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateProject(data: z.infer<typeof updateProjectSchema>) {
|
||||||
|
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 { id, ...rest } = updateProjectSchema.parse(data);
|
||||||
|
|
||||||
|
const updated = await db.project.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(rest.name !== undefined && { name: rest.name }),
|
||||||
|
...(rest.code !== undefined && { code: rest.code.toUpperCase() }),
|
||||||
|
...(rest.showId !== undefined && { showId: rest.showId }),
|
||||||
|
...(rest.projectType !== undefined && { projectType: rest.projectType }),
|
||||||
|
...(rest.description !== undefined && { description: rest.description ?? undefined }),
|
||||||
|
...(rest.status !== undefined && { status: rest.status }),
|
||||||
|
...(rest.clientId !== undefined && { clientId: rest.clientId }),
|
||||||
|
...(rest.producerId !== undefined && { producerId: rest.producerId }),
|
||||||
|
...(rest.supervisorId !== undefined && { supervisorId: rest.supervisorId }),
|
||||||
|
...(rest.dueDate !== undefined && { dueDate: rest.dueDate ? new Date(rest.dueDate) : null }),
|
||||||
|
...(rest.startDate !== undefined && { startDate: rest.startDate ? new Date(rest.startDate) : null }),
|
||||||
|
...(rest.slackWebhook !== undefined && { slackWebhook: rest.slackWebhook || null }),
|
||||||
|
...(rest.slackChannel !== undefined && { slackChannel: rest.slackChannel || null }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${id}`);
|
||||||
|
revalidatePath("/projects");
|
||||||
|
return { success: true, project: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProjectStatus(
|
||||||
|
projectId: string,
|
||||||
|
status: ProjectStatus
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
if (!["ADMIN", "PRODUCER"].includes(session.user.role)) {
|
||||||
|
throw new Error("Insufficient permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.project.update({ where: { id: projectId }, data: { status } });
|
||||||
|
revalidatePath(`/projects/${projectId}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjects() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
// Clients only see their assigned projects
|
||||||
|
if (session.user.role === "CLIENT") {
|
||||||
|
const access = await db.clientAccess.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: { clientId: true },
|
||||||
|
});
|
||||||
|
const clientIds = access.map((a) => a.clientId);
|
||||||
|
return db.project.findMany({
|
||||||
|
where: { clientId: { in: clientIds }, status: { not: "ARCHIVED" } },
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
_count: { select: { shots: true } },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.project.findMany({
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
producer: { select: { id: true, name: true } },
|
||||||
|
_count: { select: { shots: true } },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectById(id: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.project.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
producer: { select: { id: true, name: true, email: true } },
|
||||||
|
supervisor: { select: { id: true, name: true, email: true } },
|
||||||
|
shots: {
|
||||||
|
include: {
|
||||||
|
artist: { select: { id: true, name: true, image: true } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
orderBy: { shotCode: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
function requireScheduleAccess(role: string) {
|
||||||
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) {
|
||||||
|
throw new Error("Insufficient permissions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleTaskSchema = z.object({
|
||||||
|
taskId: z.string().cuid(),
|
||||||
|
scheduledStartDate: z.string().nullable(),
|
||||||
|
scheduledEndDate: z.string().nullable(),
|
||||||
|
assignedArtistId: z.string().cuid().nullable().optional(),
|
||||||
|
scheduleNotes: z.string().nullable().optional(),
|
||||||
|
estimatedHours: z.number().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function scheduleTask(data: z.infer<typeof scheduleTaskSchema>) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
requireScheduleAccess(session.user.role);
|
||||||
|
|
||||||
|
const parsed = scheduleTaskSchema.parse(data);
|
||||||
|
|
||||||
|
await db.task.update({
|
||||||
|
where: { id: parsed.taskId },
|
||||||
|
data: {
|
||||||
|
scheduledStartDate: parsed.scheduledStartDate
|
||||||
|
? new Date(parsed.scheduledStartDate)
|
||||||
|
: null,
|
||||||
|
scheduledEndDate: parsed.scheduledEndDate
|
||||||
|
? new Date(parsed.scheduledEndDate)
|
||||||
|
: null,
|
||||||
|
...(parsed.assignedArtistId !== undefined && {
|
||||||
|
assignedArtistId: parsed.assignedArtistId,
|
||||||
|
}),
|
||||||
|
...(parsed.scheduleNotes !== undefined && {
|
||||||
|
scheduleNotes: parsed.scheduleNotes,
|
||||||
|
}),
|
||||||
|
...(parsed.estimatedHours !== undefined && {
|
||||||
|
estimatedHours: parsed.estimatedHours,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/schedule");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unscheduleTask(taskId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
requireScheduleAccess(session.user.role);
|
||||||
|
|
||||||
|
await db.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
scheduledStartDate: null,
|
||||||
|
scheduledEndDate: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/schedule");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkSchedule(
|
||||||
|
tasks: {
|
||||||
|
taskId: string;
|
||||||
|
scheduledStartDate: string;
|
||||||
|
scheduledEndDate: string;
|
||||||
|
assignedArtistId?: string;
|
||||||
|
}[]
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
requireScheduleAccess(session.user.role);
|
||||||
|
|
||||||
|
await db.$transaction(
|
||||||
|
tasks.map((t) =>
|
||||||
|
db.task.update({
|
||||||
|
where: { id: t.taskId },
|
||||||
|
data: {
|
||||||
|
scheduledStartDate: new Date(t.scheduledStartDate),
|
||||||
|
scheduledEndDate: new Date(t.scheduledEndDate),
|
||||||
|
...(t.assignedArtistId ? { assignedArtistId: t.assignedArtistId } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidatePath("/schedule");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ShotStatus, ShotPriority } from "@prisma/client";
|
||||||
|
|
||||||
|
const createShotSchema = z.object({
|
||||||
|
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
|
||||||
|
episode: z.string().max(50).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
projectId: z.string().cuid(),
|
||||||
|
artistId: z.string().cuid().optional().or(z.literal("")),
|
||||||
|
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
|
||||||
|
fps: z.number().default(24),
|
||||||
|
frameStart: z.number().int().optional(),
|
||||||
|
frameEnd: z.number().int().optional(),
|
||||||
|
dueDate: z.string().optional(),
|
||||||
|
thumbnailUrl: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createShot(data: z.infer<typeof createShotSchema>) {
|
||||||
|
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 = createShotSchema.parse(data);
|
||||||
|
const scene = parsed.scene.toUpperCase();
|
||||||
|
const episode = parsed.episode?.toUpperCase() ?? null;
|
||||||
|
|
||||||
|
// Fetch project for showId and projectType
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: parsed.projectId },
|
||||||
|
select: { showId: true, projectType: true },
|
||||||
|
});
|
||||||
|
if (!project) throw new Error("Project not found");
|
||||||
|
if (!project.showId) {
|
||||||
|
throw new Error("Project has no Show ID set. Please edit the project to add one.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// For episodic projects, episode is required
|
||||||
|
if (project.projectType === "EPISODIC" && !episode) {
|
||||||
|
throw new Error("Episode is required for episodic projects.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine shot number scope: projectId + scene (+ episode for episodic)
|
||||||
|
const scopeWhere = {
|
||||||
|
projectId: parsed.projectId,
|
||||||
|
scene,
|
||||||
|
...(project.projectType === "EPISODIC" ? { episode } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxShot = await db.shot.findFirst({
|
||||||
|
where: scopeWhere,
|
||||||
|
orderBy: { shotNumber: "desc" },
|
||||||
|
select: { shotNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
|
||||||
|
const paddedNumber = shotNumber.toString().padStart(4, "0");
|
||||||
|
|
||||||
|
// Build shot code per naming convention
|
||||||
|
const shotCode =
|
||||||
|
project.projectType === "EPISODIC" && episode
|
||||||
|
? `${project.showId}_${episode}_${scene}_${paddedNumber}`
|
||||||
|
: `${project.showId}_${scene}_${paddedNumber}`;
|
||||||
|
|
||||||
|
const shot = await db.shot.create({
|
||||||
|
data: {
|
||||||
|
shotCode,
|
||||||
|
scene,
|
||||||
|
episode,
|
||||||
|
shotNumber,
|
||||||
|
description: parsed.description,
|
||||||
|
projectId: parsed.projectId,
|
||||||
|
artistId: parsed.artistId || undefined,
|
||||||
|
priority: parsed.priority,
|
||||||
|
fps: parsed.fps,
|
||||||
|
frameStart: parsed.frameStart,
|
||||||
|
frameEnd: parsed.frameEnd,
|
||||||
|
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
|
||||||
|
thumbnailUrl: parsed.thumbnailUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${parsed.projectId}`);
|
||||||
|
return { success: true, shot };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update Shot ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const updateShotSchema = z.object({
|
||||||
|
shotId: z.string().cuid(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
status: z.nativeEnum(ShotStatus).optional(),
|
||||||
|
priority: z.nativeEnum(ShotPriority).optional(),
|
||||||
|
fps: z.number().optional(),
|
||||||
|
frameStart: z.number().int().optional().nullable(),
|
||||||
|
frameEnd: z.number().int().optional().nullable(),
|
||||||
|
dueDate: z.string().optional().nullable(),
|
||||||
|
artistId: z.string().cuid().optional().nullable().or(z.literal("")),
|
||||||
|
thumbnailUrl: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateShot(data: z.infer<typeof updateShotSchema>) {
|
||||||
|
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 = updateShotSchema.parse(data);
|
||||||
|
const { shotId, dueDate, artistId, ...rest } = parsed;
|
||||||
|
|
||||||
|
const shot = await db.shot.update({
|
||||||
|
where: { id: shotId },
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
dueDate: dueDate ? new Date(dueDate) : dueDate === null ? null : undefined,
|
||||||
|
artistId: artistId === "" ? null : artistId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${shot.projectId}`);
|
||||||
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
||||||
|
return { success: true, shot };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSV Import ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function importShotsFromCsv(
|
||||||
|
projectId: string,
|
||||||
|
rows: Array<{
|
||||||
|
scene: string;
|
||||||
|
episode?: string;
|
||||||
|
description?: string;
|
||||||
|
priority?: string;
|
||||||
|
fps?: number;
|
||||||
|
frameStart?: number;
|
||||||
|
frameEnd?: number;
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
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 project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { showId: true, projectType: true },
|
||||||
|
});
|
||||||
|
if (!project) throw new Error("Project not found");
|
||||||
|
if (!project.showId) throw new Error("Project has no Show ID. Add one in Project Settings first.");
|
||||||
|
|
||||||
|
const VALID_PRIORITIES = ["LOW", "NORMAL", "HIGH", "CRITICAL"];
|
||||||
|
const created: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const scene = row.scene.trim().toUpperCase();
|
||||||
|
if (!scene) { errors.push("Empty scene name — skipped"); continue; }
|
||||||
|
|
||||||
|
const episode = row.episode?.trim().toUpperCase() || null;
|
||||||
|
|
||||||
|
if (project.projectType === "EPISODIC" && !episode) {
|
||||||
|
errors.push(`${scene}: episode required for episodic project — skipped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPriority = row.priority?.trim().toUpperCase();
|
||||||
|
const priority = VALID_PRIORITIES.includes(rawPriority ?? "")
|
||||||
|
? (rawPriority as ShotPriority)
|
||||||
|
: ShotPriority.NORMAL;
|
||||||
|
|
||||||
|
const scopeWhere = {
|
||||||
|
projectId,
|
||||||
|
scene,
|
||||||
|
...(project.projectType === "EPISODIC" ? { episode } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxShot = await db.shot.findFirst({
|
||||||
|
where: scopeWhere,
|
||||||
|
orderBy: { shotNumber: "desc" },
|
||||||
|
select: { shotNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
|
||||||
|
const paddedNumber = shotNumber.toString().padStart(4, "0");
|
||||||
|
const shotCode =
|
||||||
|
project.projectType === "EPISODIC" && episode
|
||||||
|
? `${project.showId}_${episode}_${scene}_${paddedNumber}`
|
||||||
|
: `${project.showId}_${scene}_${paddedNumber}`;
|
||||||
|
|
||||||
|
await db.shot.create({
|
||||||
|
data: {
|
||||||
|
shotCode,
|
||||||
|
scene,
|
||||||
|
episode,
|
||||||
|
shotNumber,
|
||||||
|
description: row.description?.trim() || undefined,
|
||||||
|
projectId,
|
||||||
|
priority,
|
||||||
|
fps: row.fps ?? 24,
|
||||||
|
frameStart: row.frameStart ?? undefined,
|
||||||
|
frameEnd: row.frameEnd ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created.push(shotCode);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errors.push(`${row.scene}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${projectId}`);
|
||||||
|
return { success: true, created, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShotStatus(
|
||||||
|
shotId: string,
|
||||||
|
status: ShotStatus
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const shot = await db.shot.update({
|
||||||
|
where: { id: shotId },
|
||||||
|
data: { status },
|
||||||
|
include: { project: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${shot.projectId}`);
|
||||||
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShotById(shotId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.shot.findUnique({
|
||||||
|
where: { id: shotId },
|
||||||
|
include: {
|
||||||
|
project: { include: { client: true } },
|
||||||
|
artist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
versions: {
|
||||||
|
include: {
|
||||||
|
artist: { select: { id: true, name: true, image: true } },
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShotsByProject(projectId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.shot.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
include: {
|
||||||
|
artist: { select: { id: true, name: true, image: true } },
|
||||||
|
versions: {
|
||||||
|
where: { isLatest: true },
|
||||||
|
take: 1,
|
||||||
|
include: { _count: { select: { comments: true } } },
|
||||||
|
},
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { TaskStatus, TaskType, ShotPriority } from "@prisma/client";
|
||||||
|
import { recalcShotStatus } from "@/lib/shot-status";
|
||||||
|
import {
|
||||||
|
notifyTaskAssigned,
|
||||||
|
notifyTaskReadyForReview,
|
||||||
|
notifyTaskApproved,
|
||||||
|
notifyTaskChangesRequested,
|
||||||
|
} from "@/lib/notifications";
|
||||||
|
import {
|
||||||
|
slackNotifyTaskAssigned,
|
||||||
|
slackNotifyTaskReadyForReview,
|
||||||
|
} from "@/lib/slack";
|
||||||
|
|
||||||
|
const createTaskSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required").max(200),
|
||||||
|
description: z.string().optional(),
|
||||||
|
type: z.nativeEnum(TaskType).default("GENERAL"),
|
||||||
|
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
|
||||||
|
dueDate: z.string().optional(),
|
||||||
|
estimatedHours: z.coerce.number().positive().optional().or(z.literal("")),
|
||||||
|
shotId: z.string().cuid().optional().or(z.literal("")),
|
||||||
|
assetId: z.string().cuid().optional().or(z.literal("")),
|
||||||
|
assignedArtistId: z.string().cuid().optional().or(z.literal("")),
|
||||||
|
projectId: z.string().cuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createTask(data: z.infer<typeof createTaskSchema>) {
|
||||||
|
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 = createTaskSchema.parse(data);
|
||||||
|
|
||||||
|
// Get sort order (append at end)
|
||||||
|
const lastTask = await db.task.findFirst({
|
||||||
|
where: {
|
||||||
|
shotId: parsed.shotId || undefined,
|
||||||
|
assetId: parsed.assetId || undefined,
|
||||||
|
},
|
||||||
|
orderBy: { sortOrder: "desc" },
|
||||||
|
select: { sortOrder: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = await db.task.create({
|
||||||
|
data: {
|
||||||
|
title: parsed.title,
|
||||||
|
description: parsed.description,
|
||||||
|
type: parsed.type,
|
||||||
|
priority: parsed.priority,
|
||||||
|
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
|
||||||
|
estimatedHours: parsed.estimatedHours ? Number(parsed.estimatedHours) : undefined,
|
||||||
|
sortOrder: (lastTask?.sortOrder ?? -1) + 1,
|
||||||
|
shotId: parsed.shotId || undefined,
|
||||||
|
assetId: parsed.assetId || undefined,
|
||||||
|
assignedArtistId: parsed.assignedArtistId || undefined,
|
||||||
|
createdById: session.user.id,
|
||||||
|
projectId: parsed.projectId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recalculate shot status when a new task is added
|
||||||
|
if (parsed.shotId) {
|
||||||
|
await recalcShotStatus(parsed.shotId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications: task assigned
|
||||||
|
if (parsed.assignedArtistId) {
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: parsed.projectId },
|
||||||
|
select: { name: true, code: true, slackWebhook: true },
|
||||||
|
});
|
||||||
|
const contextShot = parsed.shotId
|
||||||
|
? await db.shot.findUnique({ where: { id: parsed.shotId }, select: { shotCode: true } })
|
||||||
|
: null;
|
||||||
|
const contextAsset = parsed.assetId
|
||||||
|
? await db.asset.findUnique({ where: { id: parsed.assetId }, select: { assetCode: true } })
|
||||||
|
: null;
|
||||||
|
const contextCode = contextShot?.shotCode ?? contextAsset?.assetCode ?? null;
|
||||||
|
const taskUrl = `${process.env.NEXTAUTH_URL ?? ""}/tasks/${task.id}`;
|
||||||
|
const assignedByName = session.user.name ?? session.user.email ?? "Someone";
|
||||||
|
const artist = await db.user.findUnique({
|
||||||
|
where: { id: parsed.assignedArtistId },
|
||||||
|
select: { name: true, email: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyTaskAssigned({
|
||||||
|
artistId: parsed.assignedArtistId,
|
||||||
|
taskTitle: task.title,
|
||||||
|
taskId: task.id,
|
||||||
|
contextCode,
|
||||||
|
assignedByName,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
if (project?.slackWebhook) {
|
||||||
|
await slackNotifyTaskAssigned(project.slackWebhook, {
|
||||||
|
taskTitle: task.title,
|
||||||
|
contextCode,
|
||||||
|
artistName: artist?.name ?? artist?.email ?? "artist",
|
||||||
|
assignedByName,
|
||||||
|
projectName: project.name,
|
||||||
|
taskUrl,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${parsed.projectId}`);
|
||||||
|
if (parsed.shotId) revalidatePath(`/projects/${parsed.projectId}/shots/${parsed.shotId}`);
|
||||||
|
return { success: true, task };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTaskSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
type: z.nativeEnum(TaskType).optional(),
|
||||||
|
status: z.nativeEnum(TaskStatus).optional(),
|
||||||
|
priority: z.nativeEnum(ShotPriority).optional(),
|
||||||
|
dueDate: z.string().nullable().optional(),
|
||||||
|
estimatedHours: z.coerce.number().positive().nullable().optional(),
|
||||||
|
assignedArtistId: z.string().cuid().nullable().optional(),
|
||||||
|
sortOrder: z.number().int().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateTask(taskId: string, data: z.infer<typeof updateTaskSchema>) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const parsed = updateTaskSchema.parse(data);
|
||||||
|
|
||||||
|
const task = await db.task.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
include: {
|
||||||
|
shot: { select: { shotCode: true } },
|
||||||
|
asset: { select: { assetCode: true } },
|
||||||
|
project: { select: { name: true, slackWebhook: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!task) throw new Error("Task not found");
|
||||||
|
|
||||||
|
const updated = await db.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
...(parsed.title !== undefined && { title: parsed.title }),
|
||||||
|
...(parsed.description !== undefined && { description: parsed.description }),
|
||||||
|
...(parsed.type !== undefined && { type: parsed.type }),
|
||||||
|
...(parsed.status !== undefined && { status: parsed.status }),
|
||||||
|
...(parsed.priority !== undefined && { priority: parsed.priority }),
|
||||||
|
...(parsed.dueDate !== undefined && {
|
||||||
|
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : null,
|
||||||
|
}),
|
||||||
|
...(parsed.estimatedHours !== undefined && { estimatedHours: parsed.estimatedHours }),
|
||||||
|
...(parsed.assignedArtistId !== undefined && { assignedArtistId: parsed.assignedArtistId }),
|
||||||
|
...(parsed.sortOrder !== undefined && { sortOrder: parsed.sortOrder }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify new assignee when assignee changes
|
||||||
|
if (
|
||||||
|
parsed.assignedArtistId !== undefined &&
|
||||||
|
parsed.assignedArtistId !== task.assignedArtistId &&
|
||||||
|
parsed.assignedArtistId !== null
|
||||||
|
) {
|
||||||
|
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode ?? null;
|
||||||
|
const assignedByName = session.user.name ?? session.user.email ?? "Someone";
|
||||||
|
const artist = await db.user.findUnique({
|
||||||
|
where: { id: parsed.assignedArtistId },
|
||||||
|
select: { name: true, email: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyTaskAssigned({
|
||||||
|
artistId: parsed.assignedArtistId,
|
||||||
|
taskTitle: task.title,
|
||||||
|
taskId,
|
||||||
|
contextCode,
|
||||||
|
assignedByName,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
if (task.project.slackWebhook) {
|
||||||
|
const taskUrl = `${process.env.NEXTAUTH_URL ?? ""}/tasks/${taskId}`;
|
||||||
|
await slackNotifyTaskAssigned(task.project.slackWebhook, {
|
||||||
|
taskTitle: task.title,
|
||||||
|
contextCode,
|
||||||
|
artistName: artist?.name ?? artist?.email ?? "artist",
|
||||||
|
assignedByName,
|
||||||
|
projectName: task.project.name,
|
||||||
|
taskUrl,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${task.projectId}`);
|
||||||
|
revalidatePath(`/tasks/${taskId}`);
|
||||||
|
return { success: true, task: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTaskStatus(taskId: string, status: TaskStatus) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const task = await db.task.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
include: {
|
||||||
|
shot: { select: { id: true, shotCode: true } },
|
||||||
|
asset: { select: { assetCode: true } },
|
||||||
|
project: { select: { id: true, name: true, slackWebhook: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!task) throw new Error("Task not found");
|
||||||
|
|
||||||
|
const updated = await db.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode ?? null;
|
||||||
|
const taskUrl = `${process.env.NEXTAUTH_URL ?? ""}/tasks/${taskId}`;
|
||||||
|
const actorName = session.user.name ?? session.user.email ?? "Someone";
|
||||||
|
|
||||||
|
// Task moved to internal review — notify supervisors/producers
|
||||||
|
if (status === "INTERNAL_REVIEW") {
|
||||||
|
await notifyTaskReadyForReview({
|
||||||
|
taskId,
|
||||||
|
taskTitle: task.title,
|
||||||
|
contextCode,
|
||||||
|
artistName: actorName,
|
||||||
|
projectId: task.projectId,
|
||||||
|
taskUrl,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
if (task.project.slackWebhook) {
|
||||||
|
await slackNotifyTaskReadyForReview(task.project.slackWebhook, {
|
||||||
|
taskTitle: task.title,
|
||||||
|
contextCode,
|
||||||
|
artistName: actorName,
|
||||||
|
projectName: task.project.name,
|
||||||
|
taskUrl,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task approved (moved to DONE by reviewer) — notify artist
|
||||||
|
if (status === "DONE" && task.assignedArtistId && task.assignedArtistId !== session.user.id) {
|
||||||
|
await notifyTaskApproved({
|
||||||
|
artistId: task.assignedArtistId,
|
||||||
|
taskTitle: task.title,
|
||||||
|
taskId,
|
||||||
|
contextCode,
|
||||||
|
reviewerName: actorName,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changes requested — notify artist
|
||||||
|
if (status === "CHANGES" && task.assignedArtistId && task.assignedArtistId !== session.user.id) {
|
||||||
|
await notifyTaskChangesRequested({
|
||||||
|
artistId: task.assignedArtistId,
|
||||||
|
taskTitle: task.title,
|
||||||
|
taskId,
|
||||||
|
contextCode,
|
||||||
|
reviewerName: actorName,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate shot status from task states
|
||||||
|
if (task.shot) {
|
||||||
|
await recalcShotStatus(task.shot.id).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${task.projectId}`);
|
||||||
|
revalidatePath(`/tasks/${taskId}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTask(taskId: 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 task = await db.task.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task) throw new Error("Task not found");
|
||||||
|
|
||||||
|
const shotId = task.shotId;
|
||||||
|
await db.task.delete({ where: { id: taskId } });
|
||||||
|
|
||||||
|
// Recalculate shot status after task removal
|
||||||
|
if (shotId) {
|
||||||
|
await recalcShotStatus(shotId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${task.projectId}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskById(taskId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.task.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
include: {
|
||||||
|
shot: { select: { id: true, shotCode: true, projectId: true } },
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true, projectId: true } },
|
||||||
|
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
createdBy: { select: { id: true, name: true, email: true } },
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
versions: {
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
include: {
|
||||||
|
artist: { select: { id: true, name: true, image: true } },
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectTasks(projectId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.task.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: [{ shotId: "asc" }, { sortOrder: "asc" }],
|
||||||
|
include: {
|
||||||
|
shot: { select: { id: true, shotCode: true } },
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
|
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShotTasks(shotId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.task.findMany({
|
||||||
|
where: { shotId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: {
|
||||||
|
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
versions: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAssetTasks(assetId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.task.findMany({
|
||||||
|
where: { assetId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: {
|
||||||
|
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
versions: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyTasks() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.task.findMany({
|
||||||
|
where: { assignedArtistId: session.user.id },
|
||||||
|
orderBy: [{ status: "asc" }, { dueDate: "asc" }],
|
||||||
|
include: {
|
||||||
|
shot: { select: { id: true, shotCode: true } },
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Role } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const MANAGED_BY_ADMIN_ONLY: Role[] = [Role.ADMIN];
|
||||||
|
|
||||||
|
function requireAdmin(role: string) {
|
||||||
|
if (role !== "ADMIN") throw new Error("Admin access required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create User ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const createUserSchema = z.object({
|
||||||
|
name: z.string().max(100).optional(),
|
||||||
|
email: z.string().email("Invalid email"),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
role: z.nativeEnum(Role),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createUser(data: z.infer<typeof createUserSchema>) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
requireAdmin(session.user.role as string);
|
||||||
|
|
||||||
|
const parsed = createUserSchema.parse(data);
|
||||||
|
|
||||||
|
const existing = await db.user.findUnique({ where: { email: parsed.email } });
|
||||||
|
if (existing) throw new Error("A user with that email already exists");
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(parsed.password, 12);
|
||||||
|
|
||||||
|
const user = await db.user.create({
|
||||||
|
data: {
|
||||||
|
name: parsed.name || null,
|
||||||
|
email: parsed.email,
|
||||||
|
passwordHash,
|
||||||
|
role: parsed.role,
|
||||||
|
isActive: true,
|
||||||
|
mustChangePassword: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/users");
|
||||||
|
return { success: true, userId: user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update User ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const updateUserSchema = z.object({
|
||||||
|
userId: z.string().cuid(),
|
||||||
|
name: z.string().max(100).optional(),
|
||||||
|
role: z.nativeEnum(Role),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
newPassword: z.string().min(8, "Password must be at least 8 characters").optional().or(z.literal("")),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateUser(data: z.infer<typeof updateUserSchema>) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
requireAdmin(session.user.role as string);
|
||||||
|
|
||||||
|
const parsed = updateUserSchema.parse(data);
|
||||||
|
|
||||||
|
// Prevent demoting or deactivating yourself
|
||||||
|
if (parsed.userId === session.user.id) {
|
||||||
|
if (!parsed.isActive) throw new Error("You cannot deactivate your own account");
|
||||||
|
if (parsed.role !== "ADMIN") throw new Error("You cannot change your own role");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {
|
||||||
|
name: parsed.name || null,
|
||||||
|
role: parsed.role,
|
||||||
|
isActive: parsed.isActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parsed.newPassword) {
|
||||||
|
updateData.passwordHash = await bcrypt.hash(parsed.newPassword, 12);
|
||||||
|
updateData.mustChangePassword = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.user.update({ where: { id: parsed.userId }, data: updateData });
|
||||||
|
|
||||||
|
revalidatePath("/users");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Change Own Password ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const changeOwnPasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1, "Current password is required"),
|
||||||
|
newPassword: z.string().min(8, "New password must be at least 8 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function changeOwnPassword(data: z.infer<typeof changeOwnPasswordSchema>) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const parsed = changeOwnPasswordSchema.parse(data);
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { passwordHash: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.passwordHash) throw new Error("No password set on this account");
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(parsed.currentPassword, user.passwordHash);
|
||||||
|
if (!isValid) throw new Error("Current password is incorrect");
|
||||||
|
|
||||||
|
if (parsed.currentPassword === parsed.newPassword) {
|
||||||
|
throw new Error("New password must be different from the current password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(parsed.newPassword, 12);
|
||||||
|
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { passwordHash, mustChangePassword: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete User ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function deleteUser(userId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
requireAdmin(session.user.role as string);
|
||||||
|
|
||||||
|
if (userId === session.user.id) throw new Error("You cannot delete your own account");
|
||||||
|
|
||||||
|
await db.user.delete({ where: { id: userId } });
|
||||||
|
|
||||||
|
revalidatePath("/users");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { recalcShotStatus } from "@/lib/shot-status";
|
||||||
|
import { notifyNewVersionUploaded } from "@/lib/notifications";
|
||||||
|
import { slackNotifyVersionUploaded } from "@/lib/slack";
|
||||||
|
|
||||||
|
const createVersionSchema = z.object({
|
||||||
|
taskId: z.string().cuid(),
|
||||||
|
fileUrl: z.string().min(1),
|
||||||
|
fileName: z.string(),
|
||||||
|
fileSize: z.number().optional(),
|
||||||
|
mimeType: z.string().optional(),
|
||||||
|
thumbnailUrl: z.string().min(1).optional().or(z.literal("")),
|
||||||
|
fps: z.number().default(24),
|
||||||
|
duration: z.number().optional(),
|
||||||
|
frameCount: z.number().int().optional(),
|
||||||
|
width: z.number().int().optional(),
|
||||||
|
height: z.number().int().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createVersion(data: z.infer<typeof createVersionSchema>) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const parsed = createVersionSchema.parse(data);
|
||||||
|
|
||||||
|
// All versions must belong to a task
|
||||||
|
const task = await db.task.findUnique({
|
||||||
|
where: { id: parsed.taskId },
|
||||||
|
include: { project: true, shot: { select: { id: true, shotCode: true } } },
|
||||||
|
});
|
||||||
|
if (!task) throw new Error("Task not found");
|
||||||
|
|
||||||
|
const projectId = task.projectId;
|
||||||
|
const slackWebhook = task.project.slackWebhook ?? undefined;
|
||||||
|
const projectName = task.project.name;
|
||||||
|
const shotCode = task.shot?.shotCode;
|
||||||
|
|
||||||
|
// Mark all existing versions for this task as not latest
|
||||||
|
await db.version.updateMany({ where: { taskId: parsed.taskId }, data: { isLatest: false } });
|
||||||
|
|
||||||
|
// Move task to INTERNAL_REVIEW on upload
|
||||||
|
await db.task.update({
|
||||||
|
where: { id: parsed.taskId },
|
||||||
|
data: { status: "INTERNAL_REVIEW" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recalculate shot status
|
||||||
|
if (task.shot) {
|
||||||
|
await recalcShotStatus(task.shot.id).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the next version number for this task
|
||||||
|
const lastVersion = await db.version.findFirst({
|
||||||
|
where: { taskId: parsed.taskId },
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
});
|
||||||
|
const versionNumber = (lastVersion?.versionNumber ?? 0) + 1;
|
||||||
|
|
||||||
|
// Create the new version
|
||||||
|
const version = await db.version.create({
|
||||||
|
data: {
|
||||||
|
versionNumber,
|
||||||
|
taskId: parsed.taskId,
|
||||||
|
artistId: session.user.id,
|
||||||
|
fileUrl: parsed.fileUrl,
|
||||||
|
fileName: parsed.fileName,
|
||||||
|
fileSize: parsed.fileSize ? BigInt(parsed.fileSize) : undefined,
|
||||||
|
mimeType: parsed.mimeType,
|
||||||
|
thumbnailUrl: parsed.thumbnailUrl || undefined,
|
||||||
|
fps: parsed.fps,
|
||||||
|
duration: parsed.duration,
|
||||||
|
frameCount: parsed.frameCount,
|
||||||
|
width: parsed.width,
|
||||||
|
height: parsed.height,
|
||||||
|
notes: parsed.notes,
|
||||||
|
isLatest: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send notifications
|
||||||
|
const user = await db.user.findUnique({ where: { id: session.user.id } });
|
||||||
|
const versionLabelStr = `v${String(versionNumber).padStart(3, "0")}`;
|
||||||
|
|
||||||
|
if (shotCode) {
|
||||||
|
await notifyNewVersionUploaded({
|
||||||
|
shotCode,
|
||||||
|
versionNumber,
|
||||||
|
projectId,
|
||||||
|
versionId: version.id,
|
||||||
|
artistName: user?.name ?? session.user.email ?? "Artist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slack notification
|
||||||
|
if (slackWebhook && shotCode) {
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||||
|
await slackNotifyVersionUploaded(slackWebhook, {
|
||||||
|
shotCode,
|
||||||
|
versionLabel: versionLabelStr,
|
||||||
|
artistName: user?.name ?? "Artist",
|
||||||
|
projectName,
|
||||||
|
reviewUrl: `${appUrl}/review/${version.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${projectId}`);
|
||||||
|
if (task.shot) revalidatePath(`/projects/${projectId}/shots/${task.shot.id}`);
|
||||||
|
revalidatePath(`/tasks/${parsed.taskId}`);
|
||||||
|
|
||||||
|
return { success: true, version, versionNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share a version with the client — marks it as client-visible,
|
||||||
|
* records who shared it, and moves the associated task to CLIENT_REVIEW.
|
||||||
|
*/
|
||||||
|
export async function shareVersionWithClient(versionId: 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 version = await db.version.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
select: { id: true, taskId: true, isClientVisible: true },
|
||||||
|
});
|
||||||
|
if (!version) throw new Error("Version not found");
|
||||||
|
|
||||||
|
await db.version.update({
|
||||||
|
where: { id: versionId },
|
||||||
|
data: {
|
||||||
|
isClientVisible: true,
|
||||||
|
sharedAt: new Date(),
|
||||||
|
sharedById: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move task to CLIENT_REVIEW if it is in INTERNAL_REVIEW; recalc shot status
|
||||||
|
if (version.taskId) {
|
||||||
|
const task = await db.task.findUnique({
|
||||||
|
where: { id: version.taskId },
|
||||||
|
select: { status: true, projectId: true, shotId: true },
|
||||||
|
});
|
||||||
|
if (task && task.status === "INTERNAL_REVIEW") {
|
||||||
|
await db.task.update({
|
||||||
|
where: { id: version.taskId },
|
||||||
|
data: { status: "CLIENT_REVIEW" },
|
||||||
|
});
|
||||||
|
if (task.shotId) {
|
||||||
|
await recalcShotStatus(task.shotId).catch(() => {});
|
||||||
|
}
|
||||||
|
revalidatePath(`/tasks/${version.taskId}`);
|
||||||
|
revalidatePath(`/projects/${task.projectId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/review/${versionId}`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVersionById(versionId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return db.version.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
include: {
|
||||||
|
task: {
|
||||||
|
include: {
|
||||||
|
project: { include: { client: true } },
|
||||||
|
shot: true,
|
||||||
|
asset: true,
|
||||||
|
versions: {
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
artist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
approvals: {
|
||||||
|
include: { user: { select: { id: true, name: true, role: true } } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVersionComments(versionId: string) {
|
||||||
|
return db.comment.findMany({
|
||||||
|
where: { versionId },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, email: true, image: true, role: true } },
|
||||||
|
replies: {
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, email: true, image: true, role: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
select: { id: true, frameNumber: true, drawingData: true, color: true, isVisible: true, authorId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { frameNumber: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { auth } from '@/auth';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Montserrat } from 'next/font/google';
|
||||||
|
|
||||||
|
const montserrat = Montserrat({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['200', '500', '600'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
if (session?.user) redirect('/dashboard');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-zinc-950">
|
||||||
|
<div className="w-full max-w-md px-4">
|
||||||
|
{/* Logo / Brand */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-md bg-black border border-zinc-800 shadow-2xl">
|
||||||
|
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${montserrat.className} mt-4`}>
|
||||||
|
<span className="block text-2xl font-light tracking-wide text-white leading-none">
|
||||||
|
TWO TALES
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="block text-[11px] tracking-[0.28em] italic uppercase text-zinc-500 leading-none mt-1">
|
||||||
|
vfx review
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Eye, EyeOff, LogIn } from "lucide-react";
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string().min(1, "Password is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||||
|
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<LoginFormValues>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginFormValues) => {
|
||||||
|
setAuthError(null);
|
||||||
|
const result = await signIn("credentials", {
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setAuthError("Invalid email or password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(callbackUrl);
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-xl align-middle text-center">Sign in</CardTitle>
|
||||||
|
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@studio.com"
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
{...register("email")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-xs text-red-400">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="pr-10"
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-xs text-red-400">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authError && (
|
||||||
|
<p className="text-sm text-red-400 text-center">{authError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full gap-2" disabled={isSubmitting}>
|
||||||
|
<LogIn className="h-4 w-4" />
|
||||||
|
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { Building2, Mail, User2, Phone, ArrowLeft, ExternalLink, Copy } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ShareReviewDialog } from "@/components/clients/ShareReviewDialog";
|
||||||
|
import { ReviewSessionList } from "@/components/clients/ReviewSessionList";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Client Detail" };
|
||||||
|
|
||||||
|
async function getClient(clientId: string) {
|
||||||
|
return db.client.findUnique({
|
||||||
|
where: { id: clientId },
|
||||||
|
include: {
|
||||||
|
projects: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
_count: { select: { shots: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getReviewSessions(projectIds: string[]) {
|
||||||
|
if (projectIds.length === 0) return [];
|
||||||
|
return db.reviewSession.findMany({
|
||||||
|
where: { projectId: { in: projectIds }, isActive: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { project: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ClientDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ clientId: string }>;
|
||||||
|
}) {
|
||||||
|
const { clientId } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
if (!session || !["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient(clientId);
|
||||||
|
if (!client) notFound();
|
||||||
|
|
||||||
|
const projectIds = client.projects.map((p) => p.id);
|
||||||
|
const reviewSessions = await getReviewSessions(projectIds);
|
||||||
|
|
||||||
|
const PROJECT_STATUS_COLORS: Record<string, string> = {
|
||||||
|
ACTIVE: "text-emerald-400 bg-emerald-500/10",
|
||||||
|
COMPLETED: "text-zinc-400 bg-zinc-700",
|
||||||
|
ON_HOLD: "text-amber-400 bg-amber-500/10",
|
||||||
|
CANCELLED: "text-red-400 bg-red-500/10",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||||
|
{/* Back */}
|
||||||
|
<Link
|
||||||
|
href="/clients"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
All Clients
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Client header */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-zinc-800 border border-zinc-700 flex items-center justify-center shrink-0">
|
||||||
|
<Building2 className="h-6 w-6 text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">{client.company}</h1>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full mt-1 inline-block",
|
||||||
|
client.isActive
|
||||||
|
? "bg-emerald-500/10 text-emerald-400"
|
||||||
|
: "bg-zinc-700 text-zinc-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{client.isActive ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ShareReviewDialog
|
||||||
|
clientId={client.id}
|
||||||
|
clientEmail={client.email ?? ""}
|
||||||
|
projects={client.projects}
|
||||||
|
>
|
||||||
|
<Button className="gap-2 shrink-0">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Share Review Link
|
||||||
|
</Button>
|
||||||
|
</ShareReviewDialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{client.contactPerson && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-300">
|
||||||
|
<User2 className="h-4 w-4 text-zinc-600 shrink-0" />
|
||||||
|
<span>{client.contactPerson}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.email && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-300">
|
||||||
|
<Mail className="h-4 w-4 text-zinc-600 shrink-0" />
|
||||||
|
<a href={`mailto:${client.email}`} className="hover:text-amber-400 transition-colors">
|
||||||
|
{client.email}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.phone && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-300">
|
||||||
|
<Phone className="h-4 w-4 text-zinc-600 shrink-0" />
|
||||||
|
<span>{client.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{client.notes && (
|
||||||
|
<p className="mt-4 text-sm text-zinc-400 border-t border-zinc-800 pt-4">
|
||||||
|
{client.notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-3">
|
||||||
|
Projects ({client.projects.length})
|
||||||
|
</h2>
|
||||||
|
{client.projects.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">No projects assigned to this client.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{client.projects.map((project) => (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
href={`/projects/${project.id}`}
|
||||||
|
className="flex items-center gap-4 p-4 rounded-xl border border-zinc-800 bg-zinc-900 hover:border-zinc-600 hover:bg-zinc-800/60 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-white">{project.name}</p>
|
||||||
|
<p className="text-xs font-mono text-zinc-500">{project.code}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
{project._count.shots} shots
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full",
|
||||||
|
PROJECT_STATUS_COLORS[project.status] ?? "text-zinc-400 bg-zinc-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{project.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Review sessions */}
|
||||||
|
<ReviewSessionList sessions={reviewSessions} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { PlusCircle, Building2, Mail, User2, FolderOpen } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { NewClientDialog } from "@/components/clients/NewClientDialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Clients" };
|
||||||
|
|
||||||
|
async function getClients() {
|
||||||
|
return db.client.findMany({
|
||||||
|
orderBy: { company: "asc" },
|
||||||
|
include: { _count: { select: { projects: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ClientsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session || !["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = await getClients();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Clients</h1>
|
||||||
|
<p className="text-sm text-zinc-400 mt-0.5">
|
||||||
|
{clients.length} client{clients.length !== 1 ? "s" : ""} total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NewClientDialog>
|
||||||
|
<Button className="gap-2">
|
||||||
|
<PlusCircle className="h-4 w-4" />
|
||||||
|
New Client
|
||||||
|
</Button>
|
||||||
|
</NewClientDialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
{clients.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-zinc-700 bg-zinc-900/40 p-16 text-center">
|
||||||
|
<Building2 className="h-10 w-10 mx-auto mb-3 text-zinc-600" />
|
||||||
|
<p className="text-zinc-400 font-medium">No clients yet</p>
|
||||||
|
<p className="text-zinc-600 text-sm mt-1">
|
||||||
|
Add your first client to start sharing reviews.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{clients.map((client) => (
|
||||||
|
<Link
|
||||||
|
key={client.id}
|
||||||
|
href={`/clients/${client.id}`}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4",
|
||||||
|
"hover:border-zinc-600 hover:bg-zinc-800/60 transition-all group"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Avatar + company */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-zinc-800 border border-zinc-700 flex items-center justify-center shrink-0 group-hover:bg-zinc-700 transition-colors">
|
||||||
|
<Building2 className="h-5 w-5 text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-white truncate">{client.company}</p>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full",
|
||||||
|
client.isActive
|
||||||
|
? "bg-emerald-500/10 text-emerald-400"
|
||||||
|
: "bg-zinc-700 text-zinc-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{client.isActive ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{client.contactPerson && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-zinc-400">
|
||||||
|
<User2 className="h-3.5 w-3.5 text-zinc-600 shrink-0" />
|
||||||
|
<span className="truncate">{client.contactPerson}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.email && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-zinc-400">
|
||||||
|
<Mail className="h-3.5 w-3.5 text-zinc-600 shrink-0" />
|
||||||
|
<span className="truncate">{client.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project count */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-zinc-500 pt-2 border-t border-zinc-800">
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
<span>{client._count.projects} project{client._count.projects !== 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { StatsCards } from "@/components/dashboard/StatsCards";
|
||||||
|
import { ShotQueue } from "@/components/dashboard/ShotQueue";
|
||||||
|
import { RecentActivity } from "@/components/dashboard/RecentActivity";
|
||||||
|
import { TaskWidgets } from "@/components/dashboard/TaskWidgets";
|
||||||
|
import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets";
|
||||||
|
import type { DashboardStats } from "@/types";
|
||||||
|
|
||||||
|
export const metadata = { title: "Dashboard" };
|
||||||
|
|
||||||
|
async function getDashboardData(userId: string, role: string) {
|
||||||
|
const isArtist = role === "ARTIST";
|
||||||
|
const isClient = role === "CLIENT";
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const todayEnd = new Date(todayStart.getTime() + 86400000);
|
||||||
|
|
||||||
|
const [awaitingReview, needsRevisions, approved, activeProjects,
|
||||||
|
tasksDueToday, tasksInReview, tasksOverdue, myTasksCount] =
|
||||||
|
await Promise.all([
|
||||||
|
db.version.count({ where: { approvalStatus: "PENDING_REVIEW" } }),
|
||||||
|
db.version.count({ where: { approvalStatus: "NEEDS_CHANGES" } }),
|
||||||
|
db.version.count({ where: { approvalStatus: "APPROVED" } }),
|
||||||
|
db.project.count({ where: { status: "ACTIVE" } }),
|
||||||
|
db.task.count({
|
||||||
|
where: {
|
||||||
|
dueDate: { gte: todayStart, lt: todayEnd },
|
||||||
|
status: { not: "DONE" },
|
||||||
|
...(isArtist ? { assignedArtistId: userId } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.task.count({
|
||||||
|
where: {
|
||||||
|
status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] },
|
||||||
|
...(isArtist ? { assignedArtistId: userId } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.task.count({
|
||||||
|
where: {
|
||||||
|
dueDate: { lt: todayStart },
|
||||||
|
status: { not: "DONE" },
|
||||||
|
...(isArtist ? { assignedArtistId: userId } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.task.count({
|
||||||
|
where: { assignedArtistId: userId, status: { not: "DONE" } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shotsWhere = isArtist ? { artistId: userId } : {};
|
||||||
|
const shots = await db.shot.findMany({
|
||||||
|
where: { ...shotsWhere, status: { not: "COMPLETE" } },
|
||||||
|
take: 15,
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
include: {
|
||||||
|
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
versions: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
include: { comments: { select: { id: true, isResolved: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// My tasks (for widget)
|
||||||
|
const myTasks = await db.task.findMany({
|
||||||
|
where: {
|
||||||
|
assignedArtistId: userId,
|
||||||
|
status: { not: "DONE" },
|
||||||
|
},
|
||||||
|
take: 8,
|
||||||
|
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
|
||||||
|
include: {
|
||||||
|
shot: { select: { shotCode: true } },
|
||||||
|
asset: { select: { assetCode: true } },
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tasks in review (for supervisors/producers)
|
||||||
|
const reviewTasks = isArtist || isClient ? [] : await db.task.findMany({
|
||||||
|
where: { status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] } },
|
||||||
|
take: 8,
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
include: {
|
||||||
|
shot: { select: { shotCode: true } },
|
||||||
|
asset: { select: { assetCode: true } },
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
assignedArtist: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activity = await db.notification.findMany({
|
||||||
|
where: isClient ? { userId } : {},
|
||||||
|
take: 20,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { user: { select: { name: true, image: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule data (for producers/supervisors/admins)
|
||||||
|
const scheduledTasks =
|
||||||
|
!isArtist && !isClient
|
||||||
|
? await db.task.findMany({
|
||||||
|
where: { scheduledStartDate: { not: null }, status: { not: "DONE" } },
|
||||||
|
include: {
|
||||||
|
shot: { select: { shotCode: true } },
|
||||||
|
asset: { select: { assetCode: true } },
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
assignedArtist: {
|
||||||
|
select: { id: true, name: true, email: true, image: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { scheduledStartDate: "asc" },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const scheduleArtists =
|
||||||
|
!isArtist && !isClient
|
||||||
|
? await db.user.findMany({
|
||||||
|
where: { isActive: true, role: { not: "CLIENT" } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
image: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const stats: DashboardStats = {
|
||||||
|
awaitingReview,
|
||||||
|
needsRevisions,
|
||||||
|
approved,
|
||||||
|
overdue: tasksOverdue,
|
||||||
|
activeProjects,
|
||||||
|
tasksDueToday,
|
||||||
|
tasksInReview,
|
||||||
|
tasksOverdue,
|
||||||
|
myTasksCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return null;
|
||||||
|
|
||||||
|
const { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists } =
|
||||||
|
await getDashboardData(session.user.id, session.user.role);
|
||||||
|
|
||||||
|
const isClient = session.user.role === "CLIENT";
|
||||||
|
const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
|
||||||
|
session.user.role
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
|
||||||
|
<p className="text-zinc-400 mt-1">
|
||||||
|
Welcome back, {session.user.name ?? session.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatsCards stats={stats} />
|
||||||
|
|
||||||
|
{!isClient && (
|
||||||
|
<TaskWidgets
|
||||||
|
myTasks={myTasks as any}
|
||||||
|
reviewTasks={reviewTasks as any}
|
||||||
|
role={session.user.role}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canSeeSchedule && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Schedule Overview
|
||||||
|
</h2>
|
||||||
|
<ScheduleWidgets
|
||||||
|
scheduledTasks={scheduledTasks as any}
|
||||||
|
artists={scheduleArtists}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
<div className="xl:col-span-2 space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Shot Queue
|
||||||
|
</h2>
|
||||||
|
<div className="rounded-lg border border-border bg-card p-1">
|
||||||
|
<ShotQueue shots={shots as any} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Recent Activity
|
||||||
|
</h2>
|
||||||
|
<div className="rounded-lg border border-border bg-card h-[400px] overflow-hidden">
|
||||||
|
<RecentActivity activities={activity as any} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Sidebar } from "@/components/layout/Sidebar";
|
||||||
|
import { Header } from "@/components/layout/Header";
|
||||||
|
|
||||||
|
export default async function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-zinc-950 overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ShotCard } from "@/components/shots/ShotCard";
|
||||||
|
import { NewShotDialog } from "@/components/shots/NewShotDialog";
|
||||||
|
import { Film, Plus } from "lucide-react";
|
||||||
|
import type { ShotWithDetails } from "@/types";
|
||||||
|
|
||||||
|
interface ProjectShotsClientProps {
|
||||||
|
projectId: string;
|
||||||
|
shots: ShotWithDetails[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectShotsClient({ projectId, shots }: ProjectShotsClientProps) {
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold">Shots</h2>
|
||||||
|
<Button size="sm" className="gap-2" onClick={() => setShowNew(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Shot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shots.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-muted-foreground border border-dashed border-border rounded-lg">
|
||||||
|
<Film className="h-8 w-8 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">No shots yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{shots.map((shot) => (
|
||||||
|
<ShotCard key={shot.id} shot={shot} projectId={projectId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NewShotDialog
|
||||||
|
projectId={projectId}
|
||||||
|
open={showNew}
|
||||||
|
onClose={() => setShowNew(false)}
|
||||||
|
onSuccess={() => setShowNew(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ShotCard } from "@/components/shots/ShotCard";
|
||||||
|
import { NewShotDialog } from "@/components/shots/NewShotDialog";
|
||||||
|
import { ImportShotsDialog } from "@/components/shots/ImportShotsDialog";
|
||||||
|
import { AssetCard } from "@/components/assets/AssetCard";
|
||||||
|
import { NewAssetDialog } from "@/components/assets/NewAssetDialog";
|
||||||
|
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 } from "lucide-react";
|
||||||
|
import type { ShotWithDetails } from "@/types";
|
||||||
|
import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab";
|
||||||
|
|
||||||
|
type Tab = "shots" | "assets" | "tasks" | "kanban" | "settings";
|
||||||
|
|
||||||
|
interface Artist {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
id: string;
|
||||||
|
company: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamMember {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectTabsClientProps {
|
||||||
|
projectId: string;
|
||||||
|
projectType: "STANDARD" | "EPISODIC";
|
||||||
|
projectSettings: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
showId: string;
|
||||||
|
projectType: "STANDARD" | "EPISODIC";
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
clientId: string | null;
|
||||||
|
producerId: string | null;
|
||||||
|
supervisorId: string | null;
|
||||||
|
dueDate: Date | null;
|
||||||
|
startDate: Date | null;
|
||||||
|
slackWebhook: string | null;
|
||||||
|
slackChannel: string | null;
|
||||||
|
};
|
||||||
|
clients: Client[];
|
||||||
|
teamMembers: TeamMember[];
|
||||||
|
shots: ShotWithDetails[];
|
||||||
|
assets: any[];
|
||||||
|
tasks: any[];
|
||||||
|
artists: Artist[];
|
||||||
|
canManage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectTabsClient({
|
||||||
|
projectId,
|
||||||
|
projectType,
|
||||||
|
projectSettings,
|
||||||
|
clients,
|
||||||
|
teamMembers,
|
||||||
|
shots,
|
||||||
|
assets,
|
||||||
|
tasks,
|
||||||
|
artists,
|
||||||
|
canManage,
|
||||||
|
}: ProjectTabsClientProps) {
|
||||||
|
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 tabs: { id: Tab; label: string; icon: React.ElementType; count: number; managerOnly?: boolean }[] = [
|
||||||
|
{ id: "shots", label: "Shots", icon: Film, count: shots.length },
|
||||||
|
{ id: "assets", label: "Assets", icon: Package, count: assets.length },
|
||||||
|
{ id: "tasks", label: "All Tasks", icon: ListTodo, count: tasks.length },
|
||||||
|
{ id: "kanban", label: "Kanban", icon: LayoutDashboard, count: 0 },
|
||||||
|
{ id: "settings", label: "Settings", icon: Settings, count: 0, managerOnly: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const visibleTabs = tabs.filter((t) => !t.managerOnly || canManage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-0">
|
||||||
|
<div className="flex">
|
||||||
|
{visibleTabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||||
|
activeTab === tab.id
|
||||||
|
? "border-amber-500 text-amber-400"
|
||||||
|
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{tab.label}
|
||||||
|
{tab.count > 0 && (
|
||||||
|
<span className="text-xs text-zinc-500">{tab.count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context-sensitive add button */}
|
||||||
|
{canManage && (
|
||||||
|
<div className="pb-1">
|
||||||
|
{activeTab === "shots" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="gap-2 h-8" onClick={() => setShowImportShots(true)}>
|
||||||
|
<FileUp className="h-3.5 w-3.5" /> Import CSV
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewShot(true)}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> New Shot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === "assets" && (
|
||||||
|
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewAsset(true)}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> New Asset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(activeTab === "tasks" || activeTab === "kanban") && (
|
||||||
|
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewTask(true)}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> New Task
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{activeTab === "shots" && (
|
||||||
|
<div>
|
||||||
|
{shots.length === 0 ? (
|
||||||
|
<EmptyState icon={Film} label="No shots yet" />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{shots.map((shot) => (
|
||||||
|
<ShotCard key={shot.id} shot={shot} projectId={projectId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "assets" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assets.length === 0 ? (
|
||||||
|
<EmptyState icon={Package} label="No assets yet" />
|
||||||
|
) : (
|
||||||
|
assets.map((asset) => (
|
||||||
|
<AssetCard
|
||||||
|
key={asset.id}
|
||||||
|
asset={asset}
|
||||||
|
projectId={projectId}
|
||||||
|
artists={artists}
|
||||||
|
canManage={canManage}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "tasks" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<EmptyState icon={ListTodo} label="No tasks yet" />
|
||||||
|
) : (
|
||||||
|
tasks.map((task) => (
|
||||||
|
<TaskCard key={task.id} task={task} projectId={projectId} canManage={canManage} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "kanban" && (
|
||||||
|
<KanbanBoard tasks={tasks} projectId={projectId} artists={artists} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "settings" && canManage && (
|
||||||
|
<ProjectSettingsTab
|
||||||
|
project={projectSettings}
|
||||||
|
clients={clients}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<NewShotDialog
|
||||||
|
projectId={projectId}
|
||||||
|
projectType={projectType}
|
||||||
|
open={showNewShot}
|
||||||
|
onClose={() => setShowNewShot(false)}
|
||||||
|
onSuccess={() => setShowNewShot(false)}
|
||||||
|
/>
|
||||||
|
<ImportShotsDialog
|
||||||
|
projectId={projectId}
|
||||||
|
projectType={projectType}
|
||||||
|
open={showImportShots}
|
||||||
|
onClose={() => setShowImportShots(false)}
|
||||||
|
onSuccess={() => setShowImportShots(false)}
|
||||||
|
/>
|
||||||
|
<NewAssetDialog
|
||||||
|
projectId={projectId}
|
||||||
|
open={showNewAsset}
|
||||||
|
onClose={() => setShowNewAsset(false)}
|
||||||
|
onSuccess={() => setShowNewAsset(false)}
|
||||||
|
/>
|
||||||
|
<NewTaskDialog
|
||||||
|
projectId={projectId}
|
||||||
|
artists={artists}
|
||||||
|
open={showNewTask}
|
||||||
|
onClose={() => setShowNewTask(false)}
|
||||||
|
onSuccess={() => setShowNewTask(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ icon: Icon, label }: { icon: React.ElementType; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10 text-muted-foreground border border-dashed border-border rounded-lg">
|
||||||
|
<Icon className="h-8 w-8 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
|
||||||
|
import { ArrowLeft, LayoutDashboard } from "lucide-react";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
return { title: project ? `${project.name} — Kanban` : "Kanban" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function KanbanPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
// Clients cannot access kanban
|
||||||
|
if (session.user.role === "CLIENT") redirect(`/projects/${id}`);
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, name: true, code: true },
|
||||||
|
});
|
||||||
|
if (!project) notFound();
|
||||||
|
|
||||||
|
const [tasks, artists] = await Promise.all([
|
||||||
|
db.task.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
|
||||||
|
include: {
|
||||||
|
shot: { select: { id: true, shotCode: true } },
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
|
assignedArtist: {
|
||||||
|
select: { id: true, name: true, email: true, image: true },
|
||||||
|
},
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
versions: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versionNumber: true,
|
||||||
|
approvalStatus: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.user.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6 max-w-[1800px] mx-auto">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Link href="/projects" className="hover:text-foreground transition-colors">
|
||||||
|
Projects
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link
|
||||||
|
href={`/projects/${id}`}
|
||||||
|
className="hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">Kanban</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/projects/${id}`}
|
||||||
|
className="text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<LayoutDashboard className="h-5 w-5 text-amber-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">{project.name}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Kanban Board — {tasks.length} tasks</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Board */}
|
||||||
|
<KanbanBoard tasks={tasks} projectId={id} artists={artists} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ProjectTabsClient } from "./ProjectTabsClient";
|
||||||
|
import {
|
||||||
|
Film,
|
||||||
|
Layers,
|
||||||
|
CheckCircle2,
|
||||||
|
ListTodo,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const project = await db.project.findUnique({ where: { id }, select: { name: true } });
|
||||||
|
return { title: project?.name ?? "Project" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProject(id: string) {
|
||||||
|
return db.project.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
client: true,
|
||||||
|
producer: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
supervisor: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
shots: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
versions: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
include: {
|
||||||
|
comments: { select: { id: true, isResolved: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
orderBy: { assetCode: "asc" },
|
||||||
|
include: {
|
||||||
|
lead: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
_count: { select: { tasks: true } },
|
||||||
|
tasks: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: {
|
||||||
|
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
versions: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
|
||||||
|
include: {
|
||||||
|
shot: { select: { id: true, shotCode: true } },
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
|
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
versions: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectArtists() {
|
||||||
|
return db.user.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getClients() {
|
||||||
|
return db.client.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, company: true },
|
||||||
|
orderBy: { company: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTeamMembers() {
|
||||||
|
return db.user.findMany({
|
||||||
|
where: { isActive: true, role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] } },
|
||||||
|
select: { id: true, name: true, email: true, role: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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([
|
||||||
|
getProject(id),
|
||||||
|
getProjectArtists(),
|
||||||
|
getClients(),
|
||||||
|
getTeamMembers(),
|
||||||
|
]);
|
||||||
|
if (!project) notFound();
|
||||||
|
|
||||||
|
const canManage = session?.user && ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
|
||||||
|
|
||||||
|
const totalShots = project.shots.length;
|
||||||
|
const approvedShots = project.shots.filter((s) => s.status === "COMPLETE").length;
|
||||||
|
const totalTasks = project.tasks.length;
|
||||||
|
const doneTasks = project.tasks.filter((t) => t.status === "DONE").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||||
|
<Link href="/projects" className="hover:text-white transition-colors">
|
||||||
|
Projects
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-white">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<span className="font-mono text-sm text-muted-foreground">{project.code}</span>
|
||||||
|
{project.client && (
|
||||||
|
<span className="text-sm text-muted-foreground">• {project.client.company}</span>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
project.status === "ACTIVE"
|
||||||
|
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
|
||||||
|
: "bg-zinc-500/10 text-zinc-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{project.status.replace("_", " ")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">{project.name}</h1>
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-zinc-400 mt-1">{project.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 max-w-md">
|
||||||
|
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
|
||||||
|
<Layers className="h-4 w-4 text-zinc-400 mb-1" />
|
||||||
|
<span className="text-xl font-bold text-white">{totalShots}</span>
|
||||||
|
<span className="text-xs text-zinc-400">shots</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-400 mb-1" />
|
||||||
|
<span className="text-xl font-bold text-white">{approvedShots}</span>
|
||||||
|
<span className="text-xs text-zinc-400">approved</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
|
||||||
|
<ListTodo className="h-4 w-4 text-amber-400 mb-1" />
|
||||||
|
<span className="text-xl font-bold text-white">{totalTasks}</span>
|
||||||
|
<span className="text-xs text-zinc-400">tasks</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
|
||||||
|
<Film className="h-4 w-4 text-blue-400 mb-1" />
|
||||||
|
<span className="text-xl font-bold text-white">{doneTasks}</span>
|
||||||
|
<span className="text-xs text-zinc-400">done</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<ProjectTabsClient
|
||||||
|
projectId={id}
|
||||||
|
projectType={project.projectType}
|
||||||
|
projectSettings={{
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
code: project.code,
|
||||||
|
showId: project.showId,
|
||||||
|
projectType: project.projectType,
|
||||||
|
description: project.description,
|
||||||
|
status: project.status,
|
||||||
|
clientId: project.clientId,
|
||||||
|
producerId: project.producerId,
|
||||||
|
supervisorId: project.supervisorId,
|
||||||
|
dueDate: project.dueDate,
|
||||||
|
startDate: project.startDate,
|
||||||
|
slackWebhook: project.slackWebhook,
|
||||||
|
slackChannel: project.slackChannel,
|
||||||
|
}}
|
||||||
|
clients={clients}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
shots={project.shots as any}
|
||||||
|
assets={project.assets as any}
|
||||||
|
tasks={project.tasks as any}
|
||||||
|
artists={artists}
|
||||||
|
canManage={!!canManage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { TaskList } from "@/components/tasks/TaskList";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { getInitials } from "@/lib/utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Film,
|
||||||
|
ArrowLeft,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Settings,
|
||||||
|
ListTodo,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ShotWithDetails } from "@/types";
|
||||||
|
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; className: string; Icon: React.ElementType }
|
||||||
|
> = {
|
||||||
|
WAITING: { label: "Waiting", className: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", Icon: Clock },
|
||||||
|
IN_PROGRESS: { label: "In Progress", className: "bg-blue-500/10 text-blue-400 border-blue-500/20", Icon: Film },
|
||||||
|
IN_REVIEW: { label: "In Review", className: "bg-amber-500/10 text-amber-400 border-amber-500/20", Icon: AlertCircle },
|
||||||
|
REVISIONS: { label: "Revisions", className: "bg-orange-500/10 text-orange-400 border-orange-500/20", Icon: AlertCircle },
|
||||||
|
COMPLETE: { label: "Complete", className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", Icon: CheckCircle2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
|
||||||
|
LOW: { label: "Low", dot: "bg-zinc-400" },
|
||||||
|
NORMAL: { label: "Normal", dot: "bg-blue-400" },
|
||||||
|
HIGH: { label: "High", dot: "bg-amber-400" },
|
||||||
|
CRITICAL: { label: "Critical", dot: "bg-red-500" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ShotDetailPage() {
|
||||||
|
const params = useParams<{ id: string; shotId: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [shot, setShot] = useState<ShotWithDetails | null>(null);
|
||||||
|
const [projectName, setProjectName] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [canApprove, setCanApprove] = useState(false);
|
||||||
|
const [tasks, setTasks] = useState<any[]>([]);
|
||||||
|
const [artists, setArtists] = useState<any[]>([]);
|
||||||
|
const [canManage, setCanManage] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<"tasks" | "settings">("tasks");
|
||||||
|
|
||||||
|
const fetchShot = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/shots/${params.shotId}?projectId=${params.id}`);
|
||||||
|
if (res.status === 404) {
|
||||||
|
router.push("/projects");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setShot(data.shot);
|
||||||
|
setProjectName(data.projectName ?? "");
|
||||||
|
setCanApprove(data.canApprove ?? false);
|
||||||
|
setTasks(data.tasks ?? []);
|
||||||
|
setArtists(data.artists ?? []);
|
||||||
|
setCanManage(data.canApprove ?? false);
|
||||||
|
} catch {
|
||||||
|
router.push("/projects");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchShot();
|
||||||
|
}, [params.shotId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Film className="h-6 w-6 animate-pulse text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shot) return null;
|
||||||
|
|
||||||
|
const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
|
||||||
|
const priorityCfg = PRIORITY_CONFIG[shot.priority] ?? PRIORITY_CONFIG.NORMAL;
|
||||||
|
const { Icon: StatusIcon } = statusCfg;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6 max-w-[1400px] mx-auto">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Link href="/projects" className="hover:text-foreground transition-colors">
|
||||||
|
Projects
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={`/projects/${params.id}`} className="hover:text-foreground transition-colors">
|
||||||
|
{projectName}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground font-mono">{shot.shotCode}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-6">
|
||||||
|
{/* Thumbnail – cinema scope 2.39:1 */}
|
||||||
|
<Button variant="ghost" size="icon" asChild className="-ml-2 h-8 w-8">
|
||||||
|
<Link href={`/projects/${params.id}`}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{shot.thumbnailUrl && (
|
||||||
|
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
|
||||||
|
|
||||||
|
<Image
|
||||||
|
src={shot.thumbnailUrl}
|
||||||
|
alt={shot.shotCode}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
|
||||||
|
{shot.sequence && (
|
||||||
|
<span className="text-sm text-muted-foreground">Seq: {shot.sequence}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shot.description && (
|
||||||
|
<p className="text-muted-foreground ">{shot.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<Badge className={statusCfg.className} variant="outline">
|
||||||
|
<StatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{statusCfg.label}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={`h-2 w-2 rounded-full ${priorityCfg.dot}`}
|
||||||
|
/>
|
||||||
|
{priorityCfg.label} Priority
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground">{shot.fps} fps</span>
|
||||||
|
|
||||||
|
{shot.artist && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Avatar className="h-5 w-5">
|
||||||
|
<AvatarImage src={shot.artist.image ?? undefined} />
|
||||||
|
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||||
|
{getInitials(shot.artist.name ?? shot.artist.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{shot.artist.name ?? shot.artist.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div>
|
||||||
|
<div className="flex border-b border-border mb-5">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("tasks")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||||
|
activeTab === "tasks"
|
||||||
|
? "border-amber-500 text-amber-400"
|
||||||
|
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ListTodo className="h-4 w-4" />
|
||||||
|
Tasks
|
||||||
|
</button>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("settings")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||||
|
activeTab === "settings"
|
||||||
|
? "border-amber-500 text-amber-400"
|
||||||
|
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "tasks" && (
|
||||||
|
<TaskList
|
||||||
|
tasks={tasks}
|
||||||
|
projectId={params.id}
|
||||||
|
shotId={shot.id}
|
||||||
|
artists={artists}
|
||||||
|
canManage={canManage}
|
||||||
|
onTaskCreated={fetchShot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "settings" && canManage && (
|
||||||
|
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ProjectCard } from "@/components/projects/ProjectCard";
|
||||||
|
import { NewProjectDialog } from "@/components/projects/NewProjectDialog";
|
||||||
|
import { Plus, Search, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["projects", search],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/projects?q=${encodeURIComponent(search)}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch projects");
|
||||||
|
return res.json() as Promise<{ projects: any[] }>;
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: clientsData } = useQuery({
|
||||||
|
queryKey: ["clients"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/clients");
|
||||||
|
if (!res.ok) return { clients: [] };
|
||||||
|
return res.json() as Promise<{ clients: { id: string; company: string }[] }>;
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const projects = data?.projects ?? [];
|
||||||
|
const clients = clientsData?.clients ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||||
|
<div className="flex items-center justify-between gap-4 mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">Projects</h1>
|
||||||
|
<p className="text-zinc-400 mt-1">
|
||||||
|
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowNew(true)} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search projects..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<p>No projects found.</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => setShowNew(true)}
|
||||||
|
>
|
||||||
|
Create your first project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NewProjectDialog
|
||||||
|
open={showNew}
|
||||||
|
onClose={() => setShowNew(false)}
|
||||||
|
clients={clients}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragStartEvent,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
startOfWeek,
|
||||||
|
startOfDay,
|
||||||
|
format,
|
||||||
|
differenceInDays,
|
||||||
|
isBefore,
|
||||||
|
parseISO,
|
||||||
|
} from "date-fns";
|
||||||
|
import { scheduleTask, unscheduleTask } from "@/actions/schedule";
|
||||||
|
import { ScheduleTimeline } from "@/components/schedule/ScheduleTimeline";
|
||||||
|
import { BacklogPanel } from "@/components/schedule/BacklogPanel";
|
||||||
|
import { ScheduleFilters } from "@/components/schedule/ScheduleFilters";
|
||||||
|
import { CalendarDays, PanelRightOpen, PanelRightClose, Columns2, Rows } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { TaskStatus, TaskType } from "@prisma/client";
|
||||||
|
|
||||||
|
export const DAY_WIDTH = 216;
|
||||||
|
export const ROW_HEIGHT = 56;
|
||||||
|
export const HEADER_HEIGHT = 44;
|
||||||
|
export const LABEL_WIDTH = 208;
|
||||||
|
export const NUM_DAYS = 35;
|
||||||
|
export const SLOT_HEIGHT = 48; // task height = ROW_HEIGHT - 8
|
||||||
|
|
||||||
|
export interface ScheduleTask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: TaskType;
|
||||||
|
status: TaskStatus;
|
||||||
|
priority: string;
|
||||||
|
dueDate: string | null;
|
||||||
|
estimatedHours: number | null;
|
||||||
|
scheduledStartDate: string | null;
|
||||||
|
scheduledEndDate: string | null;
|
||||||
|
scheduleNotes: string | null;
|
||||||
|
assignedArtistId: string | null;
|
||||||
|
assignedArtist: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
image: string | null;
|
||||||
|
} | null;
|
||||||
|
shot: { id: string; shotCode: string; thumbnailUrl: string | null } | null;
|
||||||
|
asset: { id: string; assetCode: string; name: string } | null;
|
||||||
|
project: { id: string; name: string; code: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleArtist {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
image: string | null;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveDragData {
|
||||||
|
type: "scheduled" | "backlog" | "resize";
|
||||||
|
taskId: string;
|
||||||
|
duration?: number;
|
||||||
|
estimatedHours?: number | null;
|
||||||
|
originalEndDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchedulePageClientProps {
|
||||||
|
artists: ScheduleArtist[];
|
||||||
|
tasks: ScheduleTask[];
|
||||||
|
backlog: ScheduleTask[];
|
||||||
|
projects: { id: string; name: string; code: string }[];
|
||||||
|
canEdit: boolean;
|
||||||
|
currentUserId: string;
|
||||||
|
activeProject?: string;
|
||||||
|
activeArtist?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDate(val: string | null | undefined): Date | null {
|
||||||
|
if (!val) return null;
|
||||||
|
try {
|
||||||
|
return parseISO(val);
|
||||||
|
} catch {
|
||||||
|
return new Date(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcDuration(task: ScheduleTask): number {
|
||||||
|
return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchedulePageClient({
|
||||||
|
artists,
|
||||||
|
tasks,
|
||||||
|
backlog,
|
||||||
|
projects,
|
||||||
|
canEdit,
|
||||||
|
currentUserId,
|
||||||
|
activeProject,
|
||||||
|
activeArtist,
|
||||||
|
}: SchedulePageClientProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [localTasks, setLocalTasks] = useState<ScheduleTask[]>(tasks);
|
||||||
|
const [localBacklog, setLocalBacklog] = useState<ScheduleTask[]>(backlog);
|
||||||
|
const [viewStart, setViewStart] = useState<Date>(() =>
|
||||||
|
startOfWeek(new Date(), { weekStartsOn: 1 })
|
||||||
|
);
|
||||||
|
const [showBacklog, setShowBacklog] = useState(true);
|
||||||
|
const [filterProject, setFilterProject] = useState(activeProject ?? "");
|
||||||
|
const [filterArtist, setFilterArtist] = useState(activeArtist ?? "");
|
||||||
|
const [filterStatus, setFilterStatus] = useState("");
|
||||||
|
const [activeDrag, setActiveDrag] = useState<ActiveDragData | null>(null);
|
||||||
|
const [resizePreview, setResizePreview] = useState<{
|
||||||
|
taskId: string;
|
||||||
|
endDate: Date;
|
||||||
|
estimatedHours: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [dayWidth, setDayWidth] = useState(DAY_WIDTH);
|
||||||
|
const [rowHeight, setRowHeight] = useState(ROW_HEIGHT);
|
||||||
|
|
||||||
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: { distance: 6 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const days = useMemo(
|
||||||
|
() => Array.from({ length: NUM_DAYS }, (_, i) => addDays(viewStart, i)),
|
||||||
|
[viewStart]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredArtists = useMemo(() => {
|
||||||
|
if (!filterArtist) return artists;
|
||||||
|
return artists.filter((a) => a.id === filterArtist);
|
||||||
|
}, [artists, filterArtist]);
|
||||||
|
|
||||||
|
const filteredScheduledTasks = useMemo(() => {
|
||||||
|
return localTasks.filter((t) => {
|
||||||
|
if (filterProject && t.project.id !== filterProject) return false;
|
||||||
|
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
|
||||||
|
if (filterStatus && t.status !== filterStatus) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [localTasks, filterProject, filterArtist, filterStatus]);
|
||||||
|
|
||||||
|
const filteredBacklog = useMemo(() => {
|
||||||
|
return localBacklog.filter((t) => {
|
||||||
|
if (filterProject && t.project.id !== filterProject) return false;
|
||||||
|
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
|
||||||
|
if (filterStatus && t.status !== filterStatus) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [localBacklog, filterProject, filterArtist, filterStatus]);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(event: DragStartEvent) => {
|
||||||
|
const data = event.active.data.current as ActiveDragData;
|
||||||
|
setActiveDrag(data);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
setActiveDrag(null);
|
||||||
|
const { active, delta, over } = event;
|
||||||
|
if (!active.data.current) return;
|
||||||
|
|
||||||
|
const dragData = active.data.current as ActiveDragData;
|
||||||
|
|
||||||
|
// Resize handle - only deltaX matters
|
||||||
|
if (dragData.type === "resize") {
|
||||||
|
const task = localTasks.find((t) => t.id === dragData.taskId);
|
||||||
|
if (!task?.scheduledStartDate || !dragData.originalEndDate) return;
|
||||||
|
|
||||||
|
const originalEnd = toDate(dragData.originalEndDate)!;
|
||||||
|
const daysDelta = Math.round(delta.x / dayWidth);
|
||||||
|
let newEnd = addDays(originalEnd, daysDelta);
|
||||||
|
|
||||||
|
const startDate = toDate(task.scheduledStartDate)!;
|
||||||
|
if (isBefore(newEnd, startDate)) newEnd = startDate;
|
||||||
|
|
||||||
|
const prevEnd = task.scheduledEndDate;
|
||||||
|
setLocalTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === dragData.taskId
|
||||||
|
? { ...t, scheduledEndDate: newEnd.toISOString() }
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleTask({
|
||||||
|
taskId: dragData.taskId,
|
||||||
|
scheduledStartDate: task.scheduledStartDate,
|
||||||
|
scheduledEndDate: newEnd.toISOString(),
|
||||||
|
}).catch(() => {
|
||||||
|
setLocalTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === dragData.taskId
|
||||||
|
? { ...t, scheduledEndDate: prevEnd }
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast({ title: "Failed to resize task", variant: "destructive" });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if dropped over the backlog panel (unschedule)
|
||||||
|
if (over?.id === "backlog-drop-zone" && dragData.type === "scheduled") {
|
||||||
|
const task = localTasks.find((t) => t.id === dragData.taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
setLocalTasks((prev) => prev.filter((t) => t.id !== dragData.taskId));
|
||||||
|
setLocalBacklog((prev) => [
|
||||||
|
{
|
||||||
|
...task,
|
||||||
|
scheduledStartDate: null,
|
||||||
|
scheduledEndDate: null,
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
|
|
||||||
|
unscheduleTask(dragData.taskId).catch(() => {
|
||||||
|
setLocalTasks((prev) => [...prev, task]);
|
||||||
|
setLocalBacklog((prev) =>
|
||||||
|
prev.filter((t) => t.id !== dragData.taskId)
|
||||||
|
);
|
||||||
|
toast({ title: "Failed to unschedule task", variant: "destructive" });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop on timeline - calculate position from translated rect
|
||||||
|
const translatedRect = active.rect.current.translated;
|
||||||
|
if (!translatedRect || !timelineRef.current) return;
|
||||||
|
|
||||||
|
const timelineBounds = timelineRef.current.getBoundingClientRect();
|
||||||
|
const scrollLeft = timelineRef.current.scrollLeft;
|
||||||
|
|
||||||
|
const centerX = translatedRect.left + translatedRect.width / 2;
|
||||||
|
const centerY = translatedRect.top + translatedRect.height / 2;
|
||||||
|
|
||||||
|
// Check if drop is within timeline bounds
|
||||||
|
const inTimeline =
|
||||||
|
centerX >= timelineBounds.left &&
|
||||||
|
centerX <= timelineBounds.right &&
|
||||||
|
centerY >= timelineBounds.top + HEADER_HEIGHT &&
|
||||||
|
centerY <= timelineBounds.bottom;
|
||||||
|
|
||||||
|
if (!inTimeline && dragData.type !== "backlog") return;
|
||||||
|
if (!inTimeline && dragData.type === "backlog") return;
|
||||||
|
|
||||||
|
const dayIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
Math.floor((centerX - timelineBounds.left + scrollLeft) / dayWidth),
|
||||||
|
NUM_DAYS - 1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const artistIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
Math.floor(
|
||||||
|
(centerY - timelineBounds.top - HEADER_HEIGHT) / rowHeight
|
||||||
|
),
|
||||||
|
filteredArtists.length - 1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newArtist = filteredArtists[artistIndex];
|
||||||
|
if (!newArtist) return;
|
||||||
|
|
||||||
|
const taskId = dragData.taskId;
|
||||||
|
|
||||||
|
// Duration always derived from estimatedHours so width = hours
|
||||||
|
let duration = 1;
|
||||||
|
if (dragData.type === "scheduled") {
|
||||||
|
const task = localTasks.find((t) => t.id === taskId);
|
||||||
|
if (task) duration = calcDuration(task);
|
||||||
|
} else if (dragData.type === "backlog") {
|
||||||
|
const task = localBacklog.find((t) => t.id === taskId);
|
||||||
|
if (task) duration = calcDuration(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pack task within the dropped day using hour offsets.
|
||||||
|
// Sum hours already scheduled for this artist on that day,
|
||||||
|
// and start the new task at that hour (stored in scheduledStartDate's time component).
|
||||||
|
const existingForArtist = localTasks.filter(
|
||||||
|
(t) =>
|
||||||
|
t.assignedArtistId === newArtist.id &&
|
||||||
|
t.id !== taskId &&
|
||||||
|
t.scheduledStartDate
|
||||||
|
);
|
||||||
|
|
||||||
|
const droppedDay = startOfDay(addDays(viewStart, dayIndex));
|
||||||
|
const hoursOnDay = existingForArtist
|
||||||
|
.filter((t) => {
|
||||||
|
const tStart = toDate(t.scheduledStartDate!);
|
||||||
|
return tStart && differenceInDays(startOfDay(tStart), droppedDay) === 0;
|
||||||
|
})
|
||||||
|
.reduce((sum, t) => sum + (t.estimatedHours ?? 8), 0);
|
||||||
|
|
||||||
|
const newStartDate = new Date(droppedDay);
|
||||||
|
newStartDate.setHours(hoursOnDay);
|
||||||
|
const newEndDate = addDays(newStartDate, duration - 1);
|
||||||
|
|
||||||
|
if (dragData.type === "backlog") {
|
||||||
|
const task = localBacklog.find((t) => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const scheduledTask: ScheduleTask = {
|
||||||
|
...task,
|
||||||
|
scheduledStartDate: newStartDate.toISOString(),
|
||||||
|
scheduledEndDate: newEndDate.toISOString(),
|
||||||
|
assignedArtistId: newArtist.id,
|
||||||
|
assignedArtist: {
|
||||||
|
id: newArtist.id,
|
||||||
|
name: newArtist.name,
|
||||||
|
email: newArtist.email,
|
||||||
|
image: newArtist.image,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
|
||||||
|
setLocalTasks((prev) => [...prev, scheduledTask]);
|
||||||
|
|
||||||
|
scheduleTask({
|
||||||
|
taskId,
|
||||||
|
scheduledStartDate: newStartDate.toISOString(),
|
||||||
|
scheduledEndDate: newEndDate.toISOString(),
|
||||||
|
assignedArtistId: newArtist.id,
|
||||||
|
}).catch(() => {
|
||||||
|
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||||||
|
setLocalBacklog((prev) => [task, ...prev]);
|
||||||
|
toast({ title: "Failed to schedule task", variant: "destructive" });
|
||||||
|
});
|
||||||
|
} else if (dragData.type === "scheduled") {
|
||||||
|
const prevTask = localTasks.find((t) => t.id === taskId);
|
||||||
|
|
||||||
|
setLocalTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === taskId
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
scheduledStartDate: newStartDate.toISOString(),
|
||||||
|
scheduledEndDate: newEndDate.toISOString(),
|
||||||
|
assignedArtistId: newArtist.id,
|
||||||
|
assignedArtist: {
|
||||||
|
id: newArtist.id,
|
||||||
|
name: newArtist.name,
|
||||||
|
email: newArtist.email,
|
||||||
|
image: newArtist.image,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleTask({
|
||||||
|
taskId,
|
||||||
|
scheduledStartDate: newStartDate.toISOString(),
|
||||||
|
scheduledEndDate: newEndDate.toISOString(),
|
||||||
|
assignedArtistId: newArtist.id,
|
||||||
|
}).catch(() => {
|
||||||
|
if (prevTask) {
|
||||||
|
setLocalTasks((prev) =>
|
||||||
|
prev.map((t) => (t.id === taskId ? prevTask : t))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toast({ title: "Failed to move task", variant: "destructive" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[localTasks, localBacklog, viewStart, filteredArtists, toast, dayWidth, rowHeight]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResizeMouseDown = useCallback(
|
||||||
|
(taskId: string, _currentEndDate: string) =>
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const prevTask = localTasks.find((t) => t.id === taskId);
|
||||||
|
if (!prevTask?.scheduledStartDate) return;
|
||||||
|
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startDate = toDate(prevTask.scheduledStartDate)!;
|
||||||
|
const originalHours = prevTask.estimatedHours ?? 8;
|
||||||
|
const HOUR_WIDTH = dayWidth / 8; // px per 1 hour
|
||||||
|
let currentHours = originalHours;
|
||||||
|
|
||||||
|
const onMouseMove = (me: MouseEvent) => {
|
||||||
|
const deltaPx = me.clientX - startX;
|
||||||
|
const hoursDelta = Math.round(deltaPx / HOUR_WIDTH);
|
||||||
|
currentHours = Math.max(1, originalHours + hoursDelta);
|
||||||
|
const newDays = Math.max(1, Math.ceil(currentHours / 8));
|
||||||
|
const newEndDate = addDays(startDate, newDays - 1);
|
||||||
|
setResizePreview({
|
||||||
|
taskId,
|
||||||
|
endDate: newEndDate,
|
||||||
|
estimatedHours: currentHours,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
|
||||||
|
const newDays = Math.max(1, Math.ceil(currentHours / 8));
|
||||||
|
const finalEnd = addDays(startDate, newDays - 1);
|
||||||
|
|
||||||
|
setResizePreview(null);
|
||||||
|
setLocalTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === taskId
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
scheduledEndDate: finalEnd.toISOString(),
|
||||||
|
estimatedHours: currentHours,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleTask({
|
||||||
|
taskId,
|
||||||
|
scheduledStartDate: prevTask.scheduledStartDate,
|
||||||
|
scheduledEndDate: finalEnd.toISOString(),
|
||||||
|
estimatedHours: currentHours,
|
||||||
|
}).catch(() => {
|
||||||
|
setLocalTasks((prev) =>
|
||||||
|
prev.map((t) => (t.id === taskId ? prevTask : t))
|
||||||
|
);
|
||||||
|
toast({ title: "Failed to resize task", variant: "destructive" });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
},
|
||||||
|
[localTasks, toast, dayWidth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeDragTask = activeDrag
|
||||||
|
? localTasks.find((t) => t.id === activeDrag.taskId) ??
|
||||||
|
localBacklog.find((t) => t.id === activeDrag.taskId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleUnschedule = useCallback(
|
||||||
|
(taskId: string) => {
|
||||||
|
const task = localTasks.find((t) => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||||||
|
setLocalBacklog((prev) => [
|
||||||
|
{ ...task, scheduledStartDate: null, scheduledEndDate: null },
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
|
unscheduleTask(taskId).catch(() => {
|
||||||
|
setLocalTasks((prev) => [...prev, task]);
|
||||||
|
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
|
||||||
|
toast({ title: "Failed to unschedule task", variant: "destructive" });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[localTasks, toast]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full overflow-hidden bg-zinc-950">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-b border-zinc-800 bg-zinc-900 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarDays className="h-5 w-5 text-amber-400" />
|
||||||
|
<h1 className="text-lg font-semibold text-white">Schedule</h1>
|
||||||
|
<span className="text-sm text-zinc-500">
|
||||||
|
{format(viewStart, "MMM d")} —{" "}
|
||||||
|
{format(addDays(viewStart, NUM_DAYS - 1), "MMM d, yyyy")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Zoom controls */}
|
||||||
|
<div className="flex items-center gap-3 border border-zinc-700 rounded-md px-2.5 py-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Columns2 className="h-3 w-3 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={60}
|
||||||
|
max={400}
|
||||||
|
step={8}
|
||||||
|
value={dayWidth}
|
||||||
|
onChange={(e) => setDayWidth(Number(e.target.value))}
|
||||||
|
className="w-20 accent-amber-400 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Rows className="h-3 w-3 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={40}
|
||||||
|
max={120}
|
||||||
|
step={4}
|
||||||
|
value={rowHeight}
|
||||||
|
onChange={(e) => setRowHeight(Number(e.target.value))}
|
||||||
|
className="w-20 accent-amber-400 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowBacklog((b) => !b)}
|
||||||
|
className="text-zinc-400 hover:text-white gap-2"
|
||||||
|
>
|
||||||
|
{showBacklog ? (
|
||||||
|
<PanelRightClose className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<PanelRightOpen className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{showBacklog ? "Hide" : "Backlog"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<ScheduleFilters
|
||||||
|
projects={projects}
|
||||||
|
artists={artists}
|
||||||
|
filterProject={filterProject}
|
||||||
|
filterArtist={filterArtist}
|
||||||
|
filterStatus={filterStatus}
|
||||||
|
viewStart={viewStart}
|
||||||
|
onProjectChange={setFilterProject}
|
||||||
|
onArtistChange={setFilterArtist}
|
||||||
|
onStatusChange={setFilterStatus}
|
||||||
|
onViewStartChange={setViewStart}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<ScheduleTimeline
|
||||||
|
artists={filteredArtists}
|
||||||
|
tasks={filteredScheduledTasks}
|
||||||
|
days={days}
|
||||||
|
viewStart={viewStart}
|
||||||
|
canEdit={canEdit}
|
||||||
|
timelineRef={timelineRef}
|
||||||
|
resizePreview={resizePreview}
|
||||||
|
onResizeMouseDown={handleResizeMouseDown}
|
||||||
|
activeDragId={activeDrag?.taskId ?? null}
|
||||||
|
onUnschedule={handleUnschedule}
|
||||||
|
dayWidth={dayWidth}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showBacklog && (
|
||||||
|
<BacklogPanel
|
||||||
|
tasks={filteredBacklog}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drag overlay */}
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeDragTask && (
|
||||||
|
<div className="rounded-md border border-amber-500/60 bg-amber-500/20 px-2 py-1.5 text-xs font-medium text-amber-200 shadow-xl opacity-90 max-w-[200px] truncate pointer-events-none">
|
||||||
|
{activeDragTask.shot?.shotCode ??
|
||||||
|
activeDragTask.asset?.assetCode ??
|
||||||
|
activeDragTask.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { SchedulePageClient } from "./SchedulePageClient";
|
||||||
|
|
||||||
|
export const metadata = { title: "Schedule" };
|
||||||
|
|
||||||
|
export default async function SchedulePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ project?: string; artist?: string; status?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (session.user.role === "CLIENT") redirect("/dashboard");
|
||||||
|
|
||||||
|
const { project, artist, status } = await searchParams;
|
||||||
|
|
||||||
|
const [artists, scheduledTasks, backlogTasks, projects] = await Promise.all([
|
||||||
|
db.user.findMany({
|
||||||
|
where: { isActive: true, role: { not: "CLIENT" } },
|
||||||
|
select: { id: true, name: true, email: true, image: true, role: true },
|
||||||
|
orderBy: [{ role: "asc" }, { name: "asc" }],
|
||||||
|
}),
|
||||||
|
|
||||||
|
db.task.findMany({
|
||||||
|
where: {
|
||||||
|
scheduledStartDate: { not: null },
|
||||||
|
status: { not: "DONE" },
|
||||||
|
...(project ? { projectId: project } : {}),
|
||||||
|
...(artist ? { assignedArtistId: artist } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
assignedArtist: {
|
||||||
|
select: { id: true, name: true, email: true, image: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { scheduledStartDate: "asc" },
|
||||||
|
}),
|
||||||
|
|
||||||
|
db.task.findMany({
|
||||||
|
where: {
|
||||||
|
scheduledStartDate: null,
|
||||||
|
status: { not: "DONE" },
|
||||||
|
...(project ? { projectId: project } : {}),
|
||||||
|
...(artist ? { assignedArtistId: artist } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
assignedArtist: {
|
||||||
|
select: { id: true, name: true, email: true, image: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
|
||||||
|
}),
|
||||||
|
|
||||||
|
db.project.findMany({
|
||||||
|
where: { status: "ACTIVE" },
|
||||||
|
select: { id: true, name: true, code: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const canEdit = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
|
||||||
|
session.user.role
|
||||||
|
);
|
||||||
|
|
||||||
|
const serializeTask = (t: (typeof scheduledTasks)[number]) => ({
|
||||||
|
...t,
|
||||||
|
dueDate: t.dueDate ? t.dueDate.toISOString() : null,
|
||||||
|
scheduledStartDate: t.scheduledStartDate ? t.scheduledStartDate.toISOString() : null,
|
||||||
|
scheduledEndDate: t.scheduledEndDate ? t.scheduledEndDate.toISOString() : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SchedulePageClient
|
||||||
|
artists={artists}
|
||||||
|
tasks={scheduledTasks.map(serializeTask)}
|
||||||
|
backlog={backlogTasks.map(serializeTask)}
|
||||||
|
projects={projects}
|
||||||
|
canEdit={canEdit}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
activeProject={project}
|
||||||
|
activeArtist={artist}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { getInitials } from "@/lib/utils";
|
||||||
|
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Settings" };
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6 max-w-2xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold">Account Settings</h1>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Your Profile</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center gap-4">
|
||||||
|
<Avatar className="h-14 w-14">
|
||||||
|
{session.user.image && <AvatarImage src={session.user.image} />}
|
||||||
|
<AvatarFallback className="text-lg">
|
||||||
|
{getInitials(session.user.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{session.user.name ?? "—"}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{session.user.email}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 capitalize">
|
||||||
|
{session.user.role?.toLowerCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ChangePasswordForm mustChangePassword={session.user.mustChangePassword ?? false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn, getInitials } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
ListTodo,
|
||||||
|
AlertTriangle,
|
||||||
|
Eye,
|
||||||
|
Clock,
|
||||||
|
Layers,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||||
|
import { TaskStatus, TaskType } from "@prisma/client";
|
||||||
|
|
||||||
|
const PRIORITY_DOT: Record<string, string> = {
|
||||||
|
LOW: "bg-zinc-500",
|
||||||
|
NORMAL: "bg-blue-500",
|
||||||
|
HIGH: "bg-amber-500",
|
||||||
|
URGENT: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Artist {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: TaskType;
|
||||||
|
status: TaskStatus;
|
||||||
|
priority: string;
|
||||||
|
dueDate: Date | null;
|
||||||
|
shot?: { id: string; shotCode: string } | null;
|
||||||
|
asset?: { id: string; assetCode: string; name: string } | null;
|
||||||
|
project: { id: string; name: string; code: string };
|
||||||
|
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
|
||||||
|
_count?: { versions: number };
|
||||||
|
versions?: { id: string; versionNumber: number; approvalStatus: string; createdAt: Date }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TasksPageClientProps {
|
||||||
|
tasks: Task[];
|
||||||
|
artists: Artist[];
|
||||||
|
currentUserId: string;
|
||||||
|
role: string;
|
||||||
|
counts: { today: number; overdue: number; inReview: number; total: number };
|
||||||
|
activeStatus?: string;
|
||||||
|
activeAssignee?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_TABS = [
|
||||||
|
{ label: "All", status: undefined, icon: ListTodo, color: "text-zinc-400" },
|
||||||
|
{ label: "Due Today", status: "today", icon: Clock, color: "text-amber-400" },
|
||||||
|
{ label: "Overdue", status: "overdue", icon: AlertTriangle, color: "text-red-400" },
|
||||||
|
{ label: "In Review", status: "INTERNAL_REVIEW", icon: Eye, color: "text-purple-400" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TasksPageClient({
|
||||||
|
tasks,
|
||||||
|
artists,
|
||||||
|
currentUserId,
|
||||||
|
role,
|
||||||
|
counts,
|
||||||
|
activeStatus,
|
||||||
|
activeAssignee,
|
||||||
|
}: TasksPageClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const isArtist = role === "ARTIST";
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
// Client-side filter
|
||||||
|
const filtered = tasks.filter((task) => {
|
||||||
|
if (activeStatus === "today") {
|
||||||
|
return (
|
||||||
|
task.dueDate &&
|
||||||
|
new Date(task.dueDate) >= todayStart &&
|
||||||
|
new Date(task.dueDate) < new Date(todayStart.getTime() + 86400000) &&
|
||||||
|
task.status !== "DONE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (activeStatus === "overdue") {
|
||||||
|
return task.dueDate && new Date(task.dueDate) < todayStart && task.status !== "DONE";
|
||||||
|
}
|
||||||
|
if (activeStatus === "INTERNAL_REVIEW") {
|
||||||
|
return ["INTERNAL_REVIEW", "CLIENT_REVIEW"].includes(task.status);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigate = (params: { status?: string; assignee?: string }) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params.status) sp.set("status", params.status);
|
||||||
|
if (params.assignee) sp.set("assignee", params.assignee);
|
||||||
|
router.push(`/tasks?${sp.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">
|
||||||
|
{isArtist ? "My Tasks" : "All Tasks"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-400 mt-0.5">
|
||||||
|
{counts.total} total · {counts.overdue > 0 && (
|
||||||
|
<span className="text-red-400">{counts.overdue} overdue · </span>
|
||||||
|
)}
|
||||||
|
{counts.inReview} in review
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignee filter (non-artists) */}
|
||||||
|
{!isArtist && artists.length > 0 && (
|
||||||
|
<Select
|
||||||
|
value={activeAssignee ?? "__all__"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
navigate({ status: activeStatus, assignee: v === "__all__" ? undefined : v })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-44 h-8 text-sm">
|
||||||
|
<SelectValue placeholder="All artists" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">All artists</SelectItem>
|
||||||
|
{artists.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name ?? a.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex gap-1 border-b border-zinc-800 pb-0">
|
||||||
|
{FILTER_TABS.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive =
|
||||||
|
(!activeStatus && !tab.status) || activeStatus === tab.status;
|
||||||
|
const count =
|
||||||
|
tab.status === "today"
|
||||||
|
? counts.today
|
||||||
|
: tab.status === "overdue"
|
||||||
|
? counts.overdue
|
||||||
|
: tab.status === "INTERNAL_REVIEW"
|
||||||
|
? counts.inReview
|
||||||
|
: counts.total;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.label}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ status: tab.status, assignee: activeAssignee })
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-amber-500 text-amber-400"
|
||||||
|
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn("h-3.5 w-3.5", isActive ? "text-amber-400" : tab.color)} />
|
||||||
|
{tab.label}
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="text-xs text-zinc-600 bg-zinc-800 rounded-full px-1.5 font-mono">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task list */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="text-center py-16 border border-dashed border-zinc-800 rounded-xl">
|
||||||
|
<ListTodo className="h-8 w-8 mx-auto mb-3 text-zinc-700" />
|
||||||
|
<p className="text-zinc-500 text-sm">No tasks found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filtered.map((task) => {
|
||||||
|
const cfg = TASK_STATUS_CONFIG[task.status];
|
||||||
|
const Icon = cfg.icon;
|
||||||
|
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||||
|
const isOverdue =
|
||||||
|
task.dueDate &&
|
||||||
|
new Date(task.dueDate) < now &&
|
||||||
|
task.status !== "DONE";
|
||||||
|
const latestVersion = task.versions?.[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={task.id}
|
||||||
|
href={`/tasks/${task.id}`}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg border border-transparent hover:border-zinc-800 hover:bg-zinc-900/60 transition-all group"
|
||||||
|
>
|
||||||
|
{/* Priority dot */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full shrink-0",
|
||||||
|
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title + context */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-zinc-200 group-hover:text-amber-400 transition-colors truncate">
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
{contextCode && (
|
||||||
|
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded shrink-0">
|
||||||
|
{contextCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-zinc-500">
|
||||||
|
<span>{TASK_TYPE_LABELS[task.type]}</span>
|
||||||
|
<span className="text-zinc-700">·</span>
|
||||||
|
<span>{task.project.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
{latestVersion && (
|
||||||
|
<span className="text-xs font-mono text-zinc-500">
|
||||||
|
v{latestVersion.versionNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(task._count?.versions ?? 0) > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-zinc-500">
|
||||||
|
<Layers className="h-3 w-3" />
|
||||||
|
{task._count!.versions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.dueDate && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-[11px]",
|
||||||
|
isOverdue ? "text-red-400" : "text-zinc-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-3 w-3" />
|
||||||
|
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.assignedArtist && (
|
||||||
|
<Avatar className="h-5 w-5">
|
||||||
|
<AvatarImage src={task.assignedArtist.image ?? undefined} />
|
||||||
|
<AvatarFallback className="text-[8px] bg-zinc-700 text-zinc-300">
|
||||||
|
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] border px-1.5 py-0 h-5 gap-1",
|
||||||
|
cfg.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-2.5 w-2.5" />
|
||||||
|
{cfg.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getInitials, formatRelativeDate, formatFileSize } from "@/lib/utils";
|
||||||
|
import { updateTask, updateTaskStatus } from "@/actions/tasks";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { TaskStatus, TaskType } from "@prisma/client";
|
||||||
|
import { formatDistanceToNow, format } from "date-fns";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CalendarDays,
|
||||||
|
Clock,
|
||||||
|
Film,
|
||||||
|
Layers,
|
||||||
|
Play,
|
||||||
|
Upload,
|
||||||
|
ChevronDown,
|
||||||
|
CheckCircle2,
|
||||||
|
Eye,
|
||||||
|
RefreshCw,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
User,
|
||||||
|
ExternalLink,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||||
|
import { VersionUpload } from "@/components/versions/VersionUpload";
|
||||||
|
import type { CommentWithReplies } from "@/types";
|
||||||
|
import { CommentPanel } from "@/components/comments/CommentPanel";
|
||||||
|
|
||||||
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
|
LOW: "text-zinc-400", NORMAL: "text-blue-400", HIGH: "text-amber-400", URGENT: "text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const APPROVAL_STYLES: Record<string, string> = {
|
||||||
|
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||||
|
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||||
|
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||||
|
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Artist {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
image: string | null;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Version {
|
||||||
|
id: string;
|
||||||
|
versionNumber: number;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: bigint | null;
|
||||||
|
fps: number;
|
||||||
|
approvalStatus: string;
|
||||||
|
isLatest: boolean;
|
||||||
|
isClientVisible: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
notes: string | null;
|
||||||
|
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
||||||
|
_count: { comments: number };
|
||||||
|
approvals: { id: string; user: { id: string; name: string | null; role: string } }[];
|
||||||
|
comments: CommentWithReplies[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
type: TaskType;
|
||||||
|
status: TaskStatus;
|
||||||
|
priority: string;
|
||||||
|
dueDate: Date | null;
|
||||||
|
estimatedHours: number | null;
|
||||||
|
projectId: string;
|
||||||
|
assignedArtistId: string | null;
|
||||||
|
assignedArtist: Artist | null;
|
||||||
|
createdBy: { id: string; name: string | null; email: string };
|
||||||
|
project: { id: string; name: string; code: string };
|
||||||
|
shot: { id: string; shotCode: string; projectId: string } | null;
|
||||||
|
asset: { id: string; assetCode: string; name: string; projectId: string } | null;
|
||||||
|
versions: Version[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskDetailClientProps {
|
||||||
|
task: Task;
|
||||||
|
artists: Artist[];
|
||||||
|
currentUserId: string;
|
||||||
|
canManage: boolean;
|
||||||
|
canUpload: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetailClient({
|
||||||
|
task,
|
||||||
|
artists,
|
||||||
|
currentUserId,
|
||||||
|
canManage,
|
||||||
|
canUpload,
|
||||||
|
}: TaskDetailClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||||
|
|
||||||
|
const statusCfg = TASK_STATUS_CONFIG[task.status];
|
||||||
|
const StatusIcon = statusCfg.icon;
|
||||||
|
const latestVersion = task.versions[0];
|
||||||
|
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== "DONE";
|
||||||
|
|
||||||
|
const parentLink = task.shot
|
||||||
|
? { label: task.shot.shotCode, href: `/projects/${task.project.id}` }
|
||||||
|
: task.asset
|
||||||
|
? { label: `${task.asset.assetCode} — ${task.asset.name}`, href: `/projects/${task.project.id}` }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleStatusChange = async (newStatus: TaskStatus) => {
|
||||||
|
setUpdatingStatus(true);
|
||||||
|
try {
|
||||||
|
await updateTaskStatus(task.id, newStatus);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to update status", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setUpdatingStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssigneeChange = async (artistId: string) => {
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { assignedArtistId: artistId === "__none__" ? null : artistId });
|
||||||
|
router.refresh();
|
||||||
|
toast({ title: "Assignment updated" });
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to update assignment", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||||
|
<Link href="/projects" className="hover:text-white transition-colors">Projects</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={`/projects/${task.project.id}`} className="hover:text-white transition-colors">
|
||||||
|
{task.project.name}
|
||||||
|
</Link>
|
||||||
|
{parentLink && (
|
||||||
|
<>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={parentLink.href} className="hover:text-white transition-colors">
|
||||||
|
{parentLink.label}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-zinc-300">{task.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main column */}
|
||||||
|
<div className="lg:col-span-2 space-y-5">
|
||||||
|
{/* Task header */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold text-white">{task.title}</h1>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||||
|
<span className="font-mono">{TASK_TYPE_LABELS[task.type]}</span>
|
||||||
|
{parentLink && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<Link href={parentLink.href} className="hover:text-zinc-300">
|
||||||
|
{parentLink.label}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canUpload && (
|
||||||
|
<Button onClick={() => setShowUpload(true)} className="gap-2 shrink-0">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Upload Version
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-zinc-400 text-sm leading-relaxed">{task.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version history */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="font-semibold text-zinc-300 flex items-center gap-2">
|
||||||
|
<Layers className="h-4 w-4" />
|
||||||
|
Versions
|
||||||
|
{task.versions.length > 0 && (
|
||||||
|
<span className="text-xs text-zinc-500 font-normal">({task.versions.length})</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{task.versions.length === 0 ? (
|
||||||
|
<div className="text-center py-8 border border-dashed border-border rounded-lg">
|
||||||
|
<Film className="h-8 w-8 mx-auto mb-2 text-zinc-600" />
|
||||||
|
<p className="text-sm text-muted-foreground">No versions uploaded yet</p>
|
||||||
|
{canUpload && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3 gap-2"
|
||||||
|
onClick={() => setShowUpload(true)}
|
||||||
|
>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
Upload first version
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{task.versions.map((v) => (
|
||||||
|
<div
|
||||||
|
key={v.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors",
|
||||||
|
v.isLatest
|
||||||
|
? "border-amber-500/30 bg-amber-500/5"
|
||||||
|
: "border-border bg-card"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-mono font-medium text-sm text-white">
|
||||||
|
v{String(v.versionNumber).padStart(3, "0")}
|
||||||
|
</span>
|
||||||
|
{v.isLatest && (
|
||||||
|
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-amber-500/15 text-amber-400 border-amber-500/30">
|
||||||
|
Latest
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge className={cn("text-xs border px-1.5 py-0 h-5", APPROVAL_STYLES[v.approvalStatus])}>
|
||||||
|
{v.approvalStatus.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
{v.isClientVisible && (
|
||||||
|
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-blue-500/15 text-blue-400 border-blue-500/30">
|
||||||
|
Client Visible
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-zinc-500">
|
||||||
|
<span>{v.fileName}</span>
|
||||||
|
{v.fileSize != null && (
|
||||||
|
<span>{formatFileSize(Number(v.fileSize))}</span>
|
||||||
|
)}
|
||||||
|
<span>{formatRelativeDate(new Date(v.createdAt))}</span>
|
||||||
|
{v._count.comments > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{v._count.comments} comments
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{v.artist && (
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={v.artist.image ?? undefined} />
|
||||||
|
<AvatarFallback className="text-[10px]">
|
||||||
|
{getInitials(v.artist.name ?? v.artist.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
<Link href={`/review/${v.id}`}>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs">
|
||||||
|
<Play className="h-3 w-3" />
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments on latest version */}
|
||||||
|
{latestVersion && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="font-semibold text-zinc-300 flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
Comments
|
||||||
|
</h2>
|
||||||
|
<CommentPanel
|
||||||
|
versionId={latestVersion.id}
|
||||||
|
fps={latestVersion.fps}
|
||||||
|
comments={latestVersion.comments}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status */}
|
||||||
|
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-zinc-400">Status</h3>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
disabled={updatingStatus}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center justify-between gap-2 px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
||||||
|
statusCfg.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<StatusIcon className="h-3.5 w-3.5" />
|
||||||
|
{statusCfg.label}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-48">
|
||||||
|
{(Object.keys(TASK_STATUS_CONFIG) as TaskStatus[]).map((s) => {
|
||||||
|
const cfg = TASK_STATUS_CONFIG[s];
|
||||||
|
const Icon = cfg.icon;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={s}
|
||||||
|
onClick={() => handleStatusChange(s)}
|
||||||
|
className={cn(s === task.status && "font-medium")}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5 mr-2" />
|
||||||
|
{cfg.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<div className={cn("flex items-center gap-2 px-3 py-2 rounded-md border text-sm font-medium", statusCfg.color)}>
|
||||||
|
<StatusIcon className="h-3.5 w-3.5" />
|
||||||
|
{statusCfg.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta fields */}
|
||||||
|
<div className="space-y-3 pt-2 border-t border-border">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-1">Type</p>
|
||||||
|
<p className="text-sm font-medium">{TASK_TYPE_LABELS[task.type]}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-1">Priority</p>
|
||||||
|
<p className={cn("text-sm font-medium capitalize", PRIORITY_COLORS[task.priority])}>
|
||||||
|
{task.priority}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.dueDate && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-1">Due Date</p>
|
||||||
|
<p className={cn("text-sm flex items-center gap-1.5", isOverdue ? "text-red-400" : "text-zinc-300")}>
|
||||||
|
<CalendarDays className="h-3.5 w-3.5" />
|
||||||
|
{format(new Date(task.dueDate), "MMM d, yyyy")}
|
||||||
|
{isOverdue && <span className="text-xs">(Overdue)</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.estimatedHours && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-1">Estimated</p>
|
||||||
|
<p className="text-sm flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3.5 w-3.5 text-zinc-400" />
|
||||||
|
{task.estimatedHours}h
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-zinc-400">Assigned Artist</h3>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<Select
|
||||||
|
value={task.assignedArtistId ?? "__none__"}
|
||||||
|
onValueChange={handleAssigneeChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-sm">
|
||||||
|
<SelectValue placeholder="Unassigned" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">Unassigned</SelectItem>
|
||||||
|
{artists.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name ?? a.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : task.assignedArtist ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-7 w-7">
|
||||||
|
<AvatarImage src={task.assignedArtist.image ?? undefined} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{task.assignedArtist.name ?? task.assignedArtist.email}</p>
|
||||||
|
<p className="text-xs text-zinc-500">{task.assignedArtist.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-500">Unassigned</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project context */}
|
||||||
|
<div className="rounded-lg border border-border bg-card p-4 space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-zinc-400">Project</h3>
|
||||||
|
<Link
|
||||||
|
href={`/projects/${task.project.id}`}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-white hover:text-amber-400 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-zinc-500">{task.project.code}</span>
|
||||||
|
{task.project.name}
|
||||||
|
<ExternalLink className="h-3 w-3 text-zinc-500" />
|
||||||
|
</Link>
|
||||||
|
{task.shot && (
|
||||||
|
<p className="text-xs text-zinc-500">Shot: {task.shot.shotCode}</p>
|
||||||
|
)}
|
||||||
|
{task.asset && (
|
||||||
|
<p className="text-xs text-zinc-500">Asset: {task.asset.assetCode} — {task.asset.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version upload dialog */}
|
||||||
|
{showUpload && (
|
||||||
|
<VersionUpload
|
||||||
|
taskId={task.id}
|
||||||
|
projectId={task.projectId}
|
||||||
|
currentVersionNumber={latestVersion?.versionNumber ?? 0}
|
||||||
|
open={showUpload}
|
||||||
|
onClose={() => setShowUpload(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowUpload(false);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { TaskDetailClient } from "./TaskDetailClient";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ taskId: string }> }) {
|
||||||
|
const { taskId } = await params;
|
||||||
|
const task = await db.task.findUnique({ where: { id: taskId }, select: { title: true } });
|
||||||
|
return { title: task?.title ?? "Task" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTask(taskId: string) {
|
||||||
|
return db.task.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
include: {
|
||||||
|
shot: { select: { id: true, shotCode: true, projectId: true } },
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true, projectId: true } },
|
||||||
|
assignedArtist: { select: { id: true, name: true, email: true, image: true, role: true } },
|
||||||
|
createdBy: { select: { id: true, name: true, email: true } },
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
versions: {
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
include: {
|
||||||
|
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
approvals: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
include: { user: { select: { id: true, name: true, role: true } } },
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, email: true, image: true, role: true } },
|
||||||
|
replies: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, email: true, image: true, role: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectArtists(projectId: string) {
|
||||||
|
return db.user.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, email: true, image: true, role: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TaskPage({ params }: { params: Promise<{ taskId: string }> }) {
|
||||||
|
const { taskId } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
const task = await getTask(taskId);
|
||||||
|
if (!task) notFound();
|
||||||
|
|
||||||
|
const artists = await getProjectArtists(task.projectId);
|
||||||
|
|
||||||
|
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
|
||||||
|
const isAssigned = task.assignedArtistId === session.user.id;
|
||||||
|
const canUpload = canManage || isAssigned;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TaskDetailClient
|
||||||
|
task={task as any}
|
||||||
|
artists={artists}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
canManage={canManage}
|
||||||
|
canUpload={canUpload}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { TasksPageClient } from "./TasksPageClient";
|
||||||
|
|
||||||
|
export const metadata = { title: "My Tasks" };
|
||||||
|
|
||||||
|
export default async function TasksPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ status?: string; assignee?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (session.user.role === "CLIENT") redirect("/dashboard");
|
||||||
|
|
||||||
|
const { status, assignee } = await searchParams;
|
||||||
|
const isArtist = session.user.role === "ARTIST";
|
||||||
|
|
||||||
|
// Artists only see their own tasks; others can filter by assignee
|
||||||
|
const assigneeFilter = isArtist
|
||||||
|
? session.user.id
|
||||||
|
: assignee || undefined;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
const tasks = await db.task.findMany({
|
||||||
|
where: {
|
||||||
|
...(assigneeFilter ? { assignedArtistId: assigneeFilter } : {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ dueDate: "asc" }, { priority: "desc" }, { createdAt: "desc" }],
|
||||||
|
include: {
|
||||||
|
shot: { select: { id: true, shotCode: true } },
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
versions: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const artists = isArtist
|
||||||
|
? []
|
||||||
|
: await db.user.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Counts for filter tabs
|
||||||
|
const today = tasks.filter(
|
||||||
|
(t) => t.dueDate && new Date(t.dueDate) >= todayStart && new Date(t.dueDate) < new Date(todayStart.getTime() + 86400000) && t.status !== "DONE"
|
||||||
|
).length;
|
||||||
|
const overdue = tasks.filter(
|
||||||
|
(t) => t.dueDate && new Date(t.dueDate) < todayStart && t.status !== "DONE"
|
||||||
|
).length;
|
||||||
|
const inReview = tasks.filter((t) =>
|
||||||
|
["INTERNAL_REVIEW", "CLIENT_REVIEW"].includes(t.status)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TasksPageClient
|
||||||
|
tasks={tasks as any}
|
||||||
|
artists={artists}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
role={session.user.role}
|
||||||
|
counts={{ today, overdue, inReview, total: tasks.length }}
|
||||||
|
activeStatus={status}
|
||||||
|
activeAssignee={assignee}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { UsersClient } from "@/components/users/UsersClient";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "User Management" };
|
||||||
|
|
||||||
|
export default async function UsersPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session || session.user.role !== "ADMIN") {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await db.user.findMany({
|
||||||
|
orderBy: [{ role: "asc" }, { name: "asc" }, { email: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serialize dates
|
||||||
|
const serialized = users.map((u) => ({
|
||||||
|
...u,
|
||||||
|
createdAt: u.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center shrink-0">
|
||||||
|
<Users className="h-5 w-5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">User Management</h1>
|
||||||
|
<p className="text-sm text-zinc-400 mt-0.5">Manage studio accounts, roles, and access</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UsersClient users={serialized} currentUserId={session.user.id!} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { ApprovalStatus } from "@prisma/client";
|
||||||
|
import { recalcShotStatus } from "@/lib/shot-status";
|
||||||
|
|
||||||
|
async function getOrCreateClientUser(email: string, label?: string | null) {
|
||||||
|
const existing = await db.user.findUnique({ where: { email } });
|
||||||
|
if (existing) return existing;
|
||||||
|
return db.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name: label ?? email.split("@")[0],
|
||||||
|
role: "CLIENT",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateToken(token: string) {
|
||||||
|
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||||
|
if (!session || !session.isActive) return null;
|
||||||
|
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ token: string }> }
|
||||||
|
) {
|
||||||
|
const { token } = await params;
|
||||||
|
const session = await validateToken(token);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { versionId, status, notes } = body;
|
||||||
|
|
||||||
|
const validStatuses: ApprovalStatus[] = ["APPROVED", "REJECTED", "NEEDS_CHANGES"];
|
||||||
|
if (!versionId || !validStatuses.includes(status)) {
|
||||||
|
return NextResponse.json({ error: "versionId and valid status required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the version belongs to this project via its task
|
||||||
|
const version = await db.version.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
include: {
|
||||||
|
task: {
|
||||||
|
include: { shot: true, project: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectId = version?.task?.projectId;
|
||||||
|
if (!version || projectId !== session.projectId) {
|
||||||
|
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
||||||
|
const user = await getOrCreateClientUser(email, session.label);
|
||||||
|
|
||||||
|
// Record approval
|
||||||
|
await db.approval.create({
|
||||||
|
data: { versionId, userId: user.id, status, notes },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update version approval status
|
||||||
|
await db.version.update({
|
||||||
|
where: { id: versionId },
|
||||||
|
data: { approvalStatus: status },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update task status based on approval decision
|
||||||
|
if (version.task) {
|
||||||
|
if (status === "APPROVED") {
|
||||||
|
await db.task.update({ where: { id: version.task.id }, data: { status: "DONE" } });
|
||||||
|
} else {
|
||||||
|
await db.task.update({ where: { id: version.task.id }, data: { status: "CHANGES" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate derived shot status
|
||||||
|
if (version.task.shot) {
|
||||||
|
await recalcShotStatus(version.task.shot.id).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slack notification
|
||||||
|
if (version.task?.project?.slackWebhook) {
|
||||||
|
const { slackNotifyApproval } = await import("@/lib/slack");
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
|
||||||
|
const contextCode = version.task.shot?.shotCode ?? version.task.title;
|
||||||
|
await slackNotifyApproval(version.task.project.slackWebhook, {
|
||||||
|
shotCode: contextCode,
|
||||||
|
versionLabel: `v${String(version.versionNumber).padStart(3, "0")}`,
|
||||||
|
reviewerName: user.name ?? "Client",
|
||||||
|
status,
|
||||||
|
projectName: version.task.project.name,
|
||||||
|
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { slackNotifyNewFeedback } from "@/lib/slack";
|
||||||
|
|
||||||
|
/** Find or create a guest user for the client reviewer based on the session email */
|
||||||
|
async function getOrCreateClientUser(email: string, label?: string | null) {
|
||||||
|
const existing = await db.user.findUnique({ where: { email } });
|
||||||
|
if (existing) return existing;
|
||||||
|
return db.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name: label ?? email.split("@")[0],
|
||||||
|
role: "CLIENT",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateToken(token: string) {
|
||||||
|
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||||
|
if (!session || !session.isActive) return null;
|
||||||
|
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ token: string }> }
|
||||||
|
) {
|
||||||
|
const { token } = await params;
|
||||||
|
const session = await validateToken(token);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { versionId, frameNumber, timestamp, text } = body;
|
||||||
|
|
||||||
|
if (!versionId || frameNumber == null || timestamp == null || !text?.trim()) {
|
||||||
|
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the version belongs to this project
|
||||||
|
const version = await db.version.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
include: {
|
||||||
|
shot: { select: { projectId: true, shotCode: true, project: { select: { slackWebhook: true } } } },
|
||||||
|
task: { select: { projectId: true, title: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
|
||||||
|
if (!version || projectId !== session.projectId) {
|
||||||
|
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve commenter identity
|
||||||
|
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
||||||
|
const user = await getOrCreateClientUser(email, session.label);
|
||||||
|
|
||||||
|
const comment = await db.comment.create({
|
||||||
|
data: {
|
||||||
|
versionId,
|
||||||
|
authorId: user.id,
|
||||||
|
frameNumber,
|
||||||
|
timestamp,
|
||||||
|
text: text.trim(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
replies: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Slack notification
|
||||||
|
const slackWebhook =
|
||||||
|
version.shot?.project?.slackWebhook ?? version.task?.project?.slackWebhook ?? null;
|
||||||
|
const shotCode =
|
||||||
|
version.shot?.shotCode ?? version.task?.shot?.shotCode ?? version.task?.title ?? "Task";
|
||||||
|
if (slackWebhook) {
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||||
|
await slackNotifyNewFeedback(slackWebhook, {
|
||||||
|
shotCode,
|
||||||
|
frameNumber,
|
||||||
|
authorName: user.name ?? user.email,
|
||||||
|
commentText: text.trim(),
|
||||||
|
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ comment }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
async function validateToken(token: string) {
|
||||||
|
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||||
|
if (!session || !session.isActive) return null;
|
||||||
|
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ token: string }> }
|
||||||
|
) {
|
||||||
|
const { token } = await params;
|
||||||
|
const session = await validateToken(token);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: session.projectId },
|
||||||
|
select: { id: true, name: true, code: true, description: true, status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find shots that have at least one task with a client-visible version
|
||||||
|
const shots = await db.shot.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: session.projectId,
|
||||||
|
tasks: {
|
||||||
|
some: {
|
||||||
|
versions: { some: { isClientVisible: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
shotCode: true,
|
||||||
|
sequence: true,
|
||||||
|
description: true,
|
||||||
|
status: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
tasks: {
|
||||||
|
where: {
|
||||||
|
versions: { some: { isClientVisible: true } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
status: true,
|
||||||
|
versions: {
|
||||||
|
where: { isClientVisible: true, isLatest: true },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versionNumber: true,
|
||||||
|
approvalStatus: true,
|
||||||
|
fps: true,
|
||||||
|
duration: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
notes: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Asset tasks with client-visible versions (no shotId)
|
||||||
|
const assetTasks = await db.task.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: session.projectId,
|
||||||
|
shotId: null,
|
||||||
|
versions: { some: { isClientVisible: true } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
status: true,
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
|
versions: {
|
||||||
|
where: { isClientVisible: true, isLatest: true },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versionNumber: true,
|
||||||
|
approvalStatus: true,
|
||||||
|
fps: true,
|
||||||
|
duration: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
notes: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increment access count
|
||||||
|
await db.reviewSession.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { accessCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
project,
|
||||||
|
shots,
|
||||||
|
assetTasks,
|
||||||
|
sessionLabel: session.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
async function validateToken(token: string) {
|
||||||
|
const session = await db.reviewSession.findUnique({ where: { token } });
|
||||||
|
if (!session || !session.isActive) return null;
|
||||||
|
if (session.expiresAt && session.expiresAt < new Date()) return null;
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ token: string; versionId: string }> }
|
||||||
|
) {
|
||||||
|
const { token, versionId } = await params;
|
||||||
|
const session = await validateToken(token);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = await db.version.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
include: {
|
||||||
|
shot: {
|
||||||
|
include: {
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
versions: {
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versionNumber: true,
|
||||||
|
approvalStatus: true,
|
||||||
|
isLatest: true,
|
||||||
|
fps: true,
|
||||||
|
duration: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
include: {
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
shot: { select: { shotCode: true } },
|
||||||
|
asset: { select: { assetCode: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
approvals: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve project: from shot or from task
|
||||||
|
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
|
||||||
|
|
||||||
|
if (!version || projectId !== session.projectId) {
|
||||||
|
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For task-only versions (no shot), require explicit client sharing
|
||||||
|
if (!version.shot && !version.isClientVisible) {
|
||||||
|
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await db.comment.findMany({
|
||||||
|
where: { versionId },
|
||||||
|
orderBy: { frameNumber: "asc" },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
replies: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const serializedVersion = {
|
||||||
|
...version,
|
||||||
|
fileSize: version.fileSize?.toString() ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({ version: serializedVersion, comments });
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ clientId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId } = await params;
|
||||||
|
|
||||||
|
const client = await db.client.findUnique({
|
||||||
|
where: { id: clientId },
|
||||||
|
include: {
|
||||||
|
projects: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
_count: { select: { shots: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return NextResponse.json({ error: "Client not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ client });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ clientId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const { company, contactPerson, email, phone, notes, isActive } = body;
|
||||||
|
|
||||||
|
const client = await db.client.update({
|
||||||
|
where: { id: clientId },
|
||||||
|
data: { company, contactPerson, email, phone, notes, isActive },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ client });
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = await db.client.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
company: true,
|
||||||
|
contactPerson: true,
|
||||||
|
email: true,
|
||||||
|
isActive: true,
|
||||||
|
_count: { select: { projects: true } },
|
||||||
|
},
|
||||||
|
orderBy: { company: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ clients });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { company, contactPerson, email, phone, notes } = body;
|
||||||
|
|
||||||
|
if (!company || !contactPerson || !email) {
|
||||||
|
return NextResponse.json({ error: "company, contactPerson and email are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await db.client.create({
|
||||||
|
data: { company, contactPerson, email, phone, notes },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ client }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ key: string[] }> }
|
||||||
|
) {
|
||||||
|
const { key } = await params;
|
||||||
|
// key is a catch-all segment array, e.g. ["videos", "uuid-filename.mp4"]
|
||||||
|
const relativePath = key.join("/");
|
||||||
|
|
||||||
|
// Sanitize: prevent path traversal
|
||||||
|
const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads");
|
||||||
|
const filePath = path.resolve(path.join(uploadDir, relativePath));
|
||||||
|
|
||||||
|
if (!filePath.startsWith(uploadDir)) {
|
||||||
|
return new NextResponse("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".mov": "video/quicktime",
|
||||||
|
".avi": "video/x-msvideo",
|
||||||
|
".mxf": "application/mxf",
|
||||||
|
".webm": "video/webm",
|
||||||
|
};
|
||||||
|
const contentType = mimeMap[ext] ?? "application/octet-stream";
|
||||||
|
|
||||||
|
// Support range requests so the HTML5 video player can seek
|
||||||
|
const rangeHeader = req.headers.get("range");
|
||||||
|
|
||||||
|
if (rangeHeader) {
|
||||||
|
const [startStr, endStr] = rangeHeader.replace("bytes=", "").split("-");
|
||||||
|
const start = parseInt(startStr, 10);
|
||||||
|
const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
|
||||||
|
const chunkSize = end - start + 1;
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(filePath, { start, end });
|
||||||
|
const nodeStream = stream as unknown as ReadableStream;
|
||||||
|
|
||||||
|
return new NextResponse(nodeStream, {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": String(chunkSize),
|
||||||
|
"Content-Type": contentType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(filePath) as unknown as ReadableStream;
|
||||||
|
return new NextResponse(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Length": String(stat.size),
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = await db.notification.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadCount = await db.notification.count({
|
||||||
|
where: { userId: session.user.id, isRead: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ notifications, unreadCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.notification.updateMany({
|
||||||
|
where: { userId: session.user.id, isRead: false },
|
||||||
|
data: { isRead: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = searchParams.get("q") ?? "";
|
||||||
|
const status = searchParams.get("status");
|
||||||
|
|
||||||
|
const projects = await db.project.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q, mode: "insensitive" } },
|
||||||
|
{ code: { contains: q, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
status ? { status: status as any } : {},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
client: { select: { id: true, company: true } },
|
||||||
|
producer: { select: { id: true, name: true, image: true } },
|
||||||
|
_count: { select: { shots: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ projects });
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { addDays } from "date-fns";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = req.nextUrl.searchParams.get("projectId");
|
||||||
|
|
||||||
|
const sessions = await db.reviewSession.findMany({
|
||||||
|
where: projectId ? { projectId } : undefined,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ sessions });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { projectId, label, email, expiresInDays = 30 } = body;
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({ where: { id: projectId } });
|
||||||
|
if (!project) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewSession = await db.reviewSession.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
label: label || `Review — ${project.name}`,
|
||||||
|
email: email || null,
|
||||||
|
expiresAt: addDays(new Date(), expiresInDays),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const appUrl =
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
`${req.headers.get("x-forwarded-proto") ?? "https"}://${req.headers.get("host")}`;
|
||||||
|
const portalUrl = `${appUrl}/client/${reviewSession.token}`;
|
||||||
|
|
||||||
|
return NextResponse.json({ session: reviewSession, portalUrl }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = req.nextUrl.searchParams.get("id");
|
||||||
|
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||||
|
|
||||||
|
await db.reviewSession.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ shotId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { shotId } = await params;
|
||||||
|
|
||||||
|
const shot = await db.shot.findUnique({
|
||||||
|
where: { id: shotId },
|
||||||
|
include: {
|
||||||
|
artist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
versions: {
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
include: {
|
||||||
|
artist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
approvals: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { user: { select: { id: true, name: true, image: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shot) {
|
||||||
|
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: shot.projectId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [tasks, artists] = await Promise.all([
|
||||||
|
db.task.findMany({
|
||||||
|
where: { shotId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
include: {
|
||||||
|
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
versions: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.user.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
|
||||||
|
session.user.role as string
|
||||||
|
);
|
||||||
|
|
||||||
|
// Serialize BigInt fields (fileSize) so JSON.stringify doesn't throw
|
||||||
|
const shotSerialized = {
|
||||||
|
...shot,
|
||||||
|
versions: shot.versions.map((v) => ({
|
||||||
|
...v,
|
||||||
|
fileSize: v.fileSize != null ? v.fileSize.toString() : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
shot: shotSerialized,
|
||||||
|
projectName: project?.name ?? "",
|
||||||
|
canApprove,
|
||||||
|
tasks,
|
||||||
|
artists,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ taskId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { taskId } = await params;
|
||||||
|
const task = await db.task.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
include: {
|
||||||
|
shot: { select: { id: true, shotCode: true } },
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
|
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
createdBy: { select: { id: true, name: true, email: true } },
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
return NextResponse.json(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ taskId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { taskId } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const task = await db.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ taskId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { taskId } = await params;
|
||||||
|
await db.task.delete({ where: { id: taskId } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const projectId = searchParams.get("projectId");
|
||||||
|
const shotId = searchParams.get("shotId");
|
||||||
|
const assetId = searchParams.get("assetId");
|
||||||
|
|
||||||
|
const tasks = await db.task.findMany({
|
||||||
|
where: {
|
||||||
|
...(projectId && { projectId }),
|
||||||
|
...(shotId && { shotId }),
|
||||||
|
...(assetId && { assetId }),
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
include: {
|
||||||
|
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
|
_count: { select: { versions: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(tasks);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { uploadFile } from "@/lib/storage";
|
||||||
|
|
||||||
|
export const config = { api: { bodyParser: false } };
|
||||||
|
|
||||||
|
// Max 2 GB
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.type.match(/video\//)) {
|
||||||
|
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 2 * 1024 * 1024 * 1024) {
|
||||||
|
return NextResponse.json({ error: "File too large (max 2 GB)" }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const result = await uploadFile(buffer, file.name, file.type, "videos");
|
||||||
|
|
||||||
|
return NextResponse.json({ url: result.url, key: result.key });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[local-upload]", err);
|
||||||
|
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { uploadFile } from "@/lib/storage";
|
||||||
|
|
||||||
|
export const config = { api: { bodyParser: false } };
|
||||||
|
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
const type = (formData.get("type") as string) || "videos";
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type based on upload type
|
||||||
|
if (type === "image" && !file.type.match(/image\//)) {
|
||||||
|
return NextResponse.json({ error: "Only image files are accepted" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "video" && !file.type.match(/video\//)) {
|
||||||
|
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size limit: 500MB for images, 2GB for videos
|
||||||
|
const maxSize = type === "image" ? 500 * 1024 * 1024 : 2 * 1024 * 1024 * 1024;
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
const maxSizeStr = type === "image" ? "500 MB" : "2 GB";
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File too large (max ${maxSizeStr})` },
|
||||||
|
{ status: 413 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const result = await uploadFile(buffer, file.name, file.type, type);
|
||||||
|
|
||||||
|
return NextResponse.json({ url: result.url, key: result.key });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[upload]", err);
|
||||||
|
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const disabled = () =>
|
||||||
|
NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"UploadThing is not configured. Add UPLOADTHING_SECRET to .env or use STORAGE_PROVIDER=local.",
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lazily resolve the real handlers only when the secret is present.
|
||||||
|
// This prevents the UploadThing SDK from throwing at module-load time
|
||||||
|
// when the env var is missing (e.g. STORAGE_PROVIDER=local).
|
||||||
|
async function getHandlers() {
|
||||||
|
if (!process.env.UPLOADTHING_SECRET) return null;
|
||||||
|
const { createRouteHandler } = await import("uploadthing/next");
|
||||||
|
const { uploadRouter } = await import("@/lib/uploadthing");
|
||||||
|
return createRouteHandler({ router: uploadRouter });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const h = await getHandlers();
|
||||||
|
return h ? h.GET(req) : disabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const h = await getHandlers();
|
||||||
|
return h ? h.POST(req) : disabled();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ versionId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { versionId } = await params;
|
||||||
|
|
||||||
|
const annotations = await db.annotation.findMany({
|
||||||
|
where: { versionId, isVisible: true },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
},
|
||||||
|
orderBy: { frameNumber: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ annotations });
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ versionId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { versionId } = await params;
|
||||||
|
|
||||||
|
const comments = await db.comment.findMany({
|
||||||
|
where: { versionId },
|
||||||
|
orderBy: { frameNumber: "asc" },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
replies: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ comments });
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import {
|
||||||
|
Film,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
ChevronRight,
|
||||||
|
Package,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Montserrat } from 'next/font/google';
|
||||||
|
|
||||||
|
const montserrat = Montserrat({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['200', '500', '600'],
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ClientVersion {
|
||||||
|
id: string;
|
||||||
|
versionNumber: number;
|
||||||
|
approvalStatus: string;
|
||||||
|
fps: number;
|
||||||
|
duration: number | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientTask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
versions: ClientVersion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientShot {
|
||||||
|
id: string;
|
||||||
|
shotCode: string;
|
||||||
|
sequence: string | null;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
tasks: ClientTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetTask extends ClientTask {
|
||||||
|
asset?: { id: string; assetCode: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPROVAL_STYLES: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; className: string; Icon: React.ElementType }
|
||||||
|
> = {
|
||||||
|
PENDING_REVIEW: {
|
||||||
|
label: 'Awaiting Review',
|
||||||
|
className: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||||
|
Icon: Clock,
|
||||||
|
},
|
||||||
|
APPROVED: {
|
||||||
|
label: 'Approved',
|
||||||
|
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||||
|
Icon: CheckCircle2,
|
||||||
|
},
|
||||||
|
REJECTED: {
|
||||||
|
label: 'Rejected',
|
||||||
|
className: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||||
|
Icon: XCircle,
|
||||||
|
},
|
||||||
|
NEEDS_CHANGES: {
|
||||||
|
label: 'Needs Changes',
|
||||||
|
className: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
|
||||||
|
Icon: AlertCircle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TASK_TYPE_LABELS: Record<string, string> = {
|
||||||
|
TRACK: 'Tracking',
|
||||||
|
ROTO: 'Roto',
|
||||||
|
KEY: 'Keying',
|
||||||
|
COMP: 'Comp',
|
||||||
|
FX: 'FX',
|
||||||
|
LIGHTING: 'Lighting',
|
||||||
|
RENDER: 'Render',
|
||||||
|
ANIMATION: 'Animation',
|
||||||
|
MODEL: 'Model',
|
||||||
|
TEXTURE: 'Texture',
|
||||||
|
RIG: 'Rig',
|
||||||
|
LOOKDEV: 'Lookdev',
|
||||||
|
GENERAL: 'Task',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getLatestVersion(task: ClientTask): ClientVersion | undefined {
|
||||||
|
return task.versions[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientPortalPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ token: string }>;
|
||||||
|
}) {
|
||||||
|
const [token, setToken] = useState<string>('');
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [shots, setShots] = useState<ClientShot[]>([]);
|
||||||
|
const [assetTasks, setAssetTasks] = useState<AssetTask[]>([]);
|
||||||
|
const [sessionLabel, setSessionLabel] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
params.then(({ token: t }) => {
|
||||||
|
setToken(t);
|
||||||
|
fetch(`/api/client/${t}/project`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error('Invalid or expired review link');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setProject(data.project);
|
||||||
|
setShots(data.shots ?? []);
|
||||||
|
setAssetTasks(data.assetTasks ?? []);
|
||||||
|
setSessionLabel(data.sessionLabel ?? '');
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
});
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
||||||
|
<div className="h-8 w-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !project) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center gap-4 text-center px-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-amber-500 flex items-center justify-center">
|
||||||
|
<Film className="h-6 w-6 text-black" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">
|
||||||
|
Review link unavailable
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-400 text-sm max-w-sm">
|
||||||
|
{error ??
|
||||||
|
'This review link has expired or is no longer active. Please request a new link from your studio contact.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTasks = [...shots.flatMap((s) => s.tasks), ...assetTasks];
|
||||||
|
const totalTasks = allTasks.length;
|
||||||
|
const approved = allTasks.filter(
|
||||||
|
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
|
||||||
|
).length;
|
||||||
|
const needsChanges = allTasks.filter((t) =>
|
||||||
|
['REJECTED', 'NEEDS_CHANGES'].includes(
|
||||||
|
getLatestVersion(t)?.approvalStatus ?? '',
|
||||||
|
),
|
||||||
|
).length;
|
||||||
|
const pending = totalTasks - approved - needsChanges;
|
||||||
|
|
||||||
|
const shotsBySequence = shots.reduce<Record<string, ClientShot[]>>(
|
||||||
|
(acc, shot) => {
|
||||||
|
const seq = shot.sequence ?? 'Shots';
|
||||||
|
if (!acc[seq]) acc[seq] = [];
|
||||||
|
acc[seq].push(shot);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 text-white">
|
||||||
|
<header className="border-b border-zinc-800 bg-zinc-900">
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-black">
|
||||||
|
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={montserrat.className}>
|
||||||
|
<span className="block text-2xl font-light text-white leading-none">
|
||||||
|
TWO TALES
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="block text-[11px] tracking-[0.18em] italic text-zinc-400 leading-none -mt-0.25">
|
||||||
|
vfx review
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sessionLabel && (
|
||||||
|
<span className="text-sm text-zinc-400">{sessionLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="border-b border-zinc-800 bg-zinc-900/50">
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<p className="text-xs text-zinc-500 uppercase tracking-wider font-medium mb-1">
|
||||||
|
{project.code}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">{project.name}</h1>
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-zinc-400 text-sm max-w-xl">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-3 mt-6">
|
||||||
|
<div className="bg-zinc-800 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||||
|
<p className="text-2xl font-bold text-white">{totalTasks}</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-0.5">Items</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-900/30 border border-emerald-800/30 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||||
|
<p className="text-2xl font-bold text-emerald-400">{approved}</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-0.5">Approved</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-900/20 border border-amber-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||||
|
<p className="text-2xl font-bold text-amber-400">{pending}</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-0.5">Awaiting Review</p>
|
||||||
|
</div>
|
||||||
|
{needsChanges > 0 && (
|
||||||
|
<div className="bg-red-900/20 border border-red-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
|
||||||
|
<p className="text-2xl font-bold text-red-400">
|
||||||
|
{needsChanges}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-0.5">Needs Changes</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8 space-y-10">
|
||||||
|
{Object.entries(shotsBySequence).map(([sequence, seqShots]) => (
|
||||||
|
<div key={sequence}>
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
|
||||||
|
{sequence}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{seqShots.map((shot) => (
|
||||||
|
<div key={shot.id} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-3 px-1">
|
||||||
|
{shot.thumbnailUrl && (
|
||||||
|
<div className="relative flex-shrink-0 w-40 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
|
||||||
|
<Image
|
||||||
|
src={shot.thumbnailUrl}
|
||||||
|
alt={shot.shotCode}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="font-mono text-sm font-semibold text-zinc-300">
|
||||||
|
{shot.shotCode}
|
||||||
|
{shot.description && (
|
||||||
|
<span className="font-sans font-normal text-zinc-500 ml-2">
|
||||||
|
{shot.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 pl-3 border-l border-zinc-800">
|
||||||
|
{shot.tasks.map((task) => {
|
||||||
|
const ver = getLatestVersion(task);
|
||||||
|
const approvalKey =
|
||||||
|
ver?.approvalStatus ?? 'PENDING_REVIEW';
|
||||||
|
const approval =
|
||||||
|
APPROVAL_STYLES[approvalKey] ??
|
||||||
|
APPROVAL_STYLES.PENDING_REVIEW;
|
||||||
|
const ApprovalIcon = approval.Icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={task.id}
|
||||||
|
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
|
||||||
|
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
|
||||||
|
!ver && 'pointer-events-none opacity-40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
||||||
|
</p>
|
||||||
|
{ver?.notes && (
|
||||||
|
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
|
||||||
|
“{ver.notes}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ver ? (
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<span className="text-xs font-mono text-zinc-500">
|
||||||
|
v{String(ver.versionNumber).padStart(3, '0')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
|
||||||
|
approval.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ApprovalIcon className="h-3 w-3" />
|
||||||
|
{approval.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-zinc-600 shrink-0">
|
||||||
|
No versions yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{assetTasks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
|
||||||
|
Assets
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assetTasks.map((task) => {
|
||||||
|
const ver = getLatestVersion(task);
|
||||||
|
const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW';
|
||||||
|
const approval =
|
||||||
|
APPROVAL_STYLES[approvalKey] ??
|
||||||
|
APPROVAL_STYLES.PENDING_REVIEW;
|
||||||
|
const ApprovalIcon = approval.Icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={task.id}
|
||||||
|
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
|
||||||
|
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
|
||||||
|
!ver && 'pointer-events-none opacity-40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Package className="h-4 w-4 text-zinc-500 shrink-0" />
|
||||||
|
<div className="min-w-[90px]">
|
||||||
|
<p className="font-mono font-semibold text-white text-sm">
|
||||||
|
{task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-zinc-300 truncate">
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
{ver?.notes && (
|
||||||
|
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
|
||||||
|
“{ver.notes}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ver ? (
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<span className="text-xs font-mono text-zinc-500">
|
||||||
|
v{String(ver.versionNumber).padStart(3, '0')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
|
||||||
|
approval.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ApprovalIcon className="h-3 w-3" />
|
||||||
|
{approval.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-zinc-600 shrink-0">
|
||||||
|
No versions yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalTasks === 0 && (
|
||||||
|
<div className="text-center py-16 text-zinc-500">
|
||||||
|
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>No items have been shared for review yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-zinc-800 py-6 text-center text-xs text-zinc-600">
|
||||||
|
Powered by <span className="text-zinc-400">TTDEV</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { cn, frameToTimecode, getInitials } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Film,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
MessageSquare,
|
||||||
|
Send,
|
||||||
|
Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useReviewStore } from "@/hooks/use-review-player";
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
frameNumber: number;
|
||||||
|
timestamp: number;
|
||||||
|
text: string;
|
||||||
|
isResolved: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
author: { id: string; name: string | null; image: string | null; email: string };
|
||||||
|
replies: { id: string; text: string; createdAt: string; author: { name: string | null } }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Version {
|
||||||
|
id: string;
|
||||||
|
versionNumber: number;
|
||||||
|
fileUrl: string;
|
||||||
|
fps: number;
|
||||||
|
duration: number | null;
|
||||||
|
approvalStatus: string;
|
||||||
|
notes: string | null;
|
||||||
|
shot?: {
|
||||||
|
id: string;
|
||||||
|
shotCode: string;
|
||||||
|
project: { id: string; name: string; code: string };
|
||||||
|
} | null;
|
||||||
|
task?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
project: { id: string; name: string; code: string };
|
||||||
|
shot?: { shotCode: string } | null;
|
||||||
|
asset?: { assetCode: string; name: string } | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPROVAL_STATUS_STYLES: Record<string, string> = {
|
||||||
|
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||||
|
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||||
|
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||||
|
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ClientReviewPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ token: string; versionId: string }>;
|
||||||
|
}) {
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [version, setVersion] = useState<Version | null>(null);
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const currentFrame = useReviewStore((s) => s.currentFrame);
|
||||||
|
|
||||||
|
// Comment form state
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
|
const [commentFrame, setCommentFrame] = useState<number | null>(null);
|
||||||
|
const [submittingComment, setSubmittingComment] = useState(false);
|
||||||
|
|
||||||
|
// Approval dialog
|
||||||
|
const [approvalDialog, setApprovalDialog] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null;
|
||||||
|
}>({ open: false, status: null });
|
||||||
|
const [approvalNotes, setApprovalNotes] = useState("");
|
||||||
|
const [submittingApproval, setSubmittingApproval] = useState(false);
|
||||||
|
const [currentApprovalStatus, setCurrentApprovalStatus] = useState("PENDING_REVIEW");
|
||||||
|
|
||||||
|
const playerRef = useRef<ReviewPlayerRef>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
params.then(({ token: t, versionId }) => {
|
||||||
|
setToken(t);
|
||||||
|
fetch(`/api/client/${t}/versions/${versionId}`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error("Invalid or expired review link");
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setVersion(data.version);
|
||||||
|
setComments(data.comments);
|
||||||
|
setCurrentApprovalStatus(data.version.approvalStatus);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
});
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
const refreshComments = useCallback(async (t: string, vId: string) => {
|
||||||
|
const res = await fetch(`/api/client/${t}/versions/${vId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setComments(data.comments);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePlayerAddComment = useCallback((frameNumber: number, _timestamp: number) => {
|
||||||
|
playerRef.current?.pause();
|
||||||
|
setCommentFrame(frameNumber);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmitComment = async () => {
|
||||||
|
if (!commentText.trim() || commentFrame === null || !version) return;
|
||||||
|
setSubmittingComment(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/client/${token}/comment`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
versionId: version.id,
|
||||||
|
frameNumber: commentFrame,
|
||||||
|
timestamp: commentFrame / version.fps,
|
||||||
|
text: commentText.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to post comment");
|
||||||
|
setCommentText("");
|
||||||
|
setCommentFrame(null);
|
||||||
|
await refreshComments(token, version.id);
|
||||||
|
toast({ title: "Comment added" });
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to add comment", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setSubmittingComment(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitApproval = async () => {
|
||||||
|
if (!approvalDialog.status || !version) return;
|
||||||
|
setSubmittingApproval(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/client/${token}/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
versionId: version.id,
|
||||||
|
status: approvalDialog.status,
|
||||||
|
notes: approvalNotes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to submit decision");
|
||||||
|
setCurrentApprovalStatus(approvalDialog.status);
|
||||||
|
setApprovalDialog({ open: false, status: null });
|
||||||
|
setApprovalNotes("");
|
||||||
|
toast({
|
||||||
|
title:
|
||||||
|
approvalDialog.status === "APPROVED"
|
||||||
|
? "Version approved!"
|
||||||
|
: approvalDialog.status === "REJECTED"
|
||||||
|
? "Version rejected"
|
||||||
|
: "Changes requested",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to submit decision", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setSubmittingApproval(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
||||||
|
<div className="h-8 w-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !version) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center gap-4 text-center px-4">
|
||||||
|
<Film className="h-10 w-10 text-zinc-600" />
|
||||||
|
<h1 className="text-xl font-semibold text-white">Version unavailable</h1>
|
||||||
|
<p className="text-zinc-400 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalStyle = APPROVAL_STATUS_STYLES[currentApprovalStatus] ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen bg-zinc-950 text-white overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex items-center gap-3 px-4 py-2.5 border-b border-zinc-800 bg-zinc-900 shrink-0">
|
||||||
|
<Link
|
||||||
|
href={`/client/${token}`}
|
||||||
|
className="text-zinc-400 hover:text-white transition-colors flex items-center gap-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">All Shots</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="h-4 w-px bg-zinc-700" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div className="w-5 h-5 rounded bg-amber-500 flex items-center justify-center shrink-0">
|
||||||
|
<Film className="h-3 w-3 text-black" />
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const project = version.shot?.project ?? version.task?.project;
|
||||||
|
const contextCode =
|
||||||
|
version.shot?.shotCode ??
|
||||||
|
version.task?.shot?.shotCode ??
|
||||||
|
version.task?.asset?.assetCode ??
|
||||||
|
null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-zinc-500 hidden sm:block">{project?.code}</span>
|
||||||
|
<span className="text-zinc-600">/</span>
|
||||||
|
{contextCode && (
|
||||||
|
<>
|
||||||
|
<span className="font-mono font-semibold">{contextCode}</span>
|
||||||
|
<span className="text-zinc-600">/</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-sm text-zinc-300">
|
||||||
|
v{String(version.versionNumber).padStart(3, "0")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2.5 py-1 rounded-full border hidden sm:inline-flex items-center gap-1",
|
||||||
|
approvalStyle
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{currentApprovalStatus === "PENDING_REVIEW" ? (
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
) : currentApprovalStatus === "APPROVED" ? (
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
) : currentApprovalStatus === "NEEDS_CHANGES" ? (
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{currentApprovalStatus.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Decision buttons */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
|
||||||
|
onClick={() => setApprovalDialog({ open: true, status: "NEEDS_CHANGES" })}
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Needs Changes</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||||
|
onClick={() => setApprovalDialog({ open: true, status: "APPROVED" })}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Approve</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main: player + comments */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Player */}
|
||||||
|
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
|
||||||
|
<ReviewPlayer
|
||||||
|
ref={playerRef}
|
||||||
|
videoUrl={version.fileUrl}
|
||||||
|
fps={version.fps}
|
||||||
|
comments={comments as any}
|
||||||
|
versionId={version.id}
|
||||||
|
onAddComment={handlePlayerAddComment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment panel */}
|
||||||
|
<div className="w-72 xl:w-80 shrink-0 flex flex-col border-l border-zinc-800 bg-zinc-900">
|
||||||
|
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-4 w-4 text-zinc-400" />
|
||||||
|
Notes
|
||||||
|
{comments.length > 0 && (
|
||||||
|
<span className="text-xs text-zinc-500">({comments.length})</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 px-4 py-3">
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-zinc-500 text-sm">
|
||||||
|
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p>No notes yet.</p>
|
||||||
|
<p className="text-xs mt-1">Pause the video and add a note at any frame.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="space-y-2">
|
||||||
|
<div
|
||||||
|
className="rounded-lg bg-zinc-800/70 p-3 space-y-2 cursor-pointer hover:bg-zinc-700/70 transition-colors"
|
||||||
|
onClick={() => playerRef.current?.seekToFrame(comment.frameNumber)}
|
||||||
|
title={`Jump to frame ${comment.frameNumber}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-5 w-5">
|
||||||
|
<AvatarFallback className="text-[9px] bg-zinc-700">
|
||||||
|
{getInitials(comment.author.name ?? comment.author.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-xs text-zinc-300 font-medium">
|
||||||
|
{comment.author.name ?? comment.author.email.split("@")[0]}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto font-mono text-xs text-amber-400/80">
|
||||||
|
{frameToTimecode(comment.frameNumber, version.fps)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
|
||||||
|
</div>
|
||||||
|
{comment.replies.map((reply) => (
|
||||||
|
<div key={reply.id} className="ml-4 rounded-lg bg-zinc-800/40 border border-zinc-700/50 p-2.5">
|
||||||
|
<span className="text-xs text-zinc-400 font-medium">
|
||||||
|
{reply.author.name ?? "Reply"}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-zinc-300 mt-1">{reply.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Add note */}
|
||||||
|
{commentFrame !== null ? (
|
||||||
|
<div className="border-t border-zinc-800 p-3 space-y-2">
|
||||||
|
<p className="text-xs text-zinc-500 font-mono">
|
||||||
|
Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)}
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add your note..."
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
className="min-h-[80px] text-sm resize-none"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmitComment();
|
||||||
|
if (e.key === "Escape") setCommentFrame(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs"
|
||||||
|
onClick={() => { setCommentFrame(null); setCommentText(""); }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-8 text-xs gap-1"
|
||||||
|
onClick={handleSubmitComment}
|
||||||
|
disabled={!commentText.trim() || submittingComment}
|
||||||
|
>
|
||||||
|
<Send className="h-3 w-3" />
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border-t border-zinc-800 p-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full text-sm gap-2 h-9"
|
||||||
|
onClick={() => {
|
||||||
|
playerRef.current?.pause();
|
||||||
|
setCommentFrame(currentFrame);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
Add Note at Current Frame
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approval dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={approvalDialog.open}
|
||||||
|
onOpenChange={(o) => !o && setApprovalDialog({ open: false, status: null })}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{approvalDialog.status === "APPROVED"
|
||||||
|
? "✅ Approve this version"
|
||||||
|
: approvalDialog.status === "NEEDS_CHANGES"
|
||||||
|
? "✏️ Request changes"
|
||||||
|
: "❌ Reject this version"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
{approvalDialog.status === "APPROVED"
|
||||||
|
? "Confirm you're happy with this version. You can add an optional note below."
|
||||||
|
: "Describe what needs to change so the team can action it quickly."}
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
value={approvalNotes}
|
||||||
|
onChange={(e) => setApprovalNotes(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
approvalDialog.status === "APPROVED"
|
||||||
|
? "Looks great! (optional)"
|
||||||
|
: "Please describe the changes needed..."
|
||||||
|
}
|
||||||
|
className="min-h-[100px]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setApprovalDialog({ open: false, status: null })}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmitApproval}
|
||||||
|
disabled={submittingApproval}
|
||||||
|
className={cn(
|
||||||
|
approvalDialog.status === "APPROVED"
|
||||||
|
? "bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||||
|
: approvalDialog.status === "NEEDS_CHANGES"
|
||||||
|
? "bg-orange-600 hover:bg-orange-500 text-white"
|
||||||
|
: "bg-red-700 hover:bg-red-600 text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submittingApproval
|
||||||
|
? "Submitting..."
|
||||||
|
: approvalDialog.status === "APPROVED"
|
||||||
|
? "Approve"
|
||||||
|
: approvalDialog.status === "NEEDS_CHANGES"
|
||||||
|
? "Request Changes"
|
||||||
|
: "Reject"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ── Dark-first CSS variables (shadcn/ui compatible) ─────────────────────── */
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* Zinc dark palette — always dark */
|
||||||
|
--background: 0 0% 3.9%; /* zinc-950 */
|
||||||
|
--foreground: 0 0% 98%; /* white */
|
||||||
|
|
||||||
|
--card: 0 0% 9%; /* zinc-900 */
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--popover: 0 0% 9%; /* zinc-900 */
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
/* Amber accent — brand colour */
|
||||||
|
--primary: 38 92% 50%; /* amber-500 */
|
||||||
|
--primary-foreground: 38 100% 6%;
|
||||||
|
|
||||||
|
--secondary: 0 0% 14.9%; /* zinc-800 */
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--muted: 0 0% 14.9%; /* zinc-800 */
|
||||||
|
--muted-foreground: 0 0% 63.9%; /* zinc-400 */
|
||||||
|
|
||||||
|
--accent: 0 0% 14.9%; /* zinc-800 */
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 72% 51%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--border: 0 0% 14.9%; /* zinc-800 */
|
||||||
|
--input: 0 0% 9%; /* zinc-900 */
|
||||||
|
--ring: 38 92% 50%; /* amber-500 */
|
||||||
|
|
||||||
|
--radius: 0.625rem; /* 10px */
|
||||||
|
|
||||||
|
/* Status colors */
|
||||||
|
--status-approved: 142 71% 45%;
|
||||||
|
--status-rejected: 0 72% 51%;
|
||||||
|
--status-pending: 38 92% 50%;
|
||||||
|
--status-changes: 271 76% 53%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force dark mode globally */
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-border rounded-full;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-muted-foreground/50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Custom utilities ────────────────────────────────────────────────────── */
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cinematic glow effect for review player */
|
||||||
|
.player-glow {
|
||||||
|
box-shadow: 0 0 60px rgba(0, 0, 0, 0.8), inset 0 0 120px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Annotation canvas cursor crosshair */
|
||||||
|
.cursor-crosshair {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline scrub cursor */
|
||||||
|
.cursor-ew-resize {
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status colors */
|
||||||
|
.status-approved {
|
||||||
|
color: hsl(var(--status-approved));
|
||||||
|
}
|
||||||
|
.status-rejected {
|
||||||
|
color: hsl(var(--status-rejected));
|
||||||
|
}
|
||||||
|
.status-pending {
|
||||||
|
color: hsl(var(--status-pending));
|
||||||
|
}
|
||||||
|
.status-changes {
|
||||||
|
color: hsl(var(--status-changes));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-status-approved {
|
||||||
|
background-color: hsl(var(--status-approved) / 0.15);
|
||||||
|
}
|
||||||
|
.bg-status-rejected {
|
||||||
|
background-color: hsl(var(--status-rejected) / 0.15);
|
||||||
|
}
|
||||||
|
.bg-status-pending {
|
||||||
|
background-color: hsl(var(--status-pending) / 0.15);
|
||||||
|
}
|
||||||
|
.bg-status-changes {
|
||||||
|
background-color: hsl(var(--status-changes) / 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Review player specific styles ──────────────────────────────────────── */
|
||||||
|
.review-player-container {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-player-container video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Annotation canvas overlay */
|
||||||
|
.annotation-canvas-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-canvas-overlay.is-annotating {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frame timeline */
|
||||||
|
.frame-timeline {
|
||||||
|
position: relative;
|
||||||
|
height: 48px;
|
||||||
|
background: hsl(0 0% 6%);
|
||||||
|
border-top: 1px solid hsl(0 0% 14%);
|
||||||
|
cursor: ew-resize;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comment badge on timeline */
|
||||||
|
.timeline-comment-marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: hsl(213 94% 68%);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyframe indicator */
|
||||||
|
.playhead {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: hsl(0 72% 51%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player controls glassmorphism */
|
||||||
|
.player-controls {
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0) 100%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Providers } from "@/components/layout/Providers";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
template: "%s | FeedBack",
|
||||||
|
default: "FeedBack — VFX Review Platform",
|
||||||
|
},
|
||||||
|
description: "Frame-accurate review and approval for VFX and animation studios.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark" suppressHydrationWarning>
|
||||||
|
<body className={`${inter.variable} font-sans antialiased`}>
|
||||||
|
<Providers>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
export default async function RootPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (session?.user) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
} else {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
|
||||||
|
import { CommentPanel } from "@/components/comments/CommentPanel";
|
||||||
|
import { VersionList } from "@/components/versions/VersionList";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { submitApproval } from "@/actions/approvals";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { ShareWithClientButton } from "@/components/versions/ShareWithClientButton";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ArrowLeft,
|
||||||
|
Film,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface ReviewPageClientProps {
|
||||||
|
version: {
|
||||||
|
id: string;
|
||||||
|
versionNumber: number;
|
||||||
|
fileUrl: string;
|
||||||
|
fps: number;
|
||||||
|
duration: number | null;
|
||||||
|
frameCount: number | null;
|
||||||
|
approvalStatus: string;
|
||||||
|
notes: string | null;
|
||||||
|
isClientVisible: boolean;
|
||||||
|
shot?: {
|
||||||
|
id: string;
|
||||||
|
shotCode: string;
|
||||||
|
project: { id: string; name: string; code: string };
|
||||||
|
versions: {
|
||||||
|
id: string;
|
||||||
|
versionNumber: number;
|
||||||
|
approvalStatus: string;
|
||||||
|
isLatest: boolean;
|
||||||
|
fps: number;
|
||||||
|
duration: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
fileSize: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}[];
|
||||||
|
} | null;
|
||||||
|
task?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
project: { id: string; name: string; code: string };
|
||||||
|
shot?: { id: string; shotCode: string } | null;
|
||||||
|
asset?: { id: string; assetCode: string; name: string } | null;
|
||||||
|
versions: {
|
||||||
|
id: string;
|
||||||
|
versionNumber: number;
|
||||||
|
approvalStatus: string;
|
||||||
|
isLatest: boolean;
|
||||||
|
fps: number;
|
||||||
|
duration: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
fileSize: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}[];
|
||||||
|
} | null;
|
||||||
|
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
||||||
|
};
|
||||||
|
comments: any[];
|
||||||
|
annotations: any[];
|
||||||
|
canApprove: boolean;
|
||||||
|
canShare: boolean;
|
||||||
|
currentUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPROVAL_STATUS_STYLES: Record<string, string> = {
|
||||||
|
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||||
|
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||||
|
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||||
|
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReviewPageClient({
|
||||||
|
version,
|
||||||
|
comments: initialComments,
|
||||||
|
annotations: initialAnnotations,
|
||||||
|
canApprove,
|
||||||
|
canShare,
|
||||||
|
currentUserId,
|
||||||
|
}: ReviewPageClientProps) {
|
||||||
|
const [comments, setComments] = useState(initialComments);
|
||||||
|
const [annotations, setAnnotations] = useState(initialAnnotations);
|
||||||
|
const [pendingFrame, setPendingFrame] = useState<number | null>(null);
|
||||||
|
const [approvalDialog, setApprovalDialog] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null;
|
||||||
|
}>({ open: false, status: null });
|
||||||
|
const [approvalNotes, setApprovalNotes] = useState("");
|
||||||
|
const [isSubmittingApproval, setIsSubmittingApproval] = useState(false);
|
||||||
|
const [showVersions, setShowVersions] = useState(false);
|
||||||
|
|
||||||
|
const playerRef = useRef<ReviewPlayerRef>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleAddComment = useCallback(() => {
|
||||||
|
// Pause player first
|
||||||
|
playerRef.current?.pause();
|
||||||
|
const frame = playerRef.current ? undefined : undefined;
|
||||||
|
// Frame is tracked in ReviewPlayer store — CommentPanel reads from there
|
||||||
|
setPendingFrame(Date.now()); // trigger effect with timestamp trick
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCommentsChange = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/versions/${version.id}/comments`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setComments(data.comments);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently ignore
|
||||||
|
}
|
||||||
|
}, [version.id]);
|
||||||
|
|
||||||
|
const handleAnnotationSaved = useCallback(async () => {
|
||||||
|
// Refresh both annotations (for timeline markers) and comments (for the auto-comment)
|
||||||
|
try {
|
||||||
|
const [annRes, cmtRes] = await Promise.all([
|
||||||
|
fetch(`/api/versions/${version.id}/annotations`),
|
||||||
|
fetch(`/api/versions/${version.id}/comments`),
|
||||||
|
]);
|
||||||
|
if (annRes.ok) setAnnotations((await annRes.json()).annotations);
|
||||||
|
if (cmtRes.ok) setComments((await cmtRes.json()).comments);
|
||||||
|
} catch {
|
||||||
|
// silently ignore
|
||||||
|
}
|
||||||
|
}, [version.id]);
|
||||||
|
|
||||||
|
const handleApprovalSubmit = async () => {
|
||||||
|
if (!approvalDialog.status) return;
|
||||||
|
setIsSubmittingApproval(true);
|
||||||
|
try {
|
||||||
|
await submitApproval({
|
||||||
|
versionId: version.id,
|
||||||
|
status: approvalDialog.status,
|
||||||
|
notes: approvalNotes,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title:
|
||||||
|
approvalDialog.status === "APPROVED"
|
||||||
|
? "Version approved!"
|
||||||
|
: approvalDialog.status === "REJECTED"
|
||||||
|
? "Version rejected"
|
||||||
|
: "Changes requested",
|
||||||
|
});
|
||||||
|
setApprovalDialog({ open: false, status: null });
|
||||||
|
setApprovalNotes("");
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to submit approval",
|
||||||
|
description: (err as Error).message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingApproval(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openApproval = (status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES") => {
|
||||||
|
setApprovalDialog({ open: true, status });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve context: shot or task
|
||||||
|
const project = version.shot?.project ?? version.task?.project;
|
||||||
|
const contextCode =
|
||||||
|
version.shot?.shotCode ??
|
||||||
|
version.task?.shot?.shotCode ??
|
||||||
|
version.task?.asset?.assetCode ??
|
||||||
|
null;
|
||||||
|
const backHref = version.shot
|
||||||
|
? `/projects/${project?.id}/shots/${version.shot.id}`
|
||||||
|
: version.task
|
||||||
|
? `/tasks/${version.task.id}`
|
||||||
|
: `/dashboard`;
|
||||||
|
const versionList = version.shot?.versions ?? version.task?.versions ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen bg-background overflow-hidden">
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-card shrink-0">
|
||||||
|
<Link
|
||||||
|
href={backHref}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span className="text-xs text-muted-foreground hidden sm:block">
|
||||||
|
{project?.code}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
{contextCode && (
|
||||||
|
<>
|
||||||
|
<span className="font-mono text-sm font-semibold">{contextCode}</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Version selector */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 gap-1 font-mono text-sm">
|
||||||
|
<Film className="h-3.5 w-3.5" />
|
||||||
|
v{String(version.versionNumber).padStart(3, "0")}
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
{versionList.map((v) => (
|
||||||
|
<DropdownMenuItem key={v.id} asChild>
|
||||||
|
<Link href={`/review/${v.id}`}>
|
||||||
|
v{String(v.versionNumber).padStart(3, "0")}
|
||||||
|
{v.isLatest && " (latest)"}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approval status */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full border hidden sm:inline-flex items-center gap-1",
|
||||||
|
APPROVAL_STATUS_STYLES[version.approvalStatus] ?? ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{version.approvalStatus.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Share with Client */}
|
||||||
|
{canShare && (
|
||||||
|
<ShareWithClientButton
|
||||||
|
versionId={version.id}
|
||||||
|
isAlreadyShared={version.isClientVisible}
|
||||||
|
onShared={() => {/* router.refresh() handled inside component */}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Approval actions */}
|
||||||
|
{canApprove && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
|
||||||
|
onClick={() => openApproval("NEEDS_CHANGES")}
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
<span className="hidden sm:inline">Needs Changes</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs gap-1 text-red-400 border-red-500/30 hover:bg-red-500/10"
|
||||||
|
onClick={() => openApproval("REJECTED")}
|
||||||
|
>
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
<span className="hidden sm:inline">Reject</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||||
|
onClick={() => openApproval("APPROVED")}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
<span className="hidden sm:inline">Approve</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content — player + comments */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Player — 70% */}
|
||||||
|
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
|
||||||
|
<ReviewPlayer
|
||||||
|
ref={playerRef}
|
||||||
|
videoUrl={version.fileUrl}
|
||||||
|
fps={version.fps}
|
||||||
|
comments={comments}
|
||||||
|
annotations={annotations}
|
||||||
|
versionId={version.id}
|
||||||
|
onAddComment={handleAddComment}
|
||||||
|
onAnnotationSaved={handleAnnotationSaved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment panel — 30%, min 280px */}
|
||||||
|
<div className="w-80 xl:w-96 shrink-0 flex flex-col border-l border-border">
|
||||||
|
<CommentPanel
|
||||||
|
versionId={version.id}
|
||||||
|
fps={version.fps}
|
||||||
|
comments={comments}
|
||||||
|
onCommentsChange={handleCommentsChange}
|
||||||
|
pendingFrame={pendingFrame}
|
||||||
|
onPendingFrameCleared={() => setPendingFrame(null)}
|
||||||
|
onSeekToFrame={(frame) => playerRef.current?.seekToFrame(frame)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approval Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={approvalDialog.open}
|
||||||
|
onOpenChange={(o) => !o && setApprovalDialog({ open: false, status: null })}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{approvalDialog.status === "APPROVED"
|
||||||
|
? "Approve Version"
|
||||||
|
: approvalDialog.status === "REJECTED"
|
||||||
|
? "Reject Version"
|
||||||
|
: "Request Changes"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={approvalNotes}
|
||||||
|
onChange={(e) => setApprovalNotes(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
approvalDialog.status === "APPROVED"
|
||||||
|
? "Any final comments..."
|
||||||
|
: "Describe the required changes..."
|
||||||
|
}
|
||||||
|
className="min-h-[100px]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setApprovalDialog({ open: false, status: null })}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApprovalSubmit}
|
||||||
|
disabled={isSubmittingApproval}
|
||||||
|
className={
|
||||||
|
approvalDialog.status === "APPROVED"
|
||||||
|
? "bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||||
|
: approvalDialog.status === "REJECTED"
|
||||||
|
? "bg-red-600 hover:bg-red-500 text-white"
|
||||||
|
: "bg-orange-600 hover:bg-orange-500 text-white"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSubmittingApproval ? "Submitting..." : "Confirm"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { ReviewPageClient } from "./ReviewPageClient";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ versionId: string }> }) {
|
||||||
|
const { versionId } = await params;
|
||||||
|
const version = await db.version.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
include: {
|
||||||
|
shot: { select: { shotCode: true } },
|
||||||
|
task: { select: { title: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!version) return { title: "Not Found" };
|
||||||
|
const label = version.shot?.shotCode ?? version.task?.title ?? "Version";
|
||||||
|
return { title: `Review — ${label} v${version.versionNumber}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getReviewData(versionId: string) {
|
||||||
|
const version = await db.version.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
include: {
|
||||||
|
shot: {
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: { id: true, name: true, code: true },
|
||||||
|
},
|
||||||
|
versions: {
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versionNumber: true,
|
||||||
|
approvalStatus: true,
|
||||||
|
isLatest: true,
|
||||||
|
fps: true,
|
||||||
|
duration: true,
|
||||||
|
notes: true,
|
||||||
|
fileSize: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
include: {
|
||||||
|
project: { select: { id: true, name: true, code: true } },
|
||||||
|
shot: { select: { id: true, shotCode: true } },
|
||||||
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
|
versions: {
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versionNumber: true,
|
||||||
|
approvalStatus: true,
|
||||||
|
isLatest: true,
|
||||||
|
fps: true,
|
||||||
|
duration: true,
|
||||||
|
notes: true,
|
||||||
|
fileSize: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
artist: {
|
||||||
|
select: { id: true, name: true, image: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!version) return null;
|
||||||
|
|
||||||
|
const comments = await db.comment.findMany({
|
||||||
|
where: { versionId },
|
||||||
|
orderBy: { frameNumber: "asc" },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
replies: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, name: true, image: true, email: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const annotations = await db.annotation.findMany({
|
||||||
|
where: { versionId },
|
||||||
|
orderBy: { frameNumber: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { version, comments, annotations };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ReviewPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ versionId: string }>;
|
||||||
|
}) {
|
||||||
|
const { versionId } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
// Already handled by middleware but be safe
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getReviewData(versionId);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(
|
||||||
|
session.user.role
|
||||||
|
);
|
||||||
|
const canShare = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReviewPageClient
|
||||||
|
version={data.version as any}
|
||||||
|
comments={data.comments as any}
|
||||||
|
annotations={data.annotations as any}
|
||||||
|
canApprove={canApprove}
|
||||||
|
canShare={canShare}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
adapter: PrismaAdapter(db) as any,
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
token.role = (user as { role: Role }).role;
|
||||||
|
token.mustChangePassword = (user as { mustChangePassword?: boolean }).mustChangePassword ?? false;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (token && session.user) {
|
||||||
|
session.user.id = token.id as string;
|
||||||
|
session.user.role = token.role as Role;
|
||||||
|
session.user.mustChangePassword = token.mustChangePassword as boolean;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: "credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) return null;
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { email: credentials.email as string },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.passwordHash || !user.isActive) return null;
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(
|
||||||
|
credentials.password as string,
|
||||||
|
user.passwordHash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image,
|
||||||
|
role: user.role,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
root@ubuntu-4gb-nbg1-3:/opt/vfxreview# docker inspect vfxreview
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Id": "c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320",
|
||||||
|
"Created": "2026-05-15T11:13:46.919887274Z",
|
||||||
|
"Path": "docker-entrypoint.sh",
|
||||||
|
"Args": [
|
||||||
|
"node",
|
||||||
|
"server.js"
|
||||||
|
],
|
||||||
|
"State": {
|
||||||
|
"Status": "restarting",
|
||||||
|
"Running": true,
|
||||||
|
"Paused": false,
|
||||||
|
"Restarting": true,
|
||||||
|
"OOMKilled": false,
|
||||||
|
"Dead": false,
|
||||||
|
"Pid": 0,
|
||||||
|
"ExitCode": 255,
|
||||||
|
"Error": "",
|
||||||
|
"StartedAt": "2026-05-15T11:17:31.349008071Z",
|
||||||
|
"FinishedAt": "2026-05-15T11:17:31.553910724Z"
|
||||||
|
},
|
||||||
|
"Image": "sha256:69368bb47b96170569bd23be2343dded31696127c8d1e0c1ddff6fbc6b9d4fb2",
|
||||||
|
"ResolvConfPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/resolv.conf",
|
||||||
|
"HostnamePath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/hostname",
|
||||||
|
"HostsPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/hosts",
|
||||||
|
"LogPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320-json.log",
|
||||||
|
"Name": "/vfxreview",
|
||||||
|
"RestartCount": 13,
|
||||||
|
"Driver": "overlay2",
|
||||||
|
"Platform": "linux",
|
||||||
|
"MountLabel": "",
|
||||||
|
"ProcessLabel": "",
|
||||||
|
"AppArmorProfile": "docker-default",
|
||||||
|
"ExecIDs": null,
|
||||||
|
"HostConfig": {
|
||||||
|
"Binds": [
|
||||||
|
"/opt/vfxreview/uploads:/uploads:rw"
|
||||||
|
],
|
||||||
|
"ContainerIDFile": "",
|
||||||
|
"LogConfig": {
|
||||||
|
"Type": "json-file",
|
||||||
|
"Config": {}
|
||||||
|
},
|
||||||
|
"NetworkMode": "vfxreview_app-net",
|
||||||
|
"PortBindings": {},
|
||||||
|
"RestartPolicy": {
|
||||||
|
"Name": "unless-stopped",
|
||||||
|
"MaximumRetryCount": 0
|
||||||
|
},
|
||||||
|
"AutoRemove": false,
|
||||||
|
"VolumeDriver": "",
|
||||||
|
"VolumesFrom": [],
|
||||||
|
"ConsoleSize": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"CapAdd": null,
|
||||||
|
"CapDrop": null,
|
||||||
|
"CgroupnsMode": "private",
|
||||||
|
"Dns": null,
|
||||||
|
"DnsOptions": null,
|
||||||
|
"DnsSearch": null,
|
||||||
|
"ExtraHosts": null,
|
||||||
|
"GroupAdd": null,
|
||||||
|
"IpcMode": "private",
|
||||||
|
"Cgroup": "",
|
||||||
|
"Links": null,
|
||||||
|
"OomScoreAdj": 0,
|
||||||
|
"PidMode": "",
|
||||||
|
"Privileged": false,
|
||||||
|
"PublishAllPorts": false,
|
||||||
|
"ReadonlyRootfs": false,
|
||||||
|
"SecurityOpt": null,
|
||||||
|
"UTSMode": "",
|
||||||
|
"UsernsMode": "",
|
||||||
|
"ShmSize": 67108864,
|
||||||
|
"Runtime": "runc",
|
||||||
|
"Isolation": "",
|
||||||
|
"CpuShares": 0,
|
||||||
|
"Memory": 0,
|
||||||
|
"NanoCpus": 0,
|
||||||
|
"CgroupParent": "",
|
||||||
|
"BlkioWeight": 0,
|
||||||
|
"BlkioWeightDevice": null,
|
||||||
|
"BlkioDeviceReadBps": null,
|
||||||
|
"BlkioDeviceWriteBps": null,
|
||||||
|
"BlkioDeviceReadIOps": null,
|
||||||
|
"BlkioDeviceWriteIOps": null,
|
||||||
|
"CpuPeriod": 0,
|
||||||
|
"CpuQuota": 0,
|
||||||
|
"CpuRealtimePeriod": 0,
|
||||||
|
"CpuRealtimeRuntime": 0,
|
||||||
|
"CpusetCpus": "",
|
||||||
|
"CpusetMems": "",
|
||||||
|
"Devices": null,
|
||||||
|
"DeviceCgroupRules": null,
|
||||||
|
"DeviceRequests": null,
|
||||||
|
"MemoryReservation": 0,
|
||||||
|
"MemorySwap": 0,
|
||||||
|
"MemorySwappiness": null,
|
||||||
|
"OomKillDisable": null,
|
||||||
|
"PidsLimit": null,
|
||||||
|
"Ulimits": null,
|
||||||
|
"CpuCount": 0,
|
||||||
|
"CpuPercent": 0,
|
||||||
|
"IOMaximumIOps": 0,
|
||||||
|
"IOMaximumBandwidth": 0,
|
||||||
|
"MaskedPaths": [
|
||||||
|
"/proc/acpi",
|
||||||
|
"/proc/asound",
|
||||||
|
"/proc/interrupts",
|
||||||
|
"/proc/kcore",
|
||||||
|
"/proc/keys",
|
||||||
|
"/proc/latency_stats",
|
||||||
|
"/proc/sched_debug",
|
||||||
|
"/proc/scsi",
|
||||||
|
"/proc/timer_list",
|
||||||
|
"/proc/timer_stats",
|
||||||
|
"/sys/devices/virtual/powercap",
|
||||||
|
"/sys/firmware"
|
||||||
|
],
|
||||||
|
"ReadonlyPaths": [
|
||||||
|
"/proc/bus",
|
||||||
|
"/proc/fs",
|
||||||
|
"/proc/irq",
|
||||||
|
"/proc/sys",
|
||||||
|
"/proc/sysrq-trigger"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GraphDriver": {
|
||||||
|
"Data": {
|
||||||
|
"ID": "c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320",
|
||||||
|
"LowerDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c-init/diff:/var/lib/docker/overlay2/99da5f835f83c7550d6a8f82f03fc4f7147c86a02f934cc8688fc5444110352c/diff:/var/lib/docker/overlay2/a824d9563ca0af7794e667c9b4799f57c6a76dcff84b0f6375018dc90c4f50ce/diff:/var/lib/docker/overlay2/6c9a5c50a54b3111494f6780dbb640559b6db9310b92826e875fd4d3048bc590/diff:/var/lib/docker/overlay2/ba53a31b5ba6e15b84be1424c9fb2053a16dbba42a2056b39cc0c0ca68a23ea3/diff:/var/lib/docker/overlay2/5a6252acf39dbbb2daa9a95050adbd5d61f7079c3ca281544a72d7942bdb86fa/diff:/var/lib/docker/overlay2/2fec287c58bf77e6f80c042a360d9814b77a747b1c6436ba68b4dcee309b8bb4/diff:/var/lib/docker/overlay2/100154f217c53a8e26927fa82f3fe56074e192506607c283a0cc3dfd7d4261a7/diff:/var/lib/docker/overlay2/c8d0b59c20926f8c6337ab778303dd37701740dcab6494a8ef7547eccfd266ed/diff:/var/lib/docker/overlay2/adb4f687409d280798957b268c1eef0fcc93ab6ecc24f291ba2ff4afaf5ef9c1/diff:/var/lib/docker/overlay2/0567d27adf4684c0b9d2be40dd32e2be2a81e43d2b118c8ac10697f64ce2b41c/diff:/var/lib/docker/overlay2/509a8569764702e18a334b6c7dd49e7dfdf237b821bcd34fc9102c11378e6c2b/diff:/var/lib/docker/overlay2/66cb5fe679daa03d24693ef170278322206904e36cad21c55a582175c7ea2298/diff",
|
||||||
|
"MergedDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/merged",
|
||||||
|
"UpperDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/diff",
|
||||||
|
"WorkDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/work"
|
||||||
|
},
|
||||||
|
"Name": "overlay2"
|
||||||
|
},
|
||||||
|
"Mounts": [
|
||||||
|
{
|
||||||
|
"Type": "bind",
|
||||||
|
"Source": "/opt/vfxreview/uploads",
|
||||||
|
"Destination": "/uploads",
|
||||||
|
"Mode": "rw",
|
||||||
|
"RW": true,
|
||||||
|
"Propagation": "rprivate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Config": {
|
||||||
|
"Hostname": "c657a3a390f6",
|
||||||
|
"Domainname": "",
|
||||||
|
"User": "nextjs",
|
||||||
|
"AttachStdin": false,
|
||||||
|
"AttachStdout": false,
|
||||||
|
"AttachStderr": false,
|
||||||
|
"ExposedPorts": {
|
||||||
|
"3000/tcp": {}
|
||||||
|
},
|
||||||
|
"Tty": false,
|
||||||
|
"OpenStdin": false,
|
||||||
|
"StdinOnce": false,
|
||||||
|
"Env": [
|
||||||
|
"DATABASE_URL=postgresql://postgres:postgres@postgres:5432/feedback",
|
||||||
|
"NEXTAUTH_SECRET=your-secret-here-change-in-production",
|
||||||
|
"NEXTAUTH_URL=http://localhost:3000",
|
||||||
|
"UPLOADTHING_SECRET=eyJhcGlLZXkiOiJza19saXZlXzM0YmMzMTQ0NmVkZTJlMDQ3NmUwYmMzY2IyYzJkNTAyOTM1ODk0ZmM0YWRiNTQ1ODIxODhhM2VjNmU5OTE2NGMiLCJhcHBJZCI6ImVrN20xbWg2cXUiLCJyZWdpb25zIjpbInNlYTEiXX0=",
|
||||||
|
"UPLOADTHING_APP_ID=",
|
||||||
|
"STORAGE_PROVIDER=local",
|
||||||
|
"AWS_ACCESS_KEY_ID=",
|
||||||
|
"AWS_SECRET_ACCESS_KEY=",
|
||||||
|
"AWS_REGION=us-east-1",
|
||||||
|
"AWS_BUCKET_NAME=",
|
||||||
|
"R2_ACCESS_KEY_ID=",
|
||||||
|
"R2_SECRET_ACCESS_KEY=",
|
||||||
|
"R2_ACCOUNT_ID=",
|
||||||
|
"R2_BUCKET_NAME=",
|
||||||
|
"R2_PUBLIC_URL=",
|
||||||
|
"B2_APPLICATION_KEY_ID=",
|
||||||
|
"B2_APPLICATION_KEY=",
|
||||||
|
"B2_BUCKET_NAME=",
|
||||||
|
"B2_ENDPOINT=",
|
||||||
|
"MINIO_ENDPOINT=http://localhost:9000",
|
||||||
|
"MINIO_ACCESS_KEY=",
|
||||||
|
"MINIO_SECRET_KEY=",
|
||||||
|
"MINIO_BUCKET_NAME=vfx-review",
|
||||||
|
"LOCAL_UPLOAD_DIR=./uploads",
|
||||||
|
"EMAIL_FROM=noreply@yourcompany.com",
|
||||||
|
"EMAIL_SERVER_HOST=smtp.gmail.com",
|
||||||
|
"EMAIL_SERVER_PORT=587",
|
||||||
|
"EMAIL_SERVER_USER=",
|
||||||
|
"EMAIL_SERVER_PASSWORD=",
|
||||||
|
"SLACK_DEFAULT_WEBHOOK=",
|
||||||
|
"NEXT_PUBLIC_APP_URL=http://localhost:3000",
|
||||||
|
"NEXT_PUBLIC_APP_NAME=VFX Review",
|
||||||
|
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||||
|
"NODE_VERSION=20.20.2",
|
||||||
|
"YARN_VERSION=1.22.22",
|
||||||
|
"NODE_ENV=production",
|
||||||
|
"NEXT_TELEMETRY_DISABLED=1",
|
||||||
|
"PORT=3000",
|
||||||
|
"HOSTNAME=0.0.0.0"
|
||||||
|
],
|
||||||
|
"Cmd": [
|
||||||
|
"node",
|
||||||
|
"server.js"
|
||||||
|
],
|
||||||
|
"Image": "vfxreview:1.0.0",
|
||||||
|
"Volumes": {
|
||||||
|
"/uploads": {}
|
||||||
|
},
|
||||||
|
"WorkingDir": "/app",
|
||||||
|
"Entrypoint": [
|
||||||
|
"docker-entrypoint.sh"
|
||||||
|
],
|
||||||
|
"Labels": {
|
||||||
|
"com.docker.compose.config-hash": "be61c44b05bc31b228b3b1ab25372b6e4e6b8358b1bc7c96a2b131e642a81358",
|
||||||
|
"com.docker.compose.container-number": "1",
|
||||||
|
"com.docker.compose.oneoff": "False",
|
||||||
|
"com.docker.compose.project": "vfxreview",
|
||||||
|
"com.docker.compose.project.config_files": "docker-compose.yml",
|
||||||
|
"com.docker.compose.project.working_dir": "/opt/vfxreview",
|
||||||
|
"com.docker.compose.service": "vfxreview",
|
||||||
|
"com.docker.compose.version": "1.29.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NetworkSettings": {
|
||||||
|
"SandboxID": "",
|
||||||
|
"SandboxKey": "",
|
||||||
|
"Ports": {},
|
||||||
|
"Networks": {
|
||||||
|
"vfxreview_app-net": {
|
||||||
|
"IPAMConfig": null,
|
||||||
|
"Links": null,
|
||||||
|
"Aliases": [
|
||||||
|
"vfxreview",
|
||||||
|
"c657a3a390f6"
|
||||||
|
],
|
||||||
|
"DriverOpts": null,
|
||||||
|
"GwPriority": 0,
|
||||||
|
"NetworkID": "e3f79c0c30cca1c12a74793b70818cc7573e9ec6ffa5ee393d3a81b26bae33ee",
|
||||||
|
"EndpointID": "",
|
||||||
|
"Gateway": "",
|
||||||
|
"IPAddress": "",
|
||||||
|
"MacAddress": "",
|
||||||
|
"IPPrefixLen": 0,
|
||||||
|
"IPv6Gateway": "",
|
||||||
|
"GlobalIPv6Address": "",
|
||||||
|
"GlobalIPv6PrefixLen": 0,
|
||||||
|
"DNSNames": [
|
||||||
|
"vfxreview",
|
||||||
|
"c657a3a390f6"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
MouseEvent,
|
||||||
|
} from "react";
|
||||||
|
import { useReviewStore } from "@/hooks/use-review-player";
|
||||||
|
import { saveAnnotation } from "@/actions/annotations";
|
||||||
|
import { addComment } from "@/actions/comments";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import type { AnnotationShape, AnnotationDrawingData, AnnotationPoint } from "@/types";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
interface AnnotationCanvasProps {
|
||||||
|
versionId: string;
|
||||||
|
frameNumber: number;
|
||||||
|
fps: number;
|
||||||
|
isAnnotating: boolean;
|
||||||
|
showAnnotations: boolean;
|
||||||
|
existingAnnotations?: unknown[];
|
||||||
|
onAnnotationSaved?: (frameNumber: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawingState = {
|
||||||
|
isDrawing: boolean;
|
||||||
|
currentShape: AnnotationShape | null;
|
||||||
|
shapes: AnnotationShape[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AnnotationCanvas({
|
||||||
|
versionId,
|
||||||
|
frameNumber,
|
||||||
|
fps,
|
||||||
|
isAnnotating,
|
||||||
|
showAnnotations,
|
||||||
|
existingAnnotations = [],
|
||||||
|
onAnnotationSaved,
|
||||||
|
}: AnnotationCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const { selectedTool, selectedColor, strokeWidth } = useReviewStore();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [drawingState, setDrawingState] = useState<DrawingState>({
|
||||||
|
isDrawing: false,
|
||||||
|
currentShape: null,
|
||||||
|
shapes: [],
|
||||||
|
});
|
||||||
|
const drawingStateRef = useRef(drawingState);
|
||||||
|
drawingStateRef.current = drawingState;
|
||||||
|
|
||||||
|
// Per-frame saved shapes — persists across frame navigation so annotations
|
||||||
|
// remain visible without depending on a parent API refetch.
|
||||||
|
const [savedShapesByFrame, setSavedShapesByFrame] = useState<Record<number, AnnotationShape[]>>(() => {
|
||||||
|
const map: Record<number, AnnotationShape[]> = {};
|
||||||
|
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
|
||||||
|
const shapes = a.drawingData?.shapes ?? [];
|
||||||
|
map[a.frameNumber] = [...(map[a.frameNumber] ?? []), ...shapes];
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
// Ref so redraw closure can read latest without being recreated
|
||||||
|
const savedShapesByFrameRef = useRef(savedShapesByFrame);
|
||||||
|
savedShapesByFrameRef.current = savedShapesByFrame;
|
||||||
|
|
||||||
|
// Track frames that already have an annotation comment this session,
|
||||||
|
// initialized from existing annotations so we don't double-comment on refresh.
|
||||||
|
const [annotationCommentedFrames, setAnnotationCommentedFrames] = useState<Set<number>>(() => {
|
||||||
|
const frames = new Set<number>();
|
||||||
|
for (const a of (existingAnnotations as { frameNumber: number }[])) {
|
||||||
|
frames.add(a.frameNumber);
|
||||||
|
}
|
||||||
|
return frames;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync savedShapesByFrame when the existingAnnotations prop changes (e.g. after parent API refresh).
|
||||||
|
// MERGE server shapes with locally-drawn shapes (deduplicate by shape id) so locally-drawn
|
||||||
|
// annotations are never wiped by a new array reference from the parent.
|
||||||
|
const prevExistingLengthRef = useRef(existingAnnotations.length);
|
||||||
|
const prevExistingRef = useRef(existingAnnotations);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
prevExistingRef.current === existingAnnotations ||
|
||||||
|
prevExistingLengthRef.current === existingAnnotations.length
|
||||||
|
) {
|
||||||
|
prevExistingRef.current = existingAnnotations;
|
||||||
|
prevExistingLengthRef.current = existingAnnotations.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevExistingRef.current = existingAnnotations;
|
||||||
|
prevExistingLengthRef.current = existingAnnotations.length;
|
||||||
|
|
||||||
|
setSavedShapesByFrame((prev) => {
|
||||||
|
// Build map from server data
|
||||||
|
const serverMap: Record<number, AnnotationShape[]> = {};
|
||||||
|
const serverShapeIds = new Set<string>();
|
||||||
|
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
|
||||||
|
const shapes = a.drawingData?.shapes ?? [];
|
||||||
|
serverMap[a.frameNumber] = [...(serverMap[a.frameNumber] ?? []), ...shapes];
|
||||||
|
for (const s of shapes) serverShapeIds.add(s.id);
|
||||||
|
}
|
||||||
|
// Merge: keep local shapes not yet in server data (pending save race)
|
||||||
|
const merged = { ...serverMap };
|
||||||
|
for (const [frame, shapes] of Object.entries(prev)) {
|
||||||
|
for (const s of shapes) {
|
||||||
|
if (!serverShapeIds.has(s.id)) {
|
||||||
|
const f = Number(frame);
|
||||||
|
merged[f] = [...(merged[f] ?? []), s];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also update frames that now have server-side annotation comments
|
||||||
|
setAnnotationCommentedFrames((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const a of (existingAnnotations as { frameNumber: number }[])) {
|
||||||
|
next.add(a.frameNumber);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [existingAnnotations]);
|
||||||
|
|
||||||
|
|
||||||
|
// ── Coordinate normalization ─────────────────────────────────────────────
|
||||||
|
const toNormalized = (e: MouseEvent): AnnotationPoint => {
|
||||||
|
const canvas = canvasRef.current!;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left) / rect.width,
|
||||||
|
y: (e.clientY - rect.top) / rect.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPixel = (p: AnnotationPoint, w: number, h: number) => ({
|
||||||
|
x: p.x * w,
|
||||||
|
y: p.y * h,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||||
|
const renderShape = useCallback(
|
||||||
|
(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
shape: AnnotationShape,
|
||||||
|
w: number,
|
||||||
|
h: number
|
||||||
|
) => {
|
||||||
|
if (shape.points.length === 0) return;
|
||||||
|
|
||||||
|
ctx.strokeStyle = shape.color;
|
||||||
|
ctx.lineWidth = shape.strokeWidth;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.globalAlpha = 0.85;
|
||||||
|
|
||||||
|
const pts = shape.points.map((p) => toPixel(p, w, h));
|
||||||
|
|
||||||
|
switch (shape.tool) {
|
||||||
|
case "freehand": {
|
||||||
|
if (pts.length < 2) return;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0].x, pts[0].y);
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
ctx.lineTo(pts[i].x, pts[i].y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "rectangle": {
|
||||||
|
if (pts.length < 2) return;
|
||||||
|
const x = Math.min(pts[0].x, pts[pts.length - 1].x);
|
||||||
|
const y = Math.min(pts[0].y, pts[pts.length - 1].y);
|
||||||
|
const width = Math.abs(pts[pts.length - 1].x - pts[0].x);
|
||||||
|
const height = Math.abs(pts[pts.length - 1].y - pts[0].y);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(x, y, width, height);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "circle": {
|
||||||
|
if (pts.length < 2) return;
|
||||||
|
const cx = pts[0].x;
|
||||||
|
const cy = pts[0].y;
|
||||||
|
const dx = pts[pts.length - 1].x - cx;
|
||||||
|
const dy = pts[pts.length - 1].y - cy;
|
||||||
|
const radius = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "arrow": {
|
||||||
|
if (pts.length < 2) return;
|
||||||
|
const start = pts[0];
|
||||||
|
const end = pts[pts.length - 1];
|
||||||
|
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
||||||
|
const arrowLen = Math.max(10, shape.strokeWidth * 5);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(start.x, start.y);
|
||||||
|
ctx.lineTo(end.x, end.y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Arrowhead
|
||||||
|
ctx.fillStyle = shape.color;
|
||||||
|
ctx.globalAlpha = 0.85;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(end.x, end.y);
|
||||||
|
ctx.lineTo(
|
||||||
|
end.x - arrowLen * Math.cos(angle - Math.PI / 7),
|
||||||
|
end.y - arrowLen * Math.sin(angle - Math.PI / 7)
|
||||||
|
);
|
||||||
|
ctx.lineTo(
|
||||||
|
end.x - arrowLen * Math.cos(angle + Math.PI / 7),
|
||||||
|
end.y - arrowLen * Math.sin(angle + Math.PI / 7)
|
||||||
|
);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const redraw = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
if (!showAnnotations) return;
|
||||||
|
|
||||||
|
// Draw saved shapes for this frame (seeded from DB + newly saved this session)
|
||||||
|
(savedShapesByFrameRef.current[frameNumber] ?? []).forEach((shape) => {
|
||||||
|
renderShape(ctx, shape, w, h);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw current (in-progress) shape
|
||||||
|
const current = drawingStateRef.current.currentShape;
|
||||||
|
if (current && drawingStateRef.current.isDrawing) {
|
||||||
|
renderShape(ctx, current, w, h);
|
||||||
|
}
|
||||||
|
}, [frameNumber, showAnnotations, renderShape]);
|
||||||
|
|
||||||
|
// Resize observer — stable, only recreated when redraw changes (which is rare now)
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
canvas.width = canvas.offsetWidth;
|
||||||
|
canvas.height = canvas.offsetHeight;
|
||||||
|
redraw();
|
||||||
|
});
|
||||||
|
observer.observe(canvas);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [redraw]);
|
||||||
|
|
||||||
|
// Redraw whenever frame, saved shapes map, in-progress shape, or visibility changes
|
||||||
|
useEffect(() => {
|
||||||
|
redraw();
|
||||||
|
}, [redraw, savedShapesByFrame, drawingState.currentShape, showAnnotations]);
|
||||||
|
|
||||||
|
// ── Mouse events ─────────────────────────────────────────────────────────
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isAnnotating) return;
|
||||||
|
const point = toNormalized(e);
|
||||||
|
const newShape: AnnotationShape = {
|
||||||
|
id: uuidv4(),
|
||||||
|
tool: selectedTool,
|
||||||
|
points: [point],
|
||||||
|
color: selectedColor,
|
||||||
|
strokeWidth,
|
||||||
|
frameNumber,
|
||||||
|
};
|
||||||
|
setDrawingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isDrawing: true,
|
||||||
|
currentShape: newShape,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[isAnnotating, selectedTool, selectedColor, strokeWidth, frameNumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(e: MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!drawingStateRef.current.isDrawing || !drawingStateRef.current.currentShape) return;
|
||||||
|
const point = toNormalized(e);
|
||||||
|
setDrawingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
currentShape: prev.currentShape
|
||||||
|
? { ...prev.currentShape, points: [...prev.currentShape.points, point] }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
redraw();
|
||||||
|
},
|
||||||
|
[redraw]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(async () => {
|
||||||
|
const state = drawingStateRef.current;
|
||||||
|
if (!state.isDrawing || !state.currentShape) return;
|
||||||
|
|
||||||
|
const finishedShape = state.currentShape;
|
||||||
|
// Clear the in-progress drawing state
|
||||||
|
setDrawingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isDrawing: false,
|
||||||
|
currentShape: null,
|
||||||
|
shapes: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Immediately persist shape into the per-frame cache so it survives frame navigation
|
||||||
|
setSavedShapesByFrame((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[frameNumber]: [...(prev[frameNumber] ?? []), finishedShape],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Save to DB
|
||||||
|
try {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const drawingData: AnnotationDrawingData = {
|
||||||
|
shapes: [finishedShape],
|
||||||
|
canvasWidth: canvas?.offsetWidth ?? 1920,
|
||||||
|
canvasHeight: canvas?.offsetHeight ?? 1080,
|
||||||
|
version: "1.0",
|
||||||
|
};
|
||||||
|
await saveAnnotation({
|
||||||
|
versionId,
|
||||||
|
frameNumber,
|
||||||
|
drawingData,
|
||||||
|
color: selectedColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a companion comment ONCE per frame so the annotation appears in the panel.
|
||||||
|
// Additional strokes on the same frame update the existing comment count silently.
|
||||||
|
if (!annotationCommentedFrames.has(frameNumber)) {
|
||||||
|
await addComment({
|
||||||
|
versionId,
|
||||||
|
frameNumber,
|
||||||
|
timestamp: frameNumber / fps,
|
||||||
|
text: `✏️ Annotation at frame ${frameNumber}`,
|
||||||
|
});
|
||||||
|
setAnnotationCommentedFrames((prev) => new Set([...prev, frameNumber]));
|
||||||
|
}
|
||||||
|
|
||||||
|
onAnnotationSaved?.(frameNumber);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: "Failed to save annotation",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [versionId, frameNumber, fps, selectedColor, annotationCommentedFrames, onAnnotationSaved, toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`annotation-canvas-overlay ${isAnnotating ? "is-annotating" : ""}`}
|
||||||
|
style={{
|
||||||
|
cursor: isAnnotating ? "crosshair" : "default",
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useReviewStore } from "@/hooks/use-review-player";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AnnotationTool } from "@/types";
|
||||||
|
import {
|
||||||
|
Pencil,
|
||||||
|
ArrowUpRight,
|
||||||
|
Square,
|
||||||
|
Circle,
|
||||||
|
Minus,
|
||||||
|
Plus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
const TOOLS: { tool: AnnotationTool; icon: React.ElementType; label: string }[] = [
|
||||||
|
{ tool: "freehand", icon: Pencil, label: "Freehand" },
|
||||||
|
{ tool: "arrow", icon: ArrowUpRight, label: "Arrow" },
|
||||||
|
{ tool: "rectangle", icon: Square, label: "Rectangle" },
|
||||||
|
{ tool: "circle", icon: Circle, label: "Circle" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
{ value: "#ef4444", label: "Red" },
|
||||||
|
{ value: "#f59e0b", label: "Amber" },
|
||||||
|
{ value: "#22c55e", label: "Green" },
|
||||||
|
{ value: "#3b82f6", label: "Blue" },
|
||||||
|
{ value: "#a855f7", label: "Purple" },
|
||||||
|
{ value: "#ffffff", label: "White" },
|
||||||
|
{ value: "#000000", label: "Black" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AnnotationTools() {
|
||||||
|
const {
|
||||||
|
isAnnotating,
|
||||||
|
selectedTool,
|
||||||
|
selectedColor,
|
||||||
|
strokeWidth,
|
||||||
|
setSelectedTool,
|
||||||
|
setSelectedColor,
|
||||||
|
setStrokeWidth,
|
||||||
|
setAnnotating,
|
||||||
|
} = useReviewStore();
|
||||||
|
|
||||||
|
if (!isAnnotating) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 bg-zinc-900/95 border-b border-white/5">
|
||||||
|
{/* Tool selector */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{TOOLS.map(({ tool, icon: Icon, label }) => (
|
||||||
|
<Tooltip key={tool}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
"text-zinc-400 hover:text-white",
|
||||||
|
selectedTool === tool &&
|
||||||
|
"bg-primary/20 text-primary hover:bg-primary/30"
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedTool(tool)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-5 w-px bg-white/10" />
|
||||||
|
|
||||||
|
{/* Color picker */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<Tooltip key={c.value}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5 rounded-full border-2 transition-transform hover:scale-110",
|
||||||
|
selectedColor === c.value
|
||||||
|
? "border-white scale-110"
|
||||||
|
: "border-transparent"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
onClick={() => setSelectedColor(c.value)}
|
||||||
|
title={c.label}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{c.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-5 w-px bg-white/10" />
|
||||||
|
|
||||||
|
{/* Stroke width */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-zinc-400 hover:text-white h-6 w-6"
|
||||||
|
onClick={() => setStrokeWidth(Math.max(1, strokeWidth - 1))}
|
||||||
|
>
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Thinner</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-full bg-current"
|
||||||
|
style={{
|
||||||
|
width: Math.min(16, strokeWidth * 3),
|
||||||
|
height: Math.min(16, strokeWidth * 3),
|
||||||
|
backgroundColor: selectedColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-zinc-400 hover:text-white h-6 w-6"
|
||||||
|
onClick={() => setStrokeWidth(Math.min(8, strokeWidth + 1))}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Thicker</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<span className="text-xs text-zinc-500 font-mono w-4">{strokeWidth}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { TaskList } from "@/components/tasks/TaskList";
|
||||||
|
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getInitials } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Package,
|
||||||
|
CalendarDays,
|
||||||
|
CheckCircle2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ShotStatus } from "@prisma/client";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<
|
||||||
|
ShotStatus,
|
||||||
|
{ label: string; color: string }
|
||||||
|
> = {
|
||||||
|
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" },
|
||||||
|
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
|
||||||
|
IN_REVIEW: { label: "In Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20" },
|
||||||
|
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
|
||||||
|
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_DOT: Record<string, string> = {
|
||||||
|
LOW: "bg-zinc-500", NORMAL: "bg-blue-500", HIGH: "bg-amber-500", URGENT: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Artist {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetCardProps {
|
||||||
|
asset: {
|
||||||
|
id: string;
|
||||||
|
assetCode: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: ShotStatus;
|
||||||
|
priority: string;
|
||||||
|
dueDate: Date | null;
|
||||||
|
lead?: { id: string; name: string | null; email: string; image: string | null } | null;
|
||||||
|
tasks: any[];
|
||||||
|
_count?: { tasks: number };
|
||||||
|
};
|
||||||
|
projectId: string;
|
||||||
|
artists: Artist[];
|
||||||
|
canManage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetCard({ asset, projectId, artists, canManage = false }: AssetCardProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const statusCfg = STATUS_CONFIG[asset.status] ?? STATUS_CONFIG.WAITING;
|
||||||
|
const doneTasks = asset.tasks.filter((t: any) => t.status === "DONE").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-zinc-800/50 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className={cn("w-1.5 h-1.5 rounded-full shrink-0", PRIORITY_DOT[asset.priority] ?? "bg-zinc-500")} />
|
||||||
|
|
||||||
|
<Package className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm font-medium text-zinc-300">{asset.assetCode}</span>
|
||||||
|
<span className="text-sm text-white">{asset.name}</span>
|
||||||
|
</div>
|
||||||
|
{asset.description && (
|
||||||
|
<p className="text-xs text-zinc-500 truncate">{asset.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
{asset.tasks.length > 0 && (
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
<CheckCircle2 className="h-3 w-3 inline mr-1" />
|
||||||
|
{doneTasks}/{asset.tasks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Badge className={cn("text-xs border px-1.5 py-0 h-5", statusCfg.color)}>
|
||||||
|
{statusCfg.label}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{asset.dueDate && (
|
||||||
|
<span className="text-xs text-zinc-500 hidden sm:flex items-center gap-1">
|
||||||
|
<CalendarDays className="h-3 w-3" />
|
||||||
|
{formatDistanceToNow(new Date(asset.dueDate), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{asset.lead && (
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={asset.lead.image ?? undefined} />
|
||||||
|
<AvatarFallback className="text-[10px]">
|
||||||
|
{getInitials(asset.lead.name ?? asset.lead.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-zinc-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded task list */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-4 pt-2 border-t border-border bg-zinc-900/50">
|
||||||
|
<TaskList
|
||||||
|
tasks={asset.tasks}
|
||||||
|
projectId={projectId}
|
||||||
|
assetId={asset.id}
|
||||||
|
artists={artists}
|
||||||
|
canManage={canManage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { createAsset } from "@/actions/assets";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { ShotPriority } from "@prisma/client";
|
||||||
|
|
||||||
|
const assetSchema = z.object({
|
||||||
|
assetCode: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Asset code is required")
|
||||||
|
.max(30)
|
||||||
|
.regex(/^[A-Z0-9_\-]+$/i, "Alphanumeric, dash, underscore only"),
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
priority: z.nativeEnum(ShotPriority),
|
||||||
|
dueDate: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AssetFormValues = z.infer<typeof assetSchema>;
|
||||||
|
|
||||||
|
interface Artist {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewAssetDialogProps {
|
||||||
|
projectId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewAssetDialog({ projectId, open, onClose, onSuccess }: NewAssetDialogProps) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<AssetFormValues>({
|
||||||
|
resolver: zodResolver(assetSchema),
|
||||||
|
defaultValues: { priority: "NORMAL" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: AssetFormValues) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createAsset({ projectId, status: "WAITING", ...data });
|
||||||
|
toast({ title: "Asset created" });
|
||||||
|
reset();
|
||||||
|
router.refresh();
|
||||||
|
onSuccess?.();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to create asset",
|
||||||
|
description: (err as Error).message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Asset</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="assetCode">Asset Code *</Label>
|
||||||
|
<Input
|
||||||
|
id="assetCode"
|
||||||
|
placeholder="CAR_01"
|
||||||
|
className="uppercase"
|
||||||
|
{...register("assetCode")}
|
||||||
|
/>
|
||||||
|
{errors.assetCode && (
|
||||||
|
<p className="text-xs text-destructive">{errors.assetCode.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Priority</Label>
|
||||||
|
<Select
|
||||||
|
defaultValue="NORMAL"
|
||||||
|
onValueChange={(v) => setValue("priority", v as ShotPriority)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="LOW">Low</SelectItem>
|
||||||
|
<SelectItem value="NORMAL">Normal</SelectItem>
|
||||||
|
<SelectItem value="HIGH">High</SelectItem>
|
||||||
|
<SelectItem value="URGENT">Urgent</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="name">Asset Name *</Label>
|
||||||
|
<Input id="name" placeholder="Hero Car" {...register("name")} />
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="dueDate">Due Date</Label>
|
||||||
|
<Input id="dueDate" type="date" {...register("dueDate")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="What is this asset?"
|
||||||
|
rows={2}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Creating…" : "Create Asset"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
company: z.string().min(1, "Company name is required"),
|
||||||
|
contactPerson: z.string().min(1, "Contact person is required"),
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
interface NewClientDialogProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewClientDialog({ children }: NewClientDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { company: "", contactPerson: "", email: "", phone: "", notes: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/clients", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(error.error ?? "Failed to create client");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
toast({ title: `Client "${data.company}" created` });
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to create client",
|
||||||
|
description: e instanceof Error ? e.message : undefined,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Client</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="company">Company *</Label>
|
||||||
|
<Input id="company" placeholder="Acme Productions" {...register("company")} />
|
||||||
|
{errors.company && <p className="text-xs text-red-400">{errors.company.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="contactPerson">Contact Person *</Label>
|
||||||
|
<Input id="contactPerson" placeholder="Jane Smith" {...register("contactPerson")} />
|
||||||
|
{errors.contactPerson && <p className="text-xs text-red-400">{errors.contactPerson.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">Email *</Label>
|
||||||
|
<Input id="email" type="email" placeholder="jane@acme.com" {...register("email")} />
|
||||||
|
{errors.email && <p className="text-xs text-red-400">{errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="phone">Phone</Label>
|
||||||
|
<Input id="phone" type="tel" placeholder="+1 (555) 000-0000" {...register("phone")} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="notes">Notes</Label>
|
||||||
|
<Textarea id="notes" placeholder="Any additional information..." className="resize-none" rows={3} {...register("notes")} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? "Creating..." : "Create Client"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ExternalLink, Copy, Check, Trash2, Clock } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { cn, formatRelativeDate } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ReviewSession {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
label: string | null;
|
||||||
|
email: string | null;
|
||||||
|
expiresAt: Date | string | null;
|
||||||
|
accessCount: number;
|
||||||
|
isActive: boolean;
|
||||||
|
project: { name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewSessionListProps {
|
||||||
|
sessions: ReviewSession[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "";
|
||||||
|
|
||||||
|
export function ReviewSessionList({ sessions }: ReviewSessionListProps) {
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleCopy = async (token: string, id: string) => {
|
||||||
|
const url = `${APP_URL}/client/${token}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopiedId(id);
|
||||||
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivate = async (id: string) => {
|
||||||
|
setDeletingId(id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/review-sessions?id=${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
toast({ title: "Review link deactivated" });
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to deactivate link", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white mb-3">
|
||||||
|
Active Review Links ({sessions.length})
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const portalUrl = `${APP_URL}/client/${session.token}`;
|
||||||
|
const expired =
|
||||||
|
session.expiresAt && new Date(session.expiresAt) < new Date();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-4 p-4 rounded-xl border transition-all",
|
||||||
|
expired
|
||||||
|
? "border-zinc-800 bg-zinc-900/50 opacity-60"
|
||||||
|
: "border-zinc-800 bg-zinc-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-white text-sm">{session.label || "Untitled Review"}</p>
|
||||||
|
{expired && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">
|
||||||
|
Expired
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<span className="text-xs text-zinc-500">{session.project.name}</span>
|
||||||
|
{session.email && (
|
||||||
|
<>
|
||||||
|
<span className="text-zinc-700">·</span>
|
||||||
|
<span className="text-xs text-zinc-500">{session.email}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{session.expiresAt && (
|
||||||
|
<>
|
||||||
|
<span className="text-zinc-700">·</span>
|
||||||
|
<span className="text-xs text-zinc-500 flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{expired ? "Expired" : `Expires ${formatRelativeDate(session.expiresAt)}`}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-xs text-zinc-600 truncate mt-1">{portalUrl}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<span className="text-xs text-zinc-500 mr-2">
|
||||||
|
{session.accessCount} view{session.accessCount !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2.5 gap-1.5"
|
||||||
|
onClick={() => handleCopy(session.token, session.id)}
|
||||||
|
>
|
||||||
|
{copiedId === session.id ? (
|
||||||
|
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{copiedId === session.id ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
<a href={portalUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Button size="sm" variant="outline" className="h-7 px-2">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2 text-red-400 hover:bg-red-500/10 border-red-500/20"
|
||||||
|
onClick={() => handleDeactivate(session.id)}
|
||||||
|
disabled={deletingId === session.id}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Copy, Check, ExternalLink } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
projectId: z.string().min(1, "Select a project"),
|
||||||
|
label: z.string().min(1, "Label is required"),
|
||||||
|
email: z.string().email("Invalid email"),
|
||||||
|
expiresInDays: z.number().int().positive().default(30),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShareReviewDialogProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
clientId: string;
|
||||||
|
clientEmail: string;
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareReviewDialog({
|
||||||
|
children,
|
||||||
|
clientEmail,
|
||||||
|
projects,
|
||||||
|
}: ShareReviewDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [portalUrl, setPortalUrl] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
projectId: projects[0]?.id ?? "",
|
||||||
|
label: "Review Round 1",
|
||||||
|
email: clientEmail,
|
||||||
|
expiresInDays: 30,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!portalUrl) return;
|
||||||
|
await navigator.clipboard.writeText(portalUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setPortalUrl(null);
|
||||||
|
reset({
|
||||||
|
projectId: projects[0]?.id ?? "",
|
||||||
|
label: "Review Round 1",
|
||||||
|
email: clientEmail,
|
||||||
|
expiresInDays: 30,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/review-sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error ?? "Failed to create review link");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setPortalUrl(data.portalUrl);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to create review link",
|
||||||
|
description: e instanceof Error ? e.message : undefined,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) { setPortalUrl(null); }
|
||||||
|
setOpen(o);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Share Review Link</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{portalUrl ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
Your review link is ready. Copy it and share it with your client.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||||
|
<ExternalLink className="h-4 w-4 text-zinc-500 shrink-0" />
|
||||||
|
<span className="flex-1 text-sm font-mono text-zinc-300 truncate">{portalUrl}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-2.5 gap-1.5 shrink-0"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleReset}>
|
||||||
|
Create Another
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setOpen(false)}>Done</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="projectId">Project *</Label>
|
||||||
|
<Select
|
||||||
|
defaultValue={watch("projectId")}
|
||||||
|
onValueChange={(v) => setValue("projectId", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="projectId">
|
||||||
|
<SelectValue placeholder="Select project" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name} <span className="text-zinc-500 text-xs ml-1">({p.code})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.projectId && <p className="text-xs text-red-400">{errors.projectId.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="label">Label *</Label>
|
||||||
|
<Input id="label" placeholder="e.g. Review Round 1" {...register("label")} />
|
||||||
|
{errors.label && <p className="text-xs text-red-400">{errors.label.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">Client Email *</Label>
|
||||||
|
<Input id="email" type="email" {...register("email")} />
|
||||||
|
{errors.email && <p className="text-xs text-red-400">{errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="expiresInDays">Expires In (days)</Label>
|
||||||
|
<Select
|
||||||
|
defaultValue={String(watch("expiresInDays"))}
|
||||||
|
onValueChange={(v) => setValue("expiresInDays", Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="expiresInDays">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[7, 14, 30, 60, 90].map((d) => (
|
||||||
|
<SelectItem key={d} value={String(d)}>
|
||||||
|
{d} days
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading || projects.length === 0}>
|
||||||
|
{loading ? "Generating..." : "Create Link"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useReviewStore } from "@/hooks/use-review-player";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getInitials, formatRelativeDate } from "@/lib/utils";
|
||||||
|
import { frameToTimecode } from "@/lib/frame-utils";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
MessageSquare,
|
||||||
|
Send,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Filter,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { addComment, addReply, resolveComment } from "@/actions/comments";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import type { CommentWithReplies } from "@/types";
|
||||||
|
|
||||||
|
interface CommentPanelProps {
|
||||||
|
versionId: string;
|
||||||
|
fps: number;
|
||||||
|
comments: CommentWithReplies[];
|
||||||
|
onCommentsChange?: () => void;
|
||||||
|
pendingFrame?: number | null;
|
||||||
|
onPendingFrameCleared?: () => void;
|
||||||
|
onSeekToFrame?: (frame: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentPanel({
|
||||||
|
versionId,
|
||||||
|
fps,
|
||||||
|
comments,
|
||||||
|
onCommentsChange,
|
||||||
|
pendingFrame,
|
||||||
|
onPendingFrameCleared,
|
||||||
|
onSeekToFrame,
|
||||||
|
}: CommentPanelProps) {
|
||||||
|
const { currentFrame, setCurrentFrame } = useReviewStore();
|
||||||
|
const [filterResolved, setFilterResolved] = useState<"all" | "unresolved" | "resolved">("all");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
|
const [activeCommentFrame, setActiveCommentFrame] = useState<number | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Handle pending frame from "Add Comment" button click
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingFrame !== null && pendingFrame !== undefined) {
|
||||||
|
setActiveCommentFrame(pendingFrame);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
onPendingFrameCleared?.();
|
||||||
|
}
|
||||||
|
}, [pendingFrame, onPendingFrameCleared]);
|
||||||
|
|
||||||
|
const filteredComments = comments.filter((c) => {
|
||||||
|
if (filterResolved === "unresolved") return !c.isResolved;
|
||||||
|
if (filterResolved === "resolved") return c.isResolved;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmitComment = async () => {
|
||||||
|
if (!commentText.trim() || activeCommentFrame === null) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const timestamp = activeCommentFrame / fps;
|
||||||
|
await addComment({
|
||||||
|
versionId,
|
||||||
|
frameNumber: activeCommentFrame,
|
||||||
|
timestamp,
|
||||||
|
text: commentText.trim(),
|
||||||
|
});
|
||||||
|
setCommentText("");
|
||||||
|
setActiveCommentFrame(null);
|
||||||
|
onCommentsChange?.();
|
||||||
|
toast({ title: "Comment added", variant: "default" });
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: "Failed to add comment", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmitComment();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setActiveCommentFrame(null);
|
||||||
|
setCommentText("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-card border-l border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Comments
|
||||||
|
{comments.length > 0 && (
|
||||||
|
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||||
|
({comments.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(["all", "unresolved", "resolved"] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilterResolved(f)}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 rounded text-xs transition-colors",
|
||||||
|
filterResolved === f
|
||||||
|
? "bg-primary/20 text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{f === "all" ? "All" : f === "unresolved" ? "Open" : "Resolved"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Input */}
|
||||||
|
{activeCommentFrame !== null ? (
|
||||||
|
<div className="shrink-0 border-b border-border p-3 bg-primary/5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Badge variant="pending" className="text-xs font-mono">
|
||||||
|
Frame {activeCommentFrame}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{frameToTimecode(activeCommentFrame, fps)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Add your feedback... (Ctrl+Enter to submit)"
|
||||||
|
className="min-h-[80px] text-sm bg-background/50"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<button
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => { setActiveCommentFrame(null); setCommentText(""); }}
|
||||||
|
>
|
||||||
|
Cancel (Esc)
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmitComment}
|
||||||
|
disabled={isSubmitting || !commentText.trim()}
|
||||||
|
>
|
||||||
|
<Send className="h-3 w-3 mr-1" />
|
||||||
|
{isSubmitting ? "Posting..." : "Post"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="shrink-0 px-3 py-2 border-b border-border">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-xs"
|
||||||
|
onClick={() => setActiveCommentFrame(currentFrame)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3 w-3 mr-2" />
|
||||||
|
Comment at frame {currentFrame}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comment List */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{filteredComments.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<MessageSquare className="h-8 w-8 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">No comments yet</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
Pause the video and click "Add Comment"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredComments.map((comment) => (
|
||||||
|
<CommentThread
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
fps={fps}
|
||||||
|
isActive={comment.frameNumber === currentFrame}
|
||||||
|
onJumpToFrame={(frame) => {
|
||||||
|
setCurrentFrame(frame);
|
||||||
|
onSeekToFrame?.(frame);
|
||||||
|
}}
|
||||||
|
onResolved={onCommentsChange}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Individual Comment Thread ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CommentThreadProps {
|
||||||
|
comment: CommentWithReplies;
|
||||||
|
fps: number;
|
||||||
|
isActive: boolean;
|
||||||
|
onJumpToFrame: (frame: number) => void;
|
||||||
|
onResolved?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentThread({
|
||||||
|
comment,
|
||||||
|
fps,
|
||||||
|
isActive,
|
||||||
|
onJumpToFrame,
|
||||||
|
onResolved,
|
||||||
|
}: CommentThreadProps) {
|
||||||
|
const [showReplies, setShowReplies] = useState(false);
|
||||||
|
const [replyText, setReplyText] = useState("");
|
||||||
|
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleReply = async () => {
|
||||||
|
if (!replyText.trim()) return;
|
||||||
|
setIsSubmittingReply(true);
|
||||||
|
try {
|
||||||
|
await addReply(comment.id, replyText.trim());
|
||||||
|
setReplyText("");
|
||||||
|
onResolved?.();
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to post reply", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingReply(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleResolve = async () => {
|
||||||
|
try {
|
||||||
|
await resolveComment(comment.id, !comment.isResolved);
|
||||||
|
onResolved?.();
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to update comment", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-border/60 overflow-hidden transition-all",
|
||||||
|
isActive && "border-primary/40 bg-primary/5",
|
||||||
|
comment.isResolved && "opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Comment body */}
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Avatar className="h-6 w-6 shrink-0 mt-0.5">
|
||||||
|
{comment.author?.image && (
|
||||||
|
<AvatarImage src={comment.author.image} alt={comment.author.name ?? ""} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback className="text-[9px]">
|
||||||
|
{getInitials(comment.author?.name ?? null)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{comment.author?.name ?? comment.author?.email ?? "Deleted User"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="font-mono text-xs text-primary/80 hover:text-primary hover:underline"
|
||||||
|
onClick={() => onJumpToFrame(comment.frameNumber)}
|
||||||
|
>
|
||||||
|
F{String(comment.frameNumber).padStart(4, "0")}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto">
|
||||||
|
{formatRelativeDate(comment.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-1.5 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{comment.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3 mt-2 ml-8">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={handleToggleResolve}
|
||||||
|
>
|
||||||
|
{comment.isResolved ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||||
|
<span className="text-emerald-500">Resolved</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Circle className="h-3 w-3" />
|
||||||
|
<span>Resolve</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => setShowReplies(!showReplies)}
|
||||||
|
>
|
||||||
|
{comment.replies.length > 0 && (
|
||||||
|
<>
|
||||||
|
{showReplies ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||||
|
{comment.replies.length} {comment.replies.length === 1 ? "reply" : "replies"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{comment.replies.length === 0 && "Reply"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Replies */}
|
||||||
|
{showReplies && (
|
||||||
|
<div className="border-t border-border/50 bg-background/30">
|
||||||
|
{comment.replies.map((reply) => (
|
||||||
|
<div key={reply.id} className="flex gap-2 px-3 py-2.5 border-b border-border/30 last:border-0">
|
||||||
|
<Avatar className="h-5 w-5 shrink-0 mt-0.5">
|
||||||
|
{reply.author?.image && (
|
||||||
|
<AvatarImage src={reply.author.image} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback className="text-[8px]">
|
||||||
|
{getInitials(reply.author?.name ?? null)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{reply.author?.name ?? reply.author?.email ?? "Deleted User"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatRelativeDate(reply.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-0.5 text-foreground/80 whitespace-pre-wrap">
|
||||||
|
{reply.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Reply input */}
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleReply();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Reply..."
|
||||||
|
className="min-h-[48px] text-xs py-2 bg-background/50"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={handleReply}
|
||||||
|
disabled={isSubmittingReply || !replyText.trim()}
|
||||||
|
className="self-end"
|
||||||
|
>
|
||||||
|
<Send className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { cn, getInitials, formatRelativeDate } from "@/lib/utils";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Film,
|
||||||
|
MessageSquare,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Upload,
|
||||||
|
Bell,
|
||||||
|
AtSign,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: Date;
|
||||||
|
user?: {
|
||||||
|
name: string | null;
|
||||||
|
image: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentActivityProps {
|
||||||
|
activities: ActivityItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVITY_ICONS: Record<string, { icon: React.ElementType; color: string }> = {
|
||||||
|
VERSION_UPLOADED: { icon: Upload, color: "text-blue-400 bg-blue-500/10" },
|
||||||
|
FEEDBACK_ADDED: { icon: MessageSquare, color: "text-amber-400 bg-amber-500/10" },
|
||||||
|
SHOT_APPROVED: { icon: CheckCircle2, color: "text-emerald-400 bg-emerald-500/10" },
|
||||||
|
SHOT_REJECTED: { icon: XCircle, color: "text-red-400 bg-red-500/10" },
|
||||||
|
COMMENT_REPLY: { icon: MessageSquare, color: "text-purple-400 bg-purple-500/10" },
|
||||||
|
MENTION: { icon: AtSign, color: "text-primary bg-primary/10" },
|
||||||
|
REVISION_REQUESTED: { icon: Film, color: "text-orange-400 bg-orange-500/10" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecentActivity({ activities }: RecentActivityProps) {
|
||||||
|
if (activities.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-zinc-500">
|
||||||
|
<Bell className="h-8 w-8 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">No recent activity</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-1 p-1">
|
||||||
|
{activities.map((item) => {
|
||||||
|
const cfg = ACTIVITY_ICONS[item.type] ?? { icon: Bell, color: "text-muted-foreground bg-muted/20" };
|
||||||
|
const Icon = cfg.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex gap-3 px-2 py-2.5 rounded-lg hover:bg-secondary/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className={cn("p-1.5 rounded-md shrink-0 mt-0.5", cfg.color.split(" ")[1])}>
|
||||||
|
<Icon className={cn("h-3 w-3", cfg.color.split(" ")[0])} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{item.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
|
||||||
|
{item.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||||
|
{item.user && (
|
||||||
|
<Avatar className="h-5 w-5">
|
||||||
|
{item.user.image && <AvatarImage src={item.user.image} />}
|
||||||
|
<AvatarFallback className="text-[8px]">
|
||||||
|
{getInitials(item.user.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatRelativeDate(item.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { format, addDays, startOfWeek, differenceInDays, parseISO } from "date-fns";
|
||||||
|
import { CalendarDays, AlertTriangle, Clock, Users, ExternalLink } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { getInitials } from "@/lib/utils";
|
||||||
|
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||||
|
import { TaskStatus, TaskType } from "@prisma/client";
|
||||||
|
|
||||||
|
function toDate(val: string | null | undefined): Date | null {
|
||||||
|
if (!val) return null;
|
||||||
|
try {
|
||||||
|
return parseISO(val);
|
||||||
|
} catch {
|
||||||
|
return new Date(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduleTask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
dueDate: string | null;
|
||||||
|
estimatedHours: number | null;
|
||||||
|
scheduledStartDate: string | null;
|
||||||
|
scheduledEndDate: string | null;
|
||||||
|
assignedArtistId: string | null;
|
||||||
|
assignedArtist: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
image: string | null;
|
||||||
|
} | null;
|
||||||
|
shot: { shotCode: string } | null;
|
||||||
|
asset: { assetCode: string } | null;
|
||||||
|
project: { id: string; name: string; code: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduleWidgetsProps {
|
||||||
|
scheduledTasks: ScheduleTask[];
|
||||||
|
artists: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
image: string | null;
|
||||||
|
role: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcArtistLoad(
|
||||||
|
tasks: ScheduleTask[],
|
||||||
|
artistId: string,
|
||||||
|
weekStart: Date
|
||||||
|
): number {
|
||||||
|
const weekEnd = addDays(weekStart, 6);
|
||||||
|
const artistTasks = tasks.filter((t) => t.assignedArtistId === artistId);
|
||||||
|
let totalHours = 0;
|
||||||
|
|
||||||
|
for (const task of artistTasks) {
|
||||||
|
const start = toDate(task.scheduledStartDate);
|
||||||
|
const end = toDate(task.scheduledEndDate) ?? start;
|
||||||
|
if (!start || !end) continue;
|
||||||
|
|
||||||
|
// Check overlap with week
|
||||||
|
if (start > weekEnd || end < weekStart) continue;
|
||||||
|
|
||||||
|
const dur = Math.max(1, differenceInDays(end, start) + 1);
|
||||||
|
const hoursPerDay = (task.estimatedHours ?? 8) / dur;
|
||||||
|
|
||||||
|
// Count days in this week
|
||||||
|
let daysInWeek = 0;
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const day = addDays(weekStart, i);
|
||||||
|
if (day >= start && day <= end) daysInWeek++;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalHours += hoursPerDay * daysInWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleWidgets({
|
||||||
|
scheduledTasks,
|
||||||
|
artists,
|
||||||
|
}: ScheduleWidgetsProps) {
|
||||||
|
const today = new Date();
|
||||||
|
const weekStart = startOfWeek(today, { weekStartsOn: 1 });
|
||||||
|
const weekEnd = addDays(weekStart, 6);
|
||||||
|
|
||||||
|
// Tasks due this week
|
||||||
|
const dueThisWeek = scheduledTasks.filter((t) => {
|
||||||
|
const due = toDate(t.dueDate);
|
||||||
|
return due && due >= today && due <= weekEnd && t.status !== "DONE";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overloaded artists (> 40h this week = > 8h/day avg)
|
||||||
|
const artistLoads = artists.map((a) => ({
|
||||||
|
artist: a,
|
||||||
|
hours: calcArtistLoad(scheduledTasks, a.id, weekStart),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const overloaded = artistLoads.filter((al) => al.hours > 40);
|
||||||
|
|
||||||
|
// Upcoming reviews
|
||||||
|
const upcomingReviews = scheduledTasks.filter(
|
||||||
|
(t) =>
|
||||||
|
t.status === "INTERNAL_REVIEW" || t.status === "CLIENT_REVIEW"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* Due This Week */}
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarDays className="h-4 w-4 text-amber-400" />
|
||||||
|
<span className="text-sm font-medium text-zinc-200">
|
||||||
|
Due This Week
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded-full">
|
||||||
|
{dueThisWeek.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-800/60 max-h-52 overflow-y-auto">
|
||||||
|
{dueThisWeek.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-xs text-zinc-600">
|
||||||
|
No tasks due this week
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
dueThisWeek.slice(0, 6).map((task) => {
|
||||||
|
const code = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||||
|
const cfg = TASK_STATUS_CONFIG[task.status as TaskStatus];
|
||||||
|
const StatusIcon = cfg.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={task.id}
|
||||||
|
href={`/tasks/${task.id}`}
|
||||||
|
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-zinc-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{code && (
|
||||||
|
<span className="text-[10px] font-mono text-zinc-500">
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-zinc-300 truncate">
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px]",
|
||||||
|
cfg.color.split(" ").find((c) => c.startsWith("text-"))
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
{task.dueDate && (
|
||||||
|
<span className="text-[10px] text-zinc-600">
|
||||||
|
{format(toDate(task.dueDate)!, "EEE, MMM d")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{task.assignedArtist && (
|
||||||
|
<Avatar className="h-5 w-5 shrink-0">
|
||||||
|
<AvatarImage src={task.assignedArtist.image ?? undefined} />
|
||||||
|
<AvatarFallback className="text-[8px] bg-zinc-700">
|
||||||
|
{getInitials(
|
||||||
|
task.assignedArtist.name ?? task.assignedArtist.email
|
||||||
|
)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Artist Utilization */}
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-blue-400" />
|
||||||
|
<span className="text-sm font-medium text-zinc-200">
|
||||||
|
This Week
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/schedule"
|
||||||
|
className="text-[10px] text-zinc-500 hover:text-amber-400 flex items-center gap-0.5 transition-colors"
|
||||||
|
>
|
||||||
|
Schedule <ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-800/60 max-h-52 overflow-y-auto">
|
||||||
|
{artistLoads.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-xs text-zinc-600">
|
||||||
|
No scheduled work this week
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
artistLoads
|
||||||
|
.filter((al) => al.hours > 0)
|
||||||
|
.sort((a, b) => b.hours - a.hours)
|
||||||
|
.slice(0, 6)
|
||||||
|
.map(({ artist, hours }) => {
|
||||||
|
const pct = Math.min(100, (hours / 40) * 100);
|
||||||
|
const isOver = hours > 40;
|
||||||
|
return (
|
||||||
|
<div key={artist.id} className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<Avatar className="h-5 w-5 shrink-0">
|
||||||
|
<AvatarImage src={artist.image ?? undefined} />
|
||||||
|
<AvatarFallback className="text-[8px] bg-zinc-700">
|
||||||
|
{getInitials(artist.name ?? artist.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-xs text-zinc-300 flex-1 truncate">
|
||||||
|
{artist.name ?? artist.email.split("@")[0]}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-medium",
|
||||||
|
isOver ? "text-orange-400" : "text-zinc-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Math.round(hours)}h
|
||||||
|
{isOver && " ⚠"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full transition-all",
|
||||||
|
isOver ? "bg-orange-500" : "bg-blue-500"
|
||||||
|
)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming Reviews */}
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-purple-400" />
|
||||||
|
<span className="text-sm font-medium text-zinc-200">
|
||||||
|
In Review
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded-full">
|
||||||
|
{upcomingReviews.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-800/60 max-h-52 overflow-y-auto">
|
||||||
|
{upcomingReviews.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-center text-xs text-zinc-600">
|
||||||
|
Nothing in review
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
upcomingReviews.slice(0, 6).map((task) => {
|
||||||
|
const code = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||||
|
const isClient = task.status === "CLIENT_REVIEW";
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={task.id}
|
||||||
|
href={`/tasks/${task.id}`}
|
||||||
|
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-zinc-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full shrink-0",
|
||||||
|
isClient ? "bg-amber-400" : "bg-purple-400"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{code && (
|
||||||
|
<span className="text-[10px] font-mono text-zinc-500">
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-zinc-300 truncate">
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px]",
|
||||||
|
isClient ? "text-amber-500" : "text-purple-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isClient ? "Client Review" : "Internal Review"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { cn, getInitials, formatRelativeDate } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Film,
|
||||||
|
MessageSquare,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ShotWithDetails } from "@/types";
|
||||||
|
|
||||||
|
interface ShotQueueProps {
|
||||||
|
shots: ShotWithDetails[];
|
||||||
|
projectId?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
WAITING: "text-zinc-400",
|
||||||
|
IN_PROGRESS: "text-blue-400",
|
||||||
|
IN_REVIEW: "text-purple-400",
|
||||||
|
REVISIONS: "text-orange-400",
|
||||||
|
COMPLETE: "text-emerald-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_ICONS: Record<string, React.ElementType> = {
|
||||||
|
WAITING: Clock,
|
||||||
|
IN_PROGRESS: Film,
|
||||||
|
IN_REVIEW: AlertCircle,
|
||||||
|
REVISIONS: AlertCircle,
|
||||||
|
COMPLETE: CheckCircle2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_DOT: Record<string, string> = {
|
||||||
|
LOW: "bg-zinc-500",
|
||||||
|
NORMAL: "bg-blue-500",
|
||||||
|
HIGH: "bg-amber-500",
|
||||||
|
URGENT: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ShotQueue({ shots, projectId, title = "Shot Queue" }: ShotQueueProps) {
|
||||||
|
if (shots.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||||
|
<Film className="h-8 w-8 mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">No shots yet</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{shots.map((shot) => {
|
||||||
|
const StatusIcon = STATUS_ICONS[shot.status] ?? Clock;
|
||||||
|
const latestVersion = shot.versions?.[0];
|
||||||
|
const openComments = shot.versions
|
||||||
|
?.reduce((sum, v) => sum + (v._count?.comments ?? 0), 0) ?? 0;
|
||||||
|
|
||||||
|
const href = projectId
|
||||||
|
? `/projects/${projectId}/shots/${shot.id}`
|
||||||
|
: `/projects/${(shot as any).projectId ?? "#"}/shots/${shot.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={shot.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800 transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Priority dot */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-2 h-2 rounded-full shrink-0",
|
||||||
|
PRIORITY_DOT[shot.priority] ?? "bg-zinc-500"
|
||||||
|
)}
|
||||||
|
title={shot.priority}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Shot code */}
|
||||||
|
<span className="font-mono text-xs text-muted-foreground w-32 shrink-0">
|
||||||
|
{shot.shotCode}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Description / name */}
|
||||||
|
<span className="flex-1 text-sm truncate text-foreground/90">
|
||||||
|
{shot.description ?? shot.shotCode}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Open comments */}
|
||||||
|
{openComments > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-amber-400 shrink-0">
|
||||||
|
<MessageSquare className="h-3 w-3" />
|
||||||
|
{openComments}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-xs shrink-0",
|
||||||
|
STATUS_STYLES[shot.status] ?? "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusIcon className="h-3 w-3" />
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{shot.status.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Artist */}
|
||||||
|
{shot.artist && (
|
||||||
|
<Avatar className="h-5 w-5 shrink-0">
|
||||||
|
{shot.artist.image && <img src={shot.artist.image} alt="" />}
|
||||||
|
<AvatarFallback className="text-[9px]">
|
||||||
|
{getInitials(shot.artist.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Open link */}
|
||||||
|
<Link href={href}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { DashboardStats } from "@/types";
|
||||||
|
|
||||||
|
interface StatsCardsProps {
|
||||||
|
stats: DashboardStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsCards({ stats }: StatsCardsProps) {
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
label: "Awaiting Review",
|
||||||
|
value: stats.awaitingReview,
|
||||||
|
icon: Clock,
|
||||||
|
color: "text-amber-400",
|
||||||
|
bg: "bg-amber-500/10",
|
||||||
|
ring: "ring-amber-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Needs Revisions",
|
||||||
|
value: stats.needsRevisions,
|
||||||
|
icon: AlertCircle,
|
||||||
|
color: "text-orange-400",
|
||||||
|
bg: "bg-orange-500/10",
|
||||||
|
ring: "ring-orange-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Approved",
|
||||||
|
value: stats.approved,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: "text-emerald-400",
|
||||||
|
bg: "bg-emerald-500/10",
|
||||||
|
ring: "ring-emerald-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Overdue Tasks",
|
||||||
|
value: stats.tasksOverdue ?? stats.overdue,
|
||||||
|
icon: XCircle,
|
||||||
|
color: "text-red-400",
|
||||||
|
bg: "bg-red-500/10",
|
||||||
|
ring: "ring-red-500/20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Active Projects",
|
||||||
|
value: stats.activeProjects,
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: "text-blue-400",
|
||||||
|
bg: "bg-blue-500/10",
|
||||||
|
ring: "ring-blue-500/20",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
|
{cards.map((card) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
return (
|
||||||
|
<Card key={card.label}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className={cn("p-2 rounded-md", card.bg)}>
|
||||||
|
<Icon className={cn("h-4 w-4", card.color)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-white">
|
||||||
|
{card.value}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-zinc-400 mt-0.5">{card.label}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { cn, getInitials } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
Eye,
|
||||||
|
ListTodo,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||||
|
import { TaskStatus, TaskType } from "@prisma/client";
|
||||||
|
|
||||||
|
const PRIORITY_DOT: Record<string, string> = {
|
||||||
|
LOW: "bg-zinc-500",
|
||||||
|
NORMAL: "bg-blue-500",
|
||||||
|
HIGH: "bg-amber-500",
|
||||||
|
URGENT: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TaskRow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: TaskType;
|
||||||
|
status: TaskStatus;
|
||||||
|
priority: string;
|
||||||
|
dueDate: Date | null;
|
||||||
|
shot?: { shotCode: string } | null;
|
||||||
|
asset?: { assetCode: string } | null;
|
||||||
|
project: { id: string; name: string; code: string };
|
||||||
|
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskRow({ task }: { task: TaskRow }) {
|
||||||
|
const cfg = TASK_STATUS_CONFIG[task.status];
|
||||||
|
const Icon = cfg.icon;
|
||||||
|
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||||
|
const isOverdue =
|
||||||
|
task.dueDate &&
|
||||||
|
new Date(task.dueDate) < new Date() &&
|
||||||
|
task.status !== "DONE";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/tasks/${task.id}`}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800/60 transition-colors group"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full shrink-0",
|
||||||
|
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-zinc-200 group-hover:text-amber-400 transition-colors truncate">
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
{contextCode && (
|
||||||
|
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded shrink-0">
|
||||||
|
{contextCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-[11px] text-zinc-500">{TASK_TYPE_LABELS[task.type]}</span>
|
||||||
|
<span className="text-[11px] text-zinc-700">·</span>
|
||||||
|
<span className="text-[11px] text-zinc-500">{task.project.code}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{task.assignedArtist && (
|
||||||
|
<Avatar className="h-5 w-5">
|
||||||
|
<AvatarFallback className="text-[8px] bg-zinc-700 text-zinc-300">
|
||||||
|
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
{task.dueDate && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-[10px]",
|
||||||
|
isOverdue ? "text-red-400" : "text-zinc-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-3 w-3" />
|
||||||
|
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
className={cn("text-[10px] border px-1.5 py-0 h-4 gap-1 shrink-0", cfg.color)}
|
||||||
|
>
|
||||||
|
<Icon className="h-2.5 w-2.5" />
|
||||||
|
{cfg.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskWidgetsProps {
|
||||||
|
myTasks: TaskRow[];
|
||||||
|
reviewTasks: TaskRow[];
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskWidgets({ myTasks, reviewTasks, role }: TaskWidgetsProps) {
|
||||||
|
const isArtist = role === "ARTIST";
|
||||||
|
const showReview = !isArtist && reviewTasks.length > 0;
|
||||||
|
|
||||||
|
if (myTasks.length === 0 && reviewTasks.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("grid gap-4", showReview ? "grid-cols-1 xl:grid-cols-2" : "grid-cols-1")}>
|
||||||
|
{/* My Tasks */}
|
||||||
|
{myTasks.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListTodo className="h-4 w-4 text-amber-400" />
|
||||||
|
<span className="text-sm font-semibold text-zinc-200">My Tasks</span>
|
||||||
|
<span className="text-xs text-zinc-600 bg-zinc-800 rounded-full px-2 py-0.5 font-mono">
|
||||||
|
{myTasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/tasks"
|
||||||
|
className="text-xs text-zinc-500 hover:text-amber-400 transition-colors"
|
||||||
|
>
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-800/50">
|
||||||
|
{myTasks.map((task) => (
|
||||||
|
<TaskRow key={task.id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tasks In Review (supervisors/producers) */}
|
||||||
|
{showReview && (
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Eye className="h-4 w-4 text-purple-400" />
|
||||||
|
<span className="text-sm font-semibold text-zinc-200">Needs Review</span>
|
||||||
|
<span className="text-xs text-zinc-600 bg-zinc-800 rounded-full px-2 py-0.5 font-mono">
|
||||||
|
{reviewTasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/tasks?status=INTERNAL_REVIEW"
|
||||||
|
className="text-xs text-zinc-500 hover:text-amber-400 transition-colors"
|
||||||
|
>
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-800/50">
|
||||||
|
{reviewTasks.map((task) => (
|
||||||
|
<TaskRow key={task.id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { NotificationBell } from "@/components/notifications/NotificationBell";
|
||||||
|
import { getInitials } from "@/lib/utils";
|
||||||
|
import { LogOut, User, Settings } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
title?: string;
|
||||||
|
breadcrumbs?: { label: string; href?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ title, breadcrumbs }: HeaderProps) {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const user = session?.user;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between border-b border-zinc-800 bg-zinc-950 px-6">
|
||||||
|
{/* Left: Title / Breadcrumbs */}
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{breadcrumbs ? (
|
||||||
|
<nav className="flex items-center gap-1.5 text-sm">
|
||||||
|
{breadcrumbs.map((crumb, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1.5">
|
||||||
|
{i > 0 && <span className="text-muted-foreground">/</span>}
|
||||||
|
{crumb.href ? (
|
||||||
|
<Link
|
||||||
|
href={crumb.href}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-foreground font-medium truncate">
|
||||||
|
{crumb.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
) : (
|
||||||
|
title && (
|
||||||
|
<h1 className="text-sm font-semibold truncate">{title}</h1>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Notifications + User menu */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<NotificationBell />
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-secondary transition-colors">
|
||||||
|
<Avatar className="h-7 w-7">
|
||||||
|
{user?.image && <AvatarImage src={user.image} alt={user.name ?? ""} />}
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{getInitials(user?.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="hidden sm:block text-left">
|
||||||
|
<p className="text-xs font-medium leading-none">{user?.name ?? user?.email}</p>
|
||||||
|
<p className="text-xs text-muted-foreground capitalize">
|
||||||
|
{user?.role?.toLowerCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<p className="font-medium truncate">{user?.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground font-normal truncate">
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/settings" className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive cursor-pointer"
|
||||||
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Sign out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30 * 1000, // 30s
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</TooltipProvider>
|
||||||
|
{process.env.NODE_ENV === "development" && (
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
)}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Josefin_Sans } from 'next/font/google';
|
||||||
|
import { Montserrat } from 'next/font/google';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
FolderOpen,
|
||||||
|
Users,
|
||||||
|
UserCog,
|
||||||
|
Settings,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ListTodo,
|
||||||
|
CalendarRange,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ href: '/projects', label: 'Projects', icon: FolderOpen },
|
||||||
|
{ 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 },
|
||||||
|
{ href: '/users', label: 'Users', icon: UserCog, adminOnly: true, adminStrictOnly: true },
|
||||||
|
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
const josefin = Josefin_Sans({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['300', '400'],
|
||||||
|
});
|
||||||
|
const montserrat = Montserrat({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['200', '500', '600'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const isAdmin = ['ADMIN', 'PRODUCER'].includes(session?.user?.role ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col border-r border-zinc-800 bg-zinc-900 transition-all duration-200',
|
||||||
|
collapsed ? 'w-16' : 'w-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-4 py-5 border-b border-zinc-800',
|
||||||
|
collapsed && 'justify-center px-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-black">
|
||||||
|
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className={montserrat.className}>
|
||||||
|
<span className="block text-2xl font-light text-white leading-none">
|
||||||
|
TWO TALES
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="block text-[11px] tracking-[0.18em] italic text-zinc-400 leading-none -mt-0.25">
|
||||||
|
vfx review
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 px-2 py-4 space-y-1">
|
||||||
|
{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;
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive =
|
||||||
|
pathname === item.href || pathname.startsWith(item.href + '/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||||
|
collapsed && 'justify-center px-0 w-full',
|
||||||
|
isActive
|
||||||
|
? 'bg-amber-500/10 text-amber-400'
|
||||||
|
: 'text-zinc-400 hover:text-white hover:bg-zinc-800',
|
||||||
|
)}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && <span>{item.label}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Collapse toggle */}
|
||||||
|
<div className="border-t border-zinc-800 p-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Bell, Check, CheckCheck } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { formatRelativeDate } from "@/lib/utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
data: Record<string, string> | null;
|
||||||
|
isRead: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationBell() {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const unreadCount = notifications.filter((n) => !n.isRead).length;
|
||||||
|
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/notifications");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setNotifications(data.notifications ?? []);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAllRead = async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/notifications", { method: "PATCH" });
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNotificationHref = (n: Notification): string | null => {
|
||||||
|
const data = n.data as Record<string, string> | null;
|
||||||
|
if (data?.versionId) return `/review/${data.versionId}`;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNotificationIcon = (type: string): string => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
VERSION_UPLOADED: "🎬",
|
||||||
|
FEEDBACK_ADDED: "💬",
|
||||||
|
SHOT_APPROVED: "✅",
|
||||||
|
SHOT_REJECTED: "❌",
|
||||||
|
COMMENT_REPLY: "↩️",
|
||||||
|
REVISION_REQUESTED: "⚠️",
|
||||||
|
};
|
||||||
|
return icons[type] ?? "🔔";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu onOpenChange={(open) => open && fetchNotifications()}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" className="relative">
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">
|
||||||
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<DropdownMenuLabel className="p-0 text-sm font-semibold">
|
||||||
|
Notifications
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto py-0 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={markAllRead}
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-3 w-3 mr-1" />
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-80">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||||
|
<Bell className="h-8 w-8 mb-2 opacity-30" />
|
||||||
|
<p className="text-sm">No notifications</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{notifications.map((n) => {
|
||||||
|
const href = getNotificationHref(n);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<span className="text-base mt-0.5 shrink-0">
|
||||||
|
{getNotificationIcon(n.type)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={cn("text-xs leading-relaxed", !n.isRead && "font-medium")}>
|
||||||
|
{n.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{formatRelativeDate(new Date(n.createdAt))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!n.isRead && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-primary shrink-0 mt-1.5" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const className = cn(
|
||||||
|
"flex gap-3 px-4 py-3 hover:bg-secondary/50 transition-colors cursor-pointer border-b border-border/50 last:border-0",
|
||||||
|
!n.isRead && "bg-primary/5"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link key={n.id} href={href} className={className}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={n.id} className={className}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect, useCallback, RefObject } from "react";
|
||||||
|
import { useReviewStore } from "@/hooks/use-review-player";
|
||||||
|
import { frameToPosition } from "@/lib/frame-utils";
|
||||||
|
import type { CommentWithReplies } from "@/types";
|
||||||
|
|
||||||
|
interface FrameTimelineProps {
|
||||||
|
fps: number;
|
||||||
|
comments: CommentWithReplies[];
|
||||||
|
annotations?: { frameNumber: number }[];
|
||||||
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
|
onSeek: (frame: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSeek }: FrameTimelineProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const isDragging = useRef(false);
|
||||||
|
const { currentFrame, totalFrames } = useReviewStore();
|
||||||
|
|
||||||
|
const draw = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const W = canvas.width;
|
||||||
|
const H = canvas.height;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = "hsl(0 0% 6%)";
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Frame ruler ticks
|
||||||
|
if (totalFrames > 0) {
|
||||||
|
const tickInterval = Math.max(1, Math.floor(totalFrames / (W / 40)));
|
||||||
|
|
||||||
|
ctx.strokeStyle = "hsl(0 0% 20%)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.fillStyle = "hsl(0 0% 35%)";
|
||||||
|
ctx.font = "9px monospace";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
|
||||||
|
for (let f = 0; f <= totalFrames; f += tickInterval) {
|
||||||
|
const x = Math.round((f / totalFrames) * W);
|
||||||
|
const isMajor = f % (tickInterval * 5) === 0 || tickInterval > 20;
|
||||||
|
|
||||||
|
ctx.strokeStyle = isMajor ? "hsl(0 0% 22%)" : "hsl(0 0% 16%)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, isMajor ? 0 : H / 2);
|
||||||
|
ctx.lineTo(x, H);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (isMajor && f > 0) {
|
||||||
|
ctx.fillStyle = "hsl(0 0% 35%)";
|
||||||
|
ctx.fillText(String(f), x, 11);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annotation markers — amber diamonds at top
|
||||||
|
const seenAnnotationFrames = new Set<number>();
|
||||||
|
annotations.forEach((ann) => {
|
||||||
|
if (totalFrames === 0) return;
|
||||||
|
if (seenAnnotationFrames.has(ann.frameNumber)) return;
|
||||||
|
seenAnnotationFrames.add(ann.frameNumber);
|
||||||
|
const x = Math.round(frameToPosition(ann.frameNumber, totalFrames) * W);
|
||||||
|
// Amber diamond
|
||||||
|
ctx.fillStyle = "hsl(38 92% 50%)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 2);
|
||||||
|
ctx.lineTo(x + 4, 7);
|
||||||
|
ctx.lineTo(x, 12);
|
||||||
|
ctx.lineTo(x - 4, 7);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Comment markers — blue triangles pointing down from top
|
||||||
|
comments.forEach((comment) => {
|
||||||
|
if (totalFrames === 0) return;
|
||||||
|
const x = Math.round(frameToPosition(comment.frameNumber, totalFrames) * W);
|
||||||
|
|
||||||
|
if (comment.isResolved) {
|
||||||
|
ctx.fillStyle = "hsl(142 71% 45% / 0.7)";
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = "hsl(213 94% 68%)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triangle marker pointing down from top
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x - 4, 0);
|
||||||
|
ctx.lineTo(x + 4, 0);
|
||||||
|
ctx.lineTo(x, 8);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Playhead
|
||||||
|
if (totalFrames > 0) {
|
||||||
|
const playheadX = Math.round(
|
||||||
|
frameToPosition(currentFrame, totalFrames) * W
|
||||||
|
);
|
||||||
|
|
||||||
|
// Red line
|
||||||
|
ctx.strokeStyle = "hsl(0 72% 51%)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(playheadX, 0);
|
||||||
|
ctx.lineTo(playheadX, H);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Red diamond at top
|
||||||
|
ctx.fillStyle = "hsl(0 72% 51%)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(playheadX, H - 4);
|
||||||
|
ctx.lineTo(playheadX - 5, H - 10);
|
||||||
|
ctx.lineTo(playheadX, H - 16);
|
||||||
|
ctx.lineTo(playheadX + 5, H - 10);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}, [currentFrame, totalFrames, comments, annotations]);
|
||||||
|
|
||||||
|
// Resize observer to match canvas to container
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
canvas.width = canvas.offsetWidth;
|
||||||
|
canvas.height = canvas.offsetHeight;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
observer.observe(canvas);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [draw]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draw();
|
||||||
|
}, [draw]);
|
||||||
|
|
||||||
|
// Seek on click/drag
|
||||||
|
const getFrameFromEvent = useCallback(
|
||||||
|
(e: React.MouseEvent | MouseEvent): number => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || totalFrames === 0) return 0;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||||
|
return Math.round((x / rect.width) * totalFrames);
|
||||||
|
},
|
||||||
|
[totalFrames]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
isDragging.current = true;
|
||||||
|
onSeek(getFrameFromEvent(e));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (!isDragging.current) return;
|
||||||
|
onSeek(getFrameFromEvent(e));
|
||||||
|
},
|
||||||
|
[getFrameFromEvent, onSeek]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
isDragging.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [handleMouseMove, handleMouseUp]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="frame-timeline w-full cursor-ew-resize"
|
||||||
|
style={{ height: 48 }}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RefObject } from "react";
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
ChevronFirst,
|
||||||
|
ChevronLast,
|
||||||
|
Maximize2,
|
||||||
|
MessageSquarePlus,
|
||||||
|
Pencil,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { useReviewStore } from "@/hooks/use-review-player";
|
||||||
|
import { frameToTimecode } from "@/lib/frame-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||||
|
|
||||||
|
interface PlaybackControlsProps {
|
||||||
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
|
fps: number;
|
||||||
|
isReversing: boolean;
|
||||||
|
onStepBackward: () => void;
|
||||||
|
onStepForward: () => void;
|
||||||
|
onReverse: () => void;
|
||||||
|
onTogglePlay: () => void;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
onAddComment: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaybackControls({
|
||||||
|
videoRef,
|
||||||
|
fps,
|
||||||
|
isReversing,
|
||||||
|
onStepBackward,
|
||||||
|
onStepForward,
|
||||||
|
onReverse,
|
||||||
|
onTogglePlay,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onAddComment,
|
||||||
|
}: PlaybackControlsProps) {
|
||||||
|
const {
|
||||||
|
isPlaying,
|
||||||
|
currentFrame,
|
||||||
|
currentTime,
|
||||||
|
totalFrames,
|
||||||
|
playbackRate,
|
||||||
|
isAnnotating,
|
||||||
|
showAnnotations,
|
||||||
|
setPlaybackRate,
|
||||||
|
setAnnotating,
|
||||||
|
setShowAnnotations,
|
||||||
|
} = useReviewStore();
|
||||||
|
|
||||||
|
const handleRateChange = (val: string) => {
|
||||||
|
const rate = parseFloat(val);
|
||||||
|
if (videoRef.current) videoRef.current.playbackRate = rate;
|
||||||
|
setPlaybackRate(rate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timecode = frameToTimecode(currentFrame, fps);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 bg-black/90 px-3 py-2 border-t border-white/5">
|
||||||
|
{/* Frame info */}
|
||||||
|
<div className="flex items-center gap-3 font-mono text-xs text-zinc-300 min-w-0">
|
||||||
|
<span className="hidden sm:block text-zinc-500">
|
||||||
|
{timecode}
|
||||||
|
</span>
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
F{String(currentFrame).padStart(4, "0")}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-600 hidden md:block">
|
||||||
|
/ {totalFrames}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="h-5 w-px bg-white/10 mx-1" />
|
||||||
|
|
||||||
|
{/* Transport Controls */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
if (videoRef.current) videoRef.current.currentTime = 0;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronFirst className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Go to start</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
"text-zinc-400 hover:text-white",
|
||||||
|
isReversing && "text-amber-400 bg-amber-400/10"
|
||||||
|
)}
|
||||||
|
onClick={onReverse}
|
||||||
|
>
|
||||||
|
<SkipBack className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Reverse (J)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
onClick={onStepBackward}
|
||||||
|
>
|
||||||
|
<SkipBack className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Step back (←)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Play/Pause — slightly larger */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-white hover:text-white hover:bg-white/10 h-9 w-9"
|
||||||
|
onClick={onTogglePlay}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="h-5 w-5 fill-current" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-5 w-5 fill-current" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Play / Pause (K or Space)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
onClick={onStepForward}
|
||||||
|
>
|
||||||
|
<SkipForward className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Step forward (→)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
if (videoRef.current)
|
||||||
|
videoRef.current.currentTime = videoRef.current.duration;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLast className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Go to end</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Right Controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Playback speed */}
|
||||||
|
<Select value={String(playbackRate)} onValueChange={handleRateChange}>
|
||||||
|
<SelectTrigger className="h-7 w-16 text-xs border-0 bg-white/5 text-zinc-300 px-2">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLAYBACK_RATES.map((r) => (
|
||||||
|
<SelectItem key={r} value={String(r)} className="text-xs">
|
||||||
|
{r}x
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Annotation toggle */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(
|
||||||
|
"text-zinc-400 hover:text-white",
|
||||||
|
isAnnotating && "text-amber-400 bg-amber-400/10"
|
||||||
|
)}
|
||||||
|
onClick={() => setAnnotating(!isAnnotating)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{isAnnotating ? "Stop drawing" : "Draw annotation"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Show/hide annotations */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
onClick={() => setShowAnnotations(!showAnnotations)}
|
||||||
|
>
|
||||||
|
{showAnnotations ? (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{showAnnotations ? "Hide annotations" : "Show annotations"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Add Comment */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-zinc-400 hover:text-primary"
|
||||||
|
onClick={onAddComment}
|
||||||
|
>
|
||||||
|
<MessageSquarePlus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Add comment at frame {currentFrame}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Fullscreen */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Fullscreen (F)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
} from "react";
|
||||||
|
import { useReviewStore } from "@/hooks/use-review-player";
|
||||||
|
import { AnnotationCanvas } from "@/components/annotations/AnnotationCanvas";
|
||||||
|
import { AnnotationTools } from "@/components/annotations/AnnotationTools";
|
||||||
|
import { FrameTimeline } from "./FrameTimeline";
|
||||||
|
import { PlaybackControls } from "./PlaybackControls";
|
||||||
|
import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CommentWithReplies } from "@/types";
|
||||||
|
|
||||||
|
export interface ReviewPlayerRef {
|
||||||
|
seekToFrame: (frame: number) => void;
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewPlayerProps {
|
||||||
|
videoUrl: string;
|
||||||
|
versionId: string;
|
||||||
|
fps?: number;
|
||||||
|
comments?: CommentWithReplies[];
|
||||||
|
annotations?: unknown[];
|
||||||
|
className?: string;
|
||||||
|
onAddComment?: (frameNumber: number, timestamp: number) => void;
|
||||||
|
onAnnotationSaved?: (frameNumber: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||||
|
function ReviewPlayer(
|
||||||
|
{
|
||||||
|
videoUrl,
|
||||||
|
versionId,
|
||||||
|
fps = 24,
|
||||||
|
comments = [],
|
||||||
|
annotations,
|
||||||
|
className,
|
||||||
|
onAddComment,
|
||||||
|
onAnnotationSaved,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
// Stabilise the annotations reference so AnnotationCanvas's sync effect
|
||||||
|
// only fires when the actual content changes, not on every parent re-render.
|
||||||
|
const stableAnnotations = useMemo(() => annotations ?? [], [annotations]);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const [isReversing, setIsReversing] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isPlaying,
|
||||||
|
currentFrame,
|
||||||
|
isAnnotating,
|
||||||
|
showAnnotations,
|
||||||
|
setPlaying,
|
||||||
|
setCurrentFrame,
|
||||||
|
setCurrentTime,
|
||||||
|
setDuration,
|
||||||
|
setFps,
|
||||||
|
setTotalFrames,
|
||||||
|
} = useReviewStore();
|
||||||
|
|
||||||
|
// Initialize player config from props
|
||||||
|
useEffect(() => {
|
||||||
|
setFps(fps);
|
||||||
|
}, [fps, setFps]);
|
||||||
|
|
||||||
|
// ── Playback state sync ──────────────────────────────────────────────────
|
||||||
|
const handleTimeUpdate = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const frame = timeToFrame(video.currentTime, fps);
|
||||||
|
setCurrentFrame(frame);
|
||||||
|
setCurrentTime(video.currentTime);
|
||||||
|
}, [fps, setCurrentFrame, setCurrentTime]);
|
||||||
|
|
||||||
|
const handleLoadedMetadata = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const frames = durationToFrameCount(video.duration, fps);
|
||||||
|
setDuration(video.duration);
|
||||||
|
setTotalFrames(frames);
|
||||||
|
}, [fps, setDuration, setTotalFrames]);
|
||||||
|
|
||||||
|
const handlePlay = useCallback(() => setPlaying(true), [setPlaying]);
|
||||||
|
const handlePause = useCallback(() => setPlaying(false), [setPlaying]);
|
||||||
|
|
||||||
|
// ── Exposed ref API ──────────────────────────────────────────────────────
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
seekToFrame: (frame: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.currentTime = frameToTime(frame, fps);
|
||||||
|
if (!video.paused) video.pause();
|
||||||
|
},
|
||||||
|
play: () => videoRef.current?.play(),
|
||||||
|
pause: () => videoRef.current?.pause(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Frame step functions ─────────────────────────────────────────────────
|
||||||
|
const stepFrame = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.pause();
|
||||||
|
stopReverse();
|
||||||
|
const newTime = Math.max(0, Math.min(video.duration, video.currentTime + delta / fps));
|
||||||
|
video.currentTime = newTime;
|
||||||
|
},
|
||||||
|
[fps]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopReverse = useCallback(() => {
|
||||||
|
if (reverseIntervalRef.current) {
|
||||||
|
clearInterval(reverseIntervalRef.current);
|
||||||
|
reverseIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
setIsReversing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startReverse = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.pause();
|
||||||
|
setIsReversing(true);
|
||||||
|
reverseIntervalRef.current = setInterval(() => {
|
||||||
|
if (!videoRef.current || videoRef.current.currentTime <= 0) {
|
||||||
|
stopReverse();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 1 / fps);
|
||||||
|
}, 1000 / fps);
|
||||||
|
}, [fps, stopReverse]);
|
||||||
|
|
||||||
|
const togglePlayback = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
stopReverse();
|
||||||
|
if (video.paused) {
|
||||||
|
video.play();
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
}, [stopReverse]);
|
||||||
|
|
||||||
|
// ── Keyboard shortcuts (JKL + arrows + space + F) ───────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case "j":
|
||||||
|
e.preventDefault();
|
||||||
|
if (isReversing) {
|
||||||
|
stopReverse();
|
||||||
|
} else {
|
||||||
|
startReverse();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "k":
|
||||||
|
e.preventDefault();
|
||||||
|
if (isReversing) {
|
||||||
|
stopReverse();
|
||||||
|
} else {
|
||||||
|
togglePlayback();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "l":
|
||||||
|
e.preventDefault();
|
||||||
|
stopReverse();
|
||||||
|
if (videoRef.current?.paused) videoRef.current.play();
|
||||||
|
break;
|
||||||
|
case "arrowleft":
|
||||||
|
e.preventDefault();
|
||||||
|
stepFrame(-1);
|
||||||
|
break;
|
||||||
|
case "arrowright":
|
||||||
|
e.preventDefault();
|
||||||
|
stepFrame(1);
|
||||||
|
break;
|
||||||
|
case " ":
|
||||||
|
e.preventDefault();
|
||||||
|
stopReverse();
|
||||||
|
togglePlayback();
|
||||||
|
break;
|
||||||
|
case "f":
|
||||||
|
e.preventDefault();
|
||||||
|
toggleFullscreen();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
stopReverse();
|
||||||
|
};
|
||||||
|
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback]);
|
||||||
|
|
||||||
|
// ── Fullscreen ───────────────────────────────────────────────────────────
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
el.requestFullscreen().catch(() => {});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = useCallback(
|
||||||
|
(frame: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
stopReverse();
|
||||||
|
video.currentTime = frameToTime(frame, fps);
|
||||||
|
video.pause();
|
||||||
|
},
|
||||||
|
[fps, stopReverse]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddComment = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.pause();
|
||||||
|
onAddComment?.(currentFrame, video.currentTime);
|
||||||
|
}, [currentFrame, onAddComment]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
"review-player-container flex flex-col bg-black select-none h-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Video */}
|
||||||
|
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoUrl}
|
||||||
|
className="block w-full h-full object-contain"
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
preload="metadata"
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Annotation canvas overlay */}
|
||||||
|
<AnnotationCanvas
|
||||||
|
versionId={versionId}
|
||||||
|
frameNumber={currentFrame}
|
||||||
|
fps={fps}
|
||||||
|
isAnnotating={isAnnotating && !isPlaying}
|
||||||
|
showAnnotations={showAnnotations}
|
||||||
|
existingAnnotations={stableAnnotations}
|
||||||
|
onAnnotationSaved={onAnnotationSaved}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* JKL hint overlay — shown briefly when reversing */}
|
||||||
|
{isReversing && (
|
||||||
|
<div className="absolute top-3 left-3 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
|
||||||
|
◄◄ REVERSE
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Annotation Tools (visible when annotation mode is active) */}
|
||||||
|
<AnnotationTools />
|
||||||
|
|
||||||
|
{/* Frame Timeline */}
|
||||||
|
<FrameTimeline
|
||||||
|
fps={fps}
|
||||||
|
comments={comments}
|
||||||
|
annotations={annotations as { frameNumber: number }[]}
|
||||||
|
videoRef={videoRef}
|
||||||
|
onSeek={handleSeek}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Playback Controls */}
|
||||||
|
<PlaybackControls
|
||||||
|
videoRef={videoRef}
|
||||||
|
fps={fps}
|
||||||
|
isReversing={isReversing}
|
||||||
|
onStepBackward={() => stepFrame(-1)}
|
||||||
|
onStepForward={() => stepFrame(1)}
|
||||||
|
onReverse={isReversing ? stopReverse : startReverse}
|
||||||
|
onTogglePlay={togglePlayback}
|
||||||
|
onToggleFullscreen={toggleFullscreen}
|
||||||
|
onAddComment={handleAddComment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { createProject } from "@/actions/projects";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
const projectSchema = z.object({
|
||||||
|
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||||
|
code: z.string().min(2, "Project code required").regex(/^[A-Z0-9_\-]+$/i, "Alphanumeric, dash, underscore"),
|
||||||
|
showId: z.string().min(1, "Show ID required").max(10, "Max 10 chars").regex(/^[A-Z0-9_]+$/i, "Letters, numbers, underscore only"),
|
||||||
|
projectType: z.enum(["STANDARD", "EPISODIC"]).default("STANDARD"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
deadline: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProjectFormValues = z.infer<typeof projectSchema>;
|
||||||
|
|
||||||
|
interface NewProjectDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
clients?: { id: string; company: string }[];
|
||||||
|
onSuccess?: (projectId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewProjectDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
clients = [],
|
||||||
|
onSuccess,
|
||||||
|
}: NewProjectDialogProps) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ProjectFormValues>({
|
||||||
|
resolver: zodResolver(projectSchema),
|
||||||
|
defaultValues: { projectType: "STANDARD" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: ProjectFormValues) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await createProject({
|
||||||
|
name: data.name,
|
||||||
|
code: data.code.toUpperCase(),
|
||||||
|
showId: data.showId.toUpperCase(),
|
||||||
|
projectType: data.projectType,
|
||||||
|
description: data.description,
|
||||||
|
clientId: data.clientId || undefined,
|
||||||
|
deadline: data.deadline ? new Date(data.deadline) : undefined,
|
||||||
|
});
|
||||||
|
toast({ title: "Project created" });
|
||||||
|
reset();
|
||||||
|
router.push(`/projects/${result.project.id}`);
|
||||||
|
router.refresh();
|
||||||
|
onSuccess?.(result.project.id);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to create project",
|
||||||
|
description: (err as Error).message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Project</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label htmlFor="name">Project Name *</Label>
|
||||||
|
<Input id="name" placeholder="Stellar Montage" {...register("name")} />
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="code">Code *</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
placeholder="NOVA-25"
|
||||||
|
className="uppercase"
|
||||||
|
{...register("code")}
|
||||||
|
/>
|
||||||
|
{errors.code && (
|
||||||
|
<p className="text-xs text-destructive">{errors.code.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="showId">Show ID *</Label>
|
||||||
|
<Input
|
||||||
|
id="showId"
|
||||||
|
placeholder="PRJX"
|
||||||
|
className="uppercase"
|
||||||
|
maxLength={10}
|
||||||
|
{...register("showId")}
|
||||||
|
/>
|
||||||
|
{errors.showId && (
|
||||||
|
<p className="text-xs text-destructive">{errors.showId.message}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">Used in shot codes (e.g. PRJX_SC010_0010)</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Project Type *</Label>
|
||||||
|
<Select
|
||||||
|
defaultValue="STANDARD"
|
||||||
|
onValueChange={(v) => setValue("projectType", v as "STANDARD" | "EPISODIC")}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="STANDARD">Standard</SelectItem>
|
||||||
|
<SelectItem value="EPISODIC">Episodic</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Brief description of this project..."
|
||||||
|
{...register("description")}
|
||||||
|
className="min-h-[70px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clients.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Client</Label>
|
||||||
|
<Select onValueChange={(v) => setValue("clientId", v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select client (optional)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{clients.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.company}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="deadline">Deadline</Label>
|
||||||
|
<Input id="deadline" type="date" {...register("deadline")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Creating..." : "Create Project"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { cn, getInitials, formatRelativeDate } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Film,
|
||||||
|
Layers,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
ArrowUpRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
description?: string | null;
|
||||||
|
deadline?: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
client?: { company: string } | null;
|
||||||
|
producer?: { name: string | null; image: string | null } | null;
|
||||||
|
_count?: { shots: number };
|
||||||
|
shotStats?: {
|
||||||
|
total: number;
|
||||||
|
approved: number;
|
||||||
|
inProgress: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
ACTIVE: "bg-green-900/60 text-green-300",
|
||||||
|
ON_HOLD: "bg-yellow-900/60 text-yellow-300",
|
||||||
|
COMPLETED: "bg-blue-900/60 text-blue-300",
|
||||||
|
ARCHIVED: "bg-zinc-800 text-zinc-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProjectCard({ project }: ProjectCardProps) {
|
||||||
|
const total = project.shotStats?.total ?? project._count?.shots ?? 0;
|
||||||
|
const approved = project.shotStats?.approved ?? 0;
|
||||||
|
const progress = total > 0 ? Math.round((approved / total) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="group hover:border-zinc-700 transition-all flex flex-col">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="font-mono text-xs text-zinc-500">{project.code}</span>
|
||||||
|
{project.client && (
|
||||||
|
<span className="text-xs text-zinc-600">• {project.client.company}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-base leading-tight text-white">{project.name}</h3>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full border-0 font-medium shrink-0",
|
||||||
|
STATUS_STYLES[project.status] ?? STATUS_STYLES.ACTIVE
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{project.status.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-sm text-zinc-400 line-clamp-2 mt-1">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex-1 pb-3 space-y-3">
|
||||||
|
{/* Progress */}
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Shot progress</span>
|
||||||
|
<span className="text-foreground font-medium">{approved}/{total} approved</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
|
||||||
|
<Layers className="h-3.5 w-3.5 text-zinc-400 mb-1" />
|
||||||
|
<span className="font-semibold text-white">{total}</span>
|
||||||
|
<span className="text-zinc-400">shots</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-400 mb-1" />
|
||||||
|
<span className="font-semibold text-white">{approved}</span>
|
||||||
|
<span className="text-zinc-400">done</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
|
||||||
|
<Film className="h-3.5 w-3.5 text-blue-400 mb-1" />
|
||||||
|
<span className="font-semibold text-white">
|
||||||
|
{project.shotStats?.inProgress ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-400">active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="pt-2 border-t border-zinc-800 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-zinc-400">
|
||||||
|
{project.producer ? (
|
||||||
|
<>
|
||||||
|
<Avatar className="h-5 w-5">
|
||||||
|
{project.producer.image && <AvatarImage src={project.producer.image} />}
|
||||||
|
<AvatarFallback className="text-[9px]">
|
||||||
|
{getInitials(project.producer.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{project.producer.name ?? "Producer"}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<span>{formatRelativeDate(project.createdAt)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{project.deadline && (
|
||||||
|
<span className="text-amber-400 ml-2">
|
||||||
|
Due {formatRelativeDate(project.deadline)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="ghost" size="sm" asChild className="h-7 text-xs gap-1">
|
||||||
|
<Link href={`/projects/${project.id}`}>
|
||||||
|
Open
|
||||||
|
<ArrowUpRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { updateProject } from "@/actions/projects";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Settings, Building2, Users, Webhook } from "lucide-react";
|
||||||
|
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required").max(100),
|
||||||
|
code: z.string().min(1, "Code is required").max(20).regex(/^[A-Z0-9_\-]+$/i, "Alphanumeric, dash, underscore"),
|
||||||
|
showId: z.string().min(1, "Show ID is required").max(10).regex(/^[A-Z0-9_]+$/i, "Letters, numbers, underscore only"),
|
||||||
|
projectType: z.enum(["STANDARD", "EPISODIC"]),
|
||||||
|
description: z.string().optional(),
|
||||||
|
status: z.enum(["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"]),
|
||||||
|
clientId: z.string().optional(),
|
||||||
|
producerId: z.string().optional(),
|
||||||
|
supervisorId: z.string().optional(),
|
||||||
|
dueDate: z.string().optional(),
|
||||||
|
startDate: z.string().optional(),
|
||||||
|
slackWebhook: z.string().optional(),
|
||||||
|
slackChannel: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
id: string;
|
||||||
|
company: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamMember {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectSettingsTabProps {
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
showId: string;
|
||||||
|
projectType: "STANDARD" | "EPISODIC";
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
clientId: string | null;
|
||||||
|
producerId: string | null;
|
||||||
|
supervisorId: string | null;
|
||||||
|
dueDate: Date | null;
|
||||||
|
startDate: Date | null;
|
||||||
|
slackWebhook: string | null;
|
||||||
|
slackChannel: string | null;
|
||||||
|
};
|
||||||
|
clients: Client[];
|
||||||
|
teamMembers: TeamMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NONE = "__none__";
|
||||||
|
|
||||||
|
function toDateInput(d: Date | null | undefined) {
|
||||||
|
if (!d) return "";
|
||||||
|
return new Date(d).toISOString().substring(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSettingsTabProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const producers = teamMembers.filter((m) => ["ADMIN", "PRODUCER"].includes(m.role));
|
||||||
|
const supervisors = teamMembers.filter((m) => ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(m.role));
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { errors, isDirty },
|
||||||
|
} = useForm<SettingsFormValues>({
|
||||||
|
resolver: zodResolver(settingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: project.name,
|
||||||
|
code: project.code,
|
||||||
|
showId: project.showId,
|
||||||
|
projectType: project.projectType,
|
||||||
|
description: project.description ?? "",
|
||||||
|
status: project.status as SettingsFormValues["status"],
|
||||||
|
clientId: project.clientId ?? NONE,
|
||||||
|
producerId: project.producerId ?? NONE,
|
||||||
|
supervisorId: project.supervisorId ?? NONE,
|
||||||
|
dueDate: toDateInput(project.dueDate),
|
||||||
|
startDate: toDateInput(project.startDate),
|
||||||
|
slackWebhook: project.slackWebhook ?? "",
|
||||||
|
slackChannel: project.slackChannel ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: SettingsFormValues) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await updateProject({
|
||||||
|
id: project.id,
|
||||||
|
name: data.name,
|
||||||
|
code: data.code,
|
||||||
|
showId: data.showId,
|
||||||
|
projectType: data.projectType,
|
||||||
|
description: data.description || null,
|
||||||
|
status: data.status as any,
|
||||||
|
clientId: data.clientId === NONE ? null : data.clientId || null,
|
||||||
|
producerId: data.producerId === NONE ? null : data.producerId || null,
|
||||||
|
supervisorId: data.supervisorId === NONE ? null : data.supervisorId || null,
|
||||||
|
dueDate: data.dueDate || null,
|
||||||
|
startDate: data.startDate || null,
|
||||||
|
slackWebhook: data.slackWebhook || null,
|
||||||
|
slackChannel: data.slackChannel || null,
|
||||||
|
});
|
||||||
|
toast({ title: "Project settings saved" });
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to save settings",
|
||||||
|
description: (err as Error).message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8 max-w-2xl">
|
||||||
|
|
||||||
|
{/* ── General ── */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4 text-zinc-400" />
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-200">General</h2>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label htmlFor="name">Project Name</Label>
|
||||||
|
<Input id="name" {...register("name")} />
|
||||||
|
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="code">Project Code</Label>
|
||||||
|
<Input id="code" className="uppercase font-mono" {...register("code")} />
|
||||||
|
{errors.code && <p className="text-xs text-destructive">{errors.code.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select
|
||||||
|
defaultValue={project.status}
|
||||||
|
onValueChange={(v) => setValue("status", v as SettingsFormValues["status"], { shouldDirty: true })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||||
|
<SelectItem value="ON_HOLD">On Hold</SelectItem>
|
||||||
|
<SelectItem value="COMPLETED">Completed</SelectItem>
|
||||||
|
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea id="description" className="min-h-[80px]" {...register("description")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="startDate">Start Date</Label>
|
||||||
|
<Input id="startDate" type="date" {...register("startDate")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="dueDate">Due Date</Label>
|
||||||
|
<Input id="dueDate" type="date" {...register("dueDate")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Shot Naming ── */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4 text-zinc-400" />
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-200">Shot Naming</h2>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="showId">Show ID</Label>
|
||||||
|
<Input id="showId" className="uppercase font-mono" maxLength={10} {...register("showId")} />
|
||||||
|
{errors.showId && <p className="text-xs text-destructive">{errors.showId.message}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">Used in shot codes, e.g. SHOWID_SC010_0010</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Project Type</Label>
|
||||||
|
<Select
|
||||||
|
defaultValue={project.projectType}
|
||||||
|
onValueChange={(v) => setValue("projectType", v as "STANDARD" | "EPISODIC", { shouldDirty: true })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="STANDARD">Standard</SelectItem>
|
||||||
|
<SelectItem value="EPISODIC">Episodic</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">Episodic adds an episode segment to shot codes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Client & Team ── */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-zinc-400" />
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-200">Client & Team</h2>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label>Client</Label>
|
||||||
|
<Select
|
||||||
|
defaultValue={project.clientId ?? NONE}
|
||||||
|
onValueChange={(v) => setValue("clientId", v, { shouldDirty: true })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="No client assigned" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE}>No client</SelectItem>
|
||||||
|
{clients.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.company}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Producer</Label>
|
||||||
|
<Select
|
||||||
|
defaultValue={project.producerId ?? NONE}
|
||||||
|
onValueChange={(v) => setValue("producerId", v, { shouldDirty: true })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Not assigned" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE}>Not assigned</SelectItem>
|
||||||
|
{producers.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
{m.name ?? m.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Supervisor</Label>
|
||||||
|
<Select
|
||||||
|
defaultValue={project.supervisorId ?? NONE}
|
||||||
|
onValueChange={(v) => setValue("supervisorId", v, { shouldDirty: true })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Not assigned" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE}>Not assigned</SelectItem>
|
||||||
|
{supervisors.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
{m.name ?? m.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Integrations ── */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Webhook className="h-4 w-4 text-zinc-400" />
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-200">Slack Integration</h2>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label htmlFor="slackWebhook">Webhook URL</Label>
|
||||||
|
<Input
|
||||||
|
id="slackWebhook"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
{...register("slackWebhook")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="slackChannel">Channel</Label>
|
||||||
|
<Input
|
||||||
|
id="slackChannel"
|
||||||
|
placeholder="#vfx-pipeline"
|
||||||
|
{...register("slackChannel")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<Button type="submit" disabled={isSubmitting || !isDirty} className="min-w-[120px]">
|
||||||
|
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useDroppable, useDraggable } from "@dnd-kit/core";
|
||||||
|
import { format, parseISO, formatDistanceToNow } from "date-fns";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
Clock,
|
||||||
|
GripVertical,
|
||||||
|
Layers,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { TaskStatus, TaskType } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
ScheduleTask,
|
||||||
|
ActiveDragData,
|
||||||
|
} from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||||
|
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||||
|
|
||||||
|
const PRIORITY_DOT: Record<string, string> = {
|
||||||
|
LOW: "bg-zinc-500",
|
||||||
|
NORMAL: "bg-blue-500",
|
||||||
|
HIGH: "bg-amber-500",
|
||||||
|
URGENT: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDate(val: string | null | undefined): Date | null {
|
||||||
|
if (!val) return null;
|
||||||
|
try {
|
||||||
|
return parseISO(val);
|
||||||
|
} catch {
|
||||||
|
return new Date(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function BacklogItem({
|
||||||
|
task,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
task: ScheduleTask;
|
||||||
|
canEdit: boolean;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||||
|
useDraggable({
|
||||||
|
id: task.id,
|
||||||
|
disabled: !canEdit,
|
||||||
|
data: {
|
||||||
|
type: "backlog",
|
||||||
|
taskId: task.id,
|
||||||
|
estimatedHours: task.estimatedHours,
|
||||||
|
} as ActiveDragData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = TASK_STATUS_CONFIG[task.status];
|
||||||
|
const StatusIcon = cfg.icon;
|
||||||
|
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||||
|
const dueDate = toDate(task.dueDate);
|
||||||
|
const isOverdue =
|
||||||
|
dueDate && dueDate < new Date() && task.status !== "DONE";
|
||||||
|
|
||||||
|
const style = transform
|
||||||
|
? {
|
||||||
|
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||||
|
zIndex: 50,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-start gap-2 px-3 py-2.5 rounded-lg border transition-colors",
|
||||||
|
"border-zinc-800 bg-zinc-900 hover:border-zinc-700 hover:bg-zinc-800/80",
|
||||||
|
isDragging && "opacity-30 border-amber-500/40",
|
||||||
|
canEdit && "cursor-grab active:cursor-grabbing"
|
||||||
|
)}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
{/* Grip */}
|
||||||
|
{canEdit && (
|
||||||
|
<GripVertical className="h-3.5 w-3.5 text-zinc-600 shrink-0 mt-0.5 group-hover:text-zinc-400" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Priority dot */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full shrink-0 mt-1.5",
|
||||||
|
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{contextCode && (
|
||||||
|
<span className="text-[10px] font-mono bg-zinc-800 text-zinc-400 px-1.5 py-0.5 rounded shrink-0">
|
||||||
|
{contextCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium text-zinc-200 truncate">
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-[10px] text-zinc-500">
|
||||||
|
{TASK_TYPE_LABELS[task.type as TaskType]} · {task.project.code}
|
||||||
|
</span>
|
||||||
|
{task.estimatedHours && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[10px] text-zinc-600">
|
||||||
|
<Clock className="h-2.5 w-2.5" />
|
||||||
|
{task.estimatedHours}h
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"text-[9px] border px-1 py-0 h-3.5 gap-0.5 shrink-0",
|
||||||
|
cfg.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusIcon className="h-2 w-2" />
|
||||||
|
{cfg.label}
|
||||||
|
</Badge>
|
||||||
|
{dueDate && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-0.5 text-[10px]",
|
||||||
|
isOverdue ? "text-red-400" : "text-zinc-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-2.5 w-2.5" />
|
||||||
|
{formatDistanceToNow(dueDate, { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BacklogPanelProps {
|
||||||
|
tasks: ScheduleTask[];
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BacklogPanel({ tasks, canEdit }: BacklogPanelProps) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
|
id: "backlog-drop-zone",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col border-l border-zinc-800 bg-zinc-900 transition-colors",
|
||||||
|
"w-72 shrink-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2.5 border-b border-zinc-800 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="h-4 w-4 text-zinc-400" />
|
||||||
|
<span className="text-sm font-medium text-zinc-200">Backlog</span>
|
||||||
|
<span className="text-xs text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded-full">
|
||||||
|
{tasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<span className="text-[10px] text-zinc-600">
|
||||||
|
Drag onto timeline
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drop zone + list */}
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 transition-colors",
|
||||||
|
isOver && "bg-amber-500/5 ring-1 ring-inset ring-amber-500/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-zinc-600">
|
||||||
|
<Layers className="h-8 w-8 mb-2 opacity-30" />
|
||||||
|
<p className="text-xs">All tasks scheduled</p>
|
||||||
|
{canEdit && (
|
||||||
|
<p className="text-[10px] mt-1 text-zinc-700">
|
||||||
|
Drop here to unschedule
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-2 space-y-1.5">
|
||||||
|
{canEdit && isOver && (
|
||||||
|
<div className="flex items-center justify-center py-3 rounded-lg border border-dashed border-amber-500/40 text-amber-400 text-xs">
|
||||||
|
Drop to unschedule
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<BacklogItem key={task.id} task={task} canEdit={canEdit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { addWeeks, subWeeks, addDays, format } from "date-fns";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronLeft, ChevronRight, CalendarDays } from "lucide-react";
|
||||||
|
import { ScheduleArtist } from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: "TODO", label: "To Do" },
|
||||||
|
{ value: "IN_PROGRESS", label: "In Progress" },
|
||||||
|
{ value: "INTERNAL_REVIEW", label: "Internal Review" },
|
||||||
|
{ value: "CLIENT_REVIEW", label: "Client Review" },
|
||||||
|
{ value: "CHANGES", label: "Changes" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ScheduleFiltersProps {
|
||||||
|
projects: { id: string; name: string; code: string }[];
|
||||||
|
artists: ScheduleArtist[];
|
||||||
|
filterProject: string;
|
||||||
|
filterArtist: string;
|
||||||
|
filterStatus: string;
|
||||||
|
viewStart: Date;
|
||||||
|
onProjectChange: (v: string) => void;
|
||||||
|
onArtistChange: (v: string) => void;
|
||||||
|
onStatusChange: (v: string) => void;
|
||||||
|
onViewStartChange: (d: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleFilters({
|
||||||
|
projects,
|
||||||
|
artists,
|
||||||
|
filterProject,
|
||||||
|
filterArtist,
|
||||||
|
filterStatus,
|
||||||
|
viewStart,
|
||||||
|
onProjectChange,
|
||||||
|
onArtistChange,
|
||||||
|
onStatusChange,
|
||||||
|
onViewStartChange,
|
||||||
|
}: ScheduleFiltersProps) {
|
||||||
|
const goPrev = () => onViewStartChange(subWeeks(viewStart, 1));
|
||||||
|
const goNext = () => onViewStartChange(addWeeks(viewStart, 1));
|
||||||
|
const goToday = () => {
|
||||||
|
const today = new Date();
|
||||||
|
const monday = addDays(today, -((today.getDay() + 6) % 7));
|
||||||
|
onViewStartChange(monday);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-950 shrink-0 flex-wrap">
|
||||||
|
{/* Week navigation */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-zinc-400 hover:text-white"
|
||||||
|
onClick={goPrev}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-zinc-400 hover:text-white gap-1.5"
|
||||||
|
onClick={goToday}
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-3 w-3" />
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-zinc-400 hover:text-white"
|
||||||
|
onClick={goNext}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-zinc-500 ml-1">
|
||||||
|
{format(viewStart, "MMM d")} – {format(addDays(viewStart, 34), "MMM d, yyyy")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-4 w-px bg-zinc-800" />
|
||||||
|
|
||||||
|
{/* Project filter */}
|
||||||
|
<Select
|
||||||
|
value={filterProject || "__all__"}
|
||||||
|
onValueChange={(v) => onProjectChange(v === "__all__" ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
|
||||||
|
<SelectValue placeholder="All projects" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-zinc-900 border-zinc-700">
|
||||||
|
<SelectItem value="__all__" className="text-xs">
|
||||||
|
All projects
|
||||||
|
</SelectItem>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id} className="text-xs">
|
||||||
|
[{p.code}] {p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Artist filter */}
|
||||||
|
<Select
|
||||||
|
value={filterArtist || "__all__"}
|
||||||
|
onValueChange={(v) => onArtistChange(v === "__all__" ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
|
||||||
|
<SelectValue placeholder="All artists" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-zinc-900 border-zinc-700">
|
||||||
|
<SelectItem value="__all__" className="text-xs">
|
||||||
|
All artists
|
||||||
|
</SelectItem>
|
||||||
|
{artists.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id} className="text-xs">
|
||||||
|
{a.name ?? a.email.split("@")[0]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<Select
|
||||||
|
value={filterStatus || "__all__"}
|
||||||
|
onValueChange={(v) => onStatusChange(v === "__all__" ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
|
||||||
|
<SelectValue placeholder="All statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-zinc-900 border-zinc-700">
|
||||||
|
<SelectItem value="__all__" className="text-xs">
|
||||||
|
All statuses
|
||||||
|
</SelectItem>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value} className="text-xs">
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{(filterProject || filterArtist || filterStatus) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-zinc-500 hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
onProjectChange("");
|
||||||
|
onArtistChange("");
|
||||||
|
onStatusChange("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
isAfter,
|
||||||
|
parseISO,
|
||||||
|
} from "date-fns";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CalendarDays, Clock, ExternalLink, CalendarOff } from "lucide-react";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import { TaskStatus, TaskType } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
ScheduleTask,
|
||||||
|
ActiveDragData,
|
||||||
|
} from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||||
|
import { TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<
|
||||||
|
TaskStatus,
|
||||||
|
{ bg: string; border: string; text: string; dot: string }
|
||||||
|
> = {
|
||||||
|
TODO: {
|
||||||
|
bg: "bg-zinc-800/80",
|
||||||
|
border: "border-zinc-600/60",
|
||||||
|
text: "text-zinc-300",
|
||||||
|
dot: "bg-zinc-500",
|
||||||
|
},
|
||||||
|
IN_PROGRESS: {
|
||||||
|
bg: "bg-blue-900/50",
|
||||||
|
border: "border-blue-600/60",
|
||||||
|
text: "text-blue-200",
|
||||||
|
dot: "bg-blue-400",
|
||||||
|
},
|
||||||
|
INTERNAL_REVIEW: {
|
||||||
|
bg: "bg-purple-900/50",
|
||||||
|
border: "border-purple-600/60",
|
||||||
|
text: "text-purple-200",
|
||||||
|
dot: "bg-purple-400",
|
||||||
|
},
|
||||||
|
CLIENT_REVIEW: {
|
||||||
|
bg: "bg-amber-900/50",
|
||||||
|
border: "border-amber-600/60",
|
||||||
|
text: "text-amber-200",
|
||||||
|
dot: "bg-amber-400",
|
||||||
|
},
|
||||||
|
CHANGES: {
|
||||||
|
bg: "bg-orange-900/50",
|
||||||
|
border: "border-orange-600/60",
|
||||||
|
text: "text-orange-200",
|
||||||
|
dot: "bg-orange-400",
|
||||||
|
},
|
||||||
|
DONE: {
|
||||||
|
bg: "bg-emerald-900/50",
|
||||||
|
border: "border-emerald-600/60",
|
||||||
|
text: "text-emerald-200",
|
||||||
|
dot: "bg-emerald-400",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDate(val: string | null | undefined): Date | null {
|
||||||
|
if (!val) return null;
|
||||||
|
try {
|
||||||
|
return parseISO(val);
|
||||||
|
} catch {
|
||||||
|
return new Date(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduleTaskBlockProps {
|
||||||
|
task: ScheduleTask;
|
||||||
|
dayIndex: number;
|
||||||
|
duration: number;
|
||||||
|
artistIndex: number;
|
||||||
|
viewStart: Date;
|
||||||
|
days: Date[];
|
||||||
|
canEdit: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
laneTop: number;
|
||||||
|
taskHeight: number;
|
||||||
|
onResizeMouseDown: (
|
||||||
|
taskId: string,
|
||||||
|
currentEndDate: string
|
||||||
|
) => (e: React.MouseEvent) => void;
|
||||||
|
onUnschedule: (taskId: string) => void;
|
||||||
|
dayWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleTaskBlock({
|
||||||
|
task,
|
||||||
|
dayIndex,
|
||||||
|
duration,
|
||||||
|
canEdit,
|
||||||
|
isDragging,
|
||||||
|
laneTop,
|
||||||
|
taskHeight,
|
||||||
|
onResizeMouseDown,
|
||||||
|
onUnschedule,
|
||||||
|
dayWidth,
|
||||||
|
}: ScheduleTaskBlockProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||||
|
id: task.id,
|
||||||
|
disabled: !canEdit,
|
||||||
|
data: {
|
||||||
|
type: "scheduled",
|
||||||
|
taskId: task.id,
|
||||||
|
duration,
|
||||||
|
} as ActiveDragData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskWidth = Math.max(20, (task.estimatedHours ?? 8) / 8 * dayWidth - 4);
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: "absolute" as const,
|
||||||
|
left: Math.max(0, dayIndex) * dayWidth + 2,
|
||||||
|
width: taskWidth,
|
||||||
|
top: laneTop,
|
||||||
|
height: taskHeight,
|
||||||
|
transform: transform
|
||||||
|
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||||
|
: undefined,
|
||||||
|
zIndex: isDragging ? 10 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusStyle = STATUS_STYLES[task.status];
|
||||||
|
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||||
|
const contextName = task.shot?.shotCode ?? task.asset?.name ?? task.title;
|
||||||
|
const thumbnail = task.shot?.thumbnailUrl ?? null;
|
||||||
|
|
||||||
|
const isOverdue =
|
||||||
|
task.dueDate &&
|
||||||
|
task.scheduledEndDate &&
|
||||||
|
isAfter(
|
||||||
|
toDate(task.scheduledEndDate)!,
|
||||||
|
toDate(task.dueDate)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const dueDate = toDate(task.dueDate);
|
||||||
|
const tooNarrow = (task.estimatedHours ?? 8) < 3; // < 3h = too narrow for labels
|
||||||
|
const tooShort = taskHeight < 16;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"absolute rounded-md border flex items-center overflow-hidden select-none group",
|
||||||
|
"transition-opacity duration-100",
|
||||||
|
statusStyle.bg,
|
||||||
|
statusStyle.border,
|
||||||
|
isDragging && "opacity-30",
|
||||||
|
canEdit && "cursor-grab active:cursor-grabbing",
|
||||||
|
isOverdue && "ring-1 ring-red-500/50"
|
||||||
|
)}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
{/* Status dot */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 w-1 self-stretch rounded-l-md",
|
||||||
|
statusStyle.dot
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
|
{thumbnail && !tooNarrow && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={thumbnail}
|
||||||
|
alt=""
|
||||||
|
className="shrink-0 h-full object-cover"
|
||||||
|
style={{ width: taskHeight * 2.39, minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-hidden px-1">
|
||||||
|
{!tooShort && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-mono font-semibold truncate shrink-0 max-w-[50%]",
|
||||||
|
statusStyle.text
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{contextName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!tooNarrow && !tooShort && (
|
||||||
|
<span className="text-[9px] text-zinc-400 truncate">
|
||||||
|
{TASK_TYPE_LABELS[task.type as TaskType]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!tooNarrow && !tooShort && task.estimatedHours && (
|
||||||
|
<span className="text-[9px] text-zinc-500 shrink-0 ml-auto">
|
||||||
|
{task.estimatedHours}h
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overdue indicator */}
|
||||||
|
{isOverdue && (
|
||||||
|
<div className="shrink-0 w-1.5 h-1.5 rounded-full bg-red-400 mr-1" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resize handle */}
|
||||||
|
{canEdit && task.scheduledEndDate && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={onResizeMouseDown(task.id, task.scheduledEndDate)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="w-0.5 h-3/4 rounded-full bg-current opacity-50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
className="bg-zinc-900 border-zinc-700 text-zinc-100 max-w-[240px]"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium text-xs">
|
||||||
|
{contextCode && (
|
||||||
|
<span className="text-amber-400 mr-1">{contextCode}</span>
|
||||||
|
)}
|
||||||
|
{task.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-zinc-400">
|
||||||
|
{TASK_TYPE_LABELS[task.type as TaskType]} ·{" "}
|
||||||
|
{task.project.code}
|
||||||
|
</div>
|
||||||
|
{task.scheduledStartDate && task.scheduledEndDate && (
|
||||||
|
<div className="flex items-center gap-1 text-[11px] text-zinc-400">
|
||||||
|
<CalendarDays className="h-3 w-3" />
|
||||||
|
{format(toDate(task.scheduledStartDate)!, "MMM d")} →{" "}
|
||||||
|
{format(toDate(task.scheduledEndDate)!, "MMM d")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.estimatedHours && (
|
||||||
|
<div className="flex items-center gap-1 text-[11px] text-zinc-400">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{task.estimatedHours}h estimated
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dueDate && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-[11px]",
|
||||||
|
isOverdue ? "text-red-400" : "text-zinc-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-3 w-3" />
|
||||||
|
Due {format(dueDate, "MMM d")}
|
||||||
|
{isOverdue && " — Late!"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
|
<ContextMenuContent className="bg-zinc-900 border-zinc-700 text-zinc-100 w-48">
|
||||||
|
<ContextMenuItem
|
||||||
|
className="gap-2 cursor-pointer focus:bg-zinc-800 focus:text-white"
|
||||||
|
onSelect={() => router.push(`/tasks/${task.id}`)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 text-zinc-400" />
|
||||||
|
View Task
|
||||||
|
</ContextMenuItem>
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator className="bg-zinc-800" />
|
||||||
|
<ContextMenuItem
|
||||||
|
className="gap-2 cursor-pointer focus:bg-zinc-800 focus:text-red-400 text-red-400"
|
||||||
|
onSelect={() => onUnschedule(task.id)}
|
||||||
|
>
|
||||||
|
<CalendarOff className="h-3.5 w-3.5" />
|
||||||
|
Unschedule
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, RefObject } from "react";
|
||||||
|
import { useDroppable } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
differenceInDays,
|
||||||
|
startOfDay,
|
||||||
|
isToday,
|
||||||
|
isWeekend,
|
||||||
|
isBefore,
|
||||||
|
parseISO,
|
||||||
|
addDays,
|
||||||
|
} from "date-fns";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { getInitials } from "@/lib/utils";
|
||||||
|
import { ScheduleTaskBlock } from "./ScheduleTaskBlock";
|
||||||
|
import {
|
||||||
|
ScheduleTask,
|
||||||
|
ScheduleArtist,
|
||||||
|
DAY_WIDTH,
|
||||||
|
ROW_HEIGHT,
|
||||||
|
HEADER_HEIGHT,
|
||||||
|
} from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
interface ResizePreview {
|
||||||
|
taskId: string;
|
||||||
|
endDate: Date;
|
||||||
|
estimatedHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduleTimelineProps {
|
||||||
|
artists: ScheduleArtist[];
|
||||||
|
tasks: ScheduleTask[];
|
||||||
|
days: Date[];
|
||||||
|
viewStart: Date;
|
||||||
|
canEdit: boolean;
|
||||||
|
timelineRef: RefObject<HTMLDivElement | null>;
|
||||||
|
resizePreview: ResizePreview | null;
|
||||||
|
onResizeMouseDown: (
|
||||||
|
taskId: string,
|
||||||
|
currentEndDate: string
|
||||||
|
) => (e: React.MouseEvent) => void;
|
||||||
|
activeDragId: string | null;
|
||||||
|
onUnschedule: (taskId: string) => void;
|
||||||
|
dayWidth: number;
|
||||||
|
rowHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDate(val: string | null | undefined): Date | null {
|
||||||
|
if (!val) return null;
|
||||||
|
try {
|
||||||
|
return parseISO(val);
|
||||||
|
} catch {
|
||||||
|
return new Date(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayIndex(date: Date | null, viewStart: Date): number {
|
||||||
|
if (!date) return -1;
|
||||||
|
return differenceInDays(date, viewStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Greedy lane assignment based on hour-ranges so task width = hours.
|
||||||
|
* Two tasks for the same artist conflict when their [startHour, endHour]
|
||||||
|
* ranges overlap. Each task occupies exactly one lane (fixed height).
|
||||||
|
*/
|
||||||
|
function computeLanes(
|
||||||
|
tasks: ScheduleTask[],
|
||||||
|
viewStart: Date
|
||||||
|
): Map<string, number> {
|
||||||
|
// Build hour ranges: startHour = dayIndex * 8, endHour = startHour + estimatedHours
|
||||||
|
const ranges = tasks
|
||||||
|
.filter((t) => t.scheduledStartDate)
|
||||||
|
.map((t) => {
|
||||||
|
const dayIdx = Math.max(
|
||||||
|
0,
|
||||||
|
differenceInDays(toDate(t.scheduledStartDate)!, viewStart)
|
||||||
|
);
|
||||||
|
const startHour = dayIdx * 8;
|
||||||
|
const endHour = startHour + (t.estimatedHours ?? 8);
|
||||||
|
return { id: t.id, startHour, endHour };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.startHour - b.startHour);
|
||||||
|
|
||||||
|
const laneEndHours: number[] = [];
|
||||||
|
const result = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const task of ranges) {
|
||||||
|
let lane = laneEndHours.findIndex((end) => task.startHour >= end);
|
||||||
|
if (lane === -1) lane = laneEndHours.length;
|
||||||
|
laneEndHours[lane] = task.endHour;
|
||||||
|
result.set(task.id, lane);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskDuration(task: ScheduleTask): number {
|
||||||
|
return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate daily load (hours) for each artist
|
||||||
|
function calcDailyLoad(
|
||||||
|
tasks: ScheduleTask[],
|
||||||
|
artistId: string,
|
||||||
|
days: Date[]
|
||||||
|
): Record<string, number> {
|
||||||
|
const result: Record<string, number> = {};
|
||||||
|
const artistTasks = tasks.filter((t) => t.assignedArtistId === artistId);
|
||||||
|
|
||||||
|
for (const day of days) {
|
||||||
|
const dayStr = format(day, "yyyy-MM-dd");
|
||||||
|
let totalHours = 0;
|
||||||
|
for (const task of artistTasks) {
|
||||||
|
const start = toDate(task.scheduledStartDate);
|
||||||
|
const end = toDate(task.scheduledEndDate) ?? start;
|
||||||
|
if (!start || !end) continue;
|
||||||
|
const dayStart = new Date(day);
|
||||||
|
dayStart.setHours(0, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(day);
|
||||||
|
dayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
if (start <= dayEnd && end >= dayStart) {
|
||||||
|
const dur = Math.max(1, differenceInDays(end, start) + 1);
|
||||||
|
const hoursPerDay = (task.estimatedHours ?? 8) / dur;
|
||||||
|
totalHours += hoursPerDay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[dayStr] = totalHours;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtistLabel({
|
||||||
|
artist,
|
||||||
|
isOverloaded,
|
||||||
|
rowHeight,
|
||||||
|
}: {
|
||||||
|
artist: ScheduleArtist;
|
||||||
|
isOverloaded: boolean;
|
||||||
|
rowHeight: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-3 border-b border-zinc-800 bg-zinc-900"
|
||||||
|
style={{ height: rowHeight }}
|
||||||
|
>
|
||||||
|
<Avatar className="h-7 w-7 shrink-0">
|
||||||
|
<AvatarImage src={artist.image ?? undefined} />
|
||||||
|
<AvatarFallback className="text-[10px] bg-zinc-700 text-zinc-300">
|
||||||
|
{getInitials(artist.name ?? artist.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-zinc-200 truncate">
|
||||||
|
{artist.name ?? artist.email.split("@")[0]}
|
||||||
|
</span>
|
||||||
|
{isOverloaded && (
|
||||||
|
<AlertTriangle className="h-3 w-3 text-orange-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-zinc-500 capitalize">
|
||||||
|
{artist.role.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineDropZone({ id }: { id: string }) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 transition-colors pointer-events-none",
|
||||||
|
isOver && "bg-amber-500/5"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleTimeline({
|
||||||
|
artists,
|
||||||
|
tasks,
|
||||||
|
days,
|
||||||
|
viewStart,
|
||||||
|
canEdit,
|
||||||
|
timelineRef,
|
||||||
|
resizePreview,
|
||||||
|
onResizeMouseDown,
|
||||||
|
activeDragId,
|
||||||
|
onUnschedule,
|
||||||
|
dayWidth,
|
||||||
|
rowHeight,
|
||||||
|
}: ScheduleTimelineProps) {
|
||||||
|
const totalWidth = days.length * dayWidth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Sticky left: artist labels */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 bg-zinc-900 border-r border-zinc-800 z-10"
|
||||||
|
style={{ width: 208 }}
|
||||||
|
>
|
||||||
|
{/* Header spacer */}
|
||||||
|
<div
|
||||||
|
className="border-b border-zinc-800 bg-zinc-900"
|
||||||
|
style={{ height: HEADER_HEIGHT }}
|
||||||
|
/>
|
||||||
|
{artists.map((artist) => {
|
||||||
|
const dailyLoad = calcDailyLoad(tasks, artist.id, days);
|
||||||
|
const maxLoad = Math.max(...Object.values(dailyLoad), 0);
|
||||||
|
const isOverloaded = maxLoad > 8;
|
||||||
|
return (
|
||||||
|
<ArtistLabel
|
||||||
|
key={artist.id}
|
||||||
|
artist={artist}
|
||||||
|
isOverloaded={isOverloaded}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable timeline */}
|
||||||
|
<div
|
||||||
|
ref={timelineRef}
|
||||||
|
className="flex-1 overflow-x-auto overflow-y-hidden relative"
|
||||||
|
style={{ scrollbarColor: "#3f3f46 transparent" }}
|
||||||
|
>
|
||||||
|
{/* Date header - sticky top */}
|
||||||
|
<div
|
||||||
|
className="flex sticky top-0 z-20 bg-zinc-900 border-b border-zinc-800"
|
||||||
|
style={{ width: totalWidth, height: HEADER_HEIGHT }}
|
||||||
|
>
|
||||||
|
{days.map((day, i) => {
|
||||||
|
const isT = isToday(day);
|
||||||
|
const isWE = isWeekend(day);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center border-r text-center shrink-0 select-none",
|
||||||
|
isWE ? "border-zinc-800" : "border-zinc-800/60",
|
||||||
|
isT ? "bg-amber-500/10" : isWE ? "bg-zinc-900/80" : ""
|
||||||
|
)}
|
||||||
|
style={{ width: dayWidth }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[9px] font-medium uppercase tracking-wider",
|
||||||
|
isT ? "text-amber-400" : "text-zinc-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{format(day, "EEE")}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-semibold",
|
||||||
|
isT ? "text-amber-300" : isWE ? "text-zinc-600" : "text-zinc-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{format(day, "d")}
|
||||||
|
</span>
|
||||||
|
{format(day, "d") === "1" || i === 0 ? (
|
||||||
|
<span className="text-[9px] text-zinc-600 absolute bottom-0.5">
|
||||||
|
{format(day, "MMM")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Artist rows */}
|
||||||
|
<div style={{ width: totalWidth }}>
|
||||||
|
{artists.map((artist, artistIndex) => {
|
||||||
|
const artistTasks = tasks.filter(
|
||||||
|
(t) => t.assignedArtistId === artist.id
|
||||||
|
);
|
||||||
|
const dailyLoad = calcDailyLoad(tasks, artist.id, days);
|
||||||
|
|
||||||
|
// Apply resize preview so width animates live
|
||||||
|
const tasksWithPreview = artistTasks.map((t) =>
|
||||||
|
resizePreview?.taskId === t.id
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
scheduledEndDate: resizePreview.endDate.toISOString(),
|
||||||
|
estimatedHours: resizePreview.estimatedHours,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={artist.id}
|
||||||
|
className="relative border-b border-zinc-800/60"
|
||||||
|
style={{ height: rowHeight }}
|
||||||
|
>
|
||||||
|
{/* Drop zone overlay */}
|
||||||
|
<TimelineDropZone id={`timeline-row-${artist.id}`} />
|
||||||
|
|
||||||
|
{/* Day grid lines + overload indicators */}
|
||||||
|
{days.map((day, dayIndex) => {
|
||||||
|
const dayStr = format(day, "yyyy-MM-dd");
|
||||||
|
const load = dailyLoad[dayStr] ?? 0;
|
||||||
|
const isT = isToday(day);
|
||||||
|
const isWE = isWeekend(day);
|
||||||
|
const isOverloaded = load > 8;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dayIndex}
|
||||||
|
className={cn(
|
||||||
|
"absolute top-0 bottom-0 border-r",
|
||||||
|
isWE ? "border-zinc-800 bg-zinc-900/30" : "border-zinc-800/40",
|
||||||
|
isT && "bg-amber-500/5",
|
||||||
|
isOverloaded && "bg-orange-500/10"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: dayIndex * dayWidth,
|
||||||
|
width: dayWidth,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Task blocks */}
|
||||||
|
{tasksWithPreview.map((task) => {
|
||||||
|
const startDate = toDate(task.scheduledStartDate);
|
||||||
|
if (!startDate) return null;
|
||||||
|
|
||||||
|
// dayIndex may be fractional: integer part = day column,
|
||||||
|
// fractional part = intra-day hour offset stored in the time component.
|
||||||
|
const dayIdx = getDayIndex(startOfDay(startDate), viewStart);
|
||||||
|
const hourOffset = startDate.getHours(); // hours into the 8h workday
|
||||||
|
const dayIndex = dayIdx + hourOffset / 8;
|
||||||
|
const duration = getTaskDuration(task);
|
||||||
|
|
||||||
|
// Skip tasks outside view
|
||||||
|
if (dayIndex + duration < 0 || dayIndex >= days.length)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const taskHeight = rowHeight - 8;
|
||||||
|
const laneTop = 4;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScheduleTaskBlock
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
dayIndex={dayIndex}
|
||||||
|
duration={duration}
|
||||||
|
artistIndex={artistIndex}
|
||||||
|
viewStart={viewStart}
|
||||||
|
days={days}
|
||||||
|
canEdit={canEdit}
|
||||||
|
isDragging={activeDragId === task.id}
|
||||||
|
onResizeMouseDown={onResizeMouseDown}
|
||||||
|
laneTop={laneTop}
|
||||||
|
taskHeight={taskHeight}
|
||||||
|
onUnschedule={onUnschedule}
|
||||||
|
dayWidth={dayWidth}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{artists.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center py-16 text-zinc-600 text-sm">
|
||||||
|
No artists to display
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { AlertTriangle, Eye, EyeOff, KeyRound } from 'lucide-react';
|
||||||
|
import { changeOwnPassword } from '@/actions/users';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChangePasswordForm({ mustChangePassword }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showCurrent, setShowCurrent] = useState(false);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('New password must be at least 8 characters.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('New passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await changeOwnPassword({ currentPassword, newPassword });
|
||||||
|
setSuccess(true);
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
// Reload so the session banner clears
|
||||||
|
router.refresh();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to change password.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{mustChangePassword && (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
|
||||||
|
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0 text-amber-400" />
|
||||||
|
<span>
|
||||||
|
You must set a new password before continuing. Your account was created with a
|
||||||
|
temporary password.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
Change Password
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{success ? (
|
||||||
|
<p className="text-sm text-emerald-400">
|
||||||
|
Password updated successfully.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 max-w-sm">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="currentPassword">Current password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="currentPassword"
|
||||||
|
type={showCurrent ? 'text' : 'password'}
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCurrent((v) => !v)}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="newPassword">New password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type={showNew ? 'text' : 'password'}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNew((v) => !v)}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-500">At least 8 characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm new password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirm ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm((v) => !v)}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Saving…' : 'Update password'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { importShotsFromCsv } from "@/actions/shots";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Upload, AlertCircle, CheckCircle2, ChevronRight } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ParsedRow {
|
||||||
|
scene: string;
|
||||||
|
episode?: string;
|
||||||
|
description?: string;
|
||||||
|
priority?: string;
|
||||||
|
fps?: number;
|
||||||
|
frameStart?: number;
|
||||||
|
frameEnd?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportShotsDialogProps {
|
||||||
|
projectId: string;
|
||||||
|
projectType?: "STANDARD" | "EPISODIC";
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE_STANDARD = `scene,description,priority,fps,frameStart,frameEnd
|
||||||
|
010,Opening wide shot,NORMAL,24,1001,1100
|
||||||
|
020,Close up reaction,NORMAL,24,,
|
||||||
|
030,Action sequence,HIGH,24,1001,1250`;
|
||||||
|
|
||||||
|
const TEMPLATE_EPISODIC = `scene,episode,description,priority,fps,frameStart,frameEnd
|
||||||
|
010,EP01,Opening wide shot,NORMAL,24,1001,1100
|
||||||
|
020,EP01,Close up reaction,NORMAL,24,,
|
||||||
|
010,EP02,Act two opener,HIGH,24,1001,1200`;
|
||||||
|
|
||||||
|
function parseCsv(raw: string): { rows: ParsedRow[]; parseErrors: string[] } {
|
||||||
|
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
|
if (lines.length < 2) return { rows: [], parseErrors: ["Need at least a header row and one data row"] };
|
||||||
|
|
||||||
|
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||||
|
const sceneIdx = headers.indexOf("scene");
|
||||||
|
if (sceneIdx === -1) return { rows: [], parseErrors: ['Missing required "scene" column'] };
|
||||||
|
|
||||||
|
const idx = (name: string) => {
|
||||||
|
const i = headers.indexOf(name);
|
||||||
|
return i === -1 ? null : i;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: ParsedRow[] = [];
|
||||||
|
const parseErrors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cells = lines[i].split(",").map((c) => c.trim());
|
||||||
|
const get = (name: string) => {
|
||||||
|
const j = idx(name);
|
||||||
|
return j !== null ? cells[j] ?? "" : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const scene = get("scene");
|
||||||
|
if (!scene) { parseErrors.push(`Row ${i + 1}: empty scene — skipped`); continue; }
|
||||||
|
|
||||||
|
const fpsRaw = get("fps");
|
||||||
|
const fsRaw = get("framestart") || get("frameStart");
|
||||||
|
const feRaw = get("frameend") || get("frameEnd");
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
scene,
|
||||||
|
episode: get("episode") || undefined,
|
||||||
|
description: get("description") || undefined,
|
||||||
|
priority: get("priority") || undefined,
|
||||||
|
fps: fpsRaw ? Number(fpsRaw) : undefined,
|
||||||
|
frameStart: fsRaw ? Number(fsRaw) : undefined,
|
||||||
|
frameEnd: feRaw ? Number(feRaw) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, parseErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportShotsDialog({
|
||||||
|
projectId,
|
||||||
|
projectType = "STANDARD",
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: ImportShotsDialogProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [step, setStep] = useState<"input" | "preview">("input");
|
||||||
|
const [csvText, setCsvText] = useState("");
|
||||||
|
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||||||
|
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [importResult, setImportResult] = useState<{ created: string[]; errors: string[] } | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => setCsvText(ev.target?.result as string ?? "");
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleParse = () => {
|
||||||
|
const { rows, parseErrors: errs } = parseCsv(csvText);
|
||||||
|
setParsedRows(rows);
|
||||||
|
setParseErrors(errs);
|
||||||
|
if (rows.length > 0) setStep("preview");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
const result = await importShotsFromCsv(projectId, parsedRows);
|
||||||
|
setImportResult(result);
|
||||||
|
if (result.created.length > 0) {
|
||||||
|
toast({ title: `${result.created.length} shot${result.created.length === 1 ? "" : "s"} created` });
|
||||||
|
onSuccess?.();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Import failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setStep("input");
|
||||||
|
setCsvText("");
|
||||||
|
setParsedRows([]);
|
||||||
|
setParseErrors([]);
|
||||||
|
setImportResult(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const template = projectType === "EPISODIC" ? TEMPLATE_EPISODIC : TEMPLATE_STANDARD;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Shots from CSV</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{importResult ? (
|
||||||
|
/* Result screen */
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{importResult.created.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-medium text-emerald-400">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
{importResult.created.length} shot{importResult.created.length === 1 ? "" : "s"} created
|
||||||
|
</p>
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-3 text-xs font-mono text-zinc-300 max-h-40 overflow-y-auto">
|
||||||
|
{importResult.created.map((code) => <div key={code}>{code}</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{importResult.errors.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-medium text-amber-400">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
{importResult.errors.length} warning{importResult.errors.length === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-3 text-xs text-zinc-400 max-h-32 overflow-y-auto space-y-0.5">
|
||||||
|
{importResult.errors.map((e, i) => <div key={i}>{e}</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={handleClose}>Done</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : step === "input" ? (
|
||||||
|
/* CSV input step */
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Paste CSV or upload a file. Required column: <code className="text-xs bg-zinc-800 px-1 rounded">scene</code>.
|
||||||
|
{projectType === "EPISODIC" && (
|
||||||
|
<> Also required: <code className="text-xs bg-zinc-800 px-1 rounded">episode</code>.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 shrink-0"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
Upload file
|
||||||
|
</Button>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFileUpload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
value={csvText}
|
||||||
|
onChange={(e) => setCsvText(e.target.value)}
|
||||||
|
className="font-mono text-xs min-h-[180px]"
|
||||||
|
placeholder={template}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{parseErrors.length > 0 && (
|
||||||
|
<div className="text-xs text-amber-400 space-y-0.5">
|
||||||
|
{parseErrors.map((e, i) => <div key={i} className="flex items-start gap-1.5"><AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />{e}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<details className="text-xs text-zinc-500 cursor-pointer">
|
||||||
|
<summary className="hover:text-zinc-300 transition-colors">CSV format & example</summary>
|
||||||
|
<pre className="mt-2 bg-zinc-900 border border-zinc-800 rounded p-3 text-zinc-400 whitespace-pre-wrap">{template}</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||||
|
<Button onClick={handleParse} disabled={!csvText.trim()} className="gap-1.5">
|
||||||
|
Parse
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Preview step */
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{parsedRows.length} shot{parsedRows.length === 1 ? "" : "s"} ready to import. Review and confirm.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-zinc-800 overflow-hidden">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-zinc-900 text-zinc-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Scene</th>
|
||||||
|
{projectType === "EPISODIC" && <th className="px-3 py-2 text-left font-medium">Episode</th>}
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Description</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Priority</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">FPS</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Frames</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800">
|
||||||
|
{parsedRows.map((row, i) => (
|
||||||
|
<tr key={i} className={cn("bg-zinc-950", i % 2 === 0 && "bg-zinc-900/30")}>
|
||||||
|
<td className="px-3 py-2 font-mono text-zinc-200">{row.scene}</td>
|
||||||
|
{projectType === "EPISODIC" && <td className="px-3 py-2 font-mono text-zinc-400">{row.episode ?? "—"}</td>}
|
||||||
|
<td className="px-3 py-2 text-zinc-400 truncate max-w-[160px]">{row.description ?? "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-zinc-400">{row.priority ?? "NORMAL"}</td>
|
||||||
|
<td className="px-3 py-2 text-zinc-400">{row.fps ?? 24}</td>
|
||||||
|
<td className="px-3 py-2 text-zinc-400 font-mono">
|
||||||
|
{row.frameStart || row.frameEnd ? `${row.frameStart ?? "?"}–${row.frameEnd ?? "?"}` : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parseErrors.length > 0 && (
|
||||||
|
<div className="text-xs text-amber-400 space-y-0.5">
|
||||||
|
{parseErrors.map((e, i) => <div key={i} className="flex items-start gap-1.5"><AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />{e}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setStep("input")}>Back</Button>
|
||||||
|
<Button onClick={handleImport} disabled={isImporting || parsedRows.length === 0}>
|
||||||
|
{isImporting ? "Importing…" : `Import ${parsedRows.length} Shot${parsedRows.length === 1 ? "" : "s"}`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user