Add full project files

This commit is contained in:
twotalesanimation
2026-08-18 12:11:20 +02:00
parent 8ce915bcac
commit 95ed30812e
112 changed files with 21868 additions and 225 deletions
+2
View File
@@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
+32
View File
@@ -0,0 +1,32 @@
# ---- Builder ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
# ---- Runner ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
+245
View File
@@ -0,0 +1,245 @@
# Shots API — External Integration Reference
Base URL: `https://review.twotalesvfx.com`
All requests must include an `Authorization` header with the configured API key.
---
## Authentication
```
Authorization: Bearer <API_SECRET_KEY>
```
The `API_SECRET_KEY` is set as an environment variable on the server. Contact your
system administrator for the key value.
---
## Endpoints
### 1. List Shots
```
GET /api/ext/shots?projectId={projectId}
```
Returns all shots for a project with optional filters.
#### Query Parameters
| Parameter | Required | Description |
|-------------|----------|----------------------------------------------------------|
| `projectId` | Yes | The project CUID (e.g. `cmp6l5mzq0001ua0gz07bk72f`) |
| `episode` | No | Filter by episode number (e.g. `103`) |
| `status` | No | Filter by shot status (see status values below) |
| `shotCode` | No | Return only the shot matching this exact code |
#### Shot Status Values
| Value | Description |
|---------------|------------------------------------|
| `WAITING` | Not yet started |
| `IN_PROGRESS` | Currently being worked on |
| `IN_REVIEW` | Submitted for review |
| `REVISIONS` | Changes requested |
| `COMPLETE` | Approved and complete |
#### Example Request
```http
GET /api/ext/shots?projectId=cmp6l5mzq0001ua0gz07bk72f&episode=103&status=COMPLETE
Authorization: Bearer your-api-key
```
#### Example Response
```json
{
"shots": [
{
"id": "clxxxxxxxxxxxxxx",
"shotCode": "UNG_103_010_010",
"scene": "010",
"episode": "103",
"shotNumber": 10,
"description": "Hero wide establishing shot",
"status": "COMPLETE",
"priority": "NORMAL",
"frameStart": 1001,
"frameEnd": 1120,
"fps": 24,
"dueDate": "2026-06-01T00:00:00.000Z",
"createdAt": "2026-05-20T10:00:00.000Z",
"updatedAt": "2026-05-28T14:30:00.000Z",
"artist": {
"id": "clxxxxxxxxxxxxxx",
"name": "Jane Smith",
"email": "jane@studio.com"
},
"_count": {
"versions": 3,
"tasks": 2
}
}
],
"total": 1
}
```
---
### 2. Get Single Shot
```
GET /api/ext/shots/{id}
```
Returns full detail for one shot including all tasks and the latest version status.
#### By Database ID
```http
GET /api/ext/shots/clxxxxxxxxxxxxxx
Authorization: Bearer your-api-key
```
#### By Shot Code
Add `byCode=1` and `projectId` to look up by the human-readable shot code instead:
```http
GET /api/ext/shots/UNG_103_010_010?byCode=1&projectId=cmp6l5mzq0001ua0gz07bk72f
Authorization: Bearer your-api-key
```
#### Example Response
```json
{
"shot": {
"id": "clxxxxxxxxxxxxxx",
"shotCode": "UNG_103_010_010",
"scene": "010",
"episode": "103",
"shotNumber": 10,
"description": "Hero wide establishing shot",
"status": "COMPLETE",
"priority": "NORMAL",
"frameStart": 1001,
"frameEnd": 1120,
"fps": 24,
"dueDate": "2026-06-01T00:00:00.000Z",
"createdAt": "2026-05-20T10:00:00.000Z",
"updatedAt": "2026-05-28T14:30:00.000Z",
"project": {
"id": "cmp6l5mzq0001ua0gz07bk72f",
"name": "UNG Episode 103",
"code": "UNG103",
"showId": "UNG"
},
"artist": {
"id": "clxxxxxxxxxxxxxx",
"name": "Jane Smith",
"email": "jane@studio.com"
},
"tasks": [
{
"id": "clxxxxxxxxxxxxxx",
"title": "Comp",
"type": "COMP",
"status": "DONE",
"priority": "NORMAL",
"estimatedHours": 8,
"dueDate": "2026-06-01T00:00:00.000Z",
"assignedArtist": {
"id": "clxxxxxxxxxxxxxx",
"name": "Jane Smith",
"email": "jane@studio.com"
},
"_count": {
"versions": 3
}
}
],
"versions": [
{
"id": "clxxxxxxxxxxxxxx",
"versionNumber": 3,
"approvalStatus": "APPROVED",
"createdAt": "2026-05-28T14:30:00.000Z",
"artist": {
"id": "clxxxxxxxxxxxxxx",
"name": "Jane Smith",
"email": "jane@studio.com"
}
}
],
"_count": {
"versions": 3,
"tasks": 2
}
}
}
```
---
## Error Responses
| Status | Meaning |
|--------|----------------------------------------------|
| `400` | Missing required parameter (e.g. projectId) |
| `401` | Missing or invalid API key |
| `404` | Shot not found |
| `500` | Internal server error |
```json
{ "error": "projectId is required" }
```
---
## Invoicing Use Cases
### Get all completed shots for billing
```http
GET /api/ext/shots?projectId={id}&status=COMPLETE
```
### Get all shots for a specific episode
```http
GET /api/ext/shots?projectId={id}&episode=103
```
### Get frame count for a shot (for per-frame billing)
From the single shot response, calculate:
```
frameCount = frameEnd - frameStart + 1
```
### Get estimated hours across all tasks for a shot
Sum `estimatedHours` from the `tasks` array in the single shot response.
---
## Shot Code Format
Shot codes follow the convention:
```
{showId}_{episode}_{scene}_{shotNumber}
e.g. UNG_103_010_010
^^^ ^^^ ^^^ ^^^
| | | shot number (padded)
| | scene
| episode
show ID
```
+156 -20
View File
@@ -1,36 +1,172 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# xBid — VFX Quoting & Bidding SaaS
## Getting Started
A professional full-stack VFX quoting application built with **Next.js 15**, **TypeScript**, **Tailwind CSS v4**, **Prisma ORM**, and **PostgreSQL**.
First, run the development server:
---
## Features
- **Clients** — manage client companies with full contact and billing info
- **Jobs / Quotes** — versioned quote documents with status workflow (Draft ? Sent ? Approved/Rejected)
- **Shots** — VFX shot breakdown with complexity multipliers and drag-and-drop reordering
- **Assets** — CG asset budget tracking (characters, environments, vehicles, etc.)
- **Line Items** — per-task cost rows with hours/days/artists/rates, markup, discount, tax
- **Rate Cards** — studio rate card library organized by department
- **PDF Export** — professional quote PDF with front page + detailed appendix
- **Decimal Precision** — all financials use `decimal.js` (no floating-point drift)
---
## Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router, Server Actions) |
| Language | TypeScript |
| Styling | Tailwind CSS v4 + shadcn/ui |
| ORM | Prisma 7 + PostgreSQL 16 |
| Forms | React Hook Form + Zod |
| Drag & Drop | @dnd-kit/core + @dnd-kit/sortable |
| Finance Math | decimal.js |
| Notifications | sonner |
| PDF | HTML render via `/api/pdf/[id]` |
---
## Prerequisites
- Node.js 20+
- PostgreSQL 16 (or Docker)
- npm 10+
- `ts-node` for seeding: `npm install -g ts-node typescript`
---
## Quick Start
### 1. Clone & install
```bash
git clone <repo-url>
cd xBid
npm install
```
### 2. Configure environment
```bash
cp .env.example .env
```
Edit `.env`:
```env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/xbid"
NEXTAUTH_SECRET="your-random-secret-here"
NEXTAUTH_URL="http://localhost:3000"
```
### 3. Start PostgreSQL
**Option A — Docker (recommended):**
```bash
docker compose up -d postgres
```
**Option B — local PostgreSQL:**
```sql
CREATE DATABASE xbid;
```
### 4. Run database migrations
```bash
npm run db:generate # generate Prisma client
npm run db:migrate # run migrations (enter "init" as migration name)
```
### 5. Seed sample data
```bash
npm run db:seed
```
This creates 15 rate cards, 2 sample clients, 2 jobs with shots, assets, and line items.
### 6. Start development server
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open http://localhost:3000
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
---
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Production (Docker)
## Learn More
```bash
docker compose up --build -d
```
To learn more about Next.js, take a look at the following resources:
After first run, apply migrations:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
```bash
docker compose exec app npx prisma migrate deploy
docker compose exec app npx prisma db seed
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
---
## Deploy on Vercel
## PDF Export
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Navigate to any job and click **Download PDF**. The route at `/api/pdf/[id]` generates HTML with:
- **Front page**: client info, project details, shot/asset summary, financial totals, T&C
- **Appendix**: full per-shot and per-asset line item breakdown
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Print to PDF via browser (Ctrl+P ? Save as PDF).
---
## Project Structure
```
src/
app/ Next.js App Router pages
api/pdf/[id]/ PDF generation endpoint
clients/ Client management
jobs/ Job management
rate-cards/ Rate card management
actions/ Server actions (CRUD)
components/
assets/ Asset editor
clients/ Client form & table
dashboard/ Stats & recent jobs
jobs/ Job form, table, detail, totals
layout/ Sidebar navigation
line-items/ Line item table & dialog
rate-cards/ Rate card management
shots/ Shot editor
ui/ shadcn/ui components
lib/
calculations.ts Decimal.js financial math
constants.ts Domain constants & defaults
prisma.ts Prisma client singleton
validations.ts Zod schemas
prisma/
schema.prisma Full database schema
seed.ts Sample data seeder
```
---
## Database Scripts
```bash
npm run db:generate # npx prisma generate
npm run db:migrate # npx prisma migrate dev
npm run db:seed # ts-node prisma/seed.ts
npm run db:studio # npx prisma studio (DB GUI at :5555)
```
+378
View File
@@ -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
```
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+35
View File
@@ -0,0 +1,35 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_DB: xbid
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/xbid
NEXTAUTH_SECRET: change-me-in-production
NEXTAUTH_URL: http://localhost:3000
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
+67
View File
@@ -0,0 +1,67 @@
# Shot Naming Conventions
## Shot Code Format
Shot codes are auto-generated on creation and are **read-only** after that point.
### Standard Job
```
{SHOW_ID}_{SCENE}_{SHOT_NUMBER}
```
Example: `PRJX_SC010_0010`
### Episodic Job
```
{SHOW_ID}_{EPISODE}_{SCENE}_{SHOT_NUMBER}
```
Example: `PRJX_EP01_SC010_0010`
---
## Segments
| Segment | Source | Notes |
|---|---|---|
| `SHOW_ID` | `Job.showId` | 1–10 chars, letters/numbers/underscores, stored uppercase. Set once on the job. |
| `EPISODE` | `Shot.episode` | Only present on `EPISODIC` jobs. 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 job** (and additionally to the **episode** for episodic jobs):
- On create, the system queries `MAX(shotNumber)` for all shots sharing the same `jobId + 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` | `Job` | Yes — validated 1–10 chars, `[A-Z0-9_]` |
| `projectCode` | `Job` | Yes — unique across all jobs, used for billing/reference (not in shot code) |
| `jobType` | `Job` | 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 `jobType === EPISODIC` |
| `shotGroup` | `Shot` | Optional — organisational grouping only, not part of the shot code |
---
## Notes
- `projectCode` is a separate billing/admin identifier on the Job 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.
+6537 -105
View File
File diff suppressed because it is too large Load Diff
+42 -2
View File
@@ -6,22 +6,62 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:seed": "ts-node -P tsconfig.seed.json prisma/seed.ts",
"db:studio": "prisma studio"
},
"prisma": {
"seed": "ts-node -P tsconfig.seed.json prisma/seed.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.2",
"@base-ui/react": "^1.4.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@react-pdf/renderer": "^4.5.1",
"@types/pg": "^8.20.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"decimal.js": "^10.6.0",
"lucide-react": "^1.14.0",
"next": "16.2.6",
"next-auth": "^4.24.14",
"next-themes": "^0.4.6",
"pg": "^8.20.0",
"puppeteer": "^24.43.1",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-day-picker": "^10.0.0",
"react-dom": "19.2.4",
"react-hook-form": "^7.75.0",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"uuid": "^14.0.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^10.0.0",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"ts-node": "^10.9.2",
"typescript": "^5"
}
}
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
@@ -0,0 +1,316 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'PRODUCER', 'SUPERVISOR', 'ARTIST');
-- CreateEnum
CREATE TYPE "JobStatus" AS ENUM ('DRAFT', 'SENT', 'APPROVED', 'REJECTED', 'ARCHIVED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT NOT NULL,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"password" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'ARTIST',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "accounts" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification_tokens" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "clients" (
"id" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"contactPerson" TEXT,
"email" TEXT,
"phone" TEXT,
"vatNumber" TEXT,
"companyRegNumber" TEXT,
"billingAddress" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "clients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "jobs" (
"id" TEXT NOT NULL,
"projectTitle" TEXT NOT NULL,
"projectCode" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"createdById" TEXT,
"issueDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiryDate" TIMESTAMP(3),
"currency" TEXT NOT NULL DEFAULT 'USD',
"status" "JobStatus" NOT NULL DEFAULT 'DRAFT',
"description" TEXT,
"internalNotes" TEXT,
"markupPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"taxPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"discountPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"customHeaderHtml" TEXT,
"customFooterHtml" TEXT,
"termsAndConditions" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"parentJobId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "jobs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "job_history" (
"id" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"field" TEXT NOT NULL,
"oldValue" TEXT,
"newValue" TEXT,
"changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"changedBy" TEXT,
CONSTRAINT "job_history_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "comments" (
"id" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"userId" TEXT,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rate_cards" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"department" TEXT,
"defaultDailyRate" DECIMAL(10,4) NOT NULL DEFAULT 0,
"hoursPerDay" DECIMAL(5,2) NOT NULL DEFAULT 8,
"currency" TEXT NOT NULL DEFAULT 'USD',
"notes" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "rate_cards_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shot_templates" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "shot_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shot_line_item_templates" (
"id" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"taskName" TEXT NOT NULL,
"artistRole" TEXT,
"defaultHours" DECIMAL(10,4) NOT NULL DEFAULT 0,
"rateCardId" TEXT,
"notes" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "shot_line_item_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "asset_templates" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "asset_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "asset_line_item_templates" (
"id" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"taskName" TEXT NOT NULL,
"artistRole" TEXT,
"defaultHours" DECIMAL(10,4) NOT NULL DEFAULT 0,
"rateCardId" TEXT,
"notes" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "asset_line_item_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shots" (
"id" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"shotCode" TEXT NOT NULL,
"description" TEXT,
"durationSeconds" INTEGER,
"complexityMultiplier" DECIMAL(5,4) NOT NULL DEFAULT 1,
"notes" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "shots_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "assets" (
"id" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"assetName" TEXT NOT NULL,
"assetType" TEXT,
"description" TEXT,
"complexityMultiplier" DECIMAL(5,4) NOT NULL DEFAULT 1,
"notes" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "assets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "line_items" (
"id" TEXT NOT NULL,
"shotId" TEXT,
"assetId" TEXT,
"rateCardId" TEXT,
"taskName" TEXT NOT NULL,
"artistRole" TEXT,
"quantityHours" DECIMAL(10,4) NOT NULL DEFAULT 0,
"dailyRate" DECIMAL(10,4) NOT NULL DEFAULT 0,
"hoursPerDay" DECIMAL(5,2) NOT NULL DEFAULT 8,
"numArtists" DECIMAL(5,2) NOT NULL DEFAULT 1,
"numDays" DECIMAL(10,4) NOT NULL DEFAULT 0,
"manualHourlyRate" DECIMAL(10,4),
"manualSubtotal" DECIMAL(14,4),
"markupPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"discountPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"taxPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"hourlyRate" DECIMAL(10,4) NOT NULL DEFAULT 0,
"subtotal" DECIMAL(14,4) NOT NULL DEFAULT 0,
"markupAmount" DECIMAL(14,4) NOT NULL DEFAULT 0,
"discountAmount" DECIMAL(14,4) NOT NULL DEFAULT 0,
"taxAmount" DECIMAL(14,4) NOT NULL DEFAULT 0,
"total" DECIMAL(14,4) NOT NULL DEFAULT 0,
"notes" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "line_items_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "accounts_provider_providerAccountId_key" ON "accounts"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_sessionToken_key" ON "sessions"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "verification_tokens_token_key" ON "verification_tokens"("token");
-- CreateIndex
CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "jobs_projectCode_key" ON "jobs"("projectCode");
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "jobs" ADD CONSTRAINT "jobs_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "jobs" ADD CONSTRAINT "jobs_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "jobs" ADD CONSTRAINT "jobs_parentJobId_fkey" FOREIGN KEY ("parentJobId") REFERENCES "jobs"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "job_history" ADD CONSTRAINT "job_history_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shot_line_item_templates" ADD CONSTRAINT "shot_line_item_templates_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "shot_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "asset_line_item_templates" ADD CONSTRAINT "asset_line_item_templates_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "asset_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shots" ADD CONSTRAINT "shots_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assets" ADD CONSTRAINT "assets_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "line_items" ADD CONSTRAINT "line_items_shotId_fkey" FOREIGN KEY ("shotId") REFERENCES "shots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "line_items" ADD CONSTRAINT "line_items_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "line_items" ADD CONSTRAINT "line_items_rateCardId_fkey" FOREIGN KEY ("rateCardId") REFERENCES "rate_cards"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE "jobs" ALTER COLUMN "currency" SET DEFAULT 'ZAR';
-- AlterTable
ALTER TABLE "rate_cards" ALTER COLUMN "currency" SET DEFAULT 'ZAR';
-- CreateTable
CREATE TABLE "studio_settings" (
"id" TEXT NOT NULL DEFAULT 'default',
"studioName" TEXT NOT NULL DEFAULT 'My VFX Studio',
"studioEmail" TEXT,
"studioPhone" TEXT,
"studioAddress" TEXT,
"logoUrl" TEXT,
"vatNumber" TEXT,
"companyRegNumber" TEXT,
"invoiceHeader" TEXT,
"invoiceFooter" TEXT,
"defaultTermsAndConditions" TEXT,
"defaultCurrency" TEXT NOT NULL DEFAULT 'ZAR',
"defaultMarkupPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"defaultDiscountPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"defaultTaxPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "studio_settings_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,114 @@
-- CreateEnum
CREATE TYPE "ScheduleTemplate" AS ENUM ('STANDARD_FILM', 'SMALL_COMMERCIAL', 'EPISODIC', 'CUSTOM');
-- CreateEnum
CREATE TYPE "MilestoneStatus" AS ENUM ('DRAFT', 'PENDING', 'INVOICED', 'PARTIALLY_PAID', 'PAID', 'OVERDUE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "InvoiceStatus" AS ENUM ('DRAFT', 'SENT', 'VIEWED', 'PARTIALLY_PAID', 'PAID', 'OVERDUE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "PaymentMethod" AS ENUM ('EFT', 'CASH', 'CREDIT_CARD', 'BANK_TRANSFER', 'CRYPTO', 'OTHER');
-- AlterTable
ALTER TABLE "jobs" ADD COLUMN "estimatedCompletionDate" TIMESTAMP(3),
ADD COLUMN "startDate" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "payment_schedules" (
"id" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"template" "ScheduleTemplate" NOT NULL DEFAULT 'CUSTOM',
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payment_schedules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payment_milestones" (
"id" TEXT NOT NULL,
"scheduleId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"dueDate" TIMESTAMP(3),
"percentOfTotal" DECIMAL(10,4) NOT NULL DEFAULT 0,
"fixedAmount" DECIMAL(14,4),
"status" "MilestoneStatus" NOT NULL DEFAULT 'PENDING',
"paymentReceivedDate" TIMESTAMP(3),
"notes" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payment_milestones_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "invoices" (
"id" TEXT NOT NULL,
"invoiceNumber" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"milestoneId" TEXT,
"status" "InvoiceStatus" NOT NULL DEFAULT 'DRAFT',
"issueDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dueDate" TIMESTAMP(3),
"paymentTerms" TEXT,
"currency" TEXT NOT NULL DEFAULT 'ZAR',
"subtotal" DECIMAL(14,4) NOT NULL DEFAULT 0,
"taxPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"taxAmount" DECIMAL(14,4) NOT NULL DEFAULT 0,
"totalAmount" DECIMAL(14,4) NOT NULL DEFAULT 0,
"amountPaid" DECIMAL(14,4) NOT NULL DEFAULT 0,
"amountDue" DECIMAL(14,4) NOT NULL DEFAULT 0,
"notes" TEXT,
"internalNotes" TEXT,
"bankingDetails" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "invoices_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "invoice_payments" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"amount" DECIMAL(14,4) NOT NULL,
"paymentDate" TIMESTAMP(3) NOT NULL,
"paymentMethod" "PaymentMethod" NOT NULL DEFAULT 'EFT',
"reference" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "invoice_payments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "payment_schedules_jobId_key" ON "payment_schedules"("jobId");
-- CreateIndex
CREATE UNIQUE INDEX "invoices_invoiceNumber_key" ON "invoices"("invoiceNumber");
-- CreateIndex
CREATE UNIQUE INDEX "invoices_milestoneId_key" ON "invoices"("milestoneId");
-- AddForeignKey
ALTER TABLE "payment_schedules" ADD CONSTRAINT "payment_schedules_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_milestones" ADD CONSTRAINT "payment_milestones_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "payment_schedules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "jobs"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_milestoneId_fkey" FOREIGN KEY ("milestoneId") REFERENCES "payment_milestones"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invoice_payments" ADD CONSTRAINT "invoice_payments_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- CreateEnum
CREATE TYPE "JobType" AS ENUM ('STANDARD', 'EPISODIC');
-- AlterTable
ALTER TABLE "jobs" ADD COLUMN "jobType" "JobType" NOT NULL DEFAULT 'STANDARD',
ADD COLUMN "showId" TEXT NOT NULL DEFAULT '';
-- AlterTable
ALTER TABLE "shots" ADD COLUMN "episode" TEXT,
ADD COLUMN "scene" TEXT NOT NULL DEFAULT '',
ADD COLUMN "shotNumber" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "shots" ADD COLUMN "shotGroup" TEXT;
@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "invoices" DROP CONSTRAINT "invoices_jobId_fkey";
-- AddForeignKey
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,58 @@
-- AlterTable
ALTER TABLE "line_items" ADD COLUMN "supervisionId" TEXT;
-- CreateTable
CREATE TABLE "supervisions" (
"id" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"supervisionTitle" TEXT NOT NULL,
"shootName" TEXT,
"location" TEXT,
"shootDates" TEXT,
"numShootDays" INTEGER NOT NULL DEFAULT 0,
"travelDays" INTEGER NOT NULL DEFAULT 0,
"prepDays" INTEGER NOT NULL DEFAULT 0,
"wrapDays" INTEGER NOT NULL DEFAULT 0,
"description" TEXT,
"complexityMultiplier" DECIMAL(5,4) NOT NULL DEFAULT 1,
"notes" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "supervisions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "supervision_templates" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "supervision_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "supervision_line_item_templates" (
"id" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"taskName" TEXT NOT NULL,
"artistRole" TEXT,
"defaultHours" DECIMAL(10,4) NOT NULL DEFAULT 0,
"rateCardId" TEXT,
"notes" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "supervision_line_item_templates_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "supervisions" ADD CONSTRAINT "supervisions_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "supervision_line_item_templates" ADD CONSTRAINT "supervision_line_item_templates_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "supervision_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "line_items" ADD CONSTRAINT "line_items_supervisionId_fkey" FOREIGN KEY ("supervisionId") REFERENCES "supervisions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,57 @@
-- AlterTable
ALTER TABLE "line_items" ADD COLUMN "previsId" TEXT;
-- CreateTable
CREATE TABLE "previses" (
"id" TEXT NOT NULL,
"jobId" TEXT NOT NULL,
"sequenceName" TEXT NOT NULL,
"previsCode" TEXT,
"sequenceType" TEXT NOT NULL DEFAULT 'Previs',
"description" TEXT,
"duration" TEXT,
"frameRange" TEXT,
"revisionCount" INTEGER NOT NULL DEFAULT 0,
"deliveryStage" TEXT NOT NULL DEFAULT 'Blocking',
"complexityMultiplier" DECIMAL(5,4) NOT NULL DEFAULT 1,
"notes" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "previses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "previs_templates" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "previs_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "previs_line_item_templates" (
"id" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"taskName" TEXT NOT NULL,
"artistRole" TEXT,
"defaultHours" DECIMAL(10,4) NOT NULL DEFAULT 0,
"rateCardId" TEXT,
"notes" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "previs_line_item_templates_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "previses" ADD CONSTRAINT "previses_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "previs_line_item_templates" ADD CONSTRAINT "previs_line_item_templates_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "previs_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "line_items" ADD CONSTRAINT "line_items_previsId_fkey" FOREIGN KEY ("previsId") REFERENCES "previses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "task_templates" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"taskName" TEXT NOT NULL,
"artistRole" TEXT,
"rateCardId" TEXT,
"quantityHours" DECIMAL(10,4) NOT NULL DEFAULT 0,
"dailyRate" DECIMAL(10,4) NOT NULL DEFAULT 0,
"hoursPerDay" DECIMAL(5,2) NOT NULL DEFAULT 8,
"numArtists" DECIMAL(5,2) NOT NULL DEFAULT 1,
"numDays" DECIMAL(10,4) NOT NULL DEFAULT 0,
"manualHourlyRate" DECIMAL(10,4),
"manualSubtotal" DECIMAL(14,4),
"markupPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"discountPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"taxPercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "task_templates_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "jobs" ADD COLUMN "productionFeePercent" DECIMAL(10,4) NOT NULL DEFAULT 0,
ADD COLUMN "pullManagementFeePercent" DECIMAL(10,4) NOT NULL DEFAULT 0;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+626
View File
@@ -0,0 +1,626 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ─── Auth / Users ────────────────────────────────────────────────────────────
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
password String?
role UserRole @default(ARTIST)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
jobs Job[] @relation("JobCreator")
comments Comment[]
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
enum UserRole {
ADMIN
PRODUCER
SUPERVISOR
ARTIST
}
// ─── Clients ─────────────────────────────────────────────────────────────────
model Client {
id String @id @default(cuid())
companyName String
contactPerson String?
email String?
phone String?
vatNumber String?
companyRegNumber String?
billingAddress String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
jobs Job[]
invoices Invoice[]
@@map("clients")
}
// ─── Jobs / Quotes ────────────────────────────────────────────────────────────
model Job {
id String @id @default(cuid())
projectTitle String
projectCode String @unique
showId String @default("")
jobType JobType @default(STANDARD)
clientId String
createdById String?
issueDate DateTime @default(now())
expiryDate DateTime?
currency String @default("ZAR")
status JobStatus @default(DRAFT)
description String?
internalNotes String?
markupPercent Decimal @default(0) @db.Decimal(10, 4)
taxPercent Decimal @default(0) @db.Decimal(10, 4)
discountPercent Decimal @default(0) @db.Decimal(10, 4)
productionFeePercent Decimal @default(0) @db.Decimal(10, 4)
pullManagementFeePercent Decimal @default(0) @db.Decimal(10, 4)
customHeaderHtml String?
customFooterHtml String?
termsAndConditions String?
startDate DateTime?
estimatedCompletionDate DateTime?
version Int @default(1)
parentJobId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id])
createdBy User? @relation("JobCreator", fields: [createdById], references: [id])
parentJob Job? @relation("JobVersions", fields: [parentJobId], references: [id], onDelete: SetNull)
versions Job[] @relation("JobVersions")
shots Shot[]
assets Asset[]
supervisions Supervision[]
previses Previs[]
comments Comment[]
history JobHistory[]
paymentSchedule PaymentSchedule?
invoices Invoice[]
@@map("jobs")
}
enum JobStatus {
DRAFT
SENT
APPROVED
REJECTED
ARCHIVED
}
enum JobType {
STANDARD
EPISODIC
}
model JobHistory {
id String @id @default(cuid())
jobId String
field String
oldValue String?
newValue String?
changedAt DateTime @default(now())
changedBy String?
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
@@map("job_history")
}
model Comment {
id String @id @default(cuid())
jobId String
userId String?
body String
createdAt DateTime @default(now())
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id])
@@map("comments")
}
// ─── Rate Cards ───────────────────────────────────────────────────────────────
model RateCard {
id String @id @default(cuid())
name String
department String?
defaultDailyRate Decimal @default(0) @db.Decimal(10, 4)
hoursPerDay Decimal @default(8) @db.Decimal(5, 2)
currency String @default("ZAR")
notes String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lineItems LineItem[]
@@map("rate_cards")
}
// ─── Shot Templates ───────────────────────────────────────────────────────────
model ShotTemplate {
id String @id @default(cuid())
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lineItemTemplates ShotLineItemTemplate[]
@@map("shot_templates")
}
model ShotLineItemTemplate {
id String @id @default(cuid())
templateId String
taskName String
artistRole String?
defaultHours Decimal @default(0) @db.Decimal(10, 4)
rateCardId String?
notes String?
sortOrder Int @default(0)
template ShotTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
@@map("shot_line_item_templates")
}
// ─── Asset Templates ──────────────────────────────────────────────────────────
model AssetTemplate {
id String @id @default(cuid())
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lineItemTemplates AssetLineItemTemplate[]
@@map("asset_templates")
}
model AssetLineItemTemplate {
id String @id @default(cuid())
templateId String
taskName String
artistRole String?
defaultHours Decimal @default(0) @db.Decimal(10, 4)
rateCardId String?
notes String?
sortOrder Int @default(0)
template AssetTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
@@map("asset_line_item_templates")
}
// ─── Shots ────────────────────────────────────────────────────────────────────
model Shot {
id String @id @default(cuid())
jobId String
shotCode String
shotNumber Int @default(0)
scene String @default("")
episode String?
shotGroup String?
description String?
durationSeconds Int?
complexityMultiplier Decimal @default(1) @db.Decimal(5, 4)
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
lineItems LineItem[]
@@map("shots")
}
// ─── Assets ───────────────────────────────────────────────────────────────────
model Asset {
id String @id @default(cuid())
jobId String
assetName String
assetType String?
description String?
complexityMultiplier Decimal @default(1) @db.Decimal(5, 4)
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
lineItems LineItem[]
@@map("assets")
}
// ─── Supervision ─────────────────────────────────────────────────────────────
model Supervision {
id String @id @default(cuid())
jobId String
supervisionTitle String
shootName String?
location String?
shootDates String?
numShootDays Int @default(0)
travelDays Int @default(0)
prepDays Int @default(0)
wrapDays Int @default(0)
description String?
complexityMultiplier Decimal @default(1) @db.Decimal(5, 4)
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
lineItems LineItem[]
@@map("supervisions")
}
model SupervisionTemplate {
id String @id @default(cuid())
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lineItemTemplates SupervisionLineItemTemplate[]
@@map("supervision_templates")
}
model SupervisionLineItemTemplate {
id String @id @default(cuid())
templateId String
taskName String
artistRole String?
defaultHours Decimal @default(0) @db.Decimal(10, 4)
rateCardId String?
notes String?
sortOrder Int @default(0)
template SupervisionTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
@@map("supervision_line_item_templates")
}
// ─── Previs ───────────────────────────────────────────────────────────────────
model Previs {
id String @id @default(cuid())
jobId String
sequenceName String
previsCode String?
sequenceType String @default("Previs")
description String?
duration String?
frameRange String?
revisionCount Int @default(0)
deliveryStage String @default("Blocking")
complexityMultiplier Decimal @default(1) @db.Decimal(5, 4)
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
lineItems LineItem[]
@@map("previses")
}
model PrevisTemplate {
id String @id @default(cuid())
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lineItemTemplates PrevisLineItemTemplate[]
@@map("previs_templates")
}
model PrevisLineItemTemplate {
id String @id @default(cuid())
templateId String
taskName String
artistRole String?
defaultHours Decimal @default(0) @db.Decimal(10, 4)
rateCardId String?
notes String?
sortOrder Int @default(0)
template PrevisTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
@@map("previs_line_item_templates")
}
// ─── Line Items ───────────────────────────────────────────────────────────────
model LineItem {
id String @id @default(cuid())
shotId String?
assetId String?
supervisionId String?
previsId String?
rateCardId String?
taskName String
artistRole String?
// Inputs
quantityHours Decimal @default(0) @db.Decimal(10, 4)
dailyRate Decimal @default(0) @db.Decimal(10, 4)
hoursPerDay Decimal @default(8) @db.Decimal(5, 2)
numArtists Decimal @default(1) @db.Decimal(5, 2)
numDays Decimal @default(0) @db.Decimal(10, 4)
// Overrides
manualHourlyRate Decimal? @db.Decimal(10, 4)
manualSubtotal Decimal? @db.Decimal(14, 4)
// Adjustments
markupPercent Decimal @default(0) @db.Decimal(10, 4)
discountPercent Decimal @default(0) @db.Decimal(10, 4)
taxPercent Decimal @default(0) @db.Decimal(10, 4)
// Stored computed totals (recalculated server-side before PDF)
hourlyRate Decimal @default(0) @db.Decimal(10, 4)
subtotal Decimal @default(0) @db.Decimal(14, 4)
markupAmount Decimal @default(0) @db.Decimal(14, 4)
discountAmount Decimal @default(0) @db.Decimal(14, 4)
taxAmount Decimal @default(0) @db.Decimal(14, 4)
total Decimal @default(0) @db.Decimal(14, 4)
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shot Shot? @relation(fields: [shotId], references: [id], onDelete: Cascade)
asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade)
supervision Supervision? @relation(fields: [supervisionId], references: [id], onDelete: Cascade)
previs Previs? @relation(fields: [previsId], references: [id], onDelete: Cascade)
rateCard RateCard? @relation(fields: [rateCardId], references: [id])
@@map("line_items")
}
// ─── Task Templates ───────────────────────────────────────────────────────────
model TaskTemplate {
id String @id @default(cuid())
name String
taskName String
artistRole String?
rateCardId String?
quantityHours Decimal @default(0) @db.Decimal(10, 4)
dailyRate Decimal @default(0) @db.Decimal(10, 4)
hoursPerDay Decimal @default(8) @db.Decimal(5, 2)
numArtists Decimal @default(1) @db.Decimal(5, 2)
numDays Decimal @default(0) @db.Decimal(10, 4)
manualHourlyRate Decimal? @db.Decimal(10, 4)
manualSubtotal Decimal? @db.Decimal(14, 4)
markupPercent Decimal @default(0) @db.Decimal(10, 4)
discountPercent Decimal @default(0) @db.Decimal(10, 4)
taxPercent Decimal @default(0) @db.Decimal(10, 4)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("task_templates")
}
// ─── Studio Settings ──────────────────────────────────────────────────────────
model StudioSettings {
id String @id @default("default")
studioName String @default("My VFX Studio")
studioEmail String?
studioPhone String?
studioAddress String?
logoUrl String?
vatNumber String?
companyRegNumber String?
invoiceHeader String?
invoiceFooter String?
defaultTermsAndConditions String?
defaultCurrency String @default("ZAR")
defaultMarkupPercent Decimal @default(0) @db.Decimal(10, 4)
defaultDiscountPercent Decimal @default(0) @db.Decimal(10, 4)
defaultTaxPercent Decimal @default(0) @db.Decimal(10, 4)
updatedAt DateTime @updatedAt
@@map("studio_settings")
}
// ─── Payment Schedule ─────────────────────────────────────────────────────────
enum ScheduleTemplate {
STANDARD_FILM
SMALL_COMMERCIAL
EPISODIC
CUSTOM
}
enum MilestoneStatus {
DRAFT
PENDING
INVOICED
PARTIALLY_PAID
PAID
OVERDUE
CANCELLED
}
model PaymentSchedule {
id String @id @default(cuid())
jobId String @unique
template ScheduleTemplate @default(CUSTOM)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
milestones PaymentMilestone[]
@@map("payment_schedules")
}
model PaymentMilestone {
id String @id @default(cuid())
scheduleId String
title String
description String?
dueDate DateTime?
percentOfTotal Decimal @default(0) @db.Decimal(10, 4)
fixedAmount Decimal? @db.Decimal(14, 4)
status MilestoneStatus @default(PENDING)
paymentReceivedDate DateTime?
notes String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
schedule PaymentSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
invoice Invoice?
@@map("payment_milestones")
}
// ─── Invoices ─────────────────────────────────────────────────────────────────
enum InvoiceStatus {
DRAFT
SENT
VIEWED
PARTIALLY_PAID
PAID
OVERDUE
CANCELLED
}
enum PaymentMethod {
EFT
CASH
CREDIT_CARD
BANK_TRANSFER
CRYPTO
OTHER
}
model Invoice {
id String @id @default(cuid())
invoiceNumber String @unique
clientId String
jobId String
milestoneId String? @unique
status InvoiceStatus @default(DRAFT)
issueDate DateTime @default(now())
dueDate DateTime?
paymentTerms String?
currency String @default("ZAR")
subtotal Decimal @default(0) @db.Decimal(14, 4)
taxPercent Decimal @default(0) @db.Decimal(10, 4)
taxAmount Decimal @default(0) @db.Decimal(14, 4)
totalAmount Decimal @default(0) @db.Decimal(14, 4)
amountPaid Decimal @default(0) @db.Decimal(14, 4)
amountDue Decimal @default(0) @db.Decimal(14, 4)
notes String?
internalNotes String?
bankingDetails String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id])
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
milestone PaymentMilestone? @relation(fields: [milestoneId], references: [id])
payments InvoicePayment[]
@@map("invoices")
}
model InvoicePayment {
id String @id @default(cuid())
invoiceId String
amount Decimal @db.Decimal(14, 4)
paymentDate DateTime
paymentMethod PaymentMethod @default(EFT)
reference String?
notes String?
createdAt DateTime @default(now())
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
@@map("invoice_payments")
}
+289
View File
@@ -0,0 +1,289 @@
import { config } from "dotenv";
config();
import { PrismaClient, JobStatus } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import Decimal from "decimal.js";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter } as any);
async function main() {
console.log("🌱 Seeding database...");
// --- Rate Cards ---
const rateCardsData = [
{ name: "VFX Supervisor", department: "Leadership", defaultDailyRate: 1200, hoursPerDay: 8 },
{ name: "CG Supervisor", department: "Leadership", defaultDailyRate: 1100, hoursPerDay: 8 },
{ name: "Senior Compositor", department: "Compositing", defaultDailyRate: 950, hoursPerDay: 8 },
{ name: "Mid Compositor", department: "Compositing", defaultDailyRate: 750, hoursPerDay: 8 },
{ name: "Junior Compositor", department: "Compositing", defaultDailyRate: 500, hoursPerDay: 8 },
{ name: "Senior Animator", department: "Animation", defaultDailyRate: 900, hoursPerDay: 8 },
{ name: "Mid Animator", department: "Animation", defaultDailyRate: 700, hoursPerDay: 8 },
{ name: "Senior Modeller", department: "3D Art", defaultDailyRate: 850, hoursPerDay: 8 },
{ name: "Texture Artist", department: "3D Art", defaultDailyRate: 750, hoursPerDay: 8 },
{ name: "Lighting TD", department: "Lighting", defaultDailyRate: 900, hoursPerDay: 8 },
{ name: "FX Artist", department: "FX", defaultDailyRate: 900, hoursPerDay: 8 },
{ name: "Matchmove Artist", department: "Tracking", defaultDailyRate: 650, hoursPerDay: 8 },
{ name: "Roto Artist", department: "Prep", defaultDailyRate: 450, hoursPerDay: 8 },
{ name: "Paint Artist", department: "Prep", defaultDailyRate: 500, hoursPerDay: 8 },
{ name: "Pipeline TD", department: "Pipeline", defaultDailyRate: 1000, hoursPerDay: 8 },
];
const rateCards = await Promise.all(
rateCardsData.map((rc) =>
prisma.rateCard.upsert({
where: { id: `seed-rc-${rc.name.toLowerCase().replace(/\s+/g, "-")}` },
create: {
id: `seed-rc-${rc.name.toLowerCase().replace(/\s+/g, "-")}`,
name: rc.name,
department: rc.department,
defaultDailyRate: new Decimal(rc.defaultDailyRate),
hoursPerDay: new Decimal(rc.hoursPerDay),
currency: "ZAR",
isActive: true,
},
update: {},
})
)
);
console.log(` ✓ ${rateCards.length} rate cards`);
// --- Clients ---
const client1 = await prisma.client.upsert({
where: { id: "seed-client-01" },
create: {
id: "seed-client-01",
companyName: "Acme Studios",
contactPerson: "Jane Reynolds",
email: "jane@acmestudios.com",
phone: "+1 310 555 0100",
billingAddress: "1234 Sunset Blvd\nLos Angeles, CA 90028",
vatNumber: "US-123456789",
},
update: {},
});
const client2 = await prisma.client.upsert({
where: { id: "seed-client-02" },
create: {
id: "seed-client-02",
companyName: "Nebula Films",
contactPerson: "Tom Kavanagh",
email: "tom@nebulafilms.co.uk",
phone: "+44 20 7946 0100",
billingAddress: "22 Soho Square\nLondon, W1D 4QE",
},
update: {},
});
console.log(" ✓ 2 clients");
// --- Jobs ---
const job1 = await prisma.job.upsert({
where: { id: "seed-job-01" },
create: {
id: "seed-job-01",
projectTitle: "Feature Film VFX Package",
projectCode: "ACME-2024-001",
clientId: client1.id,
status: "DRAFT" as JobStatus,
currency: "ZAR",
markupPercent: new Decimal(10),
taxPercent: new Decimal(15),
discountPercent: new Decimal(0),
description:
"Full VFX package for Acme Studios' upcoming feature film including creature work, environments, and practical enhancement.",
termsAndConditions:
"Payment terms: 50% on approval, 50% on delivery. Prices valid for 30 days. Rates exclude VAT where applicable.",
},
update: {},
});
const shot1 = await prisma.shot.upsert({
where: { id: "seed-shot-01" },
create: {
id: "seed-shot-01",
jobId: job1.id,
shotCode: "SH0010",
durationSeconds: 4,
complexityMultiplier: new Decimal(1.5),
description: "Hero creature reveal — CG creature integration in live action environment",
sortOrder: 0,
},
update: {},
});
const shot2 = await prisma.shot.upsert({
where: { id: "seed-shot-02" },
create: {
id: "seed-shot-02",
jobId: job1.id,
shotCode: "SH0020",
durationSeconds: 2,
complexityMultiplier: new Decimal(1.0),
description: "Establishing environment — CG extension of practical set",
sortOrder: 1,
},
update: {},
});
const seniorComp = rateCards.find((r) => r.name === "Senior Compositor")!;
const lightingTD = rateCards.find((r) => r.name === "Lighting TD")!;
const fxArtist = rateCards.find((r) => r.name === "FX Artist")!;
const rotoArtist = rateCards.find((r) => r.name === "Roto Artist")!;
// Line items for shot 1
const lineItemsShot1 = [
{ taskName: "Roto / Paint", rateCardId: rotoArtist.id, artistRole: rotoArtist.name, dailyRate: 450, hoursPerDay: 8, numArtists: 1, numDays: 3, sortOrder: 0 },
{ taskName: "Lighting & Rendering", rateCardId: lightingTD.id, artistRole: lightingTD.name, dailyRate: 900, hoursPerDay: 8, numArtists: 1, numDays: 5, sortOrder: 1 },
{ taskName: "FX Simulation", rateCardId: fxArtist.id, artistRole: fxArtist.name, dailyRate: 900, hoursPerDay: 8, numArtists: 1, numDays: 4, sortOrder: 2 },
{ taskName: "Compositing", rateCardId: seniorComp.id, artistRole: seniorComp.name, dailyRate: 950, hoursPerDay: 8, numArtists: 1, numDays: 7, sortOrder: 3 },
];
for (const li of lineItemsShot1) {
await prisma.lineItem.upsert({
where: { id: `seed-li-s1-${li.sortOrder}` },
create: {
id: `seed-li-s1-${li.sortOrder}`,
shotId: shot1.id,
taskName: li.taskName,
rateCardId: li.rateCardId,
artistRole: li.artistRole,
dailyRate: new Decimal(li.dailyRate),
hoursPerDay: new Decimal(li.hoursPerDay),
numArtists: new Decimal(li.numArtists),
numDays: new Decimal(li.numDays),
quantityHours: new Decimal(0),
markupPercent: new Decimal(0),
discountPercent: new Decimal(0),
taxPercent: new Decimal(0),
hourlyRate: new Decimal(li.dailyRate / li.hoursPerDay),
subtotal: new Decimal(li.dailyRate * li.numDays * li.numArtists * 1.5),
markupAmount: new Decimal(0),
discountAmount: new Decimal(0),
taxAmount: new Decimal(0),
total: new Decimal(li.dailyRate * li.numDays * li.numArtists * 1.5),
sortOrder: li.sortOrder,
},
update: {},
});
}
// Line items for shot 2
const midComp = rateCards.find((r) => r.name === "Mid Compositor")!;
const lineItemsShot2 = [
{ taskName: "Roto / Paint", rateCardId: rotoArtist.id, artistRole: rotoArtist.name, dailyRate: 450, hoursPerDay: 8, numArtists: 2, numDays: 2, sortOrder: 0 },
{ taskName: "Compositing", rateCardId: midComp.id, artistRole: midComp.name, dailyRate: 750, hoursPerDay: 8, numArtists: 1, numDays: 4, sortOrder: 1 },
];
for (const li of lineItemsShot2) {
await prisma.lineItem.upsert({
where: { id: `seed-li-s2-${li.sortOrder}` },
create: {
id: `seed-li-s2-${li.sortOrder}`,
shotId: shot2.id,
taskName: li.taskName,
rateCardId: li.rateCardId,
artistRole: li.artistRole,
dailyRate: new Decimal(li.dailyRate),
hoursPerDay: new Decimal(li.hoursPerDay),
numArtists: new Decimal(li.numArtists),
numDays: new Decimal(li.numDays),
quantityHours: new Decimal(0),
markupPercent: new Decimal(0),
discountPercent: new Decimal(0),
taxPercent: new Decimal(0),
hourlyRate: new Decimal(li.dailyRate / li.hoursPerDay),
subtotal: new Decimal(li.dailyRate * li.numDays * li.numArtists),
markupAmount: new Decimal(0),
discountAmount: new Decimal(0),
taxAmount: new Decimal(0),
total: new Decimal(li.dailyRate * li.numDays * li.numArtists),
sortOrder: li.sortOrder,
},
update: {},
});
}
console.log(" ✓ 1 job with 2 shots and line items");
// --- Asset example on job 1 ---
const asset1 = await prisma.asset.upsert({
where: { id: "seed-asset-01" },
create: {
id: "seed-asset-01",
jobId: job1.id,
assetName: "Hero Creature",
assetType: "Character",
complexityMultiplier: new Decimal(2.0),
description: "Full CG creature, hero quality, 4K textures",
sortOrder: 0,
},
update: {},
});
const seniorModeller = rateCards.find((r) => r.name === "Senior Modeller")!;
const textureArtist = rateCards.find((r) => r.name === "Texture Artist")!;
const assetLineItems = [
{ taskName: "High Res Modelling", rateCardId: seniorModeller.id, artistRole: seniorModeller.name, dailyRate: 850, numDays: 8, numArtists: 1, sortOrder: 0 },
{ taskName: "Texturing & Look Dev", rateCardId: textureArtist.id, artistRole: textureArtist.name, dailyRate: 750, numDays: 5, numArtists: 1, sortOrder: 1 },
{ taskName: "Rigging", rateCardId: seniorModeller.id, artistRole: "Rigging Artist", dailyRate: 850, numDays: 6, numArtists: 1, sortOrder: 2 },
];
for (const li of assetLineItems) {
await prisma.lineItem.upsert({
where: { id: `seed-li-a1-${li.sortOrder}` },
create: {
id: `seed-li-a1-${li.sortOrder}`,
assetId: asset1.id,
taskName: li.taskName,
rateCardId: li.rateCardId,
artistRole: li.artistRole,
dailyRate: new Decimal(li.dailyRate),
hoursPerDay: new Decimal(8),
numArtists: new Decimal(li.numArtists),
numDays: new Decimal(li.numDays),
quantityHours: new Decimal(0),
markupPercent: new Decimal(0),
discountPercent: new Decimal(0),
taxPercent: new Decimal(0),
hourlyRate: new Decimal(li.dailyRate / 8),
subtotal: new Decimal(li.dailyRate * li.numDays * li.numArtists * 2.0),
markupAmount: new Decimal(0),
discountAmount: new Decimal(0),
taxAmount: new Decimal(0),
total: new Decimal(li.dailyRate * li.numDays * li.numArtists * 2.0),
sortOrder: li.sortOrder,
},
update: {},
});
}
console.log(" ✓ 1 asset with line items");
// Second job for client 2 (sent)
await prisma.job.upsert({
where: { id: "seed-job-02" },
create: {
id: "seed-job-02",
projectTitle: "TV Commercial Package",
projectCode: "NEBULA-2024-001",
clientId: client2.id,
status: "SENT" as JobStatus,
currency: "ZAR",
markupPercent: new Decimal(15),
taxPercent: new Decimal(15),
discountPercent: new Decimal(0),
description: "30-second commercial with full CG product visualization and environment.",
},
update: {},
});
console.log(" ✓ 1 additional job (sent)");
console.log("✅ Seeding complete!");
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(async () => { await prisma.$disconnect(); });
+44
View File
@@ -0,0 +1,44 @@
import { config } from "dotenv";
config();
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter } as any);
async function main() {
const dryRun = process.argv[2] !== "--apply";
const shots = await prisma.shot.findMany({
where: { shotCode: { startsWith: "UGN_" } },
select: { id: true, shotCode: true },
});
console.log(`Found ${shots.length} shot(s) with prefix UGN_`);
if (shots.length === 0) return;
// Show sample
console.log("Sample:", JSON.stringify(shots[0]));
if (dryRun) {
console.log("\nDRY RUN — pass --apply to commit changes.");
console.log("Would rename (first 5):");
shots.slice(0, 5).forEach((s) => {
console.log(` ${s.shotCode} → UNG_${s.shotCode.slice(4)}`);
});
return;
}
// Apply updates
let updated = 0;
for (const shot of shots) {
const newCode = "UNG_" + shot.shotCode.slice(4);
await prisma.shot.update({ where: { id: shot.id }, data: { shotCode: newCode } });
updated++;
}
console.log(`\nUpdated ${updated} shot(s).`);
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());
+31
View File
@@ -0,0 +1,31 @@
INSERT INTO clients ("id", "companyName", "contactPerson", "email", "phone", "vatNumber", "companyRegNumber", "billingAddress", "createdAt", "updatedAt")
VALUES
(gen_random_uuid()::text, 'Beyond Sound (PTY) LTD', 'Dries Scholtz', 'dries@beyondsound.co.za', '0722216628', '4020265312', NULL, '64A Mount Street, Bryanston, 2021', NOW(), NOW()),
(gen_random_uuid()::text, 'The Mediabox Productions (PTY) LTD', 'Emile Wanneburg', 'emile@mediabox.co.za', '0825223999', '4060261635', '2015/301430/07', 'P.O. Box 2479, Faerie Glen, 0043', NOW(), NOW()),
(gen_random_uuid()::text, 'Red Letter Day Pictures (PTY) LTD', 'Tascha van der Westhuizen', 'production@tascha.co', '0722410928', '4810283889', '2012/209819/07', '3 Nared Road, Jamestown, Cape Town, 7600', NOW(), NOW()),
(gen_random_uuid()::text, 'Flight Centre Travel Group (Pty) Ltd', 'Tracy Porter', 'tracy.porter@flightcentre.co.za', '+27 82 891 4353', '4370159909', '1994/000253/07', 'Block 8 Pendoring Office Park, 299 Pendoring Road, Blackheath, Johannesburg 2195', NOW(), NOW()),
(gen_random_uuid()::text, 'The Irresistible Spice of Meena Rani (PTY) LTD', 'Rani Sitaram', NULL, '0721251629', '4120305984', NULL, '9 Stirling Place, Westville, KZN, 3629', NOW(), NOW()),
(gen_random_uuid()::text, 'Second Circle Films (PTY) LTD', NULL, NULL, NULL, '4370103105', '2020/147485/07', '25 Woodlands Avenue, Irene Woods Estate, Pretoria, 0062', NOW(), NOW()),
(gen_random_uuid()::text, 'Defijn (PTY) LTD', 'Eckardt Kasselman', 'eckardt@defijn.io', '0845478800', '4880291192', '2019/179296/07', '29 Campbell Drive, Knysna, Western Cape, 6571', NOW(), NOW()),
(gen_random_uuid()::text, 'The Schultz Creative (Pty) Ltd', 'Carl Schultz', 'carl@theschultzcreative.com', '071 681 1464', '4680295351', '2016/361413/07', '32 Willow Farm Estate, Willow Farm Rd, Pretoria, 0184', NOW(), NOW()),
(gen_random_uuid()::text, 'Fuge Design', 'Anri Shaw', 'anri@fuge.co.za', NULL, NULL, NULL, NULL, NOW(), NOW()),
(gen_random_uuid()::text, 'SA Filament', 'Anton van der Heever', 'info@safilament.co.za', '0761965684', NULL, NULL, NULL, NOW(), NOW()),
(gen_random_uuid()::text, 'FORGE IGNITE MEDIA AND ENTERTAINMENT PTY LTD', 'Thandeka Nodada', 'thandeka@forge-me.com', NULL, '4690301785', '2021/943427/07', 'Mesh Club, 21 Keyes Avenue, Rosebank, JHB 2196', NOW(), NOW()),
(gen_random_uuid()::text, 'Festo (PTY) LTD', 'Kershia Beharie', 'kershia.beharie@festo.com', NULL, '4350101004', '1973/003776/07', 'P.O. Box 255, Isando, 1600', NOW(), NOW()),
(gen_random_uuid()::text, 'My Stories (PTY) LTD', 'Cene de Wit', 'Cene07@gmail.com', NULL, NULL, NULL, '15 van Schouuwenburg St, Ermelo, 2350', NOW(), NOW()),
(gen_random_uuid()::text, 'Nthabiseng', 'Nthabiseng', 'postmama01@gmail.com', NULL, NULL, NULL, NULL, NOW(), NOW()),
(gen_random_uuid()::text, 'Content Box Media', 'Stephan Callitz', 'stephan@contentbox.co.za', NULL, NULL, NULL, NULL, NOW(), NOW()),
(gen_random_uuid()::text, 'Toon53', NULL, NULL, NULL, NULL, NULL, NULL, NOW(), NOW()),
(gen_random_uuid()::text, 'The Final Chapter (Pty) Ltd', NULL, NULL, NULL, '4430299539', '2016/182348/07', '35 KwaMakhutha, Amanzimtoti, 4162', NOW(), NOW()),
(gen_random_uuid()::text, 'Mrs Plum', 'Bridget Pickering', 'bridget@bumpfilms.co.za', NULL, NULL, '2021/606637/07', '4 Strandfontein Road, Summerstrand, Gqeberha 6001', NOW(), NOW()),
(gen_random_uuid()::text, 'Ochre', 'Travis Cleevely', 'travis@ochre.co.za', NULL, NULL, NULL, NULL, NOW(), NOW()),
(gen_random_uuid()::text, 'FERTILE GROUND STUDIO', 'ATT: Accounts Dept.', NULL, NULL, NULL, NULL, 'Featherdale Estate, 42 Louis Botha Road, Bonaero Park, Kempton Park, 1622', NOW(), NOW()),
(gen_random_uuid()::text, 'Sulize', 'Sulize', NULL, '0797956163', NULL, NULL, NULL, NOW(), NOW()),
(gen_random_uuid()::text, 'Happy Co.', 'Jean', 'jean@happyco.tv', NULL, NULL, NULL, NULL, NOW(), NOW()),
(gen_random_uuid()::text, 'Trickery', 'Werner Burger', 'werner@trickery.co.za', NULL, NULL, NULL, NULL, NOW(), NOW()),
(gen_random_uuid()::text, 'Gatehouse', 'Ananda Law', 'ananda@gatehouse.co.za', NULL, NULL, NULL, NULL, NOW(), NOW()),
(gen_random_uuid()::text, 'Oman In Focus LLC', 'Maisa Al Hooti', NULL, NULL, NULL, NULL, 'Office 110, Muscat Residence Building, Muscat, Sultanate of Oman', NOW(), NOW()),
(gen_random_uuid()::text, 'UNGOVERNABLE CARTEL (PTY) LTD', NULL, NULL, NULL, NULL, NULL, '348 Stallion Road Ruimsig, Roodepoort, Gauteng, 1724', NOW(), NOW()),
(gen_random_uuid()::text, 'Dries', NULL, NULL, NULL, NULL, NULL, NULL, NOW(), NOW()),
(gen_random_uuid()::text, 'EbonyLife Group', 'Heidi Uys', 'heidi@ebonylifemedia.com', NULL, NULL, NULL, '10 Engineering Close, Off Idowu Taylor Street, Victoria Island, Lagos, Nigeria', NOW(), NOW())
;
+45
View File
@@ -0,0 +1,45 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { clientSchema, type ClientFormValues } from "@/lib/validations";
export async function getClients() {
return prisma.client.findMany({
orderBy: { companyName: "asc" },
include: { _count: { select: { jobs: true } } },
});
}
export async function getClient(id: string) {
return prisma.client.findUnique({
where: { id },
include: {
jobs: {
orderBy: { createdAt: "desc" },
include: { _count: { select: { shots: true, assets: true } } },
},
},
});
}
export async function createClient(data: ClientFormValues) {
const validated = clientSchema.parse(data);
const client = await prisma.client.create({ data: validated });
revalidatePath("/clients");
return { success: true, client };
}
export async function updateClient(id: string, data: ClientFormValues) {
const validated = clientSchema.parse(data);
const client = await prisma.client.update({ where: { id }, data: validated });
revalidatePath("/clients");
revalidatePath(`/clients/${id}`);
return { success: true, client };
}
export async function deleteClient(id: string) {
await prisma.client.delete({ where: { id } });
revalidatePath("/clients");
return { success: true };
}
+364
View File
@@ -0,0 +1,364 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { invoiceSchema, invoicePaymentSchema, type InvoiceFormValues, type InvoicePaymentFormValues } from "@/lib/validations";
import Decimal from "decimal.js";
// ─── Auto invoice numbering ───────────────────────────────────────────────────
async function getNextInvoiceNumber(): Promise<string> {
const year = new Date().getFullYear();
const latest = await prisma.invoice.findFirst({
where: { invoiceNumber: { startsWith: `INV-${year}-` } },
orderBy: { invoiceNumber: "desc" },
});
if (!latest) return `INV-${year}-0001`;
const parts = latest.invoiceNumber.split("-");
const seq = parseInt(parts[parts.length - 1] ?? "0", 10) + 1;
return `INV-${year}-${String(seq).padStart(4, "0")}`;
}
// ─── Invoice CRUD ─────────────────────────────────────────────────────────────
export async function getInvoices(filters?: { clientId?: string; jobId?: string; status?: string }) {
return prisma.invoice.findMany({
where: {
...(filters?.clientId && { clientId: filters.clientId }),
...(filters?.jobId && { jobId: filters.jobId }),
...(filters?.status && { status: filters.status as any }),
},
orderBy: { createdAt: "desc" },
include: {
client: { select: { companyName: true } },
job: { select: { projectTitle: true, projectCode: true } },
payments: true,
},
});
}
export async function getInvoice(id: string) {
return prisma.invoice.findUnique({
where: { id },
include: {
client: true,
job: {
include: {
paymentSchedule: { include: { milestones: true } },
},
},
milestone: true,
payments: { orderBy: { paymentDate: "asc" } },
},
});
}
export async function createInvoice(data: InvoiceFormValues) {
const validated = invoiceSchema.parse(data);
const taxAmount = new Decimal(validated.subtotal)
.mul(new Decimal(validated.taxPercent).div(100))
.toFixed(4);
const totalAmount = new Decimal(validated.subtotal).add(taxAmount).toFixed(4);
const invoice = await prisma.invoice.create({
data: {
...validated,
invoiceNumber: validated.invoiceNumber,
issueDate: new Date(validated.issueDate),
dueDate: validated.dueDate ? new Date(validated.dueDate) : undefined,
milestoneId: validated.milestoneId || undefined,
subtotal: new Decimal(validated.subtotal),
taxPercent: new Decimal(validated.taxPercent),
taxAmount: new Decimal(taxAmount),
totalAmount: new Decimal(totalAmount),
amountPaid: new Decimal(0),
amountDue: new Decimal(totalAmount),
},
});
// Update milestone status if linked
if (validated.milestoneId) {
await prisma.paymentMilestone.update({
where: { id: validated.milestoneId },
data: { status: "INVOICED" },
});
}
revalidatePath("/invoices");
return { success: true, invoice };
}
export async function updateInvoice(id: string, data: InvoiceFormValues) {
const validated = invoiceSchema.parse(data);
const taxAmount = new Decimal(validated.subtotal)
.mul(new Decimal(validated.taxPercent).div(100))
.toFixed(4);
const totalAmount = new Decimal(validated.subtotal).add(taxAmount).toFixed(4);
const existing = await prisma.invoice.findUnique({ where: { id }, include: { payments: true } });
const amountPaid = existing?.payments.reduce((sum, p) => sum.add(p.amount), new Decimal(0)) ?? new Decimal(0);
const amountDue = new Decimal(totalAmount).sub(amountPaid);
const invoice = await prisma.invoice.update({
where: { id },
data: {
...validated,
issueDate: new Date(validated.issueDate),
dueDate: validated.dueDate ? new Date(validated.dueDate) : null,
milestoneId: validated.milestoneId || null,
subtotal: new Decimal(validated.subtotal),
taxPercent: new Decimal(validated.taxPercent),
taxAmount: new Decimal(taxAmount),
totalAmount: new Decimal(totalAmount),
amountDue: amountDue.gt(0) ? amountDue : new Decimal(0),
},
});
revalidatePath("/invoices");
revalidatePath(`/invoices/${id}`);
return { success: true, invoice };
}
export async function deleteInvoice(id: string) {
await prisma.invoice.delete({ where: { id } });
revalidatePath("/invoices");
return { success: true };
}
export async function nextInvoiceNumber() {
return getNextInvoiceNumber();
}
// ─── From milestone ───────────────────────────────────────────────────────────
export async function createInvoiceFromMilestone(milestoneId: string, jobTotal?: number) {
// Check if invoice already exists for this milestone
const existingInvoice = await prisma.invoice.findUnique({ where: { milestoneId } });
if (existingInvoice) throw new Error("Invoice already exists for this milestone");
const milestone = await prisma.paymentMilestone.findUnique({
where: { id: milestoneId },
include: {
schedule: {
include: {
job: { include: { client: true } },
milestones: true, // needed to determine remaining uninvoiced milestones
},
},
},
});
if (!milestone) throw new Error("Milestone not found");
const job = milestone.schedule.job;
const effectiveJobTotal = new Decimal(jobTotal ?? 0);
const invoiceNumber = await getNextInvoiceNumber();
// ── Remaining-balance calculation ─────────────────────────────────────────
// Sum the subtotals of all non-cancelled invoices already generated for this job.
// This ensures sent invoices are never modified — only uninvoiced milestones adjust.
const priorInvoices = await prisma.invoice.findMany({
where: { jobId: job.id, status: { not: "CANCELLED" } },
select: { subtotal: true },
});
const alreadyInvoicedTotal = priorInvoices.reduce(
(sum, inv) => sum.add(new Decimal(inv.subtotal.toString())),
new Decimal(0)
);
// Find which milestones in this schedule have already been invoiced
const priorMilestoneInvoices = await prisma.invoice.findMany({
where: { jobId: job.id, milestoneId: { not: null }, status: { not: "CANCELLED" } },
select: { milestoneId: true },
});
const invoicedMilestoneIds = new Set(priorMilestoneInvoices.map(r => r.milestoneId!));
// Uninvoiced milestones = not cancelled and no existing invoice (includes current milestone)
const uninvoicedMilestones = milestone.schedule.milestones.filter(
m => m.status !== "CANCELLED" && !invoicedMilestoneIds.has(m.id)
);
const totalUninvoicedPercent = uninvoicedMilestones.reduce(
(sum, m) => sum + Number(m.percentOfTotal),
0
);
// Amount still to be invoiced after previously sent invoices
const remainingAmount = effectiveJobTotal.sub(alreadyInvoicedTotal).toDecimalPlaces(2);
// ── Subtotal calculation ──────────────────────────────────────────────────
// Fixed amount overrides all percentage logic.
// For percent-based milestones: distribute remaining balance proportionally so that
// sum(all milestone invoices) = jobTotal regardless of when shots/assets were added.
// The final uninvoiced milestone absorbs any rounding variance.
let subtotal: Decimal;
if (milestone.fixedAmount && new Decimal(milestone.fixedAmount.toString()).gt(0)) {
subtotal = new Decimal(milestone.fixedAmount.toString());
} else if (effectiveJobTotal.gt(0)) {
if (remainingAmount.gt(0) && totalUninvoicedPercent > 0) {
const isLastUninvoiced = uninvoicedMilestones.length === 1;
if (isLastUninvoiced) {
// Final milestone absorbs the full remaining balance (handles scope changes cleanly)
subtotal = remainingAmount;
} else {
// Proportional share of the remaining balance across uninvoiced milestones
subtotal = remainingAmount
.mul(new Decimal(milestone.percentOfTotal.toString()).div(totalUninvoicedPercent))
.toDecimalPlaces(2);
}
} else {
// All balance already covered by prior invoices
subtotal = new Decimal(0);
}
} else {
subtotal = new Decimal(0);
}
const taxPercent = new Decimal(job.taxPercent.toString());
const taxAmount = subtotal.mul(taxPercent.div(100)).toDecimalPlaces(2);
const totalAmount = subtotal.add(taxAmount);
const invoice = await prisma.invoice.create({
data: {
invoiceNumber,
clientId: job.clientId,
jobId: job.id,
milestoneId: milestone.id,
status: "DRAFT",
issueDate: new Date(),
currency: "ZAR",
subtotal,
taxPercent,
taxAmount,
totalAmount,
amountPaid: new Decimal(0),
amountDue: totalAmount,
notes: milestone.description ?? undefined,
},
});
await prisma.paymentMilestone.update({
where: { id: milestoneId },
data: { status: "INVOICED" },
});
revalidatePath(`/jobs/${job.id}`);
revalidatePath("/invoices");
return { success: true, invoice };
}
// ─── Payments ─────────────────────────────────────────────────────────────────
export async function recordPayment(invoiceId: string, data: InvoicePaymentFormValues) {
const validated = invoicePaymentSchema.parse(data);
const payment = await prisma.invoicePayment.create({
data: {
invoiceId,
amount: new Decimal(validated.amount),
paymentDate: new Date(validated.paymentDate),
paymentMethod: validated.paymentMethod as any,
reference: validated.reference,
notes: validated.notes,
},
});
// Recalculate amountPaid and amountDue
const invoice = await prisma.invoice.findUnique({
where: { id: invoiceId },
include: { payments: true },
});
if (!invoice) throw new Error("Invoice not found");
const amountPaid = invoice.payments.reduce((sum, p) => sum.add(p.amount), new Decimal(0));
const amountDue = new Decimal(invoice.totalAmount.toString()).sub(amountPaid);
const isFullyPaid = amountDue.lte(0);
await prisma.invoice.update({
where: { id: invoiceId },
data: {
amountPaid,
amountDue: amountDue.gt(0) ? amountDue : new Decimal(0),
status: isFullyPaid ? "PAID" : amountPaid.gt(0) ? "PARTIALLY_PAID" : invoice.status,
},
});
// Update milestone if linked
if (invoice.milestoneId) {
await prisma.paymentMilestone.update({
where: { id: invoice.milestoneId },
data: {
status: isFullyPaid ? "PAID" : "PARTIALLY_PAID",
paymentReceivedDate: isFullyPaid ? new Date() : undefined,
},
});
}
revalidatePath("/invoices");
revalidatePath(`/invoices/${invoiceId}`);
return { success: true, payment };
}
export async function deletePayment(invoiceId: string, paymentId: string) {
await prisma.invoicePayment.delete({ where: { id: paymentId } });
// Recalculate
const invoice = await prisma.invoice.findUnique({
where: { id: invoiceId },
include: { payments: true },
});
if (!invoice) return { success: true };
const amountPaid = invoice.payments.reduce((sum, p) => sum.add(p.amount), new Decimal(0));
const amountDue = new Decimal(invoice.totalAmount.toString()).sub(amountPaid);
await prisma.invoice.update({
where: { id: invoiceId },
data: {
amountPaid,
amountDue: amountDue.gt(0) ? amountDue : new Decimal(0),
status: amountPaid.lte(0) ? "SENT" : "PARTIALLY_PAID",
},
});
revalidatePath(`/invoices/${invoiceId}`);
return { success: true };
}
// ─── Financial summary ────────────────────────────────────────────────────────
export async function getFinancialSummary() {
const invoices = await prisma.invoice.findMany({
where: { status: { not: "CANCELLED" } },
select: {
status: true,
totalAmount: true,
amountPaid: true,
amountDue: true,
dueDate: true,
},
});
const now = new Date();
let totalInvoiced = new Decimal(0);
let totalPaid = new Decimal(0);
let totalOutstanding = new Decimal(0);
let totalOverdue = new Decimal(0);
for (const inv of invoices) {
totalInvoiced = totalInvoiced.add(inv.totalAmount.toString());
totalPaid = totalPaid.add(inv.amountPaid.toString());
const due = new Decimal(inv.amountDue.toString());
if (due.gt(0)) {
totalOutstanding = totalOutstanding.add(due);
if (inv.dueDate && new Date(inv.dueDate) < now) {
totalOverdue = totalOverdue.add(due);
}
}
}
return {
totalInvoiced: totalInvoiced.toNumber(),
totalPaid: totalPaid.toNumber(),
totalOutstanding: totalOutstanding.toNumber(),
totalOverdue: totalOverdue.toNumber(),
invoiceCount: invoices.length,
};
}
+245
View File
@@ -0,0 +1,245 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { jobSchema, type JobFormValues } from "@/lib/validations";
import { calcLineItem } from "@/lib/calculations";
import Decimal from "decimal.js";
export async function getJobs(filters?: { status?: string; clientId?: string; search?: string }) {
return prisma.job.findMany({
where: {
...(filters?.status && { status: filters.status as any }),
...(filters?.clientId && { clientId: filters.clientId }),
...(filters?.search && {
OR: [
{ projectTitle: { contains: filters.search, mode: "insensitive" } },
{ projectCode: { contains: filters.search, mode: "insensitive" } },
],
}),
},
orderBy: { createdAt: "desc" },
include: {
client: { select: { companyName: true, contactPerson: true } },
_count: { select: { shots: true, assets: true } },
},
});
}
export async function getJob(id: string) {
return prisma.job.findUnique({
where: { id },
include: {
client: true,
shots: {
orderBy: { sortOrder: "asc" },
include: {
lineItems: {
orderBy: { sortOrder: "asc" },
include: { rateCard: true },
},
},
},
assets: {
orderBy: { sortOrder: "asc" },
include: {
lineItems: {
orderBy: { sortOrder: "asc" },
include: { rateCard: true },
},
},
},
supervisions: {
orderBy: { sortOrder: "asc" },
include: {
lineItems: {
orderBy: { sortOrder: "asc" },
include: { rateCard: true },
},
},
},
previses: {
orderBy: { sortOrder: "asc" },
include: {
lineItems: {
orderBy: { sortOrder: "asc" },
include: { rateCard: true },
},
},
},
comments: { orderBy: { createdAt: "asc" }, include: { user: true } },
history: { orderBy: { changedAt: "desc" }, take: 20 },
paymentSchedule: { include: { milestones: { orderBy: { sortOrder: "asc" } } } },
},
});
}
export async function createJob(data: JobFormValues) {
const validated = jobSchema.parse(data);
const { issueDate, expiryDate, startDate, estimatedCompletionDate, ...rest } = validated;
const job = await prisma.job.create({
data: {
...rest,
issueDate: new Date(issueDate),
expiryDate: expiryDate ? new Date(expiryDate) : null,
startDate: startDate ? new Date(startDate) : null,
estimatedCompletionDate: estimatedCompletionDate ? new Date(estimatedCompletionDate) : null,
},
});
revalidatePath("/jobs");
return { success: true, job };
}
export async function updateJob(id: string, data: Partial<JobFormValues>) {
const { issueDate, expiryDate, startDate, estimatedCompletionDate, ...rest } = data;
const job = await prisma.job.update({
where: { id },
data: {
...rest,
...(issueDate ? { issueDate: new Date(issueDate) } : {}),
expiryDate: expiryDate ? new Date(expiryDate) : null,
...(startDate !== undefined ? { startDate: startDate ? new Date(startDate) : null } : {}),
...(estimatedCompletionDate !== undefined ? { estimatedCompletionDate: estimatedCompletionDate ? new Date(estimatedCompletionDate) : null } : {}),
},
});
revalidatePath("/jobs");
revalidatePath(`/jobs/${id}`);
return { success: true, job };
}
export async function updateJobStatus(id: string, status: string) {
const job = await prisma.job.update({
where: { id },
data: { status: status as any },
});
revalidatePath("/jobs");
revalidatePath(`/jobs/${id}`);
return { success: true, job };
}
export async function deleteJob(id: string) {
await prisma.job.delete({ where: { id } });
revalidatePath("/jobs");
return { success: true };
}
export async function duplicateJob(id: string) {
const original = await prisma.job.findUnique({
where: { id },
include: {
shots: { include: { lineItems: true } },
assets: { include: { lineItems: true } },
supervisions: { include: { lineItems: true } },
previses: { include: { lineItems: true } },
},
});
if (!original) throw new Error("Job not found");
const { id: _id, createdAt: _ca, updatedAt: _ua, projectCode, version, ...rest } = original;
const newJob = await prisma.job.create({
data: {
...rest,
projectCode: `${projectCode}-v${version + 1}`,
version: version + 1,
parentJobId: id,
status: "DRAFT",
shots: {
create: original.shots.map(({ id: _sid, jobId: _jid, createdAt: _sca, updatedAt: _sua, ...shot }) => ({
...shot,
lineItems: {
create: shot.lineItems.map(({ id: _lid, shotId: _shid, assetId: _aid, supervisionId: _svid, previsId: _pvid, createdAt: _lca, updatedAt: _lua, ...li }) => li),
},
})),
},
assets: {
create: original.assets.map(({ id: _aid, jobId: _jid, createdAt: _aca, updatedAt: _aua, ...asset }) => ({
...asset,
lineItems: {
create: asset.lineItems.map(({ id: _lid, shotId: _shid, assetId: _aid2, supervisionId: _svid, previsId: _pvid, createdAt: _lca, updatedAt: _lua, ...li }) => li),
},
})),
},
supervisions: {
create: original.supervisions.map(({ id: _svid, jobId: _jid, createdAt: _svca, updatedAt: _svua, ...supervision }) => ({
...supervision,
lineItems: {
create: supervision.lineItems.map(({ id: _lid, shotId: _shid, assetId: _aid, supervisionId: _svid2, previsId: _pvid, createdAt: _lca, updatedAt: _lua, ...li }) => li),
},
})),
},
previses: {
create: original.previses.map(({ id: _pvid, jobId: _jid, createdAt: _pvca, updatedAt: _pvua, ...previs }) => ({
...previs,
lineItems: {
create: previs.lineItems.map(({ id: _lid, shotId: _shid, assetId: _aid, supervisionId: _svid, previsId: _pvid2, createdAt: _lca, updatedAt: _lua, ...li }) => li),
},
})),
},
},
});
revalidatePath("/jobs");
return { success: true, job: newJob };
}
/**
* Recalculate all line item totals server-side before PDF generation.
* This prevents floating-point drift in stored values.
*/
export async function recalculateJobTotals(jobId: string) {
const job = await prisma.job.findUnique({
where: { id: jobId },
include: {
shots: { include: { lineItems: true } },
assets: { include: { lineItems: true } },
supervisions: { include: { lineItems: true } },
previses: { include: { lineItems: true } },
},
});
if (!job) throw new Error("Job not found");
const updates: Promise<unknown>[] = [];
const allLineItems = [
...job.shots.flatMap((s) => s.lineItems.map((li) => ({ ...li, complexityMultiplier: s.complexityMultiplier }))),
...job.assets.flatMap((a) => a.lineItems.map((li) => ({ ...li, complexityMultiplier: a.complexityMultiplier }))),
...job.supervisions.flatMap((sv) => sv.lineItems.map((li) => ({ ...li, complexityMultiplier: sv.complexityMultiplier }))),
...job.previses.flatMap((pv) => pv.lineItems.map((li) => ({ ...li, complexityMultiplier: pv.complexityMultiplier }))),
];
for (const li of allLineItems) {
const result = calcLineItem(
{
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
},
li.complexityMultiplier.toString()
);
updates.push(
prisma.lineItem.update({
where: { id: li.id },
data: {
hourlyRate: result.hourlyRate.toFixed(4),
subtotal: result.subtotal.toFixed(4),
markupAmount: result.markupAmount.toFixed(4),
discountAmount: result.discountAmount.toFixed(4),
taxAmount: result.taxAmount.toFixed(4),
total: result.total.toFixed(4),
},
})
);
}
await Promise.all(updates);
return { success: true };
}
+194
View File
@@ -0,0 +1,194 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { milestoneSchema, type MilestoneFormValues } from "@/lib/validations";
import Decimal from "decimal.js";
// ─── Schedule CRUD ────────────────────────────────────────────────────────────
export async function getPaymentSchedule(jobId: string) {
return prisma.paymentSchedule.findUnique({
where: { jobId },
include: {
milestones: { orderBy: { sortOrder: "asc" } },
},
});
}
export async function createOrUpdateSchedule(
jobId: string,
template: string,
notes?: string
) {
const schedule = await prisma.paymentSchedule.upsert({
where: { jobId },
create: { jobId, template: template as any, notes },
update: { template: template as any, notes },
include: { milestones: { orderBy: { sortOrder: "asc" } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, schedule };
}
// ─── Milestone CRUD ───────────────────────────────────────────────────────────
export async function addMilestone(jobId: string, scheduleId: string, data: MilestoneFormValues) {
const validated = milestoneSchema.parse(data);
const count = await prisma.paymentMilestone.count({ where: { scheduleId } });
const milestone = await prisma.paymentMilestone.create({
data: {
scheduleId,
title: validated.title,
description: validated.description,
dueDate: validated.dueDate ? new Date(validated.dueDate) : undefined,
percentOfTotal: new Decimal(validated.percentOfTotal),
fixedAmount: validated.fixedAmount != null ? new Decimal(validated.fixedAmount) : undefined,
status: validated.status as any,
paymentReceivedDate: validated.paymentReceivedDate
? new Date(validated.paymentReceivedDate)
: undefined,
notes: validated.notes,
sortOrder: count,
},
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, milestone };
}
export async function updateMilestone(jobId: string, milestoneId: string, data: MilestoneFormValues) {
const validated = milestoneSchema.parse(data);
const milestone = await prisma.paymentMilestone.update({
where: { id: milestoneId },
data: {
title: validated.title,
description: validated.description,
dueDate: validated.dueDate ? new Date(validated.dueDate) : null,
percentOfTotal: new Decimal(validated.percentOfTotal),
fixedAmount: validated.fixedAmount != null ? new Decimal(validated.fixedAmount) : null,
status: validated.status as any,
paymentReceivedDate: validated.paymentReceivedDate
? new Date(validated.paymentReceivedDate)
: null,
notes: validated.notes,
},
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, milestone };
}
export async function deleteMilestone(jobId: string, milestoneId: string) {
await prisma.paymentMilestone.delete({ where: { id: milestoneId } });
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
export async function reorderMilestones(jobId: string, orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) =>
prisma.paymentMilestone.update({ where: { id }, data: { sortOrder: index } })
)
);
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
// ─── Schedule Generator ───────────────────────────────────────────────────────
type GeneratorOptions = {
template: "STANDARD_FILM" | "SMALL_COMMERCIAL" | "EPISODIC" | "CUSTOM";
jobTotal: number;
startDate?: string;
endDate?: string;
depositPercent?: number;
finalPercent?: number;
monthlyCount?: number;
};
export async function generateScheduleFromTemplate(jobId: string, opts: GeneratorOptions) {
// Ensure schedule exists
const schedule = await prisma.paymentSchedule.upsert({
where: { jobId },
create: { jobId, template: opts.template },
update: { template: opts.template },
});
// Delete existing milestones
await prisma.paymentMilestone.deleteMany({ where: { scheduleId: schedule.id } });
const milestones: { title: string; percentOfTotal: number; description?: string; dueDate?: Date }[] = [];
const start = opts.startDate ? new Date(opts.startDate) : new Date();
const end = opts.endDate ? new Date(opts.endDate) : null;
// Calculate number of months between start and end
const monthsBetween =
end
? Math.max(1, Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30)))
: opts.monthlyCount ?? 3;
const addMonths = (date: Date, n: number): Date => {
const d = new Date(date);
d.setMonth(d.getMonth() + n);
return d;
};
if (opts.template === "STANDARD_FILM") {
const deposit = opts.depositPercent ?? 50;
const final = opts.finalPercent ?? 10;
const monthly = 100 - deposit - final;
const monthlyEach = monthsBetween > 0 ? monthly / monthsBetween : monthly;
milestones.push({ title: "Deposit", percentOfTotal: deposit, description: "Upfront deposit on project commencement", dueDate: start });
for (let i = 1; i <= monthsBetween; i++) {
milestones.push({
title: `Month ${i} Progress`,
percentOfTotal: Math.round(monthlyEach * 100) / 100,
description: `Monthly progress payment`,
dueDate: addMonths(start, i),
});
}
milestones.push({ title: "Final Delivery Settlement", percentOfTotal: final, description: "Final payment on project delivery", dueDate: end ?? addMonths(start, monthsBetween + 1) });
} else if (opts.template === "SMALL_COMMERCIAL") {
const deposit = opts.depositPercent ?? 70;
milestones.push({ title: "Deposit", percentOfTotal: deposit, description: "Upfront deposit", dueDate: start });
milestones.push({ title: "Final Delivery", percentOfTotal: 100 - deposit, description: "Payment on final delivery", dueDate: end ?? addMonths(start, 2) });
} else if (opts.template === "EPISODIC") {
const deposit = opts.depositPercent ?? 25;
const final = opts.finalPercent ?? 10;
const monthly = 100 - deposit - final;
const monthlyEach = monthsBetween > 0 ? monthly / monthsBetween : monthly;
milestones.push({ title: "Initial Deposit", percentOfTotal: deposit, description: "Project commencement deposit", dueDate: start });
for (let i = 1; i <= monthsBetween; i++) {
milestones.push({
title: `Episode Batch ${i}`,
percentOfTotal: Math.round(monthlyEach * 100) / 100,
description: `Monthly billing`,
dueDate: addMonths(start, i),
});
}
milestones.push({ title: "Final Completion", percentOfTotal: final, description: "Series completion payment", dueDate: end ?? addMonths(start, monthsBetween + 1) });
}
if (milestones.length > 0) {
await prisma.paymentMilestone.createMany({
data: milestones.map((m, i) => ({
scheduleId: schedule.id,
title: m.title,
description: m.description,
percentOfTotal: new Decimal(m.percentOfTotal),
dueDate: m.dueDate,
status: "PENDING",
sortOrder: i,
})),
});
}
revalidatePath(`/jobs/${jobId}`);
const result = await prisma.paymentSchedule.findUnique({
where: { jobId },
include: { milestones: { orderBy: { sortOrder: "asc" } } },
});
return { success: true, schedule: result };
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { rateCardSchema, type RateCardFormValues } from "@/lib/validations";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function serializeRateCard(rc: any) {
return {
...rc,
defaultDailyRate: Number(rc.defaultDailyRate),
hoursPerDay: Number(rc.hoursPerDay),
};
}
export async function getRateCards() {
const cards = await prisma.rateCard.findMany({
orderBy: [{ department: "asc" }, { name: "asc" }],
where: { isActive: true },
});
return cards.map(serializeRateCard);
}
export async function getAllRateCards() {
const cards = await prisma.rateCard.findMany({ orderBy: [{ department: "asc" }, { name: "asc" }] });
return cards.map(serializeRateCard);
}
export async function createRateCard(data: RateCardFormValues) {
const validated = rateCardSchema.parse(data);
const rateCard = await prisma.rateCard.create({ data: validated });
revalidatePath("/rate-cards");
return { success: true, rateCard };
}
export async function updateRateCard(id: string, data: RateCardFormValues) {
const validated = rateCardSchema.parse(data);
const rateCard = await prisma.rateCard.update({ where: { id }, data: validated });
revalidatePath("/rate-cards");
return { success: true, rateCard };
}
export async function deleteRateCard(id: string) {
await prisma.rateCard.delete({ where: { id } });
revalidatePath("/rate-cards");
return { success: true };
}
+32
View File
@@ -0,0 +1,32 @@
"use server";
import { prisma } from "@/lib/prisma";
import { studioSettingsSchema } from "@/lib/validations";
import { revalidatePath } from "next/cache";
export async function getStudioSettings() {
let settings = await prisma.studioSettings.findUnique({ where: { id: "default" } });
if (!settings) {
settings = await prisma.studioSettings.create({ data: { id: "default" } });
}
return settings;
}
export async function updateStudioSettings(data: unknown) {
const validated = studioSettingsSchema.parse(data);
const settings = await prisma.studioSettings.upsert({
where: { id: "default" },
update: {
...validated,
studioEmail: validated.studioEmail || null,
},
create: {
id: "default",
...validated,
studioEmail: validated.studioEmail || null,
},
});
revalidatePath("/settings");
revalidatePath("/", "layout");
return { success: true, settings };
}
+484
View File
@@ -0,0 +1,484 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { shotSchema, assetSchema, supervisionSchema, previsSchema, lineItemSchema, taskTemplateSchema } from "@/lib/validations";
import { calcLineItem } from "@/lib/calculations";
// ─── External Shots API ──────────────────────────────────────────────────────
export interface ExternalShot {
id: string;
shotCode: string;
scene: string;
episode: string | null;
shotNumber: number;
description: string | null;
status: string;
priority: string;
frameStart: number | null;
frameEnd: number | null;
fps: number | null;
dueDate: string | null;
artist: { id: string; name: string; email: string } | null;
_count: { versions: number; tasks: number };
}
export async function fetchExternalShots(projectId: string, episode?: string) {
const apiKey = process.env.SHOTS_API_KEY;
if (!apiKey) throw new Error("SHOTS_API_KEY is not configured on this server");
const url = new URL("https://review.twotalesvfx.com/api/ext/shots");
url.searchParams.set("projectId", projectId.trim());
if (episode) url.searchParams.set("episode", episode.trim());
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${apiKey}` },
cache: "no-store",
});
if (res.status === 401) throw new Error("Invalid API key — check SHOTS_API_KEY");
if (res.status === 400) throw new Error("Invalid or missing project ID");
if (res.status === 404) throw new Error("Project not found");
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data = await res.json() as { shots: ExternalShot[]; total: number };
return data;
}
export async function importShotsFromApi(jobId: string, shots: ExternalShot[]) {
if (!shots.length) return { success: true, shots: [] };
const job = await prisma.job.findUnique({ where: { id: jobId }, select: { showId: true, jobType: true } });
if (!job) throw new Error("Job not found");
// Check for existing shot codes in this job to avoid duplicates
const existingCodes = await prisma.shot.findMany({
where: { jobId, shotCode: { in: shots.map((s) => s.shotCode) } },
select: { shotCode: true },
});
const existingSet = new Set(existingCodes.map((s) => s.shotCode));
const toCreate = shots.filter((s) => !existingSet.has(s.shotCode));
if (!toCreate.length) return { success: true, shots: [], skipped: shots.length };
const baseCount = await prisma.shot.count({ where: { jobId } });
const created = await Promise.all(
toCreate.map((s, i) =>
prisma.shot.create({
data: {
jobId,
shotCode: s.shotCode,
scene: s.scene,
episode: s.episode ?? null,
description: s.description ?? null,
shotNumber: s.shotNumber,
sortOrder: baseCount + i,
},
include: { lineItems: { include: { rateCard: true } } },
})
)
);
revalidatePath(`/jobs/${jobId}`);
return {
success: true,
shots: JSON.parse(JSON.stringify(created)),
skipped: existingSet.size,
};
}
export interface CsvShotRow {
shotCode: string;
durationSeconds?: number;
description?: string;
}
export async function importShotsFromCsv(jobId: string, rows: CsvShotRow[]) {
if (!rows.length) return { success: true, shots: [], skipped: 0 };
// Check for existing shot codes to avoid duplicates
const existingCodes = await prisma.shot.findMany({
where: { jobId, shotCode: { in: rows.map((r) => r.shotCode) } },
select: { shotCode: true },
});
const existingSet = new Set(existingCodes.map((s) => s.shotCode));
const toCreate = rows.filter((r) => !existingSet.has(r.shotCode));
if (!toCreate.length) return { success: true, shots: [], skipped: rows.length };
const baseCount = await prisma.shot.count({ where: { jobId } });
const created = await Promise.all(
toCreate.map((r, i) =>
prisma.shot.create({
data: {
jobId,
shotCode: r.shotCode,
scene: "SC",
description: r.description ?? null,
durationSeconds: r.durationSeconds ?? null,
shotNumber: (baseCount + i + 1) * 10,
sortOrder: baseCount + i,
},
include: { lineItems: { include: { rateCard: true } } },
})
)
);
revalidatePath(`/jobs/${jobId}`);
return {
success: true,
shots: JSON.parse(JSON.stringify(created)),
skipped: existingSet.size,
};
}
// ─── Shots ────────────────────────────────────────────────────────────────────
export async function createShot(jobId: string, data: unknown) {
const validated = shotSchema.parse(data);
// Fetch job to get showId and jobType for shot code generation
const job = await prisma.job.findUnique({
where: { id: jobId },
select: { showId: true, jobType: true },
});
if (!job) throw new Error("Job not found");
// Auto-increment shot number scoped to this scene (and episode for episodic)
const sceneKey = (validated.scene || "SC").toUpperCase();
const episodeKey = validated.episode ? validated.episode.toUpperCase() : null;
const agg = await prisma.shot.aggregate({
where: {
jobId,
scene: sceneKey,
...(job.jobType === "EPISODIC" ? { episode: episodeKey } : {}),
},
_max: { shotNumber: true },
});
const shotNumber = (agg._max.shotNumber ?? 0) + 10;
const paddedNumber = String(shotNumber).padStart(4, "0");
// Build shot code
const showId = (job.showId || "UNK").toUpperCase();
const scene = (validated.scene || "SC").toUpperCase();
const shotCode =
job.jobType === "EPISODIC" && validated.episode
? `${showId}_${validated.episode.toUpperCase()}_${scene}_${paddedNumber}`
: `${showId}_${scene}_${paddedNumber}`;
const count = await prisma.shot.count({ where: { jobId } });
const shot = await prisma.shot.create({
data: {
...validated,
jobId,
shotCode,
shotNumber,
sortOrder: count,
},
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, shot: JSON.parse(JSON.stringify(shot)) };
}
export async function updateShot(shotId: string, jobId: string, data: unknown) {
const validated = shotSchema.parse(data);
const { shotCode: customShotCode, ...rest } = validated;
const shot = await prisma.shot.update({
where: { id: shotId },
data: customShotCode ? { ...rest, shotCode: customShotCode.trim() } : rest,
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, shot: JSON.parse(JSON.stringify(shot)) };
}
export async function deleteShot(shotId: string, jobId: string) {
await prisma.shot.delete({ where: { id: shotId } });
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
export async function duplicateShot(shotId: string, jobId: string) {
const original = await prisma.shot.findUnique({
where: { id: shotId },
include: { lineItems: true },
});
if (!original) throw new Error("Shot not found");
// Auto-increment shot number scoped to the same scene (and episode)
const agg = await prisma.shot.aggregate({
where: {
jobId,
scene: original.scene,
...(original.episode != null ? { episode: original.episode } : {}),
},
_max: { shotNumber: true },
});
const shotNumber = (agg._max.shotNumber ?? 0) + 10;
const paddedNumber = String(shotNumber).padStart(4, "0");
// Rebuild shotCode preserving the original structure, just with new number
// Replace the last 4-digit segment in the code
const shotCode = original.shotCode.replace(/\d{4}$/, paddedNumber);
const { id: _id, createdAt: _ca, updatedAt: _ua, sortOrder, ...rest } = original;
const shot = await prisma.shot.create({
data: {
...rest,
shotCode,
shotNumber,
sortOrder: sortOrder + 1,
lineItems: {
create: original.lineItems.map(
({ id: _lid, shotId: _sid, assetId: _aid, createdAt: _lca, updatedAt: _lua, ...li }) => li
),
},
},
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, shot: JSON.parse(JSON.stringify(shot)) };
}
export async function reorderShots(jobId: string, orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => prisma.shot.update({ where: { id }, data: { sortOrder: index } }))
);
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
// ─── Assets ───────────────────────────────────────────────────────────────────
export async function createAsset(jobId: string, data: unknown) {
const validated = assetSchema.parse(data);
const count = await prisma.asset.count({ where: { jobId } });
const asset = await prisma.asset.create({
data: { ...validated, jobId, sortOrder: count },
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, asset };
}
export async function updateAsset(assetId: string, jobId: string, data: unknown) {
const validated = assetSchema.parse(data);
const asset = await prisma.asset.update({
where: { id: assetId },
data: validated,
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, asset };
}
export async function deleteAsset(assetId: string, jobId: string) {
await prisma.asset.delete({ where: { id: assetId } });
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
export async function reorderAssets(jobId: string, orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => prisma.asset.update({ where: { id }, data: { sortOrder: index } }))
);
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
// ─── Supervision ──────────────────────────────────────────────────────────────
export async function createSupervision(jobId: string, data: unknown) {
const validated = supervisionSchema.parse(data);
const count = await prisma.supervision.count({ where: { jobId } });
const supervision = await prisma.supervision.create({
data: { ...validated, jobId, sortOrder: count },
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, supervision: JSON.parse(JSON.stringify(supervision)) };
}
export async function updateSupervision(supervisionId: string, jobId: string, data: unknown) {
const validated = supervisionSchema.parse(data);
const supervision = await prisma.supervision.update({
where: { id: supervisionId },
data: validated,
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, supervision: JSON.parse(JSON.stringify(supervision)) };
}
export async function deleteSupervision(supervisionId: string, jobId: string) {
await prisma.supervision.delete({ where: { id: supervisionId } });
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
export async function reorderSupervisions(jobId: string, orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => prisma.supervision.update({ where: { id }, data: { sortOrder: index } }))
);
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
// ─── Previs ───────────────────────────────────────────────────────────────────
export async function createPrevis(jobId: string, data: unknown) {
const validated = previsSchema.parse(data);
const count = await prisma.previs.count({ where: { jobId } });
const previs = await prisma.previs.create({
data: { ...validated, jobId, sortOrder: count },
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, previs: JSON.parse(JSON.stringify(previs)) };
}
export async function updatePrevis(previsId: string, jobId: string, data: unknown) {
const validated = previsSchema.parse(data);
const previs = await prisma.previs.update({
where: { id: previsId },
data: validated,
include: { lineItems: { include: { rateCard: true } } },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, previs: JSON.parse(JSON.stringify(previs)) };
}
export async function deletePrevis(previsId: string, jobId: string) {
await prisma.previs.delete({ where: { id: previsId } });
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
export async function reorderPrevises(jobId: string, orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => prisma.previs.update({ where: { id }, data: { sortOrder: index } }))
);
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
// ─── Line Items ───────────────────────────────────────────────────────────────
export async function createLineItem(
parentId: string,
parentType: "shot" | "asset" | "supervision" | "previs",
jobId: string,
data: unknown
) {
const validated = lineItemSchema.parse(data);
const count =
parentType === "shot"
? await prisma.lineItem.count({ where: { shotId: parentId } })
: parentType === "asset"
? await prisma.lineItem.count({ where: { assetId: parentId } })
: parentType === "previs"
? await prisma.lineItem.count({ where: { previsId: parentId } })
: await prisma.lineItem.count({ where: { supervisionId: parentId } });
// Get complexity multiplier from parent
let complexityMultiplier = "1";
if (parentType === "shot") {
const shot = await prisma.shot.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
complexityMultiplier = shot?.complexityMultiplier.toString() ?? "1";
} else if (parentType === "asset") {
const asset = await prisma.asset.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
complexityMultiplier = asset?.complexityMultiplier.toString() ?? "1";
} else if (parentType === "previs") {
const previs = await prisma.previs.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
complexityMultiplier = previs?.complexityMultiplier.toString() ?? "1";
} else {
const supervision = await prisma.supervision.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
complexityMultiplier = supervision?.complexityMultiplier.toString() ?? "1";
}
const calc = calcLineItem(validated as any, complexityMultiplier);
const lineItem = await prisma.lineItem.create({
data: {
...validated,
...(parentType === "shot"
? { shotId: parentId }
: parentType === "asset"
? { assetId: parentId }
: parentType === "previs"
? { previsId: parentId }
: { supervisionId: parentId }),
sortOrder: count,
hourlyRate: calc.hourlyRate.toFixed(4),
subtotal: calc.subtotal.toFixed(4),
markupAmount: calc.markupAmount.toFixed(4),
discountAmount: calc.discountAmount.toFixed(4),
taxAmount: calc.taxAmount.toFixed(4),
total: calc.total.toFixed(4),
},
include: { rateCard: true },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, lineItem: JSON.parse(JSON.stringify(lineItem)) };
}
export async function updateLineItem(
lineItemId: string,
jobId: string,
data: unknown,
complexityMultiplier?: string
) {
const validated = lineItemSchema.parse(data);
const calc = calcLineItem(validated as any, complexityMultiplier ?? "1");
const lineItem = await prisma.lineItem.update({
where: { id: lineItemId },
data: {
...validated,
hourlyRate: calc.hourlyRate.toFixed(4),
subtotal: calc.subtotal.toFixed(4),
markupAmount: calc.markupAmount.toFixed(4),
discountAmount: calc.discountAmount.toFixed(4),
taxAmount: calc.taxAmount.toFixed(4),
total: calc.total.toFixed(4),
},
include: { rateCard: true },
});
revalidatePath(`/jobs/${jobId}`);
return { success: true, lineItem: JSON.parse(JSON.stringify(lineItem)) };
}
export async function deleteLineItem(lineItemId: string, jobId: string) {
await prisma.lineItem.delete({ where: { id: lineItemId } });
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
export async function reorderLineItems(parentId: string, parentType: "shot" | "asset" | "supervision" | "previs", jobId: string, orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => prisma.lineItem.update({ where: { id }, data: { sortOrder: index } }))
);
revalidatePath(`/jobs/${jobId}`);
return { success: true };
}
// ─── Task Templates ───────────────────────────────────────────────────────────
export async function getTaskTemplates() {
const templates = await prisma.taskTemplate.findMany({ orderBy: { name: "asc" } });
return JSON.parse(JSON.stringify(templates));
}
export async function createTaskTemplate(data: unknown) {
const validated = taskTemplateSchema.parse(data);
const template = await prisma.taskTemplate.create({ data: validated });
return { success: true, template: JSON.parse(JSON.stringify(template)) };
}
export async function deleteTaskTemplate(id: string) {
await prisma.taskTemplate.delete({ where: { id } });
return { success: true };
}
+835
View File
@@ -0,0 +1,835 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { recalculateJobTotals } from "@/actions/jobs";
import { calcLineItem, sumLineItems, applyJobAdjustments, formatCurrency } from "@/lib/calculations";
import Decimal from "decimal.js";
import { format } from "date-fns";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// Recalculate all line item totals server-side before generating PDF
await recalculateJobTotals(id);
const job = await prisma.job.findUnique({
where: { id },
include: {
client: true,
shots: {
orderBy: { sortOrder: "asc" },
include: {
lineItems: {
orderBy: { sortOrder: "asc" },
include: { rateCard: true },
},
},
},
assets: {
orderBy: { sortOrder: "asc" },
include: {
lineItems: {
orderBy: { sortOrder: "asc" },
include: { rateCard: true },
},
},
},
supervisions: {
orderBy: { sortOrder: "asc" },
include: {
lineItems: {
orderBy: { sortOrder: "asc" },
include: { rateCard: true },
},
},
},
previses: {
orderBy: { sortOrder: "asc" },
include: {
lineItems: {
orderBy: { sortOrder: "asc" },
include: { rateCard: true },
},
},
},
},
});
if (!job) {
return NextResponse.json({ error: "Job not found" }, { status: 404 });
}
const studio = await prisma.studioSettings.findFirst();
const currency = "ZAR";
// Build calculation data
const shotData = job.shots.map((shot) => {
const results = shot.lineItems.map((li) =>
calcLineItem({
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
}, shot.complexityMultiplier.toString())
);
const totals = sumLineItems(results);
return { shot, lineResults: results, totals };
});
// Sort shots by shotCode (natural/numeric order)
shotData.sort((a, b) =>
a.shot.shotCode.localeCompare(b.shot.shotCode, undefined, { numeric: true, sensitivity: "base" })
);
// Group shots by episode (episodic) or shotGroup (standard)
const jobType = job.jobType ?? "STANDARD";
type ShotEntry = (typeof shotData)[number];
const shotGroupMap = new Map<string, ShotEntry[]>();
shotData.forEach(entry => {
const key = jobType === "EPISODIC"
? ((entry.shot as any).episode || "Ungrouped")
: ((entry.shot as any).shotGroup || "Ungrouped");
if (!shotGroupMap.has(key)) shotGroupMap.set(key, []);
shotGroupMap.get(key)!.push(entry);
});
// Sort: named groups first, Ungrouped last
const shotGroups: { label: string; entries: ShotEntry[] }[] = [];
shotGroupMap.forEach((entries, label) => {
if (label !== "Ungrouped") shotGroups.push({ label, entries });
});
shotGroups.sort((a, b) => a.label.localeCompare(b.label));
const ungrouped = shotGroupMap.get("Ungrouped");
if (ungrouped?.length) shotGroups.push({ label: "Ungrouped", entries: ungrouped });
const hasGroups = shotGroups.length > 1 || (shotGroups.length === 1 && shotGroups[0].label !== "Ungrouped");
const assetData = job.assets.map((asset) => {
const results = asset.lineItems.map((li) =>
calcLineItem({
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
}, asset.complexityMultiplier.toString())
);
const totals = sumLineItems(results);
return { asset, lineResults: results, totals };
});
const allShotTotals = sumLineItems(shotData.map((s) => s.totals));
const allAssetTotals = sumLineItems(assetData.map((a) => a.totals));
// Build supervision data
const supervisionData = (job.supervisions ?? []).map((sv: any) => {
const results = sv.lineItems.map((li: any) =>
calcLineItem({
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
}, sv.complexityMultiplier.toString())
);
const totals = sumLineItems(results);
return { sv, lineResults: results, totals };
});
const allSupervisionTotals = sumLineItems(supervisionData.map((s: any) => s.totals));
// Build previs data
const previsData = (job.previses ?? []).map((pv: any) => {
const results = pv.lineItems.map((li: any) =>
calcLineItem({
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
}, pv.complexityMultiplier.toString())
);
const totals = sumLineItems(results);
return { pv, lineResults: results, totals };
});
const allPrevisTotal = sumLineItems(previsData.map((p: any) => p.totals));
const lineSubtotal = allShotTotals.total.add(allAssetTotals.total).add(allSupervisionTotals.total).add(allPrevisTotal.total);
const jobAdj = applyJobAdjustments(
lineSubtotal,
job.markupPercent.toString(),
job.discountPercent.toString(),
job.taxPercent.toString()
);
const productionFeePct = new Decimal((job as any).productionFeePercent ?? 0);
const pullManagementFeePct = new Decimal((job as any).pullManagementFeePercent ?? 0);
const productionFeeAmount = jobAdj.grandTotal.mul(productionFeePct.div(100));
const pullManagementFeeAmount = jobAdj.grandTotal.mul(pullManagementFeePct.div(100));
const grandTotalWithFees = jobAdj.grandTotal.add(productionFeeAmount).add(pullManagementFeeAmount);
const fmt = (v: Decimal) => formatCurrency(v, currency);
const statusColors: Record<string, string> = {
DRAFT: "#71717a", SENT: "#60a5fa", APPROVED: "#4ade80", REJECTED: "#f87171", ARCHIVED: "#52525b",
};
const statusColor = statusColors[job.status] ?? "#71717a";
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Quote — ${job.projectCode}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #fff; color: #111; font-size: 14px; line-height: 1.5; }
.page { max-width: 1200px; margin: 0 auto; padding: 48px; }
@media print { .page-break { page-break-before: always; } body { print-color-adjust: exact; -webkit-print-color-adjust: exact; } }
/* Header */
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; }
.logo { font-size: 28px; font-weight: 900; color: #111; letter-spacing: -1px; }
.logo span { color: #f59e0b; }
.studio-info { font-size: 12px; color: #555; line-height: 1.6; margin-top: 6px; }
/* Doc title */
.doc-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 2px solid #f0f0f0; }
.doc-number { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; }
.status-badge { padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 700; letter-spacing: 0.5px; color: ${statusColor}; border: 1.5px solid ${statusColor}; background: ${statusColor}1a; }
/* Parties */
.parties { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-bottom: 32px; }
.section-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #aaa; margin-bottom: 8px; }
.party-name { font-weight: 700; font-size: 15px; color: #111; }
.party-detail { font-size: 13px; color: #555; margin-top: 2px; }
/* Section */
.section { margin-bottom: 24px; }
.section-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #aaa; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid #f0f0f0; }
/* Tables */
table { width: 100%; border-collapse: collapse; }
th { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: #aaa; padding: 8px 12px; text-align: left; border-bottom: 2px solid #f0f0f0; }
th.right, td.right { text-align: right; }
td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; font-size: 13px; color: #333; vertical-align: top; }
.summary-row td { background: #fffbeb; font-weight: 600; }
/* Financials */
.financials { background: #f9f9f9; border-radius: 12px; padding: 24px; margin-bottom: 32px; }
.fin-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
.fin-row.total { border-top: 2px solid #e5e5e5; margin-top: 8px; padding-top: 12px; font-weight: 800; font-size: 18px; }
.fin-label { color: #555; }
.fin-value { font-weight: 900; }
.fin-row.total .fin-value { color: #f59e0b; }
/* Terms / footer */
.terms { padding: 20px; background: #f9f9f9; border-radius: 8px; font-size: 12px; color: #555; line-height: 1.6; margin-bottom: 24px; }
.footer { border-top: 1px solid #f0f0f0; padding-top: 24px; text-align: center; font-size: 11px; color: #bbb; margin-top: 40px; }
/* Appendix blocks */
.shot-block { margin-bottom: 20px; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
.shot-header { background: #1a1a1a; color: #fff; padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; }
.shot-header h3 { font-size: 13px; font-weight: 700; }
.shot-total { color: #f59e0b; font-size: 13px; font-weight: 700; font-family: monospace; }
.complexity-badge { font-size: 10px; color: #f59e0b; margin-left: 8px; }
.group-banner { margin-top: 20px; margin-bottom: 4px; padding: 10px 14px; background: #1a1a2e; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; break-inside: avoid; }
.group-banner span { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #f59e0b; }
</style>
</head>
<body>
<div class="page">
${job.customHeaderHtml ?? ""}
<!-- Header -->
<div class="header">
<div>
<div class="studio-info">
${studio?.studioName ? `<div style="font-size:18px;margin-top:4px;font-weight:900;">${studio.studioName}</div>` : ""}
${studio?.studioAddress ? `<div style="white-space:pre-line;">${studio.studioAddress}</div>` : ""}
${studio?.studioEmail ? `<div>${studio.studioEmail}</div>` : ""}
${studio?.studioPhone ? `<div>${studio.studioPhone}</div>` : ""}
${studio?.vatNumber ? `<div>VAT: ${studio.vatNumber}</div>` : ""}
</div>
</div>
</div>
<!-- Doc Title -->
<div class="doc-title">
<div>
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;margin-bottom:4px;">Quote</div>
<div class="doc-number">TT_${job.projectCode}</div>
</div>
</div>
<!-- Parties -->
<div class="parties">
<div>
<div class="section-label">Prepared For</div>
<div class="party-name">${job.client.companyName}</div>
${job.client.contactPerson ? `<div class="party-detail">${job.client.contactPerson}</div>` : ""}
${job.client.email ? `<div class="party-detail">${job.client.email}</div>` : ""}
${job.client.phone ? `<div class="party-detail">${job.client.phone}</div>` : ""}
${job.client.billingAddress ? `<div class="party-detail" style="margin-top:6px;white-space:pre-line;">${job.client.billingAddress}</div>` : ""}
${job.client.vatNumber ? `<div class="party-detail" style="margin-top:6px;font-size:12px;color:#aaa;">VAT: ${job.client.vatNumber}</div>` : ""}
</div>
<div>
<div class="section-label">Project Details</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 16px;font-size:13px;">
<span style="color:#aaa;">Project</span><span style="font-weight:600;">${job.projectTitle}</span>
<span style="color:#aaa;">Issue Date</span><span style="font-weight:600;">${format(new Date(job.issueDate), "d MMM yyyy")}</span>
${job.expiryDate ? `<span style="color:#aaa;">Valid Until</span><span style="font-weight:600;">${format(new Date(job.expiryDate), "d MMM yyyy")}</span>` : ""}
<span style="color:#aaa;">Currency</span><span style="font-weight:600;">${currency}</span>
<span style="color:#aaa;">Type</span><span style="font-weight:600;">${jobType === "EPISODIC" ? "Episodic" : "Standard"}</span>
</div>
</div>
</div>
${job.description ? `<div class="section"><div class="section-title">Project Description</div><p style="font-size:13px;line-height:1.7;color:#444;">${job.description}</p></div>` : ""}
<!-- Quote Summary -->
<div class="section" style="margin-bottom:32px;">
<div class="section-title">Quote Summary</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;">
${shotData.length > 0 ? `
<div style="background:#f9f9f9;border-radius:10px;padding:16px 20px;display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:4px;">VFX Shots</div>
<div style="font-size:22px;font-weight:900;color:#111;">${shotData.length}<span style="font-size:13px;font-weight:500;color:#888;margin-left:4px;">shot${shotData.length !== 1 ? "s" : ""}</span></div>
${hasGroups ? `<div style="font-size:11px;color:#aaa;margin-top:2px;">${shotGroups.length} group${shotGroups.length !== 1 ? "s" : ""}</div>` : ""}
</div>
<div style="text-align:right;">
<div style="font-size:10px;color:#aaa;margin-bottom:2px;">Subtotal</div>
<div style="font-size:16px;font-weight:800;font-family:'Courier New',monospace;">${fmt(allShotTotals.total)}</div>
</div>
</div>` : ""}
${assetData.length > 0 ? `
<div style="background:#f9f9f9;border-radius:10px;padding:16px 20px;display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:4px;">VFX Assets</div>
<div style="font-size:22px;font-weight:900;color:#111;">${assetData.length}<span style="font-size:13px;font-weight:500;color:#888;margin-left:4px;">asset${assetData.length !== 1 ? "s" : ""}</span></div>
</div>
<div style="text-align:right;">
<div style="font-size:10px;color:#aaa;margin-bottom:2px;">Subtotal</div>
<div style="font-size:16px;font-weight:800;font-family:'Courier New',monospace;">${fmt(allAssetTotals.total)}</div>
</div>
</div>` : ""}
${supervisionData.length > 0 ? `
<div style="background:#f9f9f9;border-radius:10px;padding:16px 20px;display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:4px;">On-Set Supervision</div>
<div style="font-size:22px;font-weight:900;color:#111;">${supervisionData.length}<span style="font-size:13px;font-weight:500;color:#888;margin-left:4px;">block${supervisionData.length !== 1 ? "s" : ""}</span></div>
</div>
<div style="text-align:right;">
<div style="font-size:10px;color:#aaa;margin-bottom:2px;">Subtotal</div>
<div style="font-size:16px;font-weight:800;font-family:'Courier New',monospace;">${fmt(allSupervisionTotals.total)}</div>
</div>
</div>` : ""}
${previsData.length > 0 ? `
<div style="background:#f9f9f9;border-radius:10px;padding:16px 20px;display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:4px;">Previs</div>
<div style="font-size:22px;font-weight:900;color:#111;">${previsData.length}<span style="font-size:13px;font-weight:500;color:#888;margin-left:4px;">sequence${previsData.length !== 1 ? "s" : ""}</span></div>
</div>
<div style="text-align:right;">
<div style="font-size:10px;color:#aaa;margin-bottom:2px;">Subtotal</div>
<div style="font-size:16px;font-weight:800;font-family:'Courier New',monospace;">${fmt(allPrevisTotal.total)}</div>
</div>
</div>` : ""}
</div>
<div style="background:#1a1a1a;border-radius:10px;padding:16px 24px;color:#fff;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:32px;flex-wrap:wrap;">
<div style="display:flex;gap:32px;flex-wrap:wrap;">
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Line Items Total</div>
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;">${fmt(lineSubtotal)}</div>
</div>
${Number(job.markupPercent) > 0 ? `
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Markup (${job.markupPercent}%)</div>
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;">+${fmt(jobAdj.markupAmount)}</div>
</div>` : ""}
${Number(job.discountPercent) > 0 ? `
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Discount (${job.discountPercent}%)</div>
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;color:#f87171;">-${fmt(jobAdj.discountAmount)}</div>
</div>` : ""}
${Number(job.taxPercent) > 0 ? `
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Tax / VAT (${job.taxPercent}%)</div>
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;">+${fmt(jobAdj.taxAmount)}</div>
</div>` : ""}
${productionFeePct.gt(0) ? `
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Production Fee</div>
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;">+${fmt(productionFeeAmount)}</div>
</div>` : ""}
${pullManagementFeePct.gt(0) ? `
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Pull Management Fee</div>
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;">+${fmt(pullManagementFeeAmount)}</div>
</div>` : ""}
</div>
<div style="text-align:right;border-left:1px solid #333;padding-left:32px;">
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Grand Total</div>
<div style="font-size:26px;font-weight:900;color:#f59e0b;font-family:'Courier New',monospace;">${fmt(grandTotalWithFees)}</div>
</div>
</div>
</div>
</div>
<!-- Shots Summary -->
${shotData.length > 0 ? `
<div class="section">
<div class="section-title">VFX Shots</div>
<table>
<thead><tr>
<th>${hasGroups ? (jobType === "EPISODIC" ? "Episode / Shot Code" : "Group / Shot Code") : "Shot Code"}</th>
<th>Description</th>
<th class="right">Tasks</th>
<th class="right">Total</th>
</tr></thead>
<tbody>
${hasGroups
? shotGroups.map(({ label, entries }) => {
const groupTotal = sumLineItems(entries.map(e => e.totals)).total;
return `<tr style="background:#f3f4f6;">
<td colspan="2" style="font-weight:700;font-size:13px;padding:8px 12px;border-bottom:1px solid #e5e7eb;">${label}<span style="font-weight:400;color:#888;margin-left:8px;font-size:12px;">${entries.length} shot${entries.length !== 1 ? "s" : ""}</span></td>
<td class="right" style="font-size:12px;color:#888;border-bottom:1px solid #e5e7eb;"></td>
<td class="right" style="font-weight:800;border-bottom:1px solid #e5e7eb;">${fmt(groupTotal)}</td>
</tr>
${entries.map(({ shot, totals }) => `
<tr>
<td style="padding-left:20px;"><strong style="font-size:12px;font-family:'Courier New',monospace;">${shot.shotCode}</strong></td>
<td style="color:#666;font-size:12px;">${(shot as any).description ?? ""}</td>
<td class="right" style="font-size:12px;color:#888;">${shot.lineItems.length}</td>
<td class="right" style="font-family:'Courier New',monospace;">${fmt(totals.total)}</td>
</tr>`).join("")}`;
}).join("")
: shotData.map(({ shot, totals }) => `
<tr>
<td><strong>${shot.shotCode}</strong></td>
<td style="color:#666;">${(shot as any).description ?? ""}</td>
<td class="right">${shot.lineItems.length}</td>
<td class="right" style="font-weight:800;">${fmt(totals.total)}</td>
</tr>`).join("")
}
<tr class="summary-row">
<td colspan="3">Shots Subtotal</td>
<td class="right" style="font-family:'Courier New',monospace;">${fmt(allShotTotals.total)}</td>
</tr>
</tbody>
</table>
</div>` : ""}
<!-- Assets Summary -->
${assetData.length > 0 ? `
<div class="section">
<div class="section-title">VFX Assets</div>
<table>
<thead><tr>
<th>Asset</th>
<th>Type</th>
<th>Description</th>
<th class="right">Tasks</th>
<th class="right">Total</th>
</tr></thead>
<tbody>
${assetData.map(({ asset, totals }) => `
<tr>
<td><strong>${asset.assetName}</strong></td>
<td style="color:#666;">${asset.assetType ?? ""}</td>
<td style="color:#666;">${asset.description ?? ""}</td>
<td class="right">${asset.lineItems.length}</td>
<td class="right" style="font-family:'Courier New',monospace;font-weight:600;">${fmt(totals.total)}</td>
</tr>`).join("")}
<tr class="summary-row">
<td colspan="4">Assets Subtotal</td>
<td class="right" style="font-family:'Courier New',monospace;">${fmt(allAssetTotals.total)}</td>
</tr>
</tbody>
</table>
</div>` : ""}
<!-- Supervision Summary -->
${supervisionData.length > 0 ? `
<div class="section">
<div class="section-title">On-Set Supervision</div>
<table>
<thead><tr>
<th>Supervision</th>
<th>Location</th>
<th>Dates</th>
<th class="right">Days</th>
<th class="right">Total</th>
</tr></thead>
<tbody>
${supervisionData.map(({ sv, totals }: any) => {
const totalDays = sv.numShootDays + sv.prepDays + sv.travelDays + sv.wrapDays;
return `<tr>
<td><strong>${sv.supervisionTitle}</strong>${sv.description ? `<br><span style="color:#666;font-size:12px;">${sv.description}</span>` : ""}</td>
<td style="color:#666;">${sv.location ?? ""}</td>
<td style="color:#666;">${sv.shootDates ?? ""}</td>
<td class="right">${totalDays > 0 ? totalDays : "—"}</td>
<td class="right" style="font-family:'Courier New',monospace;font-weight:600;">${fmt(totals.total)}</td>
</tr>`;
}).join("")}
<tr class="summary-row">
<td colspan="4">Supervision Subtotal</td>
<td class="right" style="font-family:'Courier New',monospace;">${fmt(allSupervisionTotals.total)}</td>
</tr>
</tbody>
</table>
</div>` : ""}
<!-- Previs Summary -->
${previsData.length > 0 ? `
<div class="section">
<div class="section-title">Previs</div>
<table>
<thead><tr>
<th>Sequence</th>
<th>Type</th>
<th>Stage</th>
<th class="right">Duration</th>
<th class="right">Total</th>
</tr></thead>
<tbody>
${previsData.map(({ pv, totals }: any) => `
<tr>
<td><strong>${pv.sequenceName}</strong>${pv.previsCode ? `<br><span style="color:#aaa;font-size:11px;">${pv.previsCode}</span>` : ""}
${pv.description ? `<br><span style="color:#666;font-size:12px;">${pv.description}</span>` : ""}
</td>
<td style="color:#666;">${pv.sequenceType}</td>
<td><span style="padding:2px 8px;border-radius:4px;background:#ede9fe;color:#5b21b6;font-size:11px;font-weight:600;">${pv.deliveryStage}</span></td>
<td class="right" style="color:#666;">${pv.duration ?? "—"}</td>
<td class="right" style="font-family:'Courier New',monospace;font-weight:600;">${fmt(totals.total)}</td>
</tr>`).join("")}
<tr class="summary-row">
<td colspan="4">Previs Subtotal</td>
<td class="right" style="font-family:'Courier New',monospace;">${fmt(allPrevisTotal.total)}</td>
</tr>
</tbody>
</table>
</div>` : ""}
<!-- Financials / Totals -->
<div class="financials">
<div class="fin-row"><span class="fin-label">Line Items Total</span><span class="fin-value">${fmt(lineSubtotal)}</span></div>
${Number(job.markupPercent) > 0 ? `<div class="fin-row"><span class="fin-label">Markup (${job.markupPercent}%)</span><span class="fin-value">+${fmt(jobAdj.markupAmount)}</span></div>` : ""}
${Number(job.discountPercent) > 0 ? `<div class="fin-row"><span class="fin-label">Discount (${job.discountPercent}%)</span><span class="fin-value" style="color:#dc2626;">-${fmt(jobAdj.discountAmount)}</span></div>` : ""}
${Number(job.taxPercent) > 0 ? `<div class="fin-row"><span class="fin-label">Tax / VAT (${job.taxPercent}%)</span><span class="fin-value">+${fmt(jobAdj.taxAmount)}</span></div>` : ""}
${productionFeePct.gt(0) ? `<div class="fin-row"><span class="fin-label">Production Fee</span><span class="fin-value">+${fmt(productionFeeAmount)}</span></div>` : ""}
${pullManagementFeePct.gt(0) ? `<div class="fin-row"><span class="fin-label">Pull Management Fee</span><span class="fin-value">+${fmt(pullManagementFeeAmount)}</span></div>` : ""}
<div class="fin-row total"><span>Grand Total</span><span class="fin-value">${fmt(grandTotalWithFees)}</span></div>
</div>
${job.termsAndConditions ? `<div class="terms"><strong style="display:block;margin-bottom:8px;">Terms &amp; Conditions</strong>${job.termsAndConditions}</div>` : ""}
<div class="footer">
${studio?.invoiceFooter ? `<p style="margin-bottom:6px;">${studio.invoiceFooter}</p>` : ""}
</div>
${job.customFooterHtml ?? ""}
</div>
<!-- APPENDIX -->
<div class="page page-break">
<div class="header">
<div style="text-align:right;">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;margin-bottom:4px;">Appendix</div>
<div class="doc-number" style="font-size:20px;">Detailed Breakdown</div>
<div style="font-size:12px;color:#666;margin-top:2px;">${job.projectCode}</div>
</div>
</div>
<div style="border-bottom:2px solid #f0f0f0;margin-bottom:32px;"></div>
${hasGroups
? shotGroups.map(({ label, entries }) => {
const groupTotal = sumLineItems(entries.map(e => e.totals)).total;
return `
<div class="group-banner">
<span>${label}</span>
<span>${fmt(groupTotal)}</span>
</div>
${entries.map(({ shot, totals }) => `
<div class="shot-block">
<div class="shot-header">
<h3>${shot.shotCode}${Number(shot.complexityMultiplier) !== 1 ? `<span class="complexity-badge">×${Number(shot.complexityMultiplier).toFixed(2)}</span>` : ""}
${shot.description ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${shot.description}</span>` : ""}
</h3>
<span class="shot-total">${fmt(totals.total)}</span>
</div>
<table>
<thead><tr>
<th>Task</th><th>Role</th>
<th class="right">Hours</th><th class="right">Day Rate</th>
<th class="right">Hrs/Day</th><th class="right">Artists</th>
<th class="right">Days</th><th class="right">Subtotal</th><th class="right">Total</th>
</tr></thead>
<tbody>
${shot.lineItems.map((li) => {
const c = calcLineItem({
quantityHours: li.quantityHours.toString(), dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(), numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(), manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(), markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(), taxPercent: li.taxPercent.toString(),
}, shot.complexityMultiplier.toString());
return `<tr>
<td>${li.taskName}</td>
<td style="color:#666;">${li.artistRole ?? "—"}</td>
<td class="right" style="">${Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}</td>
<td class="right" style="">${Number(li.dailyRate) > 0 ? fmt(new Decimal(li.dailyRate.toString())) : "—"}</td>
<td class="right">${Number(li.hoursPerDay)}</td>
<td class="right">${Number(li.numArtists)}</td>
<td class="right">${Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}</td>
<td class="right" style="">${fmt(c.subtotal)}</td>
<td class="right" style="font-weight:700;">${fmt(c.total)}</td>
</tr>`;
}).join("")}
<tr class="summary-row">
<td colspan="7">Shot Total</td>
<td class="right" style="">${fmt(totals.subtotal)}</td>
<td class="right" style="">${fmt(totals.total)}</td>
</tr>
</tbody>
</table>
${shot.notes ? `<div style="padding:10px 14px;background:#fafafa;font-size:12px;color:#666;border-top:1px solid #f0f0f0;"><em>${shot.notes}</em></div>` : ""}
</div>`).join("")}`;
}).join("")
: shotData.map(({ shot, totals }) => `
<div class="shot-block">
<div class="shot-header">
<h3>${shot.shotCode}${Number(shot.complexityMultiplier) !== 1 ? `<span class="complexity-badge">×${Number(shot.complexityMultiplier).toFixed(2)}</span>` : ""}
${shot.description ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${shot.description}</span>` : ""}
</h3>
<span class="shot-total">${fmt(totals.total)}</span>
</div>
<table>
<thead><tr>
<th>Task</th><th>Role</th>
<th class="right">Hours</th><th class="right">Day Rate</th>
<th class="right">Hrs/Day</th><th class="right">Artists</th>
<th class="right">Days</th><th class="right">Subtotal</th><th class="right">Total</th>
</tr></thead>
<tbody>
${shot.lineItems.map((li) => {
const c = calcLineItem({
quantityHours: li.quantityHours.toString(), dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(), numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(), manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(), markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(), taxPercent: li.taxPercent.toString(),
}, shot.complexityMultiplier.toString());
return `<tr>
<td>${li.taskName}</td>
<td style="color:#666;">${li.artistRole ?? "—"}</td>
<td class="right" style="">${Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}</td>
<td class="right" style="">${Number(li.dailyRate) > 0 ? fmt(new Decimal(li.dailyRate.toString())) : "—"}</td>
<td class="right">${Number(li.hoursPerDay)}</td>
<td class="right">${Number(li.numArtists)}</td>
<td class="right">${Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}</td>
<td class="right" style="">${fmt(c.subtotal)}</td>
<td class="right" style="font-weight:700;">${fmt(c.total)}</td>
</tr>`;
}).join("")}
<tr class="summary-row">
<td colspan="7">Shot Total</td>
<td class="right" style="">${fmt(totals.subtotal)}</td>
<td class="right" style="">${fmt(totals.total)}</td>
</tr>
</tbody>
</table>
${shot.notes ? `<div style="padding:10px 14px;background:#fafafa;font-size:12px;color:#666;border-top:1px solid #f0f0f0;"><em>${shot.notes}</em></div>` : ""}
</div>`).join("")
}
${assetData.map(({ asset, totals }) => `
<div class="shot-block">
<div class="shot-header">
<h3>${asset.assetName}${asset.assetType ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${asset.assetType}</span>` : ""}
${Number(asset.complexityMultiplier) !== 1 ? `<span class="complexity-badge">×${Number(asset.complexityMultiplier).toFixed(2)}</span>` : ""}
</h3>
<span class="shot-total">${fmt(totals.total)}</span>
</div>
<table>
<thead><tr>
<th>Task</th><th>Role</th>
<th class="right">Hours</th><th class="right">Day Rate</th>
<th class="right">Hrs/Day</th><th class="right">Artists</th>
<th class="right">Days</th><th class="right">Subtotal</th><th class="right">Total</th>
</tr></thead>
<tbody>
${asset.lineItems.map((li: any) => {
const c = calcLineItem({
quantityHours: li.quantityHours.toString(), dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(), numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(), manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(), markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(), taxPercent: li.taxPercent.toString(),
}, asset.complexityMultiplier.toString());
return `<tr>
<td>${li.taskName}</td>
<td style="color:#666;">${li.artistRole ?? "—"}</td>
<td class="right" style="">${Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}</td>
<td class="right" style="">${Number(li.dailyRate) > 0 ? fmt(new Decimal(li.dailyRate.toString())) : "—"}</td>
<td class="right">${Number(li.hoursPerDay)}</td>
<td class="right">${Number(li.numArtists)}</td>
<td class="right">${Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}</td>
<td class="right" style="">${fmt(c.subtotal)}</td>
<td class="right" style="font-weight:700;">${fmt(c.total)}</td>
</tr>`;
}).join("")}
<tr class="summary-row">
<td colspan="7">Asset Total</td>
<td class="right" style="">${fmt(totals.subtotal)}</td>
<td class="right" style="">${fmt(totals.total)}</td>
</tr>
</tbody>
</table>
${asset.notes ? `<div style="padding:10px 14px;background:#fafafa;font-size:12px;color:#666;border-top:1px solid #f0f0f0;"><em>${asset.notes}</em></div>` : ""}
</div>`).join("")}
${supervisionData.length > 0 ? `
<div class="group-banner">
<span>On-Set Supervision</span>
<span>${fmt(allSupervisionTotals.total)}</span>
</div>
${supervisionData.map(({ sv, totals }: any) => {
const totalDays = sv.numShootDays + sv.prepDays + sv.travelDays + sv.wrapDays;
const dayBreakdown = [
sv.numShootDays > 0 ? `${sv.numShootDays} shoot` : "",
sv.prepDays > 0 ? `${sv.prepDays} prep` : "",
sv.travelDays > 0 ? `${sv.travelDays} travel` : "",
sv.wrapDays > 0 ? `${sv.wrapDays} wrap` : "",
].filter(Boolean).join(" · ");
return `
<div class="shot-block">
<div class="shot-header">
<h3>${sv.supervisionTitle}${Number(sv.complexityMultiplier) !== 1 ? `<span class="complexity-badge">×${Number(sv.complexityMultiplier).toFixed(2)}</span>` : ""}
${sv.location ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${sv.location}</span>` : ""}
${sv.shootDates ? `<span style="font-weight:400;font-size:11px;color:#888;margin-left:8px;">${sv.shootDates}</span>` : ""}
</h3>
<span class="shot-total">${fmt(totals.total)}</span>
</div>
${totalDays > 0 ? `<div style="padding:6px 14px;background:#111;font-size:11px;color:#aaa;">${dayBreakdown} · ${totalDays} days total</div>` : ""}
<table>
<thead><tr>
<th>Task / Service</th><th>Role</th>
<th class="right">Hours</th><th class="right">Day Rate</th>
<th class="right">Hrs/Day</th><th class="right">People</th>
<th class="right">Days</th><th class="right">Subtotal</th><th class="right">Total</th>
</tr></thead>
<tbody>
${sv.lineItems.map((li: any) => {
const c = calcLineItem({
quantityHours: li.quantityHours.toString(), dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(), numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(), manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(), markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(), taxPercent: li.taxPercent.toString(),
}, sv.complexityMultiplier.toString());
return `<tr>
<td>${li.taskName}</td>
<td style="color:#666;">${li.artistRole ?? "—"}</td>
<td class="right" style="">${Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}</td>
<td class="right" style="">${Number(li.dailyRate) > 0 ? fmt(new Decimal(li.dailyRate.toString())) : "—"}</td>
<td class="right">${Number(li.hoursPerDay)}</td>
<td class="right">${Number(li.numArtists)}</td>
<td class="right">${Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}</td>
<td class="right" style="">${fmt(c.subtotal)}</td>
<td class="right" style="font-weight:700;">${fmt(c.total)}</td>
</tr>`;
}).join("")}
<tr class="summary-row">
<td colspan="7">Supervision Total</td>
<td class="right" style="">${fmt(totals.subtotal)}</td>
<td class="right" style="">${fmt(totals.total)}</td>
</tr>
</tbody>
</table>
${sv.notes ? `<div style="padding:10px 14px;background:#fafafa;font-size:12px;color:#666;border-top:1px solid #f0f0f0;"><em>${sv.notes}</em></div>` : ""}
</div>`;
}).join("")}` : ""}
${previsData.length > 0 ? `
<div class="group-banner" style="background:#1e1b4b;">
<span style="color:#a5b4fc;">Previs</span>
<span style="color:#a5b4fc;">${fmt(allPrevisTotal.total)}</span>
</div>
${previsData.map(({ pv, totals }: any) => `
<div class="shot-block">
<div class="shot-header">
<h3>${pv.sequenceName}${pv.previsCode ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${pv.previsCode}</span>` : ""}
${Number(pv.complexityMultiplier) !== 1 ? `<span class="complexity-badge">×${Number(pv.complexityMultiplier).toFixed(2)}</span>` : ""}
<span style="font-weight:400;font-size:11px;color:#a5b4fc;margin-left:8px;">${pv.sequenceType}</span>
<span style="font-weight:400;font-size:11px;color:#888;margin-left:8px;padding:2px 6px;background:#1e1b4b;border-radius:3px;">${pv.deliveryStage}</span>
${pv.duration ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${pv.duration}</span>` : ""}
</h3>
<span class="shot-total">${fmt(totals.total)}</span>
</div>
<table>
<thead><tr>
<th>Task / Service</th><th>Role</th>
<th class="right">Hours</th><th class="right">Day Rate</th>
<th class="right">Hrs/Day</th><th class="right">People</th>
<th class="right">Days</th><th class="right">Subtotal</th><th class="right">Total</th>
</tr></thead>
<tbody>
${pv.lineItems.map((li: any) => {
const c = calcLineItem({
quantityHours: li.quantityHours.toString(), dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(), numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(), manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(), markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(), taxPercent: li.taxPercent.toString(),
}, pv.complexityMultiplier.toString());
return `<tr>
<td>${li.taskName}</td>
<td style="color:#666;">${li.artistRole ?? "—"}</td>
<td class="right" style="">${Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}</td>
<td class="right" style="">${Number(li.dailyRate) > 0 ? fmt(new Decimal(li.dailyRate.toString())) : "—"}</td>
<td class="right">${Number(li.hoursPerDay)}</td>
<td class="right">${Number(li.numArtists)}</td>
<td class="right">${Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}</td>
<td class="right" style="">${fmt(c.subtotal)}</td>
<td class="right" style="font-weight:700;">${fmt(c.total)}</td>
</tr>`;
}).join("")}
<tr style="background:#1e1b4b;color:#c4b5fd;font-weight:700;">
<td colspan="7">Previs Total</td>
<td class="right" style="">${fmt(totals.subtotal)}</td>
<td class="right" style="">${fmt(totals.total)}</td>
</tr>
</tbody>
</table>
${pv.notes ? `<div style="padding:10px 14px;background:#fafafa;font-size:12px;color:#666;border-top:1px solid #f0f0f0;"><em>${pv.notes}</em></div>` : ""}
</div>`).join("")}` : ""}
<div class="footer">
<p>${job.projectCode} · Appendix</p>
<p style="margin-top:4px;">Generated ${format(new Date(), "d MMM yyyy")}</p>
</div>
</div>
</body>
</html>`;
return new NextResponse(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
}
+240
View File
@@ -0,0 +1,240 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { formatCurrency } from "@/lib/calculations";
import { format } from "date-fns";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const invoice = await prisma.invoice.findUnique({
where: { id },
include: {
client: true,
job: true,
milestone: true,
payments: { orderBy: { paymentDate: "asc" } },
},
});
if (!invoice) {
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
}
// Fetch studio settings for branding
const studio = await prisma.studioSettings.findFirst();
const fmt = (v: number | string | { toNumber?: () => number }) => formatCurrency(typeof v === 'object' && v && 'toNumber' in v ? v.toNumber!() : Number(v), invoice.currency);
const amountPaid = Number(invoice.amountPaid);
const amountDue = Number(invoice.amountDue);
const totalAmount = Number(invoice.totalAmount);
const paidPercent = totalAmount > 0 ? Math.min(100, (amountPaid / totalAmount) * 100) : 0;
const statusColors: Record<string, string> = {
DRAFT: "#71717a",
SENT: "#60a5fa",
VIEWED: "#a78bfa",
PARTIALLY_PAID: "#f97316",
PAID: "#4ade80",
OVERDUE: "#f87171",
CANCELLED: "#52525b",
};
const statusColor = statusColors[invoice.status] ?? "#71717a";
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Invoice ${invoice.invoiceNumber}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #fff; color: #111; font-size: 14px; line-height: 1.5; }
.page { max-width: 1200px; margin: 0 auto; padding: 48px 48px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; }
.logo { font-size: 28px; font-weight: 900; color: #111; letter-spacing: -1px; }
.logo span { color: #f59e0b; }
.studio-info { text-align: right; font-size: 12px; color: #555; line-height: 1.6; }
.invoice-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 2px solid #f0f0f0; }
.invoice-number { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; }
.status-badge { padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 700; letter-spacing: 0.5px; color: ${statusColor}; border: 1.5px solid ${statusColor}; background: ${statusColor}1a; }
.parties { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-bottom: 32px; }
.section-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #aaa; margin-bottom: 8px; }
.party-name { font-weight: 700; font-size: 15px; color: #111; }
.party-detail { font-size: 13px; color: #555; margin-top: 2px; }
.meta { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-bottom: 32px; }
.meta-item { }
.meta-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #aaa; }
.meta-value { font-size: 14px; color: #111; font-weight: 600; margin-top: 2px; }
.financials { background: #f9f9f9; border-radius: 12px; padding: 24px; margin-bottom: 32px; }
.fin-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
.fin-row.total { border-top: 2px solid #e5e5e5; margin-top: 8px; padding-top: 12px; font-weight: 800; font-size: 18px; }
.fin-label { color: #555; }
.fin-value { font-weight: 900; }
.fin-row.total .fin-value { color: #f59e0b; }
.payment-status { background: #f0fdf4; border: 1.5px solid #bbf7d0; border-radius: 12px; padding: 20px; margin-bottom: 32px; }
.progress-bar { height: 8px; background: #d1fae5; border-radius: 4px; overflow: hidden; margin: 8px 0; }
.progress-fill { height: 100%; background: #22c55e; border-radius: 4px; width: ${paidPercent.toFixed(1)}%; }
.progress-labels { display: flex; justify-content: space-between; font-size: 12px; color: #555; }
.payment-amounts { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-top: 12px; }
.pay-amount { }
.pay-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: #aaa; font-weight: 600; }
.pay-value { font-size: 16px; font-weight: 800; font-family: 'Courier New', monospace; margin-top: 2px; }
.payments-table { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
.payments-table th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: #aaa; font-weight: 700; padding: 8px 12px; border-bottom: 2px solid #f0f0f0; }
.payments-table td { padding: 10px 12px; font-size: 13px; border-bottom: 1px solid #f5f5f5; }
.banking { background: #fffbeb; border: 1.5px solid #fde68a; border-radius: 12px; padding: 20px; margin-bottom: 32px; }
.banking-content { font-weight: 800; font-size: 13px; color: #555; white-space: pre-line; }
.notes { margin-bottom: 32px; }
.notes-content { font-size: 13px; color: #555; white-space: pre-line; line-height: 1.6; }
.footer { border-top: 1px solid #f0f0f0; padding-top: 24px; text-align: center; font-size: 11px; color: #bbb; }
@media print { body { print-color-adjust: exact; -webkit-print-color-adjust: exact; } }
</style>
</head>
<body>
<div class="page">
<!-- Header -->
<div class="header">
<div>
${studio?.studioName ? `<div style="font-size:18px;margin-top:4px;font-weight:900;">${studio.studioName}</div>` : ""}
${studio?.studioAddress ? `<div>${studio.studioAddress}</div>` : ""}
${studio?.companyRegNumber ? `<div>REG: ${studio.companyRegNumber}</div>` : ""}
${studio?.studioEmail ? `<div>${studio.studioEmail}</div>` : ""}
${studio?.studioPhone ? `<div>${studio.studioPhone}</div>` : ""}
</div>
</div>
<!-- Invoice number + status -->
<div class="invoice-title">
<div>
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;margin-bottom:4px;">Tax Invoice</div>
<div class="invoice-number">${invoice.invoiceNumber}</div>
</div>
</div>
<!-- Parties -->
<div class="parties">
<div>
<div class="section-label">Bill To</div>
<div class="party-name">${invoice.client.companyName}</div>
${invoice.client.billingAddress ? `<div class="party-detail">${invoice.client.billingAddress.replace(/\n/g, "<br>")}</div>` : ""}
${invoice.client.companyRegNumber ? `<div class="party-detail" style="margin-top:6px;font-size:12px;color:#aaa;">REG: ${invoice.client.companyRegNumber}</div>` : ""}
${invoice.client.vatNumber ? `<div class="party-detail" style="margin-top:6px;font-size:12px;color:#aaa;">VAT: ${invoice.client.vatNumber}</div>` : ""}
</div>
<div>
<div class="section-label">Invoice Details</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 16px;font-size:13px;">
<span style="color:#aaa;">Issue Date</span><span style="font-weight:600;">${format(new Date(invoice.issueDate), "d MMM yyyy")}</span>
${invoice.dueDate ? `<span style="color:#aaa;">Due Date</span><span style="font-weight:600;">${format(new Date(invoice.dueDate), "d MMM yyyy")}</span>` : ""}
${invoice.paymentTerms ? `<span style="color:#aaa;">Terms</span><span style="font-weight:600;">${invoice.paymentTerms}</span>` : ""}
<span style="color:#aaa;">Project</span><span style="font-weight:600;">${invoice.job.projectCode}</span>
${invoice.milestone ? `<span style="color:#aaa;">Milestone</span><span style="font-weight:600;">${invoice.milestone.title}</span>` : ""}
</div>
</div>
</div>
<!-- Services / Line Items -->
<div style="margin-bottom:32px;">
<div class="section-label" style="margin-bottom:12px;">Services</div>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2px solid #f0f0f0;">
<th style="text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;width:32px;">#</th>
<th style="text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Description</th>
<th style="text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Period</th>
<th style="text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">%</th>
<th style="text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Amount</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #f5f5f5;">
<td style="padding:12px;color:#aaa;vertical-align:top;">1</td>
<td style="padding:12px;vertical-align:top;">
<div style="font-weight:700;font-size:14px;color:#111;">${invoice.job.projectCode} — ${invoice.milestone?.title ?? "Professional VFX Services"}</div>
${invoice.milestone?.description ? `<div style="font-size:12px;color:#777;margin-top:2px;">${invoice.milestone.description}</div>` : ""}
<div style="font-size:12px;color:#aaa;margin-top:2px;">${invoice.job.projectTitle}</div>
</td>
<td style="padding:12px;color:#555;vertical-align:top;white-space:nowrap;">${invoice.milestone?.dueDate ? format(new Date(invoice.milestone.dueDate), "MMM yyyy") : format(new Date(invoice.issueDate), "MMM yyyy")}</td>
<td style="padding:12px;text-align:right;color:#555;vertical-align:top;">${invoice.milestone ? `${Number(invoice.milestone.percentOfTotal).toFixed(0)}%` : "—"}</td>
<td style="padding:12px;text-align:right;font-weight:700;font-size:15px;color:#111;vertical-align:top;">${fmt(invoice.subtotal)}</td>
</tr>
</tbody>
</table>
</div>
<!-- Financial summary -->
<div class="financials">
<div class="fin-row">
<span class="fin-label">Subtotal</span>
<span class="fin-value">${fmt(invoice.subtotal)}</span>
</div>
<div class="fin-row">
<span class="fin-label">Discount (${Number(invoice.taxPercent)}%)</span>
<span class="fin-value">${fmt(invoice.taxAmount)}</span>
</div>
<div class="fin-row total">
<span>Total</span>
<span class="fin-value">${fmt(totalAmount)}</span>
</div>
</div>
<!-- Payment history -->
${invoice.payments.length > 0 ? `
<div style="margin-bottom:32px;">
<div class="section-label" style="margin-bottom:12px;">Payment History</div>
<table class="payments-table">
<thead>
<tr>
<th>Date</th>
<th>Method</th>
<th>Reference</th>
<th style="text-align:right;">Amount</th>
</tr>
</thead>
<tbody>
${invoice.payments.map(p => `
<tr>
<td>${format(new Date(p.paymentDate), "d MMM yyyy")}</td>
<td>${p.paymentMethod.replace("_", " ")}</td>
<td style="color:#777;">${p.reference ?? "—"}</td>
<td style="text-align:right;font-weight:600;color:#22c55e;">${fmt(p.amount)}</td>
</tr>`).join("")}
</tbody>
</table>
</div>` : ""}
<!-- Banking details -->
${invoice.bankingDetails ? `
<div class="banking">
<div class="section-label" style="margin-bottom:8px;">Banking Details</div>
<div class="banking-content">${invoice.bankingDetails}</div>
</div>` : ""}
<!-- Notes -->
${invoice.notes ? `
<div class="notes">
<div class="section-label" style="margin-bottom:8px;">Notes</div>
<div class="notes-content">${invoice.notes}</div>
</div>` : ""}
<!-- Footer -->
<div class="footer">
${studio?.invoiceFooter ? `<p style="margin-bottom:8px;">${studio.invoiceFooter}</p>` : ""}
<p>Generated ${format(new Date(), "d MMM yyyy 'at' HH:mm")} · ${invoice.invoiceNumber}</p>
</div>
</div>
</body>
</html>`;
return new NextResponse(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Disposition": `inline; filename="${invoice.invoiceNumber}.html"`,
},
});
}
@@ -0,0 +1,270 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { calcLineItem, sumLineItems, applyJobAdjustments, formatCurrency } from "@/lib/calculations";
import { format } from "date-fns";
import Decimal from "decimal.js";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ jobId: string }> }
) {
const { jobId } = await params;
const job = await prisma.job.findUnique({
where: { id: jobId },
include: {
client: true,
paymentSchedule: {
include: {
milestones: { orderBy: { sortOrder: "asc" } },
},
},
shots: {
include: { lineItems: true },
},
assets: {
include: { lineItems: true },
},
supervisions: {
include: { lineItems: true },
},
previses: {
include: { lineItems: true },
},
},
});
if (!job) {
return NextResponse.json({ error: "Job not found" }, { status: 404 });
}
const studio = await prisma.studioSettings.findFirst();
const currency = "ZAR";
const fmt = (v: number) => formatCurrency(v, currency);
// Compute grand total from all line items (same as job detail page)
const allResults = [
...job.shots.flatMap(s => s.lineItems.map(li => calcLineItem(li as any, s.complexityMultiplier.toString()))),
...job.assets.flatMap(a => a.lineItems.map(li => calcLineItem(li as any, a.complexityMultiplier.toString()))),
...(job.supervisions ?? []).flatMap((sv: any) => sv.lineItems.map((li: any) => calcLineItem(li as any, sv.complexityMultiplier.toString()))),
...(job.previses ?? []).flatMap((pv: any) => pv.lineItems.map((li: any) => calcLineItem(li as any, pv.complexityMultiplier.toString()))),
];
const sub = allResults.reduce((s, r) => s.add(r.subtotal), new Decimal(0));
const { grandTotal: grandTotalDecimal } = applyJobAdjustments(
sub,
job.markupPercent.toString(),
job.discountPercent.toString(),
job.taxPercent.toString()
);
const productionFeePct = new Decimal((job as any).productionFeePercent ?? 0);
const pullManagementFeePct = new Decimal((job as any).pullManagementFeePercent ?? 0);
const grandTotalWithFees = grandTotalDecimal
.add(grandTotalDecimal.mul(productionFeePct.div(100)))
.add(grandTotalDecimal.mul(pullManagementFeePct.div(100)));
const jobTotal = grandTotalWithFees.toNumber();
const schedule = job.paymentSchedule;
const milestones = schedule?.milestones ?? [];
const amountForMilestone = (m: { fixedAmount: any; percentOfTotal: any }) => {
if (m.fixedAmount && Number(m.fixedAmount) > 0) return Number(m.fixedAmount);
return (Number(m.percentOfTotal) / 100) * jobTotal;
};
const totalPercent = milestones.reduce((s, m) => s + Number(m.percentOfTotal), 0);
const paidAmount = milestones
.filter(m => m.status === "PAID" || m.status === "PARTIALLY_PAID")
.reduce((s, m) => s + amountForMilestone(m), 0);
const outstandingAmount = milestones
.filter(m => !["PAID", "CANCELLED"].includes(m.status))
.reduce((s, m) => s + amountForMilestone(m), 0);
const overdueAmount = milestones
.filter(m => m.status === "OVERDUE")
.reduce((s, m) => s + amountForMilestone(m), 0);
const statusColors: Record<string, string> = {
PENDING: "#71717a",
INVOICED: "#60a5fa",
PARTIALLY_PAID: "#f97316",
PAID: "#4ade80",
OVERDUE: "#f87171",
CANCELLED: "#52525b",
};``
const statusLabels: Record<string, string> = {
PENDING: "Pending",
INVOICED: "Invoiced",
PARTIALLY_PAID: "Partial",
PAID: "Paid",
OVERDUE: "Overdue",
CANCELLED: "Cancelled",
};
const milestoneRows = milestones
.map((m, i) => {
const amount = amountForMilestone(m);
const color = statusColors[m.status] ?? "#71717a";
const label = statusLabels[m.status] ?? m.status;
const isPaid = m.status === "PAID";
return `
<tr style="border-bottom:1px solid #f5f5f5;${isPaid ? "opacity:0.65;" : ""}">
<td style="padding:12px;color:#aaa;vertical-align:top;font-size:13px;">${i + 1}</td>
<td style="padding:12px;vertical-align:top;">
<div style="font-weight:700;font-size:14px;color:#111;">${m.title}</div>
${m.description ? `<div style="font-size:12px;color:#777;margin-top:2px;">${m.description}</div>` : ""}
</td>
<td style="padding:12px;text-align:right;color:#555;vertical-align:top;white-space:nowrap;font-size:13px;">
${m.dueDate ? format(new Date(m.dueDate), "d MMM yyyy") : "—"}
</td>
<td style="padding:12px;text-align:right;color:#555;vertical-align:top;font-size:13px;">
${Number(m.percentOfTotal).toFixed(1)}%
</td>
<td style="padding:12px;text-align:right;font-weight:700;font-size:14px;color:#111;vertical-align:top;">
${fmt(amount)}
</td>
<td style="padding:12px;vertical-align:top;text-align:center;">
<span style="padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;letter-spacing:0.5px;color:${color};border:1.5px solid ${color};background:${color}1a;">
${label}
</span>
</td>
</tr>
${m.paymentReceivedDate ? `
<tr style="border-bottom:1px solid #f5f5f5;background:#f0fdf4;">
<td style="padding:4px 12px;" colspan="2"></td>
<td style="padding:4px 12px;font-size:12px;color:#22c55e;text-align:right;" colspan="2">
Payment received: ${format(new Date(m.paymentReceivedDate), "d MMM yyyy")}
</td>
<td colspan="2"></td>
</tr>` : ""}`;
})
.join("");
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Payment Schedule — ${job.projectCode}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #fff; color: #111; font-size: 14px; line-height: 1.5; }
.page { max-width: 1200px; margin: 0 auto; padding: 48px; }
@media print { body { print-color-adjust: exact; -webkit-print-color-adjust: exact; } }
</style>
</head>
<body>
<div class="page">
<!-- Header -->
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:40px;">
<div style="font-size:12px;color:#555;line-height:1.6;">
${studio?.studioName ? `<div style="font-size:18px;font-weight:900;color:#111;margin-bottom:4px;">${studio.studioName}</div>` : ""}
${studio?.studioAddress ? `<div style="white-space:pre-line;">${studio.studioAddress}</div>` : ""}
${studio?.companyRegNumber ? `<div>REG: ${studio.companyRegNumber}</div>` : ""}
${studio?.studioEmail ? `<div>${studio.studioEmail}</div>` : ""}
${studio?.studioPhone ? `<div>${studio.studioPhone}</div>` : ""}
${studio?.vatNumber ? `<div>VAT: ${studio.vatNumber}</div>` : ""}
</div>
</div>
<!-- Document title -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:32px;padding-bottom:24px;border-bottom:2px solid #f0f0f0;">
<div>
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;margin-bottom:4px;">Payment Schedule</div>
<div style="font-size:24px;font-weight:800;letter-spacing:-0.5px;">${job.projectCode}</div>
<div style="font-size:14px;color:#555;margin-top:4px;">${job.projectTitle}</div>
</div>
</div>
<!-- Parties & project details -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:32px;margin-bottom:32px;">
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:8px;">Prepared For</div>
<div style="font-weight:700;font-size:15px;color:#111;">${job.client.companyName}</div>
${job.client.contactPerson ? `<div style="font-size:13px;color:#555;margin-top:2px;">${job.client.contactPerson}</div>` : ""}
${job.client.email ? `<div style="font-size:13px;color:#555;">${job.client.email}</div>` : ""}
${job.client.phone ? `<div style="font-size:13px;color:#555;">${job.client.phone}</div>` : ""}
${job.client.billingAddress ? `<div style="font-size:13px;color:#555;margin-top:6px;white-space:pre-line;">${job.client.billingAddress}</div>` : ""}
${job.client.vatNumber ? `<div style="font-size:12px;color:#aaa;margin-top:6px;">VAT: ${job.client.vatNumber}</div>` : ""}
</div>
<div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:8px;">Project Details</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 16px;font-size:13px;">
<span style="color:#aaa;">Issue Date</span><span style="font-weight:600;">${format(new Date(), "d MMM yyyy")}</span>
<span style="color:#aaa;">Currency</span><span style="font-weight:600;">${currency}</span>
${job.startDate ? `<span style="color:#aaa;">Start Date</span><span style="font-weight:600;">${format(new Date(job.startDate), "d MMM yyyy")}</span>` : ""}
${job.estimatedCompletionDate ? `<span style="color:#aaa;">Est. Completion</span><span style="font-weight:600;">${format(new Date(job.estimatedCompletionDate), "d MMM yyyy")}</span>` : ""}
<span style="color:#aaa;">Milestones</span><span style="font-weight:600;">${milestones.length}</span>
</div>
</div>
</div>
<!-- Summary cards -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:32px;">
${[
{ label: "Project Total", value: fmt(jobTotal), color: "#111" },
{ label: "Paid to Date", value: fmt(paidAmount), color: "#22c55e" },
{ label: "Outstanding", value: fmt(outstandingAmount), color: "#f59e0b" },
{ label: "Overdue", value: fmt(overdueAmount), color: "#f87171" },
].map(c => `
<div style="background:#f9f9f9;border-radius:12px;padding:16px;">
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:6px;">${c.label}</div>
<div style="font-size:18px;font-weight:800;color:${c.color};">${c.value}</div>
</div>`).join("")}
</div>
<!-- Milestones table -->
<div style="margin-bottom:32px;">
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:12px;">Payment Milestones</div>
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2px solid #f0f0f0;">
<th style="text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;width:32px;">#</th>
<th style="text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Milestone</th>
<th style="text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Due Date</th>
<th style="text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">%</th>
<th style="text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Amount</th>
<th style="text-align:center;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Status</th>
</tr>
</thead>
<tbody>
${milestoneRows || `<tr><td colspan="6" style="padding:24px;text-align:center;color:#aaa;font-size:13px;">No milestones defined</td></tr>`}
${milestones.length > 0 ? `
<tr style="background:#fffbeb;">
<td colspan="3" style="padding:12px;font-weight:700;font-size:13px;color:#111;">Total</td>
<td style="padding:12px;text-align:right;font-weight:700;font-size:13px;">${totalPercent.toFixed(1)}%</td>
<td style="padding:12px;text-align:right;font-weight:800;font-size:15px;color:#f59e0b;">${fmt(jobTotal)}</td>
<td></td>
</tr>` : ""}
</tbody>
</table>
</div>
<!-- Banking details -->
${studio?.invoiceFooter ? `
<div style="padding:20px;margin-bottom:32px;">
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:8px;">Notes</div>
<div style="font-size:12px;color:#aaa;text-align:center;">${studio.invoiceFooter}</div>
</div>` : ""}
<!-- Notes -->
${schedule?.notes ? `
<div style="margin-bottom:32px;">
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:8px;">Schedule Notes</div>
<div style="font-size:13px;color:#555;white-space:pre-line;line-height:1.6;">${schedule.notes}</div>
</div>` : ""}
<!-- Footer -->
<div style="border-top:1px solid #f0f0f0;padding-top:24px;text-align:center;font-size:11px;color:#bbb;">
<p>Generated ${format(new Date(), "d MMM yyyy 'at' HH:mm")} · ${job.projectCode} Payment Schedule</p>
</div>
</div>
</body>
</html>`;
return new NextResponse(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Disposition": `inline; filename="${job.projectCode}-payment-schedule.html"`,
},
});
}
+67
View File
@@ -0,0 +1,67 @@
import { getClient } from "@/actions/clients";
import { ClientForm } from "@/components/clients/client-form";
import { notFound } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { format } from "date-fns";
import { JOB_STATUSES } from "@/lib/constants";
export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const client = await getClient(id);
if (!client) notFound();
return (
<div className="p-8">
<Link href="/clients" className="flex items-center gap-2 text-zinc-400 hover:text-white mb-6 text-sm transition-colors">
<ArrowLeft className="h-4 w-4" /> Back to Clients
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Edit form */}
<div className="lg:col-span-2">
<h1 className="text-3xl font-bold text-white mb-8">{client.companyName}</h1>
<ClientForm initialData={{
companyName: client.companyName,
contactPerson: client.contactPerson ?? undefined,
email: client.email ?? undefined,
phone: client.phone ?? undefined,
vatNumber: client.vatNumber ?? undefined,
companyRegNumber: client.companyRegNumber ?? undefined,
billingAddress: client.billingAddress ?? undefined,
notes: client.notes ?? undefined,
}} clientId={id} />
</div>
{/* Jobs sidebar */}
<div>
<h2 className="text-lg font-semibold text-white mb-4">Jobs ({client.jobs.length})</h2>
<div className="space-y-2">
{client.jobs.map((job) => (
<Link
key={job.id}
href={`/jobs/${job.id}`}
className="block p-3 rounded-lg bg-zinc-900 border border-zinc-800 hover:border-amber-500/50 transition-colors"
>
<div className="font-medium text-white text-sm">{job.projectTitle}</div>
<div className="text-xs text-zinc-500 mt-1 flex gap-2">
<span>{job.projectCode}</span>
<span>·</span>
<span className={job.status === "APPROVED" ? "text-green-400" : "text-zinc-400"}>
{JOB_STATUSES.find((s) => s.value === job.status)?.label}
</span>
</div>
<div className="text-xs text-zinc-600 mt-1">
{job._count.shots} shots · {job._count.assets} assets
</div>
</Link>
))}
{client.jobs.length === 0 && (
<p className="text-sm text-zinc-500">No jobs yet.</p>
)}
</div>
</div>
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { ClientForm } from "@/components/clients/client-form";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
export default function NewClientPage() {
return (
<div className="p-8 max-w-2xl">
<Link href="/clients" className="flex items-center gap-2 text-zinc-400 hover:text-white mb-6 text-sm transition-colors">
<ArrowLeft className="h-4 w-4" /> Back to Clients
</Link>
<h1 className="text-3xl font-bold text-white mb-8">New Client</h1>
<ClientForm />
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { getClients } from "@/actions/clients";
import { ClientsTable } from "@/components/clients/clients-table";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Plus } from "lucide-react";
export default async function ClientsPage() {
const clients = JSON.parse(JSON.stringify(await getClients()));
return (
<div className="p-8">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Clients</h1>
<p className="text-zinc-400 mt-1">{clients.length} client{clients.length !== 1 ? "s" : ""}</p>
</div>
<Link
href="/clients/new"
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 h-8 bg-amber-500 hover:bg-amber-400 text-black text-sm font-semibold transition-colors"
>
<Plus className="h-4 w-4" />
New Client
</Link>
</div>
<ClientsTable clients={clients} />
</div>
);
}
+119 -15
View File
@@ -1,26 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound } from "next/navigation";
import { getInvoice } from "@/actions/invoices";
import { InvoiceForm } from "@/components/invoices/invoice-form";
import { prisma } from "@/lib/prisma";
export const metadata = { title: "Edit Invoice | xBid" };
export default async function EditInvoicePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [invoice, clients, jobs] = await Promise.all([
getInvoice(id),
prisma.client.findMany({ select: { id: true, companyName: true }, orderBy: { companyName: "asc" } }),
prisma.job.findMany({ select: { id: true, projectTitle: true, projectCode: true, clientId: true }, orderBy: { createdAt: "desc" } }),
]);
if (!invoice) notFound();
return <InvoiceForm invoice={invoice as any} clients={clients} jobs={jobs} />;
}
+12
View File
@@ -0,0 +1,12 @@
import { notFound } from "next/navigation";
import { getInvoice } from "@/actions/invoices";
import { InvoiceDetail } from "@/components/invoices/invoice-detail";
export const metadata = { title: "Invoice | xBid" };
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const invoice = await getInvoice(id);
if (!invoice) notFound();
return <InvoiceDetail invoice={JSON.parse(JSON.stringify(invoice))} />;
}
+12
View File
@@ -0,0 +1,12 @@
import { InvoiceForm } from "@/components/invoices/invoice-form";
import { prisma } from "@/lib/prisma";
export const metadata = { title: "New Invoice | xBid" };
export default async function NewInvoicePage() {
const [clients, jobs] = await Promise.all([
prisma.client.findMany({ select: { id: true, companyName: true }, orderBy: { companyName: "asc" } }),
prisma.job.findMany({ select: { id: true, projectTitle: true, projectCode: true, clientId: true }, orderBy: { createdAt: "desc" } }),
]);
return <InvoiceForm clients={clients} jobs={jobs} />;
}
+9
View File
@@ -0,0 +1,9 @@
import { getInvoices } from "@/actions/invoices";
import { InvoicesClient } from "@/components/invoices/invoices-client";
export const metadata = { title: "Invoices | xBid" };
export default async function InvoicesPage() {
const invoices = JSON.parse(JSON.stringify(await getInvoices()));
return <InvoicesClient initialInvoices={invoices} />;
}
+38
View File
@@ -0,0 +1,38 @@
import { getJob } from "@/actions/jobs";
import { getClients } from "@/actions/clients";
import { getRateCards } from "@/actions/rate-cards";
import { getTaskTemplates } from "@/actions/shots-assets";
import { notFound } from "next/navigation";
import { JobDetailView } from "@/components/jobs/job-detail-view";
import { calcLineItem, sumLineItems, applyJobAdjustments } from "@/lib/calculations";
import Decimal from "decimal.js";
export default async function JobDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [job, clients, rateCards, taskTemplates] = await Promise.all([
getJob(id),
getClients(),
getRateCards(),
getTaskTemplates(),
]);
if (!job) notFound();
// Compute grand total from all line items
const allResults = [
...job.shots.flatMap(s => s.lineItems.map(li => calcLineItem(li as any, s.complexityMultiplier.toString()))),
...job.assets.flatMap(a => a.lineItems.map(li => calcLineItem(li as any, a.complexityMultiplier.toString()))),
...(job.supervisions ?? []).flatMap((sv: any) => sv.lineItems.map((li: any) => calcLineItem(li as any, sv.complexityMultiplier.toString()))),
...(job.previses ?? []).flatMap((pv: any) => pv.lineItems.map((li: any) => calcLineItem(li as any, pv.complexityMultiplier.toString()))),
];
const sub = allResults.reduce((s, r) => s.add(r.subtotal), new Decimal(0));
const { grandTotal } = applyJobAdjustments(sub, job.markupPercent.toString(), job.discountPercent.toString(), job.taxPercent.toString());
const productionFeeAmt = grandTotal.mul(new Decimal((job as any).productionFeePercent ?? 0).div(100));
const pullManagementFeeAmt = grandTotal.mul(new Decimal((job as any).pullManagementFeePercent ?? 0).div(100));
const grandTotalWithFees = grandTotal.add(productionFeeAmt).add(pullManagementFeeAmt);
const serialized = JSON.parse(JSON.stringify(job));
const serializedClients = JSON.parse(JSON.stringify(clients));
const serializedRateCards = JSON.parse(JSON.stringify(rateCards));
return <JobDetailView job={serialized} clients={serializedClients} rateCards={serializedRateCards} taskTemplates={taskTemplates} jobTotal={grandTotalWithFees.toNumber()} />;
}
+18
View File
@@ -0,0 +1,18 @@
import { getClients } from "@/actions/clients";
import { JobForm } from "@/components/jobs/job-form";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export default async function NewJobPage() {
const clients = await getClients();
return (
<div className="p-8 max-w-3xl">
<Link href="/jobs" className="flex items-center gap-2 text-zinc-400 hover:text-white mb-6 text-sm transition-colors">
<ArrowLeft className="h-4 w-4" /> Back to Jobs
</Link>
<h1 className="text-3xl font-bold text-white mb-8">New Quote</h1>
<JobForm clients={clients} />
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { getJobs } from "@/actions/jobs";
import { JobsTable } from "@/components/jobs/jobs-table";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Plus } from "lucide-react";
export default async function JobsPage() {
const rawJobs = await getJobs();
const jobs = JSON.parse(JSON.stringify(rawJobs));
return (
<div className="p-8">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Jobs & Quotes</h1>
<p className="text-zinc-400 mt-1">{jobs.length} job{jobs.length !== 1 ? "s" : ""}</p>
</div>
<Link
href="/jobs/new"
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 h-8 bg-amber-500 hover:bg-amber-400 text-black text-sm font-semibold transition-colors"
>
<Plus className="h-4 w-4" />
New Quote
</Link>
</div>
<JobsTable jobs={jobs} />
</div>
);
}
+17 -22
View File
@@ -1,33 +1,28 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
import { Sidebar } from "@/components/layout/sidebar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "xBid — VFX Quoting & Bidding",
description: "Professional VFX quotes, bids, and invoices for VFX studios",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="en" className="dark h-full">
<body className={`${inter.className} bg-background text-foreground antialiased h-full`}>
<div className="flex h-full overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
<Toaster richColors position="top-right" />
</body>
</html>
);
}
+33 -57
View File
@@ -1,65 +1,41 @@
import Image from "next/image";
import { prisma } from "@/lib/prisma";
import { DashboardStats } from "@/components/dashboard/stats";
import { RecentJobs } from "@/components/dashboard/recent-jobs";
export default async function DashboardPage() {
const [jobCount, clientCount, draftCount, sentCount, approvedCount, recentJobs] =
await Promise.all([
prisma.job.count(),
prisma.client.count(),
prisma.job.count({ where: { status: "DRAFT" } }),
prisma.job.count({ where: { status: "SENT" } }),
prisma.job.count({ where: { status: "APPROVED" } }),
prisma.job.findMany({
take: 8,
orderBy: { updatedAt: "desc" },
include: { client: { select: { companyName: true } } },
}),
]);
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
<p className="text-zinc-400 mt-1">Welcome back to xBid VFX quoting.</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
<DashboardStats
jobCount={jobCount}
clientCount={clientCount}
draftCount={draftCount}
sentCount={sentCount}
approvedCount={approvedCount}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<div className="mt-8">
<h2 className="text-xl font-semibold text-white mb-4">Recent Jobs</h2>
<RecentJobs jobs={recentJobs} />
</div>
</main>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { getAllRateCards } from "@/actions/rate-cards";
import { RateCardsClient } from "@/components/rate-cards/rate-cards-client";
export default async function RateCardsPage() {
const rateCards = JSON.parse(JSON.stringify(await getAllRateCards()));
return (
<div className="p-8">
<RateCardsClient initialRateCards={rateCards} />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { getStudioSettings } from "@/actions/settings";
import { SettingsForm } from "@/components/settings/settings-form";
import { Settings } from "lucide-react";
export default async function SettingsPage() {
const rawSettings = await getStudioSettings();
const settings = JSON.parse(JSON.stringify(rawSettings));
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-zinc-800 rounded-lg">
<Settings className="h-5 w-5 text-amber-500" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Studio Settings</h1>
<p className="text-sm text-zinc-400">Configure your studio details, branding, and quote defaults.</p>
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
<SettingsForm initialData={settings} />
</div>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { GripVertical, ChevronDown, ChevronRight, Trash2, Pencil, Plus } from "lucide-react";
import { updateAsset, deleteLineItem, createLineItem } from "@/actions/shots-assets";
import { toast } from "sonner";
import { LineItemsTable } from "@/components/line-items/line-items-table";
import { AssetDialog } from "./asset-dialog";
import { LineItemDialog } from "@/components/line-items/line-item-dialog";
import { calcLineItem, sumLineItems, formatCurrency } from "@/lib/calculations";
interface Asset {
id: string;
assetName: string;
assetType: string | null;
description: string | null;
complexityMultiplier: any;
notes: string | null;
lineItems: any[];
}
interface Props {
asset: Asset;
jobId: string;
currency: string;
rateCards: any[];
onDelete: (id: string) => void;
onUpdate: (asset: any) => void;
}
export function AssetCard({ asset, jobId, currency, rateCards, onDelete, onUpdate }: Props) {
const [expanded, setExpanded] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [addLineOpen, setAddLineOpen] = useState(false);
const [lineItems, setLineItems] = useState(asset.lineItems);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: asset.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
const totals = sumLineItems(
lineItems.map((li) =>
calcLineItem({
quantityHours: li.quantityHours?.toString() ?? "0",
dailyRate: li.dailyRate?.toString() ?? "0",
hoursPerDay: li.hoursPerDay?.toString() ?? "8",
numArtists: li.numArtists?.toString() ?? "1",
numDays: li.numDays?.toString() ?? "0",
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent?.toString() ?? "0",
discountPercent: li.discountPercent?.toString() ?? "0",
taxPercent: li.taxPercent?.toString() ?? "0",
}, asset.complexityMultiplier?.toString() ?? "1")
)
);
const handleEdit = async (data: any) => {
try {
const result = await updateAsset(asset.id, jobId, data);
onUpdate(result.asset);
setEditOpen(false);
toast.success("Asset updated");
} catch {
toast.error("Failed to update asset");
}
};
const handleAddLineItem = async (data: any) => {
try {
const result = await createLineItem(asset.id, "asset", jobId, data);
setLineItems((prev) => [...prev, result.lineItem]);
setAddLineOpen(false);
toast.success("Line item added");
} catch {
toast.error("Failed to add line item");
}
};
const handleDeleteLineItem = async (id: string) => {
try {
await deleteLineItem(id, jobId);
setLineItems((prev) => prev.filter((li) => li.id !== id));
toast.success("Line item removed");
} catch {
toast.error("Failed to delete line item");
}
};
const handleLineItemUpdate = (updated: any) => {
setLineItems((prev) => prev.map((li) => (li.id === updated.id ? updated : li)));
};
return (
<div ref={setNodeRef} style={style} className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3">
<button {...attributes} {...listeners} className="text-zinc-600 hover:text-zinc-400 cursor-grab active:cursor-grabbing">
<GripVertical className="h-4 w-4" />
</button>
<button onClick={() => setExpanded(!expanded)} className="flex items-center gap-2 flex-1 text-left">
{expanded ? <ChevronDown className="h-4 w-4 text-zinc-400" /> : <ChevronRight className="h-4 w-4 text-zinc-400" />}
<span className="font-semibold text-white">{asset.assetName}</span>
{asset.assetType && <span className="text-xs text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded">{asset.assetType}</span>}
<span className="ml-auto text-xs text-zinc-500">{lineItems.length} tasks</span>
</button>
<span className="font-mono text-sm text-amber-400 ml-4">{formatCurrency(totals.total, currency)}</span>
<div className="flex gap-1 ml-2">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setEditOpen(true)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => onDelete(asset.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{expanded && (
<div className="border-t border-zinc-800">
{(asset.description || Number(asset.complexityMultiplier) !== 1) && (
<div className="px-4 py-3 bg-zinc-800/30 text-sm text-zinc-400 flex gap-6">
{asset.description && <span>{asset.description}</span>}
{Number(asset.complexityMultiplier) !== 1 && (
<span className="text-amber-400">×{Number(asset.complexityMultiplier).toFixed(2)} complexity</span>
)}
</div>
)}
<LineItemsTable
lineItems={lineItems}
currency={currency}
rateCards={rateCards}
complexityMultiplier={asset.complexityMultiplier?.toString() ?? "1"}
parentId={asset.id}
parentType="asset"
jobId={jobId}
onDelete={handleDeleteLineItem}
onUpdate={handleLineItemUpdate}
/>
<div className="px-4 py-3 border-t border-zinc-800">
<Button size="sm" variant="outline" className="border-zinc-700 text-sm" onClick={() => setAddLineOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> Add Task
</Button>
</div>
</div>
)}
<AssetDialog open={editOpen} onOpenChange={setEditOpen} onSubmit={handleEdit} initialData={{
assetName: asset.assetName,
assetType: asset.assetType ?? undefined,
complexityMultiplier: Number(asset.complexityMultiplier),
description: asset.description ?? undefined,
notes: asset.notes ?? undefined,
}} />
<LineItemDialog
open={addLineOpen}
onOpenChange={setAddLineOpen}
onSubmit={handleAddLineItem}
rateCards={rateCards}
defaultTasks={[
"Modeling","Sculpting","Retopology","Texturing","Shading","Lookdev",
"Rigging","Groom","Animation Prep","Simulation Prep","Kitbash",
"Scanning Cleanup","Photogrammetry Processing","Asset Optimization",
"Rendering","Turntables","Concept Design","Technical Setup",
]}
/>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { assetSchema, type AssetFormValues } from "@/lib/validations";
import type { Resolver } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { ASSET_TYPES } from "@/lib/constants";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
onSubmit: (data: AssetFormValues) => Promise<void>;
initialData?: Partial<AssetFormValues>;
}
export function AssetDialog({ open, onOpenChange, onSubmit, initialData }: Props) {
const {
register,
handleSubmit,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<AssetFormValues>({
resolver: zodResolver(assetSchema) as Resolver<AssetFormValues>,
defaultValues: { complexityMultiplier: 1, ...initialData },
});
const handleClose = () => { reset(); onOpenChange(false); };
const submit = async (data: AssetFormValues) => { await onSubmit(data); reset(); };
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-lg">
<DialogHeader>
<DialogTitle>{initialData ? "Edit Asset" : "Add Asset"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label>Asset Name *</Label>
<Input {...register("assetName")} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Hero Character" />
{errors.assetName && <p className="text-red-400 text-xs">{errors.assetName.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Asset Type</Label>
<Select onValueChange={(v) => setValue("assetType", v ?? undefined)} defaultValue={initialData?.assetType ?? undefined}>
<SelectTrigger className="bg-zinc-800 border-zinc-700">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{ASSET_TYPES.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Complexity Multiplier</Label>
<Input {...register("complexityMultiplier")} type="number" step="0.1" min="0.1" max="10" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Input {...register("description")} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
<div className="flex gap-3 justify-end pt-2">
<Button type="button" variant="outline" className="border-zinc-700" onClick={handleClose}>Cancel</Button>
<Button type="submit" disabled={isSubmitting} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{initialData ? "Save" : "Add Asset"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { createAsset, deleteAsset, reorderAssets } from "@/actions/shots-assets";
import { AssetCard } from "./asset-card";
import { AssetDialog } from "./asset-dialog";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import type { Prisma } from "@prisma/client";
type JobWithRelations = Prisma.JobGetPayload<{
include: {
client: true;
shots: { include: { lineItems: { include: { rateCard: true } } } };
assets: { include: { lineItems: { include: { rateCard: true } } } };
comments: { include: { user: true } };
history: true;
};
}>;
export function AssetsEditor({ job, rateCards }: { job: JobWithRelations; rateCards: any[] }) {
const [assets, setAssets] = useState(job.assets);
const [dialogOpen, setDialogOpen] = useState(false);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = assets.findIndex((a) => a.id === active.id);
const newIndex = assets.findIndex((a) => a.id === over.id);
const reordered = arrayMove(assets, oldIndex, newIndex);
setAssets(reordered);
await reorderAssets(job.id, reordered.map((a) => a.id));
};
const handleCreate = async (data: any) => {
try {
const result = await createAsset(job.id, data);
setAssets((prev) => [...prev, result.asset as any]);
setDialogOpen(false);
toast.success("Asset added");
} catch {
toast.error("Failed to add asset");
}
};
const handleDelete = async (assetId: string) => {
try {
await deleteAsset(assetId, job.id);
setAssets((prev) => prev.filter((a) => a.id !== assetId));
toast.success("Asset deleted");
} catch {
toast.error("Failed to delete asset");
}
};
const handleAssetUpdate = (updated: any) => {
setAssets((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Assets ({assets.length})</h2>
<Button
onClick={() => setDialogOpen(true)}
size="sm"
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
<Plus className="h-4 w-4 mr-1" /> Add Asset
</Button>
</div>
{assets.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-dashed border-zinc-700 text-zinc-500">
<p>No assets yet. Add reusable VFX assets to this quote.</p>
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={assets.map((a) => a.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{assets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset as any}
jobId={job.id}
currency="ZAR"
rateCards={rateCards}
onDelete={handleDelete}
onUpdate={handleAssetUpdate}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
<AssetDialog open={dialogOpen} onOpenChange={setDialogOpen} onSubmit={handleCreate} />
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { clientSchema, type ClientFormValues } from "@/lib/validations";
import { createClient, updateClient } from "@/actions/clients";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
interface Props {
initialData?: Partial<ClientFormValues>;
clientId?: string;
}
export function ClientForm({ initialData, clientId }: Props) {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ClientFormValues>({
resolver: zodResolver(clientSchema),
defaultValues: initialData ?? {},
});
const onSubmit = async (data: ClientFormValues) => {
try {
if (clientId) {
await updateClient(clientId, data);
toast.success("Client updated");
} else {
const result = await createClient(data);
toast.success("Client created");
router.push(`/clients/${result.client.id}`);
}
} catch {
toast.error("Something went wrong");
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div className="space-y-1.5">
<Label>Company Name *</Label>
<Input {...register("companyName")} className="bg-zinc-900 border-zinc-700" placeholder="e.g. Framestore" />
{errors.companyName && <p className="text-red-400 text-xs">{errors.companyName.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Contact Person</Label>
<Input {...register("contactPerson")} className="bg-zinc-900 border-zinc-700" placeholder="Full name" />
</div>
<div className="space-y-1.5">
<Label>Email</Label>
<Input {...register("email")} type="email" className="bg-zinc-900 border-zinc-700" placeholder="contact@studio.com" />
{errors.email && <p className="text-red-400 text-xs">{errors.email.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Phone</Label>
<Input {...register("phone")} className="bg-zinc-900 border-zinc-700" placeholder="+1 555 000 0000" />
</div>
<div className="space-y-1.5">
<Label>VAT Number</Label>
<Input {...register("vatNumber")} className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Company Reg. Number</Label>
<Input {...register("companyRegNumber")} className="bg-zinc-900 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Billing Address</Label>
<Textarea {...register("billingAddress")} className="bg-zinc-900 border-zinc-700" rows={3} />
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-900 border-zinc-700" rows={3} />
</div>
<Button
type="submit"
disabled={isSubmitting}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{clientId ? "Save Changes" : "Create Client"}
</Button>
</form>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { deleteClient } from "@/actions/clients";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pencil, Trash2, Search, Building2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Client {
id: string;
companyName: string;
contactPerson: string | null;
email: string | null;
phone: string | null;
_count: { jobs: number };
}
export function ClientsTable({ clients }: { clients: Client[] }) {
const [search, setSearch] = useState("");
const [deleteId, setDeleteId] = useState<string | null>(null);
const filtered = clients.filter(
(c) =>
c.companyName.toLowerCase().includes(search.toLowerCase()) ||
c.contactPerson?.toLowerCase().includes(search.toLowerCase()) ||
c.email?.toLowerCase().includes(search.toLowerCase())
);
const handleDelete = async () => {
if (!deleteId) return;
try {
await deleteClient(deleteId);
toast.success("Client deleted");
} catch {
toast.error("Failed to delete client");
}
setDeleteId(null);
};
return (
<>
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
placeholder="Search clients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-zinc-900 border-zinc-700"
/>
</div>
</div>
{filtered.length === 0 ? (
<div className="text-center py-16 text-zinc-500">
<Building2 className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p>No clients found.</p>
</div>
) : (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-400 text-xs uppercase tracking-wider">
<th className="px-4 py-3 text-left">Company</th>
<th className="px-4 py-3 text-left">Contact</th>
<th className="px-4 py-3 text-left">Email</th>
<th className="px-4 py-3 text-left">Phone</th>
<th className="px-4 py-3 text-center">Jobs</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((client) => (
<tr key={client.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30 transition-colors">
<td className="px-4 py-3">
<Link href={`/clients/${client.id}`} className="font-medium text-white hover:text-amber-400 transition-colors">
{client.companyName}
</Link>
</td>
<td className="px-4 py-3 text-zinc-300">{client.contactPerson ?? "—"}</td>
<td className="px-4 py-3 text-zinc-400">{client.email ?? "—"}</td>
<td className="px-4 py-3 text-zinc-400">{client.phone ?? "—"}</td>
<td className="px-4 py-3 text-center">
<span className="text-zinc-400">{client._count.jobs}</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex gap-1 justify-end">
<Link href={`/clients/${client.id}`} className="inline-flex items-center justify-center h-7 w-7 rounded-lg hover:bg-zinc-700 transition-colors text-zinc-400 hover:text-zinc-200">
<Pencil className="h-3.5 w-3.5" />
</Link>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-400 hover:text-red-300"
onClick={() => setDeleteId(client.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-800">
<AlertDialogHeader>
<AlertDialogTitle>Delete client?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the client and all associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700">Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-500">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from "next/link";
import { format } from "date-fns";
import { Badge } from "@/components/ui/badge";
import { JOB_STATUSES } from "@/lib/constants";
interface Job {
id: string;
projectTitle: string;
projectCode: string;
status: string;
updatedAt: Date;
client: { companyName: string };
}
const statusColors: Record<string, string> = {
DRAFT: "bg-zinc-700 text-zinc-300",
SENT: "bg-blue-900/60 text-blue-300",
APPROVED: "bg-green-900/60 text-green-300",
REJECTED: "bg-red-900/60 text-red-300",
ARCHIVED: "bg-yellow-900/60 text-yellow-300",
};
export function RecentJobs({ jobs }: { jobs: Job[] }) {
if (jobs.length === 0) {
return (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-10 text-center text-zinc-500">
No jobs yet.{" "}
<Link href="/jobs/new" className="text-amber-400 hover:underline">
Create your first quote
</Link>
.
</div>
);
}
return (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-400 text-xs uppercase tracking-wider">
<th className="px-4 py-3 text-left">Project</th>
<th className="px-4 py-3 text-left">Client</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Updated</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr
key={job.id}
className="border-b border-zinc-800/50 hover:bg-zinc-800/40 transition-colors"
>
<td className="px-4 py-3">
<Link href={`/jobs/${job.id}`} className="font-medium text-white hover:text-amber-400 transition-colors">
{job.projectTitle}
</Link>
<div className="text-xs text-zinc-500">{job.projectCode}</div>
</td>
<td className="px-4 py-3 text-zinc-300">{job.client.companyName}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColors[job.status]}`}>
{JOB_STATUSES.find((s) => s.value === job.status)?.label ?? job.status}
</span>
</td>
<td className="px-4 py-3 text-zinc-500">
{format(new Date(job.updatedAt), "MMM d, yyyy")}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Briefcase, Users, FileText, CheckCircle, Clock } from "lucide-react";
interface Props {
jobCount: number;
clientCount: number;
draftCount: number;
sentCount: number;
approvedCount: number;
}
export function DashboardStats({ jobCount, clientCount, draftCount, sentCount, approvedCount }: Props) {
const stats = [
{ label: "Total Jobs", value: jobCount, icon: Briefcase, color: "text-amber-400" },
{ label: "Clients", value: clientCount, icon: Users, color: "text-blue-400" },
{ label: "Drafts", value: draftCount, icon: FileText, color: "text-zinc-400" },
{ label: "Sent", value: sentCount, icon: Clock, color: "text-yellow-400" },
{ label: "Approved", value: approvedCount, icon: CheckCircle, color: "text-green-400" },
];
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{stats.map(({ label, value, icon: Icon, color }) => (
<Card key={label} className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<Icon className={`h-4 w-4 ${color}`} />
{label}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">{value}</div>
</CardContent>
</Card>
))}
</div>
);
}
+398
View File
@@ -0,0 +1,398 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { toast } from "sonner";
import { ArrowLeft, Printer, Pencil, Plus, Trash2, Send, Check } from "lucide-react";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { recordPayment, deletePayment, deleteInvoice, updateInvoice } from "@/actions/invoices";
import { formatCurrency } from "@/lib/calculations";
import type { Prisma } from "@prisma/client";
type FullInvoice = Prisma.InvoiceGetPayload<{
include: {
client: true;
job: { include: { paymentSchedule: { include: { milestones: true } } } };
milestone: true;
payments: true;
};
}>;
const STATUS_COLORS: Record<string, string> = {
DRAFT: "bg-zinc-700 text-zinc-300",
SENT: "bg-blue-900/60 text-blue-300",
VIEWED: "bg-purple-900/60 text-purple-300",
PARTIALLY_PAID: "bg-orange-900/60 text-orange-300",
PAID: "bg-green-900/60 text-green-300",
OVERDUE: "bg-red-900/60 text-red-300",
CANCELLED: "bg-zinc-800 text-zinc-500",
};
const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SENT: "Sent", VIEWED: "Viewed",
PARTIALLY_PAID: "Partially Paid", PAID: "Paid", OVERDUE: "Overdue", CANCELLED: "Cancelled",
};
const PAYMENT_METHODS = ["EFT", "CASH", "CREDIT_CARD", "BANK_TRANSFER", "CRYPTO", "OTHER"];
interface Props {
invoice: FullInvoice;
}
export function InvoiceDetail({ invoice: initialInvoice }: Props) {
const router = useRouter();
const [invoice, setInvoice] = useState(initialInvoice);
const [isPending, startTransition] = useTransition();
const [payDialogOpen, setPayDialogOpen] = useState(false);
const [payForm, setPayForm] = useState({
amount: Number(invoice.amountDue),
paymentDate: format(new Date(), "yyyy-MM-dd"),
paymentMethod: "EFT" as string,
reference: "",
notes: "",
});
const fmt = (v: number) => formatCurrency(v, "ZAR");
const amountPaid = Number(invoice.amountPaid);
const amountDue = Number(invoice.amountDue);
const totalAmount = Number(invoice.totalAmount);
const paidPercent = totalAmount > 0 ? Math.min(100, (amountPaid / totalAmount) * 100) : 0;
const handleRecordPayment = () => {
startTransition(async () => {
try {
await recordPayment(invoice.id, payForm as any);
toast.success("Payment recorded");
setPayDialogOpen(false);
router.refresh();
} catch {
toast.error("Failed to record payment");
}
});
};
const handleDeletePayment = (paymentId: string) => {
startTransition(async () => {
try {
await deletePayment(invoice.id, paymentId);
toast.success("Payment removed");
router.refresh();
} catch {
toast.error("Failed to remove payment");
}
});
};
const handleDelete = () => {
if (!confirm("Delete this invoice? This cannot be undone.")) return;
startTransition(async () => {
try {
await deleteInvoice(invoice.id);
toast.success("Invoice deleted");
router.push("/invoices");
} catch {
toast.error("Failed to delete invoice");
}
});
};
return (
<div className="p-6 space-y-6 max-w-5xl">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-1">
<Link href="/invoices" className="text-zinc-500 hover:text-zinc-300 transition-colors">
<ArrowLeft className="h-4 w-4" />
</Link>
<h1 className="text-2xl font-bold text-white font-mono">{invoice.invoiceNumber}</h1>
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-semibold ${STATUS_COLORS[invoice.status] ?? ""}`}>
{STATUS_LABELS[invoice.status] ?? invoice.status}
</span>
</div>
<p className="text-sm text-zinc-400 ml-7">
{invoice.client.companyName} · {invoice.job.projectCode}
</p>
</div>
<div className="flex items-center gap-2">
<a href={`/api/pdf/invoice/${invoice.id}`} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm" className="border-zinc-700 text-zinc-300">
<Printer className="h-3.5 w-3.5 mr-1.5" /> PDF
</Button>
</a>
<Link href={`/invoices/${invoice.id}/edit`}>
<Button variant="outline" size="sm" className="border-zinc-700 text-zinc-300">
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
</Button>
</Link>
<Button
variant="ghost" size="sm"
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={handleDelete} disabled={isPending}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" /> Delete
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-6">
{/* Left: Invoice info */}
<div className="col-span-2 space-y-6">
{/* Billing info */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 grid grid-cols-2 gap-6">
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-2">Bill To</p>
<p className="font-semibold text-white">{invoice.client.companyName}</p>
{invoice.client.billingAddress && <p className="text-sm text-zinc-400 mt-1 whitespace-pre-line">{invoice.client.billingAddress}</p>}
{invoice.client.vatNumber && <p className="text-xs text-zinc-500 mt-1">VAT: {invoice.client.vatNumber}</p>}
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-zinc-500">Issue Date</span>
<span className="text-zinc-300">{format(new Date(invoice.issueDate), "MMM d, yyyy")}</span>
</div>
{invoice.dueDate && (
<div className="flex justify-between">
<span className="text-zinc-500">Due Date</span>
<span className="text-zinc-300">{format(new Date(invoice.dueDate), "MMM d, yyyy")}</span>
</div>
)}
{invoice.paymentTerms && (
<div className="flex justify-between">
<span className="text-zinc-500">Terms</span>
<span className="text-zinc-300">{invoice.paymentTerms}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-zinc-500">Project</span>
<Link href={`/jobs/${invoice.job.id}`} className="text-amber-400 hover:text-amber-300">
{invoice.job.projectCode}
</Link>
</div>
{invoice.milestone && (
<div className="flex justify-between">
<span className="text-zinc-500">Milestone</span>
<span className="text-zinc-300">{invoice.milestone.title}</span>
</div>
)}
</div>
</div>
{/* Services / Line Items */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<div className="px-5 py-3 border-b border-zinc-800">
<p className="text-xs text-zinc-500 uppercase tracking-wider">Services</p>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left text-xs text-zinc-500 font-semibold uppercase tracking-wider px-5 py-2.5">#</th>
<th className="text-left text-xs text-zinc-500 font-semibold uppercase tracking-wider px-5 py-2.5">Description</th>
<th className="text-left text-xs text-zinc-500 font-semibold uppercase tracking-wider px-5 py-2.5">Period</th>
<th className="text-right text-xs text-zinc-500 font-semibold uppercase tracking-wider px-5 py-2.5">%</th>
<th className="text-right text-xs text-zinc-500 font-semibold uppercase tracking-wider px-5 py-2.5">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td className="px-5 py-3.5 text-zinc-500 align-top">1</td>
<td className="px-5 py-3.5 align-top">
<p className="font-medium text-white">
{invoice.job.projectCode} — {invoice.milestone?.title ?? "Professional VFX Services"}
</p>
{invoice.milestone?.description && (
<p className="text-xs text-zinc-400 mt-0.5">{invoice.milestone.description}</p>
)}
<p className="text-xs text-zinc-500 mt-0.5">{invoice.job.projectTitle}</p>
</td>
<td className="px-5 py-3.5 text-zinc-400 align-top whitespace-nowrap">
{invoice.milestone?.dueDate
? format(new Date(invoice.milestone.dueDate), "MMM yyyy")
: format(new Date(invoice.issueDate), "MMM yyyy")}
</td>
<td className="px-5 py-3.5 text-right text-zinc-400 align-top font-mono">
{invoice.milestone
? `${Number(invoice.milestone.percentOfTotal).toFixed(0)}%`
: "—"}
</td>
<td className="px-5 py-3.5 text-right font-mono font-semibold text-white align-top">
{fmt(Number(invoice.subtotal))}
</td>
</tr>
</tbody>
</table>
</div>
{/* Financials */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 space-y-3">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-3">Financials</p>
{[
{ label: "Subtotal", value: Number(invoice.subtotal) },
{ label: `Tax (${Number(invoice.taxPercent)}%)`, value: Number(invoice.taxAmount) },
].map(row => (
<div key={row.label} className="flex justify-between text-sm">
<span className="text-zinc-400">{row.label}</span>
<span className="font-mono text-zinc-300">{fmt(row.value)}</span>
</div>
))}
<Separator className="bg-zinc-700" />
<div className="flex justify-between font-bold">
<span className="text-white">Total</span>
<span className="font-mono text-amber-400 text-lg">{fmt(totalAmount)}</span>
</div>
</div>
{/* Notes */}
{invoice.notes && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-2">Notes</p>
<p className="text-sm text-zinc-300 whitespace-pre-line">{invoice.notes}</p>
</div>
)}
{/* Banking details */}
{invoice.bankingDetails && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-2">Banking Details</p>
<p className="text-sm text-zinc-300 whitespace-pre-line font-mono">{invoice.bankingDetails}</p>
</div>
)}
{/* Payment history */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">Payment History</p>
{invoice.status !== "PAID" && invoice.status !== "CANCELLED" && (
<Button size="sm" onClick={() => setPayDialogOpen(true)} className="bg-green-700 hover:bg-green-600 text-white h-7 text-xs">
<Plus className="h-3 w-3 mr-1" /> Record Payment
</Button>
)}
</div>
{invoice.payments.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-4">No payments recorded yet</p>
) : (
<div className="space-y-2">
{invoice.payments.map(p => (
<div key={p.id} className="flex items-center justify-between py-2 border-b border-zinc-800 last:border-0">
<div>
<p className="text-sm font-semibold text-green-400 font-mono">{fmt(Number(p.amount))}</p>
<p className="text-xs text-zinc-500">
{format(new Date(p.paymentDate), "MMM d, yyyy")} · {p.paymentMethod}
{p.reference && ` · ${p.reference}`}
</p>
</div>
<Button variant="ghost" size="icon" className="h-7 w-7 text-zinc-500 hover:text-red-400" onClick={() => handleDeletePayment(p.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
{/* Right: Payment status */}
<div className="space-y-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 space-y-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">Payment Status</p>
<div>
<div className="flex justify-between text-xs text-zinc-500 mb-1">
<span>{fmt(amountPaid)} paid</span>
<span>{fmt(totalAmount)}</span>
</div>
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${paidPercent}%` }}
/>
</div>
<p className="text-xs text-zinc-500 mt-1 text-right">{paidPercent.toFixed(0)}% paid</p>
</div>
<Separator className="bg-zinc-800" />
{[
{ label: "Invoice Total", value: fmt(totalAmount), color: "text-white" },
{ label: "Amount Paid", value: fmt(amountPaid), color: "text-green-400" },
{ label: "Balance Due", value: fmt(amountDue), color: amountDue > 0 ? "text-amber-400" : "text-green-400" },
].map(row => (
<div key={row.label} className="flex justify-between">
<span className="text-sm text-zinc-400">{row.label}</span>
<span className={`font-mono font-semibold text-sm ${row.color}`}>{row.value}</span>
</div>
))}
{invoice.status !== "PAID" && invoice.status !== "CANCELLED" && (
<Button
className="w-full bg-green-700 hover:bg-green-600 text-white"
onClick={() => setPayDialogOpen(true)}
disabled={isPending}
>
<Check className="h-4 w-4 mr-1.5" /> Record Payment
</Button>
)}
</div>
</div>
</div>
{/* Record payment dialog */}
<Dialog open={payDialogOpen} onOpenChange={setPayDialogOpen}>
<DialogContent className="bg-zinc-900 border-zinc-800">
<DialogHeader>
<DialogTitle>Record Payment</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label>Amount *</Label>
<Input
type="number" min={0} step={0.01}
value={payForm.amount}
onChange={e => setPayForm(f => ({ ...f, amount: Number(e.target.value) }))}
className="bg-zinc-800 border-zinc-700 font-mono"
/>
<p className="text-xs text-zinc-500">Balance due: {fmt(amountDue)}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Payment Date *</Label>
<Input type="date" value={payForm.paymentDate} onChange={e => setPayForm(f => ({ ...f, paymentDate: e.target.value }))} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Method</Label>
<Select value={payForm.paymentMethod} onValueChange={v => setPayForm(f => ({ ...f, paymentMethod: v ?? "EFT" }))}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{PAYMENT_METHODS.map(m => <SelectItem key={m} value={m}>{m.replace("_", " ")}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label>Reference</Label>
<Input value={payForm.reference} onChange={e => setPayForm(f => ({ ...f, reference: e.target.value }))} placeholder="Payment reference / EFT number" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea value={payForm.notes} onChange={e => setPayForm(f => ({ ...f, notes: e.target.value }))} className="bg-zinc-800 border-zinc-700 resize-none" rows={2} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setPayDialogOpen(false)} className="border-zinc-700">Cancel</Button>
<Button onClick={handleRecordPayment} disabled={isPending || !payForm.amount} className="bg-green-700 hover:bg-green-600">
Record Payment
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
"use client";
import { useTransition, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { toast } from "sonner";
import Decimal from "decimal.js";
import type { Resolver } from "react-hook-form";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { invoiceSchema, type InvoiceFormValues } from "@/lib/validations";
import { createInvoice, updateInvoice, nextInvoiceNumber } from "@/actions/invoices";
import { formatCurrency } from "@/lib/calculations";
import { CURRENCIES } from "@/lib/constants";
import type { Prisma } from "@prisma/client";
type ClientRow = { id: string; companyName: string };
type JobRow = { id: string; projectTitle: string; projectCode: string; clientId: string };
interface Props {
clients: ClientRow[];
jobs: JobRow[];
invoice?: Prisma.InvoiceGetPayload<Record<string, never>> | null;
}
const STATUSES = [
{ value: "DRAFT", label: "Draft" },
{ value: "SENT", label: "Sent" },
{ value: "VIEWED", label: "Viewed" },
{ value: "PARTIALLY_PAID", label: "Partially Paid" },
{ value: "PAID", label: "Paid" },
{ value: "OVERDUE", label: "Overdue" },
{ value: "CANCELLED", label: "Cancelled" },
];
export function InvoiceForm({ clients, jobs, invoice }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const today = format(new Date(), "yyyy-MM-dd");
const { register, control, watch, setValue, handleSubmit, formState: { errors } } = useForm<InvoiceFormValues>({
resolver: zodResolver(invoiceSchema) as Resolver<InvoiceFormValues>,
defaultValues: invoice
? {
invoiceNumber: invoice.invoiceNumber,
clientId: invoice.clientId,
jobId: invoice.jobId,
milestoneId: invoice.milestoneId ?? "",
status: invoice.status as any,
issueDate: format(new Date(invoice.issueDate), "yyyy-MM-dd"),
dueDate: invoice.dueDate ? format(new Date(invoice.dueDate), "yyyy-MM-dd") : "",
paymentTerms: invoice.paymentTerms ?? "",
currency: invoice.currency,
subtotal: Number(invoice.subtotal),
taxPercent: Number(invoice.taxPercent),
taxAmount: Number(invoice.taxAmount),
totalAmount: Number(invoice.totalAmount),
notes: invoice.notes ?? "",
internalNotes: invoice.internalNotes ?? "",
bankingDetails: invoice.bankingDetails ?? "",
}
: {
invoiceNumber: "",
clientId: "",
jobId: "",
status: "DRAFT",
issueDate: today,
currency: "ZAR",
subtotal: 0,
taxPercent: 15,
taxAmount: 0,
totalAmount: 0,
},
});
const subtotal = watch("subtotal") ?? 0;
const taxPercent = watch("taxPercent") ?? 0;
const currency = watch("currency") ?? "ZAR";
const clientId = watch("clientId");
// Auto-populate invoice number on new
useEffect(() => {
if (!invoice) {
nextInvoiceNumber().then(n => setValue("invoiceNumber", n));
}
}, [invoice, setValue]);
// Recalculate tax + total when subtotal or taxPercent changes
useEffect(() => {
const tax = new Decimal(subtotal || 0).mul(new Decimal(taxPercent || 0).div(100));
const total = new Decimal(subtotal || 0).add(tax);
setValue("taxAmount", tax.toDecimalPlaces(2).toNumber());
setValue("totalAmount", total.toDecimalPlaces(2).toNumber());
}, [subtotal, taxPercent, setValue]);
const filteredJobs = jobs.filter(j => !clientId || j.clientId === clientId);
const onSubmit = (data: InvoiceFormValues) => {
startTransition(async () => {
try {
if (invoice) {
await updateInvoice(invoice.id, data);
toast.success("Invoice updated");
router.push(`/invoices/${invoice.id}`);
} else {
const r = await createInvoice(data);
toast.success("Invoice created");
router.push(`/invoices/${r.invoice.id}`);
}
} catch {
toast.error("Failed to save invoice");
}
});
};
return (
<form onSubmit={handleSubmit(onSubmit as Parameters<typeof handleSubmit>[0])} className="space-y-8 p-6 max-w-3xl">
<div>
<h1 className="text-2xl font-bold text-white">{invoice ? "Edit Invoice" : "New Invoice"}</h1>
</div>
{/* Invoice details */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 space-y-5">
<h2 className="font-semibold text-white">Invoice Details</h2>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Invoice Number *</Label>
<Input {...register("invoiceNumber")} className="bg-zinc-800 border-zinc-700 font-mono" />
{errors.invoiceNumber && <p className="text-xs text-red-400">{errors.invoiceNumber.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Status</Label>
<Controller control={control} name="status" render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{STATUSES.map(s => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Client *</Label>
<Controller control={control} name="clientId" render={({ field: f }) => (
<Select value={f.value} onValueChange={v => { f.onChange(v); setValue("jobId", ""); }}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue placeholder="Select client..." /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{clients.map(c => <SelectItem key={c.id} value={c.id}>{c.companyName}</SelectItem>)}
</SelectContent>
</Select>
)} />
{errors.clientId && <p className="text-xs text-red-400">{errors.clientId.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Job / Project *</Label>
<Controller control={control} name="jobId" render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue placeholder="Select job..." /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{filteredJobs.map(j => (
<SelectItem key={j.id} value={j.id}>{j.projectCode} — {j.projectTitle}</SelectItem>
))}
</SelectContent>
</Select>
)} />
{errors.jobId && <p className="text-xs text-red-400">{errors.jobId.message}</p>}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label>Issue Date *</Label>
<Input type="date" {...register("issueDate")} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Due Date</Label>
<Input type="date" {...register("dueDate")} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Payment Terms</Label>
<Input {...register("paymentTerms")} placeholder="e.g. Net 30" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
</div>
{/* Financials */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 space-y-5">
<h2 className="font-semibold text-white">Financial</h2>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label>Currency</Label>
<Controller control={control} name="currency" render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{CURRENCIES.map(c => <SelectItem key={c.code} value={c.code}>{c.code} — {c.name}</SelectItem>)}
</SelectContent>
</Select>
)} />
</div>
<div className="space-y-1.5">
<Label>Subtotal</Label>
<Input type="number" min={0} step={0.01} {...register("subtotal", { valueAsNumber: true })} className="bg-zinc-800 border-zinc-700 font-mono" />
</div>
<div className="space-y-1.5">
<Label>Tax %</Label>
<Input type="number" min={0} max={100} step={0.5} {...register("taxPercent", { valueAsNumber: true })} className="bg-zinc-800 border-zinc-700" />
</div>
<input type="hidden" {...register("taxAmount", { valueAsNumber: true })} />
<input type="hidden" {...register("totalAmount", { valueAsNumber: true })} />
</div>
<div className="space-y-1.5 pt-2 border-t border-zinc-700">
{[
{ label: "Subtotal", value: Number(watch("subtotal") ?? 0) },
{ label: `Tax (${taxPercent}%)`, value: Number(watch("taxAmount") ?? 0) },
].map(row => (
<div key={row.label} className="flex justify-between text-sm">
<span className="text-zinc-400">{row.label}</span>
<span className="font-mono text-zinc-300">{formatCurrency(row.value, currency)}</span>
</div>
))}
<div className="flex justify-between font-bold pt-1 border-t border-zinc-700">
<span className="text-white">Total</span>
<span className="font-mono text-amber-400 text-lg">{formatCurrency(Number(watch("totalAmount") ?? 0), currency)}</span>
</div>
</div>
</div>
{/* Notes */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 space-y-5">
<h2 className="font-semibold text-white">Notes & Details</h2>
<div className="space-y-1.5">
<Label>Notes (visible to client)</Label>
<Textarea {...register("notes")} rows={3} className="bg-zinc-800 border-zinc-700 resize-none" />
</div>
<div className="space-y-1.5">
<Label>Banking Details</Label>
<Textarea {...register("bankingDetails")} rows={4} placeholder="Bank name, account number, branch code..." className="bg-zinc-800 border-zinc-700 resize-none font-mono text-sm" />
</div>
<div className="space-y-1.5">
<Label>Internal Notes</Label>
<Textarea {...register("internalNotes")} rows={2} className="bg-zinc-800 border-zinc-700 resize-none" />
</div>
</div>
<div className="flex gap-3">
<Button type="submit" disabled={isPending} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{invoice ? "Update Invoice" : "Create Invoice"}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()} className="border-zinc-700 text-zinc-300">
Cancel
</Button>
</div>
</form>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { Plus, Search, FileText, ArrowUpRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { deleteInvoice } from "@/actions/invoices";
import { formatCurrency } from "@/lib/calculations";
import { toast } from "sonner";
import type { Prisma } from "@prisma/client";
type InvoiceWithRelations = Prisma.InvoiceGetPayload<{
include: {
client: { select: { companyName: true } };
job: { select: { projectTitle: true; projectCode: true } };
payments: true;
};
}>;
const STATUS_COLORS: Record<string, string> = {
DRAFT: "bg-zinc-700 text-zinc-300",
SENT: "bg-blue-900/60 text-blue-300",
VIEWED: "bg-purple-900/60 text-purple-300",
PARTIALLY_PAID: "bg-orange-900/60 text-orange-300",
PAID: "bg-green-900/60 text-green-300",
OVERDUE: "bg-red-900/60 text-red-300",
CANCELLED: "bg-zinc-800 text-zinc-500",
};
const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SENT: "Sent", VIEWED: "Viewed",
PARTIALLY_PAID: "Partial", PAID: "Paid", OVERDUE: "Overdue", CANCELLED: "Cancelled",
};
interface Props {
initialInvoices: InvoiceWithRelations[];
}
export function InvoicesClient({ initialInvoices }: Props) {
const [invoices, setInvoices] = useState(initialInvoices);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [isPending, startTransition] = useTransition();
const filtered = invoices.filter(inv => {
const matchesSearch = search === "" ||
inv.invoiceNumber.toLowerCase().includes(search.toLowerCase()) ||
inv.client.companyName.toLowerCase().includes(search.toLowerCase()) ||
inv.job.projectCode.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === "ALL" || inv.status === statusFilter;
return matchesSearch && matchesStatus;
});
const totalInvoiced = filtered.reduce((s, i) => s + Number(i.totalAmount), 0);
const totalPaid = filtered.reduce((s, i) => s + Number(i.amountPaid), 0);
const totalDue = filtered.reduce((s, i) => s + Number(i.amountDue), 0);
const handleDelete = (id: string) => {
if (!confirm("Delete this invoice?")) return;
startTransition(async () => {
try {
await deleteInvoice(id);
setInvoices(prev => prev.filter(i => i.id !== id));
toast.success("Invoice deleted");
} catch {
toast.error("Failed to delete invoice");
}
});
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Invoices</h1>
<p className="text-sm text-zinc-400 mt-0.5">{invoices.length} invoice{invoices.length !== 1 ? "s" : ""}</p>
</div>
<Link href="/invoices/new">
<Button className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
<Plus className="h-4 w-4 mr-1.5" /> New Invoice
</Button>
</Link>
</div>
{/* Summary */}
<div className="grid grid-cols-3 gap-4">
{[
{ label: "Total Invoiced", value: formatCurrency(totalInvoiced, "ZAR"), color: "text-white" },
{ label: "Total Paid", value: formatCurrency(totalPaid, "ZAR"), color: "text-green-400" },
{ label: "Outstanding", value: formatCurrency(totalDue, "ZAR"), color: "text-amber-400" },
].map(c => (
<div key={c.label} className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{c.label}</p>
<p className={`text-xl font-bold font-mono ${c.color}`}>{c.value}</p>
</div>
))}
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search invoices..."
className="pl-9 bg-zinc-900 border-zinc-700"
/>
</div>
<Select value={statusFilter} onValueChange={v => setStatusFilter(v ?? "ALL")}>
<SelectTrigger className="w-44 bg-zinc-900 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="ALL">All Statuses</SelectItem>
{Object.entries(STATUS_LABELS).map(([v, l]) => (
<SelectItem key={v} value={v}>{l}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Table */}
{filtered.length === 0 ? (
<div className="text-center py-16 text-zinc-500">
<FileText className="h-8 w-8 mx-auto mb-3 opacity-40" />
<p>No invoices found</p>
</div>
) : (
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-zinc-900 border-b border-zinc-800">
{["Invoice #", "Client", "Project", "Issue Date", "Due Date", "Amount", "Paid", "Status", ""].map(h => (
<th key={h} className="text-left text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(inv => (
<tr key={inv.id} className="border-b border-zinc-800 hover:bg-zinc-900/50 transition-colors">
<td className="px-4 py-3">
<Link href={`/invoices/${inv.id}`} className="font-mono font-semibold text-amber-400 hover:text-amber-300 text-sm">
{inv.invoiceNumber}
</Link>
</td>
<td className="px-4 py-3 text-sm text-zinc-300">{inv.client.companyName}</td>
<td className="px-4 py-3 text-sm text-zinc-400">{inv.job.projectCode}</td>
<td className="px-4 py-3 text-sm text-zinc-400">{format(new Date(inv.issueDate), "MMM d, yyyy")}</td>
<td className="px-4 py-3 text-sm text-zinc-400">
{inv.dueDate ? format(new Date(inv.dueDate), "MMM d, yyyy") : "—"}
</td>
<td className="px-4 py-3 text-sm font-mono font-semibold text-white text-right">
{formatCurrency(Number(inv.totalAmount), "ZAR")}
</td>
<td className="px-4 py-3 text-sm font-mono text-green-400 text-right">
{formatCurrency(Number(inv.amountPaid), "ZAR")}
</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-semibold ${STATUS_COLORS[inv.status] ?? ""}`}>
{STATUS_LABELS[inv.status] ?? inv.status}
</span>
</td>
<td className="px-4 py-3">
<Link href={`/invoices/${inv.id}`}>
<Button variant="ghost" size="icon" className="h-7 w-7 text-zinc-400 hover:text-white">
<ArrowUpRight className="h-3.5 w-3.5" />
</Button>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { toast } from "sonner";
import { ArrowLeft, Download, Copy, FileText, Settings, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { JobForm } from "./job-form";
import { ShotsEditor } from "@/components/shots/shots-editor";
import { AssetsEditor } from "@/components/assets/assets-editor";
import { SupervisionEditor } from "@/components/supervision/supervision-editor";
import { PrevisEditor } from "@/components/previs/previs-editor";
import { JobTotals } from "./job-totals";
import { PaymentScheduleEditor } from "@/components/payment-schedule/payment-schedule-editor";
import { duplicateJob, updateJobStatus } from "@/actions/jobs";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { JOB_STATUSES } from "@/lib/constants";
import type { Prisma } from "@prisma/client";
type JobWithRelations = Prisma.JobGetPayload<{
include: {
client: true;
shots: { include: { lineItems: { include: { rateCard: true } } } };
assets: { include: { lineItems: { include: { rateCard: true } } } };
supervisions: { include: { lineItems: { include: { rateCard: true } } } };
previses: { include: { lineItems: { include: { rateCard: true } } } };
comments: { include: { user: true } };
history: true;
paymentSchedule: { include: { milestones: true } };
};
}>;
const statusColors: Record<string, string> = {
DRAFT: "bg-zinc-700 text-zinc-300",
SENT: "bg-blue-900/60 text-blue-300",
APPROVED: "bg-green-900/60 text-green-300",
REJECTED: "bg-red-900/60 text-red-300",
ARCHIVED: "bg-yellow-900/60 text-yellow-300",
};
export function JobDetailView({
job,
clients,
rateCards,
taskTemplates,
jobTotal,
}: {
job: JobWithRelations;
clients: { id: string; companyName: string }[];
rateCards: any[];
taskTemplates: any[];
jobTotal?: number;
}) {
const [activeStatus, setActiveStatus] = useState<string>(job.status);
const handleStatusChange = async (status: string) => {
try {
await updateJobStatus(job.id, status);
setActiveStatus(status);
toast.success(`Status updated to ${status}`);
} catch {
toast.error("Failed to update status");
}
};
const handleDuplicate = async () => {
try {
const result = await duplicateJob(job.id);
toast.success("Quote duplicated");
window.location.href = `/jobs/${result.job.id}`;
} catch {
toast.error("Failed to duplicate");
}
};
const handlePdfDownload = () => {
window.open(`/api/pdf/${job.id}`, "_blank");
};
return (
<div className="flex flex-col h-full">
{/* Top bar */}
<div className="border-b border-zinc-800 px-8 py-4 flex items-center gap-4 bg-zinc-950">
<Link href="/jobs" className="text-zinc-400 hover:text-white transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold text-white truncate">{job.projectTitle}</h1>
<div className="text-sm text-zinc-500 flex items-center gap-2">
<span>{job.projectCode}</span>
<span>·</span>
<span>{job.client.companyName}</span>
<span>·</span>
<span>Issued {format(new Date(job.issueDate), "MMM d, yyyy")}</span>
</div>
</div>
{/* Status */}
<DropdownMenu>
<DropdownMenuTrigger className={`px-3 py-1.5 rounded-md text-xs font-semibold flex items-center gap-1.5 ${statusColors[activeStatus]}`}>
{JOB_STATUSES.find((s) => s.value === activeStatus)?.label}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-zinc-900 border-zinc-800">
{JOB_STATUSES.map((s) => (
<DropdownMenuItem key={s.value} onClick={() => handleStatusChange(s.value)}>
{s.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={handleDuplicate} className="border-zinc-700">
<Copy className="h-4 w-4 mr-1.5" /> Duplicate
</Button>
<Button
size="sm"
onClick={handlePdfDownload}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
<Download className="h-4 w-4 mr-1.5" /> PDF
</Button>
</div>
{/* Tabs */}
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="shots" className="h-full flex flex-col">
<div className="border-b border-zinc-800 px-8">
<TabsList className="bg-transparent border-0 h-auto p-0 gap-0">
{["shots", "assets", "supervision", "previs", "details", "totals", "payment"].map((tab) => (
<TabsTrigger
key={tab}
value={tab}
className="rounded-none border-b-2 border-transparent data-[state=active]:border-amber-500 data-[state=active]:text-white data-[state=active]:bg-transparent text-zinc-400 capitalize px-4 py-3 text-sm"
>
{tab === "shots" ? `Shots (${job.shots.length})` :
tab === "assets" ? `Assets (${job.assets.length})` :
tab === "supervision" ? `Supervision (${(job as any).supervisions?.length ?? 0})` :
tab === "previs" ? `Previs (${(job as any).previses?.length ?? 0})` :
tab === "payment" ? "Payment Schedule" :
tab.charAt(0).toUpperCase() + tab.slice(1)}
</TabsTrigger>
))}
</TabsList>
</div>
<div className="flex-1 overflow-y-auto p-8">
<TabsContent value="shots" className="m-0">
<ShotsEditor job={job} rateCards={rateCards} taskTemplates={taskTemplates} />
</TabsContent>
<TabsContent value="assets" className="m-0">
<AssetsEditor job={job} rateCards={rateCards} />
</TabsContent>
<TabsContent value="supervision" className="m-0">
<SupervisionEditor job={job as any} rateCards={rateCards} />
</TabsContent>
<TabsContent value="previs" className="m-0">
<PrevisEditor job={job as any} rateCards={rateCards} />
</TabsContent>
<TabsContent value="details" className="m-0 max-w-3xl">
<JobForm clients={clients} initialData={{
projectTitle: job.projectTitle,
projectCode: job.projectCode,
showId: job.showId,
jobType: job.jobType as any,
clientId: job.clientId,
issueDate: format(new Date(job.issueDate), "yyyy-MM-dd"),
expiryDate: job.expiryDate ? format(new Date(job.expiryDate), "yyyy-MM-dd") : undefined,
currency: job.currency,
status: job.status as any,
description: job.description ?? undefined,
internalNotes: job.internalNotes ?? undefined,
markupPercent: Number(job.markupPercent),
taxPercent: Number(job.taxPercent),
discountPercent: Number(job.discountPercent),
productionFeePercent: Number((job as any).productionFeePercent ?? 0),
pullManagementFeePercent: Number((job as any).pullManagementFeePercent ?? 0),
termsAndConditions: job.termsAndConditions ?? undefined,
}} jobId={job.id} />
</TabsContent>
<TabsContent value="totals" className="m-0">
<JobTotals job={job} />
</TabsContent>
<TabsContent value="payment" className="m-0">
<PaymentScheduleEditor
jobId={job.id}
jobTotal={jobTotal ?? 0}
currency="ZAR"
startDate={job.startDate ? format(new Date(job.startDate), "yyyy-MM-dd") : null}
estimatedCompletionDate={job.estimatedCompletionDate ? format(new Date(job.estimatedCompletionDate), "yyyy-MM-dd") : null}
schedule={(job as any).paymentSchedule ?? null}
/>
</TabsContent>
</div>
</Tabs>
</div>
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { jobSchema, type JobFormValues } from "@/lib/validations";
import type { Resolver } from "react-hook-form";
import { createJob, updateJob } from "@/actions/jobs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { CURRENCIES, JOB_STATUSES } from "@/lib/constants";
import { format } from "date-fns";
interface Client {
id: string;
companyName: string;
}
interface Props {
clients: Client[];
initialData?: Partial<JobFormValues>;
jobId?: string;
onSuccess?: () => void;
}
export function JobForm({ clients, initialData, jobId, onSuccess }: Props) {
const router = useRouter();
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isSubmitting },
} = useForm<JobFormValues>({
resolver: zodResolver(jobSchema) as Resolver<JobFormValues>,
defaultValues: {
currency: "ZAR",
status: "DRAFT",
markupPercent: 0,
taxPercent: 0,
discountPercent: 0,
productionFeePercent: 0,
pullManagementFeePercent: 0,
issueDate: format(new Date(), "yyyy-MM-dd"),
...initialData,
},
});
const onSubmit = async (data: JobFormValues) => {
try {
if (jobId) {
await updateJob(jobId, data);
toast.success("Job updated");
onSuccess?.();
} else {
const result = await createJob(data);
toast.success("Quote created");
router.push(`/jobs/${result.job.id}`);
}
} catch (err: any) {
toast.error(err?.message ?? "Something went wrong");
}
};
return (
<form onSubmit={handleSubmit(onSubmit as Parameters<typeof handleSubmit>[0])} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div className="space-y-1.5">
<Label>Project Title *</Label>
<Input {...register("projectTitle")} className="bg-zinc-900 border-zinc-700" placeholder="e.g. Hero Sequence VFX" />
{errors.projectTitle && <p className="text-red-400 text-xs">{errors.projectTitle.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Project Code *</Label>
<Input {...register("projectCode")} className="bg-zinc-900 border-zinc-700" placeholder="e.g. VFX-2025-001" />
{errors.projectCode && <p className="text-red-400 text-xs">{errors.projectCode.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Show ID *</Label>
<Input
{...register("showId")}
className="bg-zinc-900 border-zinc-700 uppercase"
placeholder="e.g. STV"
maxLength={10}
onChange={e => {
e.target.value = e.target.value.toUpperCase();
register("showId").onChange(e);
}}
/>
<p className="text-xs text-zinc-500">Short code used in shot names (e.g. STV → STV_SQ010_0010)</p>
{errors.showId && <p className="text-red-400 text-xs">{errors.showId.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Job Type</Label>
<Select onValueChange={(v) => setValue("jobType", v as any)} defaultValue={initialData?.jobType ?? "STANDARD"}>
<SelectTrigger className="bg-zinc-900 border-zinc-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="STANDARD">Standard</SelectItem>
<SelectItem value="EPISODIC">Episodic</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Client *</Label>
<Select onValueChange={(v) => setValue("clientId", v ?? "")} defaultValue={initialData?.clientId ?? ""}>
<SelectTrigger className="bg-zinc-900 border-zinc-700">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.companyName}</SelectItem>
))}
</SelectContent>
</Select>
{errors.clientId && <p className="text-red-400 text-xs">{errors.clientId.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Status</Label>
<Select onValueChange={(v) => setValue("status", v as any)} defaultValue={initialData?.status ?? "DRAFT"}>
<SelectTrigger className="bg-zinc-900 border-zinc-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{JOB_STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Issue Date *</Label>
<Input {...register("issueDate")} type="date" className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Expiry Date</Label>
<Input {...register("expiryDate")} type="date" className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Currency</Label>
<Select onValueChange={(v) => setValue("currency", v ?? "ZAR")} defaultValue={initialData?.currency ?? "ZAR"}>
<SelectTrigger className="bg-zinc-900 border-zinc-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code}>{c.symbol} {c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Markup %</Label>
<Input {...register("markupPercent")} type="number" step="0.01" className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Tax / VAT %</Label>
<Input {...register("taxPercent")} type="number" step="0.01" className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Discount %</Label>
<Input {...register("discountPercent")} type="number" step="0.01" className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Production Fee %</Label>
<Input {...register("productionFeePercent")} type="number" step="0.01" className="bg-zinc-900 border-zinc-700" placeholder="0" />
<p className="text-xs text-zinc-500">% of total — shown as a line on quotes</p>
</div>
<div className="space-y-1.5">
<Label>Pull Management Fee %</Label>
<Input {...register("pullManagementFeePercent")} type="number" step="0.01" className="bg-zinc-900 border-zinc-700" placeholder="0" />
<p className="text-xs text-zinc-500">% of total — shown as a line on quotes</p>
</div>
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Textarea {...register("description")} className="bg-zinc-900 border-zinc-700" rows={3} />
</div>
<div className="space-y-1.5">
<Label>Internal Notes</Label>
<Textarea {...register("internalNotes")} className="bg-zinc-900 border-zinc-700" rows={2} placeholder="Not included in PDF output" />
</div>
<div className="space-y-1.5">
<Label>Terms & Conditions</Label>
<Textarea {...register("termsAndConditions")} className="bg-zinc-900 border-zinc-700" rows={4} />
</div>
<Button
type="submit"
disabled={isSubmitting}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{jobId ? "Save Changes" : "Create Quote"}
</Button>
</form>
);
}
+286
View File
@@ -0,0 +1,286 @@
"use client";
import Decimal from "decimal.js";
import { calcLineItem, sumLineItems, applyJobAdjustments, formatCurrency } from "@/lib/calculations";
import type { Prisma } from "@prisma/client";
type JobWithRelations = Prisma.JobGetPayload<{
include: {
client: true;
shots: { include: { lineItems: { include: { rateCard: true } } } };
assets: { include: { lineItems: { include: { rateCard: true } } } };
supervisions: { include: { lineItems: { include: { rateCard: true } } } };
previses: { include: { lineItems: { include: { rateCard: true } } } };
comments: { include: { user: true } };
history: true;
};
}>;
export function JobTotals({ job }: { job: JobWithRelations }) {
const currency = "ZAR";
// Calculate shot totals
const shotSections = job.shots.map((shot) => {
const results = shot.lineItems.map((li) =>
calcLineItem(
{
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
},
shot.complexityMultiplier.toString()
)
);
return { shot, totals: sumLineItems(results) };
});
// Calculate asset totals
const assetSections = job.assets.map((asset) => {
const results = asset.lineItems.map((li) =>
calcLineItem(
{
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
},
asset.complexityMultiplier.toString()
)
);
return { asset, totals: sumLineItems(results) };
});
const allShotTotals = sumLineItems(shotSections.map((s) => s.totals));
const allAssetTotals = sumLineItems(assetSections.map((a) => a.totals));
// Calculate supervision totals
const supervisionSections = ((job as any).supervisions ?? []).map((sv: any) => {
const results = sv.lineItems.map((li: any) =>
calcLineItem(
{
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
},
sv.complexityMultiplier.toString()
)
);
return { sv, totals: sumLineItems(results) };
});
const allSupervisionTotals = sumLineItems(supervisionSections.map((s: any) => s.totals));
// Calculate previs totals
const previsSections = ((job as any).previses ?? []).map((pv: any) => {
const results = pv.lineItems.map((li: any) =>
calcLineItem(
{
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
},
pv.complexityMultiplier.toString()
)
);
return { pv, totals: sumLineItems(results) };
});
const allPrevisTotal = sumLineItems(previsSections.map((s: any) => s.totals));
const lineSubtotal = allShotTotals.total.add(allAssetTotals.total).add(allSupervisionTotals.total).add(allPrevisTotal.total);
const jobAdj = applyJobAdjustments(
lineSubtotal,
job.markupPercent.toString(),
job.discountPercent.toString(),
job.taxPercent.toString()
);
const productionFeePct = new Decimal((job as any).productionFeePercent ?? 0);
const pullManagementFeePct = new Decimal((job as any).pullManagementFeePercent ?? 0);
const productionFeeAmount = jobAdj.grandTotal.mul(productionFeePct.div(100));
const pullManagementFeeAmount = jobAdj.grandTotal.mul(pullManagementFeePct.div(100));
const grandTotalWithFees = jobAdj.grandTotal.add(productionFeeAmount).add(pullManagementFeeAmount);
const fmt = (v: Decimal) => formatCurrency(v, currency);
return (
<div className="max-w-2xl space-y-8">
<div>
<h2 className="text-xl font-bold text-white mb-4">Cost Summary</h2>
{/* Shots */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden mb-4">
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-800/50">
<span className="font-semibold text-white text-sm">Shots</span>
</div>
<table className="w-full text-sm">
<tbody>
{shotSections.map(({ shot, totals }) => (
<tr key={shot.id} className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-300">{shot.shotCode}</td>
<td className="px-4 py-2 text-right font-mono text-white">{fmt(totals.total)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-zinc-700">
<td className="px-4 py-2 font-semibold text-white">Shots Subtotal</td>
<td className="px-4 py-2 text-right font-mono font-semibold text-amber-400">{fmt(allShotTotals.total)}</td>
</tr>
</tfoot>
</table>
</div>
{/* Assets */}
{assetSections.length > 0 && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden mb-4">
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-800/50">
<span className="font-semibold text-white text-sm">Assets</span>
</div>
<table className="w-full text-sm">
<tbody>
{assetSections.map(({ asset, totals }) => (
<tr key={asset.id} className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-300">{asset.assetName}</td>
<td className="px-4 py-2 text-right font-mono text-white">{fmt(totals.total)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-zinc-700">
<td className="px-4 py-2 font-semibold text-white">Assets Subtotal</td>
<td className="px-4 py-2 text-right font-mono font-semibold text-amber-400">{fmt(allAssetTotals.total)}</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* Supervision */}
{supervisionSections.length > 0 && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden mb-4">
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-800/50">
<span className="font-semibold text-white text-sm">Supervision</span>
</div>
<table className="w-full text-sm">
<tbody>
{supervisionSections.map(({ sv, totals }: any) => (
<tr key={sv.id} className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-300">{sv.supervisionTitle}</td>
<td className="px-4 py-2 text-right font-mono text-white">{fmt(totals.total)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-zinc-700">
<td className="px-4 py-2 font-semibold text-white">Supervision Subtotal</td>
<td className="px-4 py-2 text-right font-mono font-semibold text-amber-400">{fmt(allSupervisionTotals.total)}</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* Previs */}
{previsSections.length > 0 && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden mb-4">
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-800/50">
<span className="font-semibold text-white text-sm">Previs</span>
</div>
<table className="w-full text-sm">
<tbody>
{previsSections.map(({ pv, totals }: any) => (
<tr key={pv.id} className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-300">
{pv.sequenceName}
<span className="ml-2 text-xs text-zinc-500">{pv.sequenceType}</span>
</td>
<td className="px-4 py-2 text-right font-mono text-white">{fmt(totals.total)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-zinc-700">
<td className="px-4 py-2 font-semibold text-white">Previs Subtotal</td>
<td className="px-4 py-2 text-right font-mono font-semibold text-violet-400">{fmt(allPrevisTotal.total)}</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* Grand total */}
<div className="rounded-xl border border-zinc-700 bg-zinc-900 overflow-hidden">
<table className="w-full text-sm">
<tbody>
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Line Items Total</td>
<td className="px-4 py-2 text-right font-mono text-zinc-300">{fmt(lineSubtotal)}</td>
</tr>
{Number(job.markupPercent) > 0 && (
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Markup ({Number(job.markupPercent)}%)</td>
<td className="px-4 py-2 text-right font-mono text-zinc-300">+{fmt(jobAdj.markupAmount)}</td>
</tr>
)}
{Number(job.discountPercent) > 0 && (
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Discount ({Number(job.discountPercent)}%)</td>
<td className="px-4 py-2 text-right font-mono text-red-400">-{fmt(jobAdj.discountAmount)}</td>
</tr>
)}
{Number(job.taxPercent) > 0 && (
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Tax / VAT ({Number(job.taxPercent)}%)</td>
<td className="px-4 py-2 text-right font-mono text-zinc-300">+{fmt(jobAdj.taxAmount)}</td>
</tr>
)}
{productionFeePct.gt(0) && (
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Production Fee ({productionFeePct.toNumber()}%)</td>
<td className="px-4 py-2 text-right font-mono text-zinc-300">+{fmt(productionFeeAmount)}</td>
</tr>
)}
{pullManagementFeePct.gt(0) && (
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Pull Management Fee ({pullManagementFeePct.toNumber()}%)</td>
<td className="px-4 py-2 text-right font-mono text-zinc-300">+{fmt(pullManagementFeeAmount)}</td>
</tr>
)}
<tr className="border-t border-amber-500/30 bg-amber-500/5">
<td className="px-4 py-3 font-bold text-white text-base">Grand Total</td>
<td className="px-4 py-3 text-right font-mono font-bold text-amber-400 text-lg">{fmt(grandTotalWithFees)}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { deleteJob, duplicateJob, updateJobStatus } from "@/actions/jobs";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MoreHorizontal, Search, Copy, Trash2, Eye, Briefcase } from "lucide-react";
import { format } from "date-fns";
import { JOB_STATUSES } from "@/lib/constants";
interface Job {
id: string;
projectTitle: string;
projectCode: string;
status: string;
currency: string;
issueDate: Date;
updatedAt: Date;
client: { companyName: string; contactPerson: string | null };
_count: { shots: number; assets: number };
}
const statusBadge: Record<string, string> = {
DRAFT: "bg-zinc-700 text-zinc-300",
SENT: "bg-blue-900/60 text-blue-300",
APPROVED: "bg-green-900/60 text-green-300",
REJECTED: "bg-red-900/60 text-red-300",
ARCHIVED: "bg-yellow-900/60 text-yellow-300",
};
export function JobsTable({ jobs }: { jobs: Job[] }) {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("ALL");
const [deleteId, setDeleteId] = useState<string | null>(null);
const filtered = jobs.filter((j) => {
const matchSearch =
j.projectTitle.toLowerCase().includes(search.toLowerCase()) ||
j.projectCode.toLowerCase().includes(search.toLowerCase()) ||
j.client.companyName.toLowerCase().includes(search.toLowerCase());
const matchStatus = statusFilter === "ALL" || j.status === statusFilter;
return matchSearch && matchStatus;
});
const handleDuplicate = async (id: string) => {
try {
const result = await duplicateJob(id);
toast.success("Quote duplicated");
} catch {
toast.error("Failed to duplicate");
}
};
const handleDelete = async () => {
if (!deleteId) return;
try {
await deleteJob(deleteId);
toast.success("Quote deleted");
} catch {
toast.error("Failed to delete");
}
setDeleteId(null);
};
return (
<>
<div className="flex gap-3 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
placeholder="Search jobs..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-zinc-900 border-zinc-700"
/>
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v ?? "ALL")}>
<SelectTrigger className="w-40 bg-zinc-900 border-zinc-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="ALL">All Statuses</SelectItem>
{JOB_STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{filtered.length === 0 ? (
<div className="text-center py-16 text-zinc-500">
<Briefcase className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p>No jobs found.</p>
</div>
) : (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-400 text-xs uppercase tracking-wider">
<th className="px-4 py-3 text-left">Project</th>
<th className="px-4 py-3 text-left">Client</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Shots / Assets</th>
<th className="px-4 py-3 text-left">Date</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((job) => (
<tr key={job.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30 transition-colors">
<td className="px-4 py-3">
<Link href={`/jobs/${job.id}`} className="font-medium text-white hover:text-amber-400 transition-colors">
{job.projectTitle}
</Link>
<div className="text-xs text-zinc-500">{job.projectCode}</div>
</td>
<td className="px-4 py-3 text-zinc-300">{job.client.companyName}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusBadge[job.status]}`}>
{JOB_STATUSES.find((s) => s.value === job.status)?.label}
</span>
</td>
<td className="px-4 py-3 text-zinc-400">
{job._count.shots} shots · {job._count.assets} assets
</td>
<td className="px-4 py-3 text-zinc-500">
{format(new Date(job.issueDate), "MMM d, yyyy")}
</td>
<td className="px-4 py-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 w-7 rounded-lg hover:bg-zinc-700 transition-colors text-zinc-400 hover:text-zinc-200">
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-800">
<DropdownMenuItem onClick={() => window.location.href = `/jobs/${job.id}`}>
<Eye className="h-4 w-4 mr-2" /> View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicate(job.id)}>
<Copy className="h-4 w-4 mr-2" /> Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-zinc-800" />
<DropdownMenuItem
onClick={() => setDeleteId(job.id)}
className="text-red-400 focus:text-red-300"
>
<Trash2 className="h-4 w-4 mr-2" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-800">
<AlertDialogHeader>
<AlertDialogTitle>Delete quote?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the quote including all shots, assets and line items.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700">Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-500">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
Users,
Briefcase,
CreditCard,
FileText,
Film,
ChevronLeft,
ChevronRight,
Settings,
} from "lucide-react";
import { useState } from "react";
const nav = [
{ href: "/", icon: LayoutDashboard, label: "Dashboard" },
{ href: "/clients", icon: Users, label: "Clients" },
{ href: "/jobs", icon: Briefcase, label: "Jobs / Quotes" },
{ href: "/invoices", icon: FileText, label: "Invoices" },
{ href: "/rate-cards", icon: CreditCard, label: "Rate Cards" },
];
export function Sidebar() {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
return (
<aside
className={cn(
"flex flex-col bg-zinc-900 border-r border-zinc-800 transition-all duration-200",
collapsed ? "w-16" : "w-60"
)}
>
{/* Logo */}
<div className="flex items-center gap-3 px-4 py-5 border-b border-zinc-800">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-amber-500 flex items-center justify-center">
<Film className="h-4 w-4 text-black" />
</div>
{!collapsed && (
<span className="font-bold text-lg tracking-tight text-white">
x<span className="text-amber-500">Bid</span>
</span>
)}
</div>
{/* Nav */}
<nav className="flex-1 px-2 py-4 space-y-1">
{nav.map(({ href, icon: Icon, label }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
active
? "bg-amber-500/10 text-amber-400"
: "text-zinc-400 hover:text-white hover:bg-zinc-800"
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>{label}</span>}
</Link>
);
})}
</nav>
{/* Collapse toggle */}
<div className="px-2 pb-2">
{(() => {
const href = "/settings";
const active = pathname.startsWith(href);
return (
<Link
href={href}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
active
? "bg-amber-500/10 text-amber-400"
: "text-zinc-400 hover:text-white hover:bg-zinc-800"
)}
>
<Settings className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Settings</span>}
</Link>
);
})()}
</div>
<button
onClick={() => setCollapsed(!collapsed)}
className="flex items-center justify-center py-4 border-t border-zinc-800 text-zinc-500 hover:text-white transition-colors"
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</aside>
);
}
@@ -0,0 +1,358 @@
"use client";
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import type { Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { lineItemSchema, type LineItemFormValues } from "@/lib/validations";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2, BookmarkPlus, Trash2 } from "lucide-react";
import { calcLineItem, formatCurrency } from "@/lib/calculations";
import { getTaskTemplates, createTaskTemplate, deleteTaskTemplate } from "@/actions/shots-assets";
import { toast } from "sonner";
interface TaskTemplate {
id: string;
name: string;
taskName: string;
artistRole: string | null;
rateCardId: string | null;
quantityHours: any;
dailyRate: any;
hoursPerDay: any;
numArtists: any;
numDays: any;
manualHourlyRate: any;
manualSubtotal: any;
markupPercent: any;
discountPercent: any;
taxPercent: any;
notes: string | null;
}
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
onSubmit: (data: LineItemFormValues) => Promise<void>;
rateCards: any[];
defaultTasks: string[];
initialData?: Partial<LineItemFormValues>;
}
export function LineItemDialog({ open, onOpenChange, onSubmit, rateCards, defaultTasks, initialData }: Props) {
const [templates, setTemplates] = useState<TaskTemplate[]>([]);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const [saveTemplateName, setSaveTemplateName] = useState("");
const [showSaveTemplate, setShowSaveTemplate] = useState(false);
const [savingTemplate, setSavingTemplate] = useState(false);
// Load templates when dialog opens
useEffect(() => {
if (open) {
getTaskTemplates().then(setTemplates).catch(() => {});
}
}, [open]);
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<LineItemFormValues>({
resolver: zodResolver(lineItemSchema) as Resolver<LineItemFormValues>,
defaultValues: {
quantityHours: 0,
dailyRate: 0,
hoursPerDay: 8,
numArtists: 1,
numDays: 0,
markupPercent: 0,
discountPercent: 0,
taxPercent: 0,
...initialData,
},
});
const watched = watch();
const calc = calcLineItem({
quantityHours: watched.quantityHours ?? 0,
dailyRate: watched.dailyRate ?? 0,
hoursPerDay: watched.hoursPerDay ?? 8,
numArtists: watched.numArtists ?? 1,
numDays: watched.numDays ?? 0,
manualHourlyRate: watched.manualHourlyRate,
manualSubtotal: watched.manualSubtotal,
markupPercent: watched.markupPercent ?? 0,
discountPercent: watched.discountPercent ?? 0,
taxPercent: watched.taxPercent ?? 0,
});
const handleRateCardSelect = (id: string | null) => {
if (!id) return;
const rc = rateCards.find((r) => r.id === id);
if (rc) {
setValue("rateCardId", id);
setValue("dailyRate", Number(rc.defaultDailyRate));
setValue("hoursPerDay", Number(rc.hoursPerDay));
setValue("artistRole", rc.name);
}
};
const handleLoadTemplate = (templateId: string | null) => {
if (!templateId) return;
const t = templates.find((t) => t.id === templateId);
if (!t) return;
setSelectedTemplateId(templateId);
setValue("taskName", t.taskName);
if (t.artistRole) setValue("artistRole", t.artistRole);
if (t.rateCardId) setValue("rateCardId", t.rateCardId);
setValue("quantityHours", Number(t.quantityHours));
setValue("dailyRate", Number(t.dailyRate));
setValue("hoursPerDay", Number(t.hoursPerDay));
setValue("numArtists", Number(t.numArtists));
setValue("numDays", Number(t.numDays));
if (t.manualHourlyRate != null) setValue("manualHourlyRate", Number(t.manualHourlyRate));
if (t.manualSubtotal != null) setValue("manualSubtotal", Number(t.manualSubtotal));
setValue("markupPercent", Number(t.markupPercent));
setValue("discountPercent", Number(t.discountPercent));
setValue("taxPercent", Number(t.taxPercent));
if (t.notes) setValue("notes", t.notes);
};
const handleSaveTemplate = async () => {
if (!saveTemplateName.trim()) return;
setSavingTemplate(true);
try {
const current = watch();
const result = await createTaskTemplate({
name: saveTemplateName.trim(),
taskName: current.taskName ?? "",
artistRole: current.artistRole,
rateCardId: current.rateCardId,
quantityHours: current.quantityHours ?? 0,
dailyRate: current.dailyRate ?? 0,
hoursPerDay: current.hoursPerDay ?? 8,
numArtists: current.numArtists ?? 1,
numDays: current.numDays ?? 0,
manualHourlyRate: current.manualHourlyRate,
manualSubtotal: current.manualSubtotal,
markupPercent: current.markupPercent ?? 0,
discountPercent: current.discountPercent ?? 0,
taxPercent: current.taxPercent ?? 0,
notes: current.notes,
});
setTemplates((prev) => [...prev, result.template].sort((a, b) => a.name.localeCompare(b.name)));
setSaveTemplateName("");
setShowSaveTemplate(false);
toast.success("Template saved");
} catch {
toast.error("Failed to save template");
} finally {
setSavingTemplate(false);
}
};
const handleDeleteTemplate = async (id: string) => {
try {
await deleteTaskTemplate(id);
setTemplates((prev) => prev.filter((t) => t.id !== id));
if (selectedTemplateId === id) setSelectedTemplateId(null);
toast.success("Template deleted");
} catch {
toast.error("Failed to delete template");
}
};
const handleClose = () => { reset(); setShowSaveTemplate(false); setSaveTemplateName(""); setSelectedTemplateId(null); onOpenChange(false); };
const submit = async (data: LineItemFormValues) => { await onSubmit(data); reset(); };
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{initialData ? "Edit Task" : "Add Task"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* Template picker */}
{templates.length > 0 && (
<div className="space-y-1.5 col-span-2">
<Label className="text-zinc-400">Load from Template</Label>
<div className="flex gap-2">
<Select value={selectedTemplateId ?? ""} onValueChange={handleLoadTemplate}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 flex-1">
<SelectValue placeholder="Select a template…" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700 max-h-60">
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
{selectedTemplateId && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-10 w-10 shrink-0 text-zinc-500 hover:text-red-400"
onClick={() => handleDeleteTemplate(selectedTemplateId)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
{/* Task name */}
<div className="space-y-1.5 col-span-2">
<Label>Task Name *</Label>
{defaultTasks.length > 0 ? (
<Select onValueChange={(v) => setValue("taskName", v ?? "")} defaultValue={initialData?.taskName}>
<SelectTrigger className="bg-zinc-800 border-zinc-700">
<SelectValue placeholder="Select or type task" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700 max-h-60">
{defaultTasks.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
) : null}
<Input {...register("taskName")} className="bg-zinc-800 border-zinc-700" placeholder="Custom task name" />
{errors.taskName && <p className="text-red-400 text-xs">{errors.taskName.message}</p>}
</div>
{/* Rate card */}
<div className="space-y-1.5 col-span-2">
<Label>Rate Card (optional)</Label>
<Select onValueChange={handleRateCardSelect} defaultValue={initialData?.rateCardId ?? undefined}>
<SelectTrigger className="bg-zinc-800 border-zinc-700">
<SelectValue placeholder="Apply rate card..." />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{rateCards.map((rc) => (
<SelectItem key={rc.id} value={rc.id}>
{rc.name} — {formatCurrency(Number(rc.defaultDailyRate), "ZAR")}/day
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Artist Role</Label>
<Input {...register("artistRole")} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Senior Compositor" />
</div>
<div className="space-y-1.5">
<Label>Daily Rate</Label>
<Input {...register("dailyRate")} type="number" step="0.01" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Hours / Day</Label>
<Input {...register("hoursPerDay")} type="number" step="0.5" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Num. Artists</Label>
<Input {...register("numArtists")} type="number" step="0.5" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Num. Days</Label>
<Input {...register("numDays")} type="number" step="0.5" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Quantity Hours</Label>
<Input {...register("quantityHours")} type="number" step="0.5" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Markup %</Label>
<Input {...register("markupPercent")} type="number" step="0.01" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Discount %</Label>
<Input {...register("discountPercent")} type="number" step="0.01" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Tax %</Label>
<Input {...register("taxPercent")} type="number" step="0.01" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
{/* Live total preview */}
<div className="rounded-lg bg-zinc-800 p-3 grid grid-cols-3 gap-3 text-sm">
<div>
<div className="text-zinc-500 text-xs mb-0.5">Hourly Rate</div>
<div className="font-mono font-semibold text-white">{formatCurrency(calc.hourlyRate)}</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-0.5">Subtotal</div>
<div className="font-mono font-semibold text-white">{formatCurrency(calc.subtotal)}</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-0.5">Total</div>
<div className="font-mono font-semibold text-amber-400">{formatCurrency(calc.total)}</div>
</div>
</div>
<div className="flex gap-3 justify-end pt-2">
<Button type="button" variant="outline" className="border-zinc-700" onClick={handleClose}>Cancel</Button>
<Button
type="button"
variant="outline"
className="border-zinc-700 text-zinc-300"
onClick={() => setShowSaveTemplate(!showSaveTemplate)}
>
<BookmarkPlus className="h-4 w-4 mr-1.5" />
Save as Template
</Button>
<Button type="submit" disabled={isSubmitting} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{initialData ? "Save Changes" : "Add Task"}
</Button>
</div>
{/* Save template inline form */}
{showSaveTemplate && (
<div className="flex gap-2 items-center border border-zinc-700 rounded-lg p-3 bg-zinc-800/50">
<Input
value={saveTemplateName}
onChange={(e) => setSaveTemplateName(e.target.value)}
placeholder="Template name…"
className="bg-zinc-800 border-zinc-700 flex-1"
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleSaveTemplate(); } }}
/>
<Button
type="button"
onClick={handleSaveTemplate}
disabled={savingTemplate || !saveTemplateName.trim()}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold shrink-0"
>
{savingTemplate ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
</Button>
</div>
)}
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,226 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Trash2, Pencil, ChevronUp, ChevronDown } from "lucide-react";
import { calcLineItem, formatCurrency } from "@/lib/calculations";
import { LineItemDialog } from "./line-item-dialog";
import { updateLineItem } from "@/actions/shots-assets";
import { toast } from "sonner";
interface LineItem {
id: string;
taskName: string;
artistRole: string | null;
quantityHours: any;
dailyRate: any;
hoursPerDay: any;
numArtists: any;
numDays: any;
manualHourlyRate: any;
manualSubtotal: any;
markupPercent: any;
discountPercent: any;
taxPercent: any;
notes: string | null;
rateCard: any;
}
interface Props {
lineItems: LineItem[];
currency: string;
rateCards: any[];
complexityMultiplier: string;
parentId: string;
parentType: "shot" | "asset" | "supervision" | "previs";
jobId: string;
onDelete: (id: string) => void;
onUpdate: (item: any) => void;
}
export function LineItemsTable({
lineItems,
currency,
rateCards,
complexityMultiplier,
parentId,
parentType,
jobId,
onDelete,
onUpdate,
}: Props) {
const [editItem, setEditItem] = useState<LineItem | null>(null);
const [adjustingHours, setAdjustingHours] = useState<Set<string>>(new Set());
const handleAdjustHours = async (li: LineItem, delta: number) => {
const newHours = Math.max(0, Number(li.quantityHours) + delta);
setAdjustingHours((prev) => new Set(prev).add(li.id));
try {
const result = await updateLineItem(
li.id,
jobId,
{
taskName: li.taskName,
artistRole: li.artistRole ?? undefined,
rateCardId: li.rateCard?.id,
quantityHours: newHours,
dailyRate: Number(li.dailyRate),
hoursPerDay: Number(li.hoursPerDay),
numArtists: Number(li.numArtists),
numDays: Number(li.numDays),
manualHourlyRate: li.manualHourlyRate != null ? Number(li.manualHourlyRate) : null,
manualSubtotal: li.manualSubtotal != null ? Number(li.manualSubtotal) : null,
markupPercent: Number(li.markupPercent),
discountPercent: Number(li.discountPercent),
taxPercent: Number(li.taxPercent),
notes: li.notes ?? undefined,
},
complexityMultiplier
);
onUpdate(result.lineItem);
} catch {
toast.error("Failed to update hours");
} finally {
setAdjustingHours((prev) => {
const next = new Set(prev);
next.delete(li.id);
return next;
});
}
};
const handleUpdate = async (data: any) => {
if (!editItem) return;
try {
const result = await updateLineItem(editItem.id, jobId, data, complexityMultiplier);
onUpdate(result.lineItem);
setEditItem(null);
toast.success("Line item updated");
} catch {
toast.error("Failed to update line item");
}
};
if (lineItems.length === 0) {
return (
<div className="px-4 py-6 text-center text-sm text-zinc-500">
No tasks yet. Add a task below.
</div>
);
}
return (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-zinc-800 text-zinc-500 uppercase tracking-wider">
<th className="px-4 py-2 text-left">Task</th>
<th className="px-4 py-2 text-left">Role</th>
<th className="px-4 py-2 text-right">Hours</th>
<th className="px-4 py-2 text-right">Day Rate</th>
<th className="px-4 py-2 text-right">Artists</th>
<th className="px-4 py-2 text-right">Days</th>
<th className="px-4 py-2 text-right">Subtotal</th>
<th className="px-4 py-2 text-right">Total</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{lineItems.map((li) => {
const calc = calcLineItem(
{
quantityHours: li.quantityHours?.toString() ?? "0",
dailyRate: li.dailyRate?.toString() ?? "0",
hoursPerDay: li.hoursPerDay?.toString() ?? "8",
numArtists: li.numArtists?.toString() ?? "1",
numDays: li.numDays?.toString() ?? "0",
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent?.toString() ?? "0",
discountPercent: li.discountPercent?.toString() ?? "0",
taxPercent: li.taxPercent?.toString() ?? "0",
},
complexityMultiplier
);
return (
<tr key={li.id} className="border-b border-zinc-800/30 hover:bg-zinc-800/20 transition-colors">
<td className="px-4 py-2 font-medium text-white">{li.taskName}</td>
<td className="px-4 py-2 text-zinc-400">{li.artistRole ?? "—"}</td>
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<div className="flex flex-col">
<button
onClick={() => handleAdjustHours(li, 0.5)}
disabled={adjustingHours.has(li.id)}
className="text-zinc-500 hover:text-amber-400 disabled:opacity-30 leading-none"
>
<ChevronUp className="h-2.5 w-2.5" />
</button>
<button
onClick={() => handleAdjustHours(li, -0.5)}
disabled={adjustingHours.has(li.id) || Number(li.quantityHours) <= 0}
className="text-zinc-500 hover:text-amber-400 disabled:opacity-30 leading-none"
>
<ChevronDown className="h-2.5 w-2.5" />
</button>
</div>
<span className="font-mono text-zinc-300 min-w-10 text-right">
{Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}
</span>
</div>
</td>
<td className="px-4 py-2 text-right text-zinc-300 font-mono">
{Number(li.dailyRate) > 0 ? formatCurrency(Number(li.dailyRate), currency) : "—"}
</td>
<td className="px-4 py-2 text-right text-zinc-300 font-mono">{Number(li.numArtists)}</td>
<td className="px-4 py-2 text-right text-zinc-300 font-mono">
{Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}
</td>
<td className="px-4 py-2 text-right text-zinc-300 font-mono">{formatCurrency(calc.subtotal, currency)}</td>
<td className="px-4 py-2 text-right font-mono font-semibold text-white">{formatCurrency(calc.total, currency)}</td>
<td className="px-4 py-2">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItem(li)}>
<Pencil className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-400 hover:text-red-300" onClick={() => onDelete(li.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{editItem && (
<LineItemDialog
open={!!editItem}
onOpenChange={(v) => !v && setEditItem(null)}
onSubmit={handleUpdate}
rateCards={rateCards}
initialData={{
taskName: editItem.taskName,
artistRole: editItem.artistRole ?? undefined,
rateCardId: editItem.rateCard?.id,
quantityHours: Number(editItem.quantityHours),
dailyRate: Number(editItem.dailyRate),
hoursPerDay: Number(editItem.hoursPerDay),
numArtists: Number(editItem.numArtists),
numDays: Number(editItem.numDays),
manualHourlyRate: editItem.manualHourlyRate != null ? Number(editItem.manualHourlyRate) : null,
manualSubtotal: editItem.manualSubtotal != null ? Number(editItem.manualSubtotal) : null,
markupPercent: Number(editItem.markupPercent),
discountPercent: Number(editItem.discountPercent),
taxPercent: Number(editItem.taxPercent),
notes: editItem.notes ?? undefined,
}}
defaultTasks={[]}
/>
)}
</>
);
}
@@ -0,0 +1,520 @@
"use client";
import { useState, useTransition, useMemo } from "react";
import { format } from "date-fns";
import { toast } from "sonner";
import { Plus, Trash2, FileText, Wand2, ChevronDown, ChevronUp, GripVertical, Check, AlertTriangle, Info, Printer } from "lucide-react";
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 { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import {
addMilestone,
updateMilestone,
deleteMilestone,
generateScheduleFromTemplate,
createOrUpdateSchedule,
} from "@/actions/payment-schedule";
import { createInvoiceFromMilestone } from "@/actions/invoices";
import { formatCurrency } from "@/lib/calculations";
import type { Prisma } from "@prisma/client";
type Milestone = Prisma.PaymentMilestoneGetPayload<Record<string, never>>;
type Schedule = Prisma.PaymentScheduleGetPayload<{ include: { milestones: true } }> | null;
interface Props {
jobId: string;
jobTotal: number;
currency: string;
startDate?: string | null;
estimatedCompletionDate?: string | null;
schedule: Schedule;
}
const MILESTONE_STATUSES = [
{ value: "PENDING", label: "Pending", color: "bg-zinc-700 text-zinc-300" },
{ value: "INVOICED", label: "Invoiced", color: "bg-blue-900/60 text-blue-300" },
{ value: "PARTIALLY_PAID", label: "Partial", color: "bg-orange-900/60 text-orange-300" },
{ value: "PAID", label: "Paid", color: "bg-green-900/60 text-green-300" },
{ value: "OVERDUE", label: "Overdue", color: "bg-red-900/60 text-red-300" },
{ value: "CANCELLED", label: "Cancelled", color: "bg-zinc-800 text-zinc-500" },
];
const TEMPLATES = [
{ value: "STANDARD_FILM", label: "Standard Film / VFX", description: "50% deposit + monthly progress + 10% final" },
{ value: "SMALL_COMMERCIAL", label: "Small Commercial", description: "70% deposit + 30% on delivery" },
{ value: "EPISODIC", label: "Episodic / Longform", description: "25% deposit + monthly billing + 10% final" },
{ value: "CUSTOM", label: "Custom", description: "Manually create milestones" },
];
const emptyForm = {
title: "",
description: "",
dueDate: "",
percentOfTotal: 0,
fixedAmount: "" as string | number,
status: "PENDING" as string,
paymentReceivedDate: "",
notes: "",
sortOrder: 0,
};
export function PaymentScheduleEditor({ jobId, jobTotal, currency, startDate, estimatedCompletionDate, schedule: initialSchedule }: Props) {
const [schedule, setSchedule] = useState<Schedule>(initialSchedule);
const [milestones, setMilestones] = useState<Milestone[]>(initialSchedule?.milestones ?? []);
const [isPending, startTransition] = useTransition();
const [showGenerator, setShowGenerator] = useState(!initialSchedule);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
const [form, setForm] = useState({ ...emptyForm });
// Generator state
const [genTemplate, setGenTemplate] = useState("STANDARD_FILM");
const [genDeposit, setGenDeposit] = useState(50);
const [genFinal, setGenFinal] = useState(10);
const [genMonths, setGenMonths] = useState(3);
const totalPercent = useMemo(() =>
milestones.reduce((s, m) => s + Number(m.percentOfTotal), 0),
[milestones]
);
const totalFixed = useMemo(() =>
milestones.filter(m => m.fixedAmount).reduce((s, m) => s + Number(m.fixedAmount ?? 0), 0),
[milestones]
);
const fmt = (v: number) => formatCurrency(v, currency);
const amountForMilestone = (m: Milestone) => {
if (m.fixedAmount) return Number(m.fixedAmount);
return (Number(m.percentOfTotal) / 100) * jobTotal;
};
const handleGenerate = () => {
startTransition(async () => {
try {
const result = await generateScheduleFromTemplate(jobId, {
template: genTemplate as any,
jobTotal,
startDate: startDate ?? undefined,
endDate: estimatedCompletionDate ?? undefined,
depositPercent: genDeposit,
finalPercent: genFinal,
monthlyCount: genMonths,
});
if (result.success && result.schedule) {
setSchedule(result.schedule as any);
setMilestones(result.schedule.milestones);
setShowGenerator(false);
toast.success("Payment schedule generated");
}
} catch {
toast.error("Failed to generate schedule");
}
});
};
const openAdd = () => {
setEditingMilestone(null);
setForm({ ...emptyForm });
setDialogOpen(true);
};
const openEdit = (m: Milestone) => {
setEditingMilestone(m);
setForm({
title: m.title,
description: m.description ?? "",
dueDate: m.dueDate ? format(new Date(m.dueDate), "yyyy-MM-dd") : "",
percentOfTotal: Number(m.percentOfTotal),
fixedAmount: m.fixedAmount ? Number(m.fixedAmount) : "",
status: m.status,
paymentReceivedDate: m.paymentReceivedDate ? format(new Date(m.paymentReceivedDate), "yyyy-MM-dd") : "",
notes: m.notes ?? "",
sortOrder: m.sortOrder,
});
setDialogOpen(true);
};
const handleSave = () => {
startTransition(async () => {
try {
let scheduleId = schedule?.id;
if (!scheduleId) {
const r = await createOrUpdateSchedule(jobId, "CUSTOM");
scheduleId = r.schedule.id;
setSchedule(r.schedule as any);
}
const payload = {
title: form.title,
description: form.description || undefined,
dueDate: form.dueDate || undefined,
percentOfTotal: Number(form.percentOfTotal) || 0,
fixedAmount: form.fixedAmount !== "" ? Number(form.fixedAmount) : undefined,
status: form.status as any,
paymentReceivedDate: form.paymentReceivedDate || undefined,
notes: form.notes || undefined,
sortOrder: form.sortOrder,
};
if (editingMilestone) {
const r = await updateMilestone(jobId, editingMilestone.id, payload);
setMilestones(prev => prev.map(m => m.id === editingMilestone.id ? r.milestone as any : m));
toast.success("Milestone updated");
} else {
const r = await addMilestone(jobId, scheduleId!, payload);
setMilestones(prev => [...prev, r.milestone as any]);
toast.success("Milestone added");
}
setDialogOpen(false);
} catch {
toast.error("Failed to save milestone");
}
});
};
const handleDelete = (milestoneId: string) => {
startTransition(async () => {
try {
await deleteMilestone(jobId, milestoneId);
setMilestones(prev => prev.filter(m => m.id !== milestoneId));
toast.success("Milestone deleted");
} catch {
toast.error("Failed to delete milestone");
}
});
};
const handleCreateInvoice = (milestoneId: string) => {
startTransition(async () => {
try {
const r = await createInvoiceFromMilestone(milestoneId, jobTotal);
toast.success(`Invoice ${r.invoice.invoiceNumber} created`);
window.location.href = `/invoices/${r.invoice.id}`;
} catch (e: any) {
toast.error(e.message ?? "Failed to create invoice");
}
});
};
const paidAmount = milestones
.filter(m => m.status === "PAID" || m.status === "PARTIALLY_PAID")
.reduce((s, m) => s + amountForMilestone(m), 0);
const outstandingAmount = milestones
.filter(m => !["PAID", "CANCELLED"].includes(m.status))
.reduce((s, m) => s + amountForMilestone(m), 0);
const overdueAmount = milestones
.filter(m => m.status === "OVERDUE")
.reduce((s, m) => s + amountForMilestone(m), 0);
// Scope-change awareness: are there already-invoiced milestones alongside pending ones?
const hasInvoicedMilestones = milestones.some(m => ["INVOICED", "PARTIALLY_PAID", "PAID"].includes(m.status));
const hasPendingMilestones = milestones.some(m => ["PENDING", "OVERDUE"].includes(m.status));
const showScopeWarning = hasInvoicedMilestones && hasPendingMilestones;
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: "Job Total", value: fmt(jobTotal), color: "text-white" },
{ label: "Paid", value: fmt(paidAmount), color: "text-green-400" },
{ label: "Outstanding", value: fmt(outstandingAmount), color: "text-amber-400" },
{ label: "Overdue", value: fmt(overdueAmount), color: "text-red-400" },
].map(c => (
<div key={c.label} className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{c.label}</p>
<p className={`text-lg font-bold font-mono ${c.color}`}>{c.value}</p>
</div>
))}
</div>
{/* Total validation */}
{milestones.length > 0 && (
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
Math.abs(totalPercent - 100) < 0.01
? "bg-green-900/20 border border-green-800 text-green-400"
: "bg-amber-900/20 border border-amber-800 text-amber-400"
}`}>
{Math.abs(totalPercent - 100) < 0.01
? <Check className="h-4 w-4" />
: <AlertTriangle className="h-4 w-4" />}
<span>
Schedule total: <strong>{totalPercent.toFixed(2)}%</strong>
{Math.abs(totalPercent - 100) >= 0.01 && ` — ${(100 - totalPercent).toFixed(2)}% unallocated`}
</span>
</div>
)}
{/* Scope-change notice */}
{showScopeWarning && (
<div className="flex items-start gap-2.5 px-4 py-3 bg-blue-950/40 border border-blue-800/50 rounded-lg text-sm text-blue-300">
<Info className="h-4 w-4 mt-0.5 shrink-0 text-blue-400" />
<span>
Some milestones have already been invoiced. If shots or assets have been added since,{" "}
<strong>uninvoiced milestone amounts will automatically recalculate</strong> to cover the
remaining balance when you generate their invoices — previously sent invoices are never
modified.
</span>
</div>
)}
{/* Generator panel */}
{showGenerator ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-white flex items-center gap-2"><Wand2 className="h-4 w-4 text-amber-500" /> Schedule Generator</h3>
<p className="text-sm text-zinc-400 mt-0.5">Choose a template to auto-generate payment milestones</p>
</div>
{schedule && (
<Button variant="ghost" size="sm" onClick={() => setShowGenerator(false)} className="text-zinc-400">
Cancel
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3">
{TEMPLATES.map(t => (
<button
key={t.value}
onClick={() => setGenTemplate(t.value)}
className={`text-left p-4 rounded-lg border transition-all ${
genTemplate === t.value
? "border-amber-500 bg-amber-500/10"
: "border-zinc-700 bg-zinc-800 hover:border-zinc-600"
}`}
>
<p className="font-medium text-white text-sm">{t.label}</p>
<p className="text-xs text-zinc-400 mt-0.5">{t.description}</p>
</button>
))}
</div>
{genTemplate !== "CUSTOM" && (
<div className="grid grid-cols-3 gap-4 pt-2">
<div className="space-y-1.5">
<Label className="text-xs">Deposit %</Label>
<Input
type="number" min={0} max={100} value={genDeposit}
onChange={e => setGenDeposit(Number(e.target.value))}
className="bg-zinc-800 border-zinc-700"
/>
</div>
{genTemplate !== "SMALL_COMMERCIAL" && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Final %</Label>
<Input
type="number" min={0} max={100} value={genFinal}
onChange={e => setGenFinal(Number(e.target.value))}
className="bg-zinc-800 border-zinc-700"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Monthly Payments</Label>
<Input
type="number" min={1} max={36} value={genMonths}
onChange={e => setGenMonths(Number(e.target.value))}
className="bg-zinc-800 border-zinc-700"
/>
</div>
</>
)}
</div>
)}
<div className="flex gap-2 pt-2">
{genTemplate !== "CUSTOM" ? (
<Button onClick={handleGenerate} disabled={isPending} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
<Wand2 className="h-4 w-4 mr-1.5" /> Generate Schedule
</Button>
) : (
<Button onClick={() => { startTransition(async () => { await createOrUpdateSchedule(jobId, "CUSTOM"); setShowGenerator(false); }); }} disabled={isPending}>
Start Custom Schedule
</Button>
)}
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowGenerator(true)} className="border-zinc-700 text-zinc-300">
<Wand2 className="h-4 w-4 mr-1.5" /> Regenerate from Template
</Button>
<a href={`/api/pdf/payment-schedule/${jobId}`} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm" className="border-zinc-700 text-zinc-300">
<Printer className="h-4 w-4 mr-1.5" /> PDF
</Button>
</a>
</div>
<Button size="sm" onClick={openAdd} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
<Plus className="h-4 w-4 mr-1.5" /> Add Milestone
</Button>
</div>
)}
{/* Milestone table */}
{milestones.length > 0 && (
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-zinc-900 border-b border-zinc-800">
<th className="text-left text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">Milestone</th>
<th className="text-left text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">Due Date</th>
<th className="text-right text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">%</th>
<th className="text-right text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">Amount</th>
<th className="text-left text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{milestones.map((m) => {
const statusCfg = MILESTONE_STATUSES.find(s => s.value === m.status);
const amount = amountForMilestone(m);
const isPaid = m.status === "PAID";
const hasInvoice = false; // will be populated once invoice linked
return (
<tr key={m.id} className="border-b border-zinc-800 hover:bg-zinc-900/50 transition-colors">
<td className="px-4 py-3">
<p className="font-medium text-white text-sm">{m.title}</p>
{m.description && <p className="text-xs text-zinc-500 mt-0.5">{m.description}</p>}
</td>
<td className="px-4 py-3 text-sm text-zinc-400">
{m.dueDate ? format(new Date(m.dueDate), "MMM d, yyyy") : "—"}
</td>
<td className="px-4 py-3 text-right font-mono text-sm text-zinc-300">
{Number(m.percentOfTotal).toFixed(1)}%
</td>
<td className="px-4 py-3 text-right font-mono text-sm font-semibold text-white">
{fmt(amount)}{showScopeWarning && ["PENDING", "OVERDUE"].includes(m.status) ? <span className="text-blue-400 ml-0.5">*</span> : null}
</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-semibold ${statusCfg?.color ?? ""}`}>
{statusCfg?.label ?? m.status}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 justify-end">
{!isPaid && m.status !== "INVOICED" && m.status !== "CANCELLED" && (
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-zinc-400 hover:text-blue-400"
title="Create Invoice"
onClick={() => handleCreateInvoice(m.id)}
disabled={isPending}
>
<FileText className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-zinc-400 hover:text-white"
onClick={() => openEdit(m)}
>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-zinc-400 hover:text-red-400"
onClick={() => handleDelete(m.id)}
disabled={isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="bg-zinc-900">
<td colSpan={2} className="px-4 py-3 text-sm font-semibold text-zinc-400">Total</td>
<td className={`px-4 py-3 text-right font-mono font-bold text-sm ${Math.abs(totalPercent - 100) < 0.01 ? "text-green-400" : "text-amber-400"}`}>
{totalPercent.toFixed(1)}%
</td>
<td className="px-4 py-3 text-right font-mono font-bold text-white">
{fmt(totalFixed > 0 ? totalFixed : (totalPercent / 100) * jobTotal)}
</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
{showScopeWarning && (
<p className="px-4 py-2 text-xs text-blue-400/70 border-t border-zinc-800">
* Displayed amount is based on current project scope. Actual invoice amount will be recalculated at generation time to cover the remaining balance.
</p>
)}
</div>
)}
{/* Milestone dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-lg">
<DialogHeader>
<DialogTitle>{editingMilestone ? "Edit Milestone" : "Add Milestone"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label>Title *</Label>
<Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Deposit, Month 1 Progress..." />
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Due Date</Label>
<Input type="date" value={form.dueDate} onChange={e => setForm(f => ({ ...f, dueDate: e.target.value }))} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Status</Label>
<Select value={form.status} onValueChange={v => setForm(f => ({ ...f, status: v ?? "PENDING" }))}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{MILESTONE_STATUSES.map(s => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>% of Total</Label>
<Input type="number" min={0} max={100} step={0.5} value={form.percentOfTotal} onChange={e => setForm(f => ({ ...f, percentOfTotal: Number(e.target.value) }))} className="bg-zinc-800 border-zinc-700" />
<p className="text-xs text-zinc-500">= {fmt((Number(form.percentOfTotal) / 100) * jobTotal)}</p>
</div>
<div className="space-y-1.5">
<Label>Fixed Amount Override</Label>
<Input type="number" min={0} step={100} value={form.fixedAmount as number} onChange={e => setForm(f => ({ ...f, fixedAmount: e.target.value === "" ? "" : Number(e.target.value) }))} className="bg-zinc-800 border-zinc-700" placeholder="Optional" />
<p className="text-xs text-zinc-500">Overrides % if set</p>
</div>
</div>
{(form.status === "PAID" || form.status === "PARTIALLY_PAID") && (
<div className="space-y-1.5">
<Label>Payment Received Date</Label>
<Input type="date" value={form.paymentReceivedDate} onChange={e => setForm(f => ({ ...f, paymentReceivedDate: e.target.value }))} className="bg-zinc-800 border-zinc-700" />
</div>
)}
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} className="bg-zinc-800 border-zinc-700 resize-none" rows={2} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDialogOpen(false)} className="border-zinc-700">Cancel</Button>
<Button onClick={handleSave} disabled={isPending || !form.title} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{editingMilestone ? "Update" : "Add Milestone"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+249
View File
@@ -0,0 +1,249 @@
"use client";
import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { GripVertical, ChevronDown, ChevronRight, Trash2, Pencil, Plus, Film, Clock, Hash, RefreshCw } from "lucide-react";
import { updatePrevis, deleteLineItem, createLineItem } from "@/actions/shots-assets";
import { toast } from "sonner";
import { LineItemsTable } from "@/components/line-items/line-items-table";
import { PrevisDialog } from "./previs-dialog";
import { LineItemDialog } from "@/components/line-items/line-item-dialog";
import { calcLineItem, sumLineItems, formatCurrency } from "@/lib/calculations";
import { PREVIS_LINE_ITEM_FLAT } from "@/lib/constants";
interface Previs {
id: string;
sequenceName: string;
previsCode: string | null;
sequenceType: string;
description: string | null;
duration: string | null;
frameRange: string | null;
revisionCount: number;
deliveryStage: string;
complexityMultiplier: any;
notes: string | null;
lineItems: any[];
}
interface Props {
previs: Previs;
jobId: string;
currency: string;
rateCards: any[];
onDelete: (id: string) => void;
onUpdate: (previs: any) => void;
}
const STAGE_COLORS: Record<string, string> = {
"Blocking": "bg-zinc-700 text-zinc-300",
"First Pass": "bg-blue-900/40 text-blue-300",
"Client Review": "bg-amber-900/40 text-amber-300",
"Final Previs": "bg-violet-900/40 text-violet-300",
"Approved": "bg-green-900/40 text-green-300",
};
export function PrevisCard({ previs, jobId, currency, rateCards, onDelete, onUpdate }: Props) {
const [expanded, setExpanded] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [addLineOpen, setAddLineOpen] = useState(false);
const [lineItems, setLineItems] = useState(previs.lineItems);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: previs.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
const totals = sumLineItems(
lineItems.map((li) =>
calcLineItem({
quantityHours: li.quantityHours?.toString() ?? "0",
dailyRate: li.dailyRate?.toString() ?? "0",
hoursPerDay: li.hoursPerDay?.toString() ?? "8",
numArtists: li.numArtists?.toString() ?? "1",
numDays: li.numDays?.toString() ?? "0",
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent?.toString() ?? "0",
discountPercent: li.discountPercent?.toString() ?? "0",
taxPercent: li.taxPercent?.toString() ?? "0",
}, previs.complexityMultiplier?.toString() ?? "1")
)
);
const handleEdit = async (data: any) => {
try {
const result = await updatePrevis(previs.id, jobId, data);
onUpdate(result.previs);
setEditOpen(false);
toast.success("Previs updated");
} catch {
toast.error("Failed to update previs");
}
};
const handleAddLineItem = async (data: any) => {
try {
const result = await createLineItem(previs.id, "previs", jobId, data);
setLineItems((prev) => [...prev, result.lineItem]);
setAddLineOpen(false);
toast.success("Line item added");
} catch {
toast.error("Failed to add line item");
}
};
const handleDeleteLineItem = async (id: string) => {
try {
await deleteLineItem(id, jobId);
setLineItems((prev) => prev.filter((li) => li.id !== id));
toast.success("Line item removed");
} catch {
toast.error("Failed to delete line item");
}
};
const handleLineItemUpdate = (updated: any) => {
setLineItems((prev) => prev.map((li) => (li.id === updated.id ? updated : li)));
};
const stageColor = STAGE_COLORS[previs.deliveryStage] ?? "bg-zinc-700 text-zinc-300";
return (
<div ref={setNodeRef} style={style} className="border border-zinc-800 rounded-xl overflow-hidden bg-zinc-900">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-900">
<button {...attributes} {...listeners} className="text-zinc-600 hover:text-zinc-400 cursor-grab">
<GripVertical className="h-4 w-4" />
</button>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-white truncate">{previs.sequenceName}</span>
{previs.previsCode && (
<span className="inline-flex items-center gap-0.5 text-xs text-zinc-500 font-mono">
<Hash className="h-3 w-3" />{previs.previsCode}
</span>
)}
<span className="inline-flex items-center gap-0.5 text-xs text-violet-400">
<Film className="h-3 w-3" />{previs.sequenceType}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded-full ${stageColor}`}>
{previs.deliveryStage}
</span>
{previs.duration && (
<span className="inline-flex items-center gap-0.5 text-xs text-zinc-500">
<Clock className="h-3 w-3" />{previs.duration}
</span>
)}
{previs.frameRange && (
<span className="text-xs text-zinc-500 font-mono">{previs.frameRange}</span>
)}
{previs.revisionCount > 0 && (
<span className="inline-flex items-center gap-0.5 text-xs text-zinc-400">
<RefreshCw className="h-3 w-3" />{previs.revisionCount} rev{previs.revisionCount !== 1 ? "s" : ""}
</span>
)}
</div>
{previs.description && (
<p className="text-xs text-zinc-500 truncate mt-0.5">{previs.description}</p>
)}
</div>
</button>
<span className="text-sm font-mono font-semibold text-violet-400 shrink-0">
{formatCurrency(totals.total, currency)}
</span>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-zinc-400 hover:text-white"
onClick={() => setEditOpen(true)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-zinc-600 hover:text-red-400"
onClick={() => onDelete(previs.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Expanded content */}
{expanded && (
<div className="border-t border-zinc-800">
{/* Line items */}
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Line Items ({lineItems.length})
</span>
<Button
size="sm"
variant="ghost"
onClick={() => setAddLineOpen(true)}
className="h-7 text-xs text-violet-400 hover:text-violet-300"
>
<Plus className="h-3.5 w-3.5 mr-1" /> Add
</Button>
</div>
{lineItems.length > 0 ? (
<LineItemsTable
lineItems={lineItems}
complexityMultiplier={previs.complexityMultiplier?.toString() ?? "1"}
currency={currency}
parentId={previs.id}
parentType="previs"
jobId={jobId}
rateCards={rateCards}
onDelete={handleDeleteLineItem}
onUpdate={handleLineItemUpdate}
/>
) : (
<p className="text-xs text-zinc-600 py-2">No line items yet.</p>
)}
</div>
</div>
)}
<PrevisDialog
open={editOpen}
onOpenChange={setEditOpen}
onSubmit={handleEdit}
initialData={{
sequenceName: previs.sequenceName,
previsCode: previs.previsCode ?? undefined,
sequenceType: previs.sequenceType,
description: previs.description ?? undefined,
duration: previs.duration ?? undefined,
frameRange: previs.frameRange ?? undefined,
revisionCount: previs.revisionCount,
deliveryStage: previs.deliveryStage,
complexityMultiplier: Number(previs.complexityMultiplier),
notes: previs.notes ?? undefined,
}}
/>
<LineItemDialog
open={addLineOpen}
onOpenChange={setAddLineOpen}
onSubmit={handleAddLineItem}
rateCards={rateCards}
defaultTasks={PREVIS_LINE_ITEM_FLAT as unknown as string[]}
/>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { previsSchema, type PrevisFormValues } from "@/lib/validations";
import type { Resolver } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
import { PREVIS_SEQUENCE_TYPES, PREVIS_DELIVERY_STAGES } from "@/lib/constants";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
onSubmit: (data: PrevisFormValues) => Promise<void>;
initialData?: Partial<PrevisFormValues>;
}
export function PrevisDialog({ open, onOpenChange, onSubmit, initialData }: Props) {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<PrevisFormValues>({
resolver: zodResolver(previsSchema) as Resolver<PrevisFormValues>,
defaultValues: {
sequenceType: "Previs",
deliveryStage: "Blocking",
revisionCount: 0,
complexityMultiplier: 1,
...initialData,
},
});
const handleClose = () => { reset(); onOpenChange(false); };
const submit = async (data: PrevisFormValues) => { await onSubmit(data); reset(); };
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-2xl">
<DialogHeader>
<DialogTitle className="text-white">
{initialData?.sequenceName ? "Edit Previs Sequence" : "Add Previs Sequence"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
{/* Sequence name + code */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Sequence Name *</Label>
<Input
{...register("sequenceName")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. Highway Chase"
/>
{errors.sequenceName && <p className="text-red-400 text-xs">{errors.sequenceName.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Previs Code</Label>
<Input
{...register("previsCode")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. PV_SEQ_010"
/>
</div>
</div>
{/* Sequence type + delivery stage */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Sequence Type</Label>
<select
{...register("sequenceType")}
className="w-full h-9 rounded-md bg-zinc-800 border border-zinc-700 text-white text-sm px-3"
>
{PREVIS_SEQUENCE_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label>Delivery Stage</Label>
<select
{...register("deliveryStage")}
className="w-full h-9 rounded-md bg-zinc-800 border border-zinc-700 text-white text-sm px-3"
>
{PREVIS_DELIVERY_STAGES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
{/* Duration + frame range + revisions + complexity */}
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1.5">
<Label>Duration</Label>
<Input
{...register("duration")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. 2:30"
/>
</div>
<div className="space-y-1.5">
<Label>Frame Range</Label>
<Input
{...register("frameRange")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. 1001-1250"
/>
</div>
<div className="space-y-1.5">
<Label>Revisions</Label>
<Input
{...register("revisionCount")}
type="number"
min="0"
className="bg-zinc-800 border-zinc-700"
/>
</div>
<div className="space-y-1.5">
<Label>Complexity ×</Label>
<Input
{...register("complexityMultiplier")}
type="number"
step="0.1"
min="0.1"
max="10"
className="bg-zinc-800 border-zinc-700"
/>
</div>
</div>
{/* Description + Notes */}
<div className="space-y-1.5">
<Label>Description</Label>
<Input
{...register("description")}
className="bg-zinc-800 border-zinc-700"
placeholder="Brief overview of the sequence"
/>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
<div className="flex gap-3 justify-end pt-2">
<Button type="button" variant="outline" className="border-zinc-700" onClick={handleClose}>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-violet-600 hover:bg-violet-500 text-white font-semibold"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{initialData?.sequenceName ? "Save Changes" : "Add Previs"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { createPrevis, deletePrevis, reorderPrevises } from "@/actions/shots-assets";
import { PrevisCard } from "./previs-card";
import { PrevisDialog } from "./previs-dialog";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
interface Props {
job: {
id: string;
previses: any[];
currency?: string;
[key: string]: any;
};
rateCards: any[];
}
export function PrevisEditor({ job, rateCards }: Props) {
const [previews, setPreviews] = useState(job.previses ?? []);
const [dialogOpen, setDialogOpen] = useState(false);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = previews.findIndex((p) => p.id === active.id);
const newIndex = previews.findIndex((p) => p.id === over.id);
const reordered = arrayMove(previews, oldIndex, newIndex);
setPreviews(reordered);
await reorderPrevises(job.id, reordered.map((p) => p.id));
};
const handleCreate = async (data: any) => {
try {
const result = await createPrevis(job.id, data);
setPreviews((prev) => [...prev, result.previs]);
setDialogOpen(false);
toast.success("Previs sequence added");
} catch {
toast.error("Failed to add previs");
}
};
const handleDelete = async (previsId: string) => {
try {
await deletePrevis(previsId, job.id);
setPreviews((prev) => prev.filter((p) => p.id !== previsId));
toast.success("Previs deleted");
} catch {
toast.error("Failed to delete previs");
}
};
const handleUpdate = (updated: any) => {
setPreviews((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-white">Previs ({previews.length})</h2>
<p className="text-xs text-zinc-500 mt-0.5">Storyboard animation, techvis, stuntvis, pitchvis, virtual camera blocking</p>
</div>
<Button
onClick={() => setDialogOpen(true)}
size="sm"
className="bg-violet-600 hover:bg-violet-500 text-white font-semibold"
>
<Plus className="h-4 w-4 mr-1" /> Add Previs
</Button>
</div>
{previews.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-dashed border-zinc-700 text-zinc-500">
<p className="mb-1">No previs sequences yet.</p>
<p className="text-sm">Add previs, techvis, stuntvis, pitchvis or postvis sequences.</p>
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={previews.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{previews.map((previs) => (
<PrevisCard
key={previs.id}
previs={previs}
jobId={job.id}
currency={job.currency ?? "ZAR"}
rateCards={rateCards}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
<PrevisDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={handleCreate}
/>
</div>
);
}
@@ -0,0 +1,264 @@
"use client";
import { useState } from "react";
import { useForm, useFormContext } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { rateCardSchema, type RateCardFormValues } from "@/lib/validations";
import type { Resolver } from "react-hook-form";
import { createRateCard, updateRateCard, deleteRateCard } from "@/actions/rate-cards";
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 { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, CreditCard, Loader2 } from "lucide-react";
import { CURRENCIES, RATE_CARD_ROLES } from "@/lib/constants";
import { formatCurrency } from "@/lib/calculations";
interface RateCard {
id: string;
name: string;
department: string | null;
defaultDailyRate: any;
hoursPerDay: any;
currency: string;
notes: string | null;
isActive: boolean;
}
function RateCardForm({
initialData,
rateCardId,
onSuccess,
onCancel,
}: {
initialData?: Partial<RateCardFormValues>;
rateCardId?: string;
onSuccess: (rc: RateCard) => void;
onCancel: () => void;
}) {
const { register, handleSubmit, setValue, formState: { errors, isSubmitting } } = useForm<RateCardFormValues>({
resolver: zodResolver(rateCardSchema) as Resolver<RateCardFormValues>,
defaultValues: { currency: "ZAR", hoursPerDay: 8, isActive: true, ...initialData },
});
const onSubmit = async (data: RateCardFormValues) => {
try {
if (rateCardId) {
const result = await updateRateCard(rateCardId, data);
onSuccess(result.rateCard as any);
toast.success("Rate card updated");
} else {
const result = await createRateCard(data);
onSuccess(result.rateCard as any);
toast.success("Rate card created");
}
} catch {
toast.error("Something went wrong");
}
};
return (
<form onSubmit={handleSubmit(onSubmit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label>Name *</Label>
<div className="flex gap-2">
<Input {...register("name")} className="bg-zinc-800 border-zinc-700 flex-1" placeholder="e.g. Senior Compositor" />
<Select onValueChange={(v) => {
const role = RATE_CARD_ROLES.find(r => r.name === v);
if (role) { setValue("name", role.name); setValue("department", role.department); }
}}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 w-44">
<SelectValue placeholder="Preset role" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{RATE_CARD_ROLES.map((r) => <SelectItem key={r.name} value={r.name}>{r.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{errors.name && <p className="text-red-400 text-xs">{errors.name.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Department</Label>
<Input {...register("department")} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Compositing" />
</div>
<div className="space-y-1.5">
<Label>Currency</Label>
<Select onValueChange={(v) => setValue("currency", v ?? "ZAR")} defaultValue={initialData?.currency ?? "ZAR"}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.symbol} {c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Default Daily Rate *</Label>
<Input {...register("defaultDailyRate")} type="number" step="0.01" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Hours / Day</Label>
<Input {...register("hoursPerDay")} type="number" step="0.5" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
<div className="flex gap-3 justify-end">
<Button type="button" variant="outline" className="border-zinc-700" onClick={onCancel}>Cancel</Button>
<Button type="submit" disabled={isSubmitting} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{rateCardId ? "Save Changes" : "Create"}
</Button>
</div>
</form>
);
}
export function RateCardsClient({ initialRateCards }: { initialRateCards: RateCard[] }) {
const [rateCards, setRateCards] = useState(initialRateCards);
const [dialogOpen, setDialogOpen] = useState(false);
const [editCard, setEditCard] = useState<RateCard | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const handleCreated = (rc: RateCard) => { setRateCards((p) => [...p, rc]); setDialogOpen(false); };
const handleUpdated = (rc: RateCard) => { setRateCards((p) => p.map((c) => c.id === rc.id ? rc : c)); setEditCard(null); };
const handleDelete = async () => {
if (!deleteId) return;
try { await deleteRateCard(deleteId); setRateCards((p) => p.filter((c) => c.id !== deleteId)); toast.success("Deleted"); }
catch { toast.error("Failed to delete"); }
setDeleteId(null);
};
const grouped = rateCards.reduce<Record<string, RateCard[]>>((acc, rc) => {
const dept = rc.department ?? "Other";
acc[dept] = acc[dept] ? [...acc[dept], rc] : [rc];
return acc;
}, {});
return (
<>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Rate Cards</h1>
<p className="text-zinc-400 mt-1">{rateCards.length} rate card{rateCards.length !== 1 ? "s" : ""}</p>
</div>
<Button onClick={() => setDialogOpen(true)} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
<Plus className="h-4 w-4 mr-2" /> New Rate Card
</Button>
</div>
{rateCards.length === 0 ? (
<div className="text-center py-16 text-zinc-500">
<CreditCard className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p>No rate cards yet.</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(grouped).map(([dept, cards]) => (
<div key={dept}>
<h2 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-2">{dept}</h2>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-500 text-xs uppercase tracking-wider">
<th className="px-4 py-3 text-left">Name</th>
<th className="px-4 py-3 text-right">Daily Rate</th>
<th className="px-4 py-3 text-right">Hrs/Day</th>
<th className="px-4 py-3 text-right">Hourly Rate</th>
<th className="px-4 py-3 text-center">Active</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{cards.map((rc) => {
const hourly = Number(rc.hoursPerDay) > 0
? Number(rc.defaultDailyRate) / Number(rc.hoursPerDay)
: 0;
return (
<tr key={rc.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30 transition-colors">
<td className="px-4 py-3 font-medium text-white">{rc.name}</td>
<td className="px-4 py-3 text-right font-mono text-zinc-300">
{formatCurrency(Number(rc.defaultDailyRate), rc.currency)}
</td>
<td className="px-4 py-3 text-right text-zinc-400">{Number(rc.hoursPerDay)}</td>
<td className="px-4 py-3 text-right font-mono text-zinc-400">
{formatCurrency(hourly, rc.currency)}
</td>
<td className="px-4 py-3 text-center">
<span className={`text-xs ${rc.isActive ? "text-green-400" : "text-zinc-600"}`}>
{rc.isActive ? "✓" : "—"}
</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setEditCard(rc)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => setDeleteId(rc.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
{/* Create dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-lg">
<DialogHeader><DialogTitle>New Rate Card</DialogTitle></DialogHeader>
<RateCardForm onSuccess={handleCreated} onCancel={() => setDialogOpen(false)} />
</DialogContent>
</Dialog>
{/* Edit dialog */}
{editCard && (
<Dialog open={!!editCard} onOpenChange={() => setEditCard(null)}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-lg">
<DialogHeader><DialogTitle>Edit Rate Card</DialogTitle></DialogHeader>
<RateCardForm
rateCardId={editCard.id}
initialData={{
name: editCard.name,
department: editCard.department ?? undefined,
defaultDailyRate: Number(editCard.defaultDailyRate),
hoursPerDay: Number(editCard.hoursPerDay),
currency: editCard.currency,
notes: editCard.notes ?? undefined,
isActive: editCard.isActive,
}}
onSuccess={handleUpdated}
onCancel={() => setEditCard(null)}
/>
</DialogContent>
</Dialog>
)}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-800">
<AlertDialogHeader>
<AlertDialogTitle>Delete rate card?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700">Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-500">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useForm } from "react-hook-form";
import type { Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { studioSettingsSchema, type StudioSettingsFormValues } from "@/lib/validations";
import { updateStudioSettings } from "@/actions/settings";
import { CURRENCIES } from "@/lib/constants";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import type { StudioSettings } from "@prisma/client";
interface Props {
initialData: StudioSettings;
}
export function SettingsForm({ initialData }: Props) {
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<StudioSettingsFormValues>({
resolver: zodResolver(studioSettingsSchema) as Resolver<StudioSettingsFormValues>,
defaultValues: {
studioName: initialData.studioName,
studioEmail: initialData.studioEmail ?? "",
studioPhone: initialData.studioPhone ?? "",
studioAddress: initialData.studioAddress ?? "",
logoUrl: initialData.logoUrl ?? "",
vatNumber: initialData.vatNumber ?? "",
companyRegNumber: initialData.companyRegNumber ?? "",
invoiceHeader: initialData.invoiceHeader ?? "",
invoiceFooter: initialData.invoiceFooter ?? "",
defaultTermsAndConditions: initialData.defaultTermsAndConditions ?? "",
defaultCurrency: initialData.defaultCurrency,
defaultMarkupPercent: Number(initialData.defaultMarkupPercent),
defaultDiscountPercent: Number(initialData.defaultDiscountPercent),
defaultTaxPercent: Number(initialData.defaultTaxPercent),
},
});
const onSubmit = async (data: StudioSettingsFormValues) => {
const result = await updateStudioSettings(data);
if ("success" in result && result.success) {
toast.success("Settings saved");
} else {
toast.error("Failed to save settings");
}
};
return (
<form
onSubmit={handleSubmit(onSubmit as Parameters<typeof handleSubmit>[0])}
className="space-y-8"
>
{/* Studio Identity */}
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Studio Identity</h2>
<p className="text-sm text-zinc-400">Your company details shown on quotes and invoices.</p>
</div>
<Separator className="bg-zinc-800" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Studio Name *</Label>
<Input {...register("studioName")} className="bg-zinc-900 border-zinc-700" />
{errors.studioName && <p className="text-red-400 text-xs">{errors.studioName.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Email</Label>
<Input {...register("studioEmail")} type="email" className="bg-zinc-900 border-zinc-700" />
{errors.studioEmail && <p className="text-red-400 text-xs">{errors.studioEmail.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Phone</Label>
<Input {...register("studioPhone")} className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>VAT Number</Label>
<Input {...register("vatNumber")} className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Company Reg. Number</Label>
<Input {...register("companyRegNumber")} className="bg-zinc-900 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Physical / Billing Address</Label>
<Textarea
{...register("studioAddress")}
className="bg-zinc-900 border-zinc-700 resize-none"
rows={3}
placeholder={"123 Studio Lane\nCape Town, 8001\nSouth Africa"}
/>
</div>
</section>
{/* Branding */}
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Branding</h2>
<p className="text-sm text-zinc-400">Logo and quote/invoice header/footer content.</p>
</div>
<Separator className="bg-zinc-800" />
<div className="space-y-1.5">
<Label>Logo URL</Label>
<Input
{...register("logoUrl")}
className="bg-zinc-900 border-zinc-700"
placeholder="https://yourstudio.com/logo.png"
/>
<p className="text-xs text-zinc-500">Paste a publicly accessible image URL. Appears at the top of every PDF quote.</p>
</div>
<div className="space-y-1.5">
<Label>Quote / Invoice Header</Label>
<Textarea
{...register("invoiceHeader")}
className="bg-zinc-900 border-zinc-700 resize-none font-mono text-sm"
rows={4}
placeholder="Shown at the top of every quote, below the logo. Plain text or basic HTML."
/>
</div>
<div className="space-y-1.5">
<Label>Quote / Invoice Footer</Label>
<Textarea
{...register("invoiceFooter")}
className="bg-zinc-900 border-zinc-700 resize-none font-mono text-sm"
rows={4}
placeholder="Shown at the bottom of every quote. Plain text or basic HTML."
/>
</div>
</section>
{/* Default Terms */}
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Default Terms & Conditions</h2>
<p className="text-sm text-zinc-400">Pre-filled on every new quote. Can be overridden per-job.</p>
</div>
<Separator className="bg-zinc-800" />
<div className="space-y-1.5">
<Textarea
{...register("defaultTermsAndConditions")}
className="bg-zinc-900 border-zinc-700 resize-none text-sm"
rows={6}
/>
</div>
</section>
{/* Financial Defaults */}
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Financial Defaults</h2>
<p className="text-sm text-zinc-400">Applied to new jobs and rate cards. Can be overridden per-job.</p>
</div>
<Separator className="bg-zinc-800" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-1.5">
<Label>Default Currency</Label>
<Select
onValueChange={(v) => setValue("defaultCurrency", v ?? "ZAR")}
defaultValue={initialData.defaultCurrency}
>
<SelectTrigger className="bg-zinc-900 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code}>{c.symbol} {c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Default Markup %</Label>
<Input
{...register("defaultMarkupPercent")}
type="number"
min={0}
max={100}
step={0.5}
className="bg-zinc-900 border-zinc-700"
/>
</div>
<div className="space-y-1.5">
<Label>Default Discount %</Label>
<Input
{...register("defaultDiscountPercent")}
type="number"
min={0}
max={100}
step={0.5}
className="bg-zinc-900 border-zinc-700"
/>
</div>
<div className="space-y-1.5">
<Label>Default Tax (VAT) %</Label>
<Input
{...register("defaultTaxPercent")}
type="number"
min={0}
max={100}
step={0.5}
className="bg-zinc-900 border-zinc-700"
/>
</div>
</div>
</section>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting} className="min-w-32">
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save Settings"}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,282 @@
"use client";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Loader2, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { importShotsFromCsv, type CsvShotRow } from "@/actions/shots-assets";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
jobId: string;
onImported: (shots: any[]) => void;
}
// ── Simple CSV parser (handles quoted fields) ─────────────────────────────────
function parseCsv(text: string): string[][] {
const rows: string[][] = [];
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
for (const line of lines) {
if (!line.trim()) continue;
const cols: string[] = [];
let cur = "";
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuote && line[i + 1] === '"') {
cur += '"';
i++;
} else {
inQuote = !inQuote;
}
} else if (ch === "," && !inQuote) {
cols.push(cur.trim());
cur = "";
} else {
cur += ch;
}
}
cols.push(cur.trim());
rows.push(cols);
}
return rows;
}
const SHOT_CODE_ALIASES = ["shotcode", "shot_code", "shot", "name", "code", "id"];
const DURATION_ALIASES = ["duration", "durationseconds", "duration_seconds", "seconds", "secs", "sec", "dur"];
const DESCRIPTION_ALIASES = ["description", "desc", "notes", "note", "details"];
function detectColumnIndex(header: string, aliases: string[]): number {
return aliases.indexOf(header.toLowerCase().replace(/\s+/g, ""));
}
interface ParsedRow {
shotCode: string;
durationSeconds?: number;
description?: string;
}
function csvToRows(text: string): { rows: ParsedRow[]; error?: string } {
const parsed = parseCsv(text);
if (parsed.length === 0) return { rows: [], error: "No data found" };
// Detect if first row is a header
const firstRow = parsed[0];
const lcFirst = firstRow.map((c) => c.toLowerCase().replace(/\s+/g, ""));
const hasHeader =
lcFirst.some((c) => SHOT_CODE_ALIASES.includes(c)) ||
lcFirst.some((c) => DURATION_ALIASES.includes(c)) ||
lcFirst.some((c) => DESCRIPTION_ALIASES.includes(c));
let codeIdx = -1;
let durIdx = -1;
let descIdx = -1;
let dataRows: string[][];
if (hasHeader) {
lcFirst.forEach((col, i) => {
if (codeIdx === -1 && SHOT_CODE_ALIASES.includes(col)) codeIdx = i;
if (durIdx === -1 && DURATION_ALIASES.includes(col)) durIdx = i;
if (descIdx === -1 && DESCRIPTION_ALIASES.includes(col)) descIdx = i;
});
dataRows = parsed.slice(1);
} else {
// No header: assume col 0 = code, col 1 = duration, col 2 = description
codeIdx = 0;
durIdx = parsed[0].length > 1 ? 1 : -1;
descIdx = parsed[0].length > 2 ? 2 : -1;
dataRows = parsed;
}
if (codeIdx === -1) return { rows: [], error: "Could not find a shot code/name column" };
const rows: ParsedRow[] = [];
for (const cols of dataRows) {
const shotCode = cols[codeIdx]?.trim();
if (!shotCode) continue;
const rawDur = durIdx >= 0 ? cols[durIdx]?.trim() : undefined;
const durationSeconds = rawDur ? Number(rawDur) : undefined;
const description = descIdx >= 0 ? cols[descIdx]?.trim() || undefined : undefined;
rows.push({ shotCode, durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : undefined, description });
}
return { rows };
}
export function ImportCsvShotsDialog({ open, onOpenChange, jobId, onImported }: Props) {
const fileRef = useRef<HTMLInputElement>(null);
const [rows, setRows] = useState<ParsedRow[]>([]);
const [parseError, setParseError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const reset = () => {
setRows([]);
setParseError(null);
setFileName(null);
if (fileRef.current) fileRef.current.value = "";
};
const handleFile = (file: File) => {
setFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
const { rows: parsed, error } = csvToRows(text);
if (error) {
setParseError(error);
setRows([]);
} else {
setParseError(null);
setRows(parsed);
}
};
reader.readAsText(file);
};
const handleImport = async () => {
if (!rows.length) return;
setImporting(true);
try {
const csvRows: CsvShotRow[] = rows.map((r) => ({
shotCode: r.shotCode,
durationSeconds: r.durationSeconds,
description: r.description,
}));
const result = await importShotsFromCsv(jobId, csvRows);
const created = result.shots.length;
const skipped = result.skipped ?? 0;
if (created > 0) {
onImported(result.shots);
toast.success(`Imported ${created} shot${created !== 1 ? "s" : ""}${skipped ? ` (${skipped} duplicate${skipped !== 1 ? "s" : ""} skipped)` : ""}`);
} else {
toast.info(`All ${skipped} shot${skipped !== 1 ? "s" : ""} already exist — nothing imported`);
}
reset();
onOpenChange(false);
} catch (err: any) {
toast.error(err.message ?? "Import failed");
} finally {
setImporting(false);
}
};
const handleClose = () => {
reset();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-700 text-white max-w-2xl">
<DialogHeader>
<DialogTitle>Import Shots from CSV</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
Upload a CSV file with columns for shot code/name, duration (seconds), and description. The header row is optional.
</p>
{/* Expected format hint */}
<div className="rounded-md bg-zinc-800 border border-zinc-700 px-3 py-2 text-xs font-mono text-zinc-400">
<span className="text-zinc-500">Expected columns (any order, headers optional):</span>
<br />
<span className="text-amber-400">shotCode</span>
{", "}
<span className="text-amber-400">duration</span>
{", "}
<span className="text-amber-400">description</span>
<br />
<span className="text-zinc-600">e.g. &nbsp;ABC_SC_0010, 120, Wide establishing shot</span>
</div>
{/* File drop area */}
{!fileName ? (
<label
className="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-zinc-700 py-10 cursor-pointer hover:border-amber-500 transition-colors"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}}
>
<Upload className="h-6 w-6 text-zinc-500" />
<span className="text-sm text-zinc-400">
Drop a <span className="text-white">.csv</span> file here, or{" "}
<span className="text-amber-400 underline">browse</span>
</span>
<input
ref={fileRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
}}
/>
</label>
) : (
<div className="flex items-center justify-between rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2">
<span className="text-sm text-zinc-300 truncate">{fileName}</span>
<button onClick={reset} className="ml-2 text-zinc-500 hover:text-white transition-colors shrink-0">
<X className="h-4 w-4" />
</button>
</div>
)}
{parseError && (
<p className="text-sm text-red-400">{parseError}</p>
)}
{/* Preview table */}
{rows.length > 0 && (
<div className="rounded-lg border border-zinc-700 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-zinc-800 border-b border-zinc-700">
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Preview — {rows.length} shot{rows.length !== 1 ? "s" : ""}
</span>
</div>
<div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-700 text-xs text-zinc-500 uppercase tracking-wider">
<th className="px-3 py-2 text-left">Shot Code</th>
<th className="px-3 py-2 text-left">Duration (s)</th>
<th className="px-3 py-2 text-left">Description</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-b border-zinc-800 hover:bg-zinc-800/50">
<td className="px-3 py-2 font-mono text-amber-400">{r.shotCode}</td>
<td className="px-3 py-2 text-zinc-300">{r.durationSeconds ?? <span className="text-zinc-600">—</span>}</td>
<td className="px-3 py-2 text-zinc-400 max-w-xs truncate">{r.description || <span className="text-zinc-600">—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" onClick={handleClose} className="border-zinc-700 text-zinc-300">
Cancel
</Button>
<Button
onClick={handleImport}
disabled={rows.length === 0 || importing}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
{importing ? <><Loader2 className="h-4 w-4 mr-1 animate-spin" /> Importing…</> : `Import ${rows.length > 0 ? rows.length : ""} Shot${rows.length !== 1 ? "s" : ""}`}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,297 @@
"use client";
import { useState, useMemo } from "react";
import { toast } from "sonner";
import { Loader2, Search, CheckSquare, Square, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { fetchExternalShots, importShotsFromApi, type ExternalShot } from "@/actions/shots-assets";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
jobId: string;
onImported: (shots: any[]) => void;
}
const STATUS_COLORS: Record<string, string> = {
COMPLETE: "bg-green-900/60 text-green-300",
IN_REVIEW: "bg-blue-900/60 text-blue-300",
IN_PROGRESS: "bg-amber-900/60 text-amber-300",
REVISIONS: "bg-red-900/60 text-red-300",
WAITING: "bg-zinc-700 text-zinc-300",
};
export function ImportShotsDialog({ open, onOpenChange, jobId, onImported }: Props) {
const [projectId, setProjectId] = useState("");
const [fetching, setFetching] = useState(false);
const [importing, setImporting] = useState(false);
const [allShots, setAllShots] = useState<ExternalShot[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
const [hasFetched, setHasFetched] = useState(false);
// Group shots by episode (or "All" for non-episodic)
const grouped = useMemo(() => {
const map = new Map<string, ExternalShot[]>();
allShots.forEach((s) => {
const key = s.episode ?? "Ungrouped";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
});
const result: { label: string; shots: ExternalShot[] }[] = [];
// Sort episode keys
Array.from(map.keys())
.sort((a, b) => {
if (a === "Ungrouped") return 1;
if (b === "Ungrouped") return -1;
return a.localeCompare(b, undefined, { numeric: true });
})
.forEach((k) => result.push({ label: k, shots: map.get(k)! }));
return result;
}, [allShots]);
const isEpisodic = grouped.length > 1 || (grouped.length === 1 && grouped[0].label !== "Ungrouped");
const handleFetch = async () => {
if (!projectId.trim()) {
toast.error("Enter a project ID");
return;
}
setFetching(true);
setAllShots([]);
setSelected(new Set());
setHasFetched(false);
try {
const data = await fetchExternalShots(projectId.trim());
setAllShots(data.shots);
setHasFetched(true);
if (data.shots.length === 0) toast.info("No shots found for this project");
} catch (err: any) {
toast.error(err.message ?? "Failed to fetch shots");
} finally {
setFetching(false);
}
};
const toggleShot = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleEpisode = (label: string) => {
const episodeShots = grouped.find((g) => g.label === label)?.shots ?? [];
const ids = episodeShots.map((s) => s.id);
const allSelected = ids.every((id) => selected.has(id));
setSelected((prev) => {
const next = new Set(prev);
if (allSelected) {
ids.forEach((id) => next.delete(id));
} else {
ids.forEach((id) => next.add(id));
}
return next;
});
};
const toggleAll = () => {
if (selected.size === allShots.length) {
setSelected(new Set());
} else {
setSelected(new Set(allShots.map((s) => s.id)));
}
};
const toggleCollapse = (label: string) => {
setCollapsedEpisodes((prev) => {
const next = new Set(prev);
next.has(label) ? next.delete(label) : next.add(label);
return next;
});
};
const handleImport = async () => {
const toImport = allShots.filter((s) => selected.has(s.id));
if (!toImport.length) {
toast.error("Select at least one shot to import");
return;
}
setImporting(true);
try {
const result = await importShotsFromApi(jobId, toImport);
onImported(result.shots);
const skippedMsg = result.skipped ? ` (${result.skipped} already existed, skipped)` : "";
toast.success(`${result.shots.length} shot${result.shots.length !== 1 ? "s" : ""} imported${skippedMsg}`);
onOpenChange(false);
handleReset();
} catch (err: any) {
toast.error(err.message ?? "Import failed");
} finally {
setImporting(false);
}
};
const handleReset = () => {
setProjectId("");
setAllShots([]);
setSelected(new Set());
setCollapsedEpisodes(new Set());
setHasFetched(false);
};
const handleClose = (v: boolean) => {
if (!v) handleReset();
onOpenChange(v);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-4xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Import Shots from Project</DialogTitle>
</DialogHeader>
{/* Project ID input */}
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<Label className="text-xs text-zinc-400">Project ID</Label>
<Input
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleFetch()}
placeholder="e.g. cmp6l5mzq0001ua0gz07bk72f"
className="bg-zinc-800 border-zinc-700 font-mono text-sm"
disabled={fetching}
/>
</div>
<div className="flex items-end">
<Button
onClick={handleFetch}
disabled={fetching || !projectId.trim()}
className="bg-zinc-700 hover:bg-zinc-600 text-white"
>
{fetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1.5">{fetching ? "Fetching…" : "Fetch Shots"}</span>
</Button>
</div>
</div>
{/* Results */}
{hasFetched && allShots.length > 0 && (
<>
{/* Toolbar */}
<div className="flex items-center justify-between border-t border-zinc-800 pt-3">
<button
onClick={toggleAll}
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-white transition-colors"
>
{selected.size === allShots.length ? (
<CheckSquare className="h-4 w-4 text-amber-400" />
) : (
<Square className="h-4 w-4" />
)}
{selected.size === allShots.length ? "Deselect all" : "Select all"}
<span className="text-zinc-600">({allShots.length} shots)</span>
</button>
{selected.size > 0 && (
<span className="text-xs text-amber-400 font-medium">
{selected.size} selected
</span>
)}
</div>
{/* Shot list */}
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
{grouped.map(({ label, shots }) => {
const episodeSelected = shots.every((s) => selected.has(s.id));
const episodePartial = !episodeSelected && shots.some((s) => selected.has(s.id));
const collapsed = collapsedEpisodes.has(label);
return (
<div key={label}>
{/* Episode header */}
<div className="flex items-center gap-2 mb-2">
<button
onClick={() => toggleCollapse(label)}
className="text-zinc-500 hover:text-zinc-300 transition-colors"
>
{collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
<button
onClick={() => toggleEpisode(label)}
className="flex items-center gap-2 group"
>
{episodeSelected ? (
<CheckSquare className="h-4 w-4 text-amber-400 shrink-0" />
) : (
<Square className={`h-4 w-4 shrink-0 ${episodePartial ? "text-amber-600" : "text-zinc-600 group-hover:text-zinc-400"}`} />
)}
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400 group-hover:text-zinc-200 transition-colors">
{isEpisodic ? `Episode ${label}` : label}
</span>
</button>
<span className="text-xs text-zinc-600">· {shots.length} shot{shots.length !== 1 ? "s" : ""}</span>
</div>
{/* Shots */}
{!collapsed && (
<div className="space-y-1 pl-6">
{shots.map((shot) => (
<button
key={shot.id}
onClick={() => toggleShot(shot.id)}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800/60 transition-colors text-left"
>
{selected.has(shot.id) ? (
<CheckSquare className="h-4 w-4 text-amber-400 shrink-0" />
) : (
<Square className="h-4 w-4 text-zinc-600 shrink-0" />
)}
<span className="font-mono text-sm text-amber-400 shrink-0 min-w-50">
{shot.shotCode}
</span>
<span className="text-sm text-zinc-400 truncate flex-1">
{shot.description ?? "—"}
</span>
<Badge className={`text-[10px] px-1.5 py-0 shrink-0 ${STATUS_COLORS[shot.status] ?? "bg-zinc-700 text-zinc-300"}`}>
{shot.status.replace("_", " ")}
</Badge>
</button>
))}
</div>
)}
</div>
);
})}
</div>
{/* Footer */}
<div className="border-t border-zinc-800 pt-3 flex items-center justify-between">
<span className="text-xs text-zinc-500">
{selected.size} of {allShots.length} shots selected
</span>
<Button
onClick={handleImport}
disabled={importing || selected.size === 0}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
{importing && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Import {selected.size > 0 ? selected.size : ""} Shot{selected.size !== 1 ? "s" : ""}
</Button>
</div>
</>
)}
{hasFetched && allShots.length === 0 && (
<p className="text-center py-8 text-zinc-500 text-sm">No shots found for this project ID.</p>
)}
</DialogContent>
</Dialog>
);
}
+287
View File
@@ -0,0 +1,287 @@
"use client";
import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import {
GripVertical,
ChevronDown,
ChevronRight,
Trash2,
Copy,
Pencil,
Plus,
Loader2,
} from "lucide-react";
import { updateShot, deleteLineItem, createLineItem } from "@/actions/shots-assets";
import { toast } from "sonner";
import { LineItemsTable } from "@/components/line-items/line-items-table";
import { ShotDialog } from "./shot-dialog";
import { LineItemDialog } from "@/components/line-items/line-item-dialog";
import { calcLineItem, sumLineItems, formatCurrency } from "@/lib/calculations";
import Decimal from "decimal.js";
interface Shot {
id: string;
shotCode: string;
shotNumber: number;
scene: string;
episode: string | null;
shotGroup: string | null;
description: string | null;
durationSeconds: number | null;
complexityMultiplier: any;
notes: string | null;
lineItems: any[];
}
interface Props {
shot: Shot;
jobId: string;
currency: string;
rateCards: any[];
taskTemplates: any[];
showId: string;
jobType: "STANDARD" | "EPISODIC";
existingGroups?: string[];
onDelete: (id: string) => void;
onDuplicate: (id: string) => void;
onUpdate: (shot: any) => void;
}
export function ShotCard({ shot, jobId, currency, rateCards, taskTemplates, showId, jobType, existingGroups = [], onDelete, onDuplicate, onUpdate }: Props) {
const [expanded, setExpanded] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [addLineOpen, setAddLineOpen] = useState(false);
const [lineItems, setLineItems] = useState(shot.lineItems);
const [applyingTemplate, setApplyingTemplate] = useState<string | null>(null);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: shot.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const totals = sumLineItems(
lineItems.map((li) =>
calcLineItem(
{
quantityHours: li.quantityHours?.toString() ?? "0",
dailyRate: li.dailyRate?.toString() ?? "0",
hoursPerDay: li.hoursPerDay?.toString() ?? "8",
numArtists: li.numArtists?.toString() ?? "1",
numDays: li.numDays?.toString() ?? "0",
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent?.toString() ?? "0",
discountPercent: li.discountPercent?.toString() ?? "0",
taxPercent: li.taxPercent?.toString() ?? "0",
},
shot.complexityMultiplier?.toString() ?? "1"
)
)
);
const handleEdit = async (data: any) => {
try {
const result = await updateShot(shot.id, jobId, data);
onUpdate(result.shot);
setEditOpen(false);
toast.success("Shot updated");
} catch {
toast.error("Failed to update shot");
}
};
const handleAddLineItem = async (data: any) => {
try {
const result = await createLineItem(shot.id, "shot", jobId, data);
setLineItems((prev) => [...prev, result.lineItem]);
setAddLineOpen(false);
toast.success("Line item added");
} catch {
toast.error("Failed to add line item");
}
};
const handleDeleteLineItem = async (id: string) => {
try {
await deleteLineItem(id, jobId);
setLineItems((prev) => prev.filter((li) => li.id !== id));
toast.success("Line item removed");
} catch {
toast.error("Failed to delete line item");
}
};
const handleLineItemUpdate = (updated: any) => {
setLineItems((prev) => prev.map((li) => (li.id === updated.id ? updated : li)));
};
const handleApplyTemplate = async (template: any) => {
setApplyingTemplate(template.id);
try {
const result = await createLineItem(shot.id, "shot", jobId, {
taskName: template.taskName,
artistRole: template.artistRole ?? undefined,
rateCardId: template.rateCardId ?? undefined,
quantityHours: Number(template.quantityHours),
dailyRate: Number(template.dailyRate),
hoursPerDay: Number(template.hoursPerDay),
numArtists: Number(template.numArtists),
numDays: Number(template.numDays),
manualHourlyRate: template.manualHourlyRate != null ? Number(template.manualHourlyRate) : undefined,
manualSubtotal: template.manualSubtotal != null ? Number(template.manualSubtotal) : undefined,
markupPercent: Number(template.markupPercent),
discountPercent: Number(template.discountPercent),
taxPercent: Number(template.taxPercent),
notes: template.notes ?? undefined,
});
setLineItems((prev) => [...prev, result.lineItem]);
toast.success(`"${template.name}" applied`);
} catch {
toast.error("Failed to apply template");
} finally {
setApplyingTemplate(null);
}
};
return (
<div ref={setNodeRef} style={style} className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3">
<button {...attributes} {...listeners} className="text-zinc-600 hover:text-zinc-400 cursor-grab active:cursor-grabbing">
<GripVertical className="h-4 w-4" />
</button>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 flex-1 text-left"
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-zinc-400" />
) : (
<ChevronRight className="h-4 w-4 text-zinc-400" />
)}
<span className="font-semibold text-white">{shot.shotCode}</span>
{(shot.episode || shot.shotGroup) && (
<span className="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300">
{shot.episode ?? shot.shotGroup}
</span>
)}
{shot.description && (
<span className="text-zinc-500 text-sm truncate hidden sm:block">{shot.description}</span>
)}
<span className="ml-auto text-xs text-zinc-500">
{lineItems.length} tasks
</span>
</button>
<span className="font-mono text-sm text-amber-400 ml-4">
{formatCurrency(totals.total, currency)}
</span>
<div className="flex gap-1 ml-2">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setEditOpen(true)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDuplicate(shot.id)}>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => onDelete(shot.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Expanded */}
{expanded && (
<div className="border-t border-zinc-800">
{shot.description || shot.notes || Number(shot.complexityMultiplier) !== 1 ? (
<div className="px-4 py-3 bg-zinc-800/30 text-sm text-zinc-400 flex gap-6">
{shot.description && <span>{shot.description}</span>}
{Number(shot.complexityMultiplier) !== 1 && (
<span className="text-amber-400">×{Number(shot.complexityMultiplier).toFixed(2)} complexity</span>
)}
{shot.durationSeconds && <span>{shot.durationSeconds}s</span>}
</div>
) : null}
<LineItemsTable
lineItems={lineItems}
currency={currency}
rateCards={rateCards}
complexityMultiplier={shot.complexityMultiplier?.toString() ?? "1"}
parentId={shot.id}
parentType="shot"
jobId={jobId}
onDelete={handleDeleteLineItem}
onUpdate={handleLineItemUpdate}
/>
<div className="px-4 py-3 border-t border-zinc-800 flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
className="border-zinc-700 text-sm"
onClick={() => setAddLineOpen(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" /> Add Task
</Button>
{taskTemplates.map((template) => (
<Button
key={template.id}
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-400 hover:text-white hover:border-amber-500/50 text-xs h-7 px-2.5"
disabled={applyingTemplate === template.id}
onClick={() => handleApplyTemplate(template)}
>
{applyingTemplate === template.id ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Plus className="h-3 w-3 mr-1 text-amber-500/70" />
)}
{template.name}
</Button>
))}
</div>
</div>
)}
<ShotDialog
open={editOpen}
onOpenChange={setEditOpen}
onSubmit={handleEdit}
initialData={{
shotCode: shot.shotCode,
scene: shot.scene,
episode: shot.episode ?? undefined,
shotGroup: shot.shotGroup ?? undefined,
complexityMultiplier: Number(shot.complexityMultiplier),
description: shot.description ?? undefined,
durationSeconds: shot.durationSeconds ?? undefined,
notes: shot.notes ?? undefined,
}}
showId={showId}
jobType={jobType}
nextShotNumber={shot.shotNumber}
existingGroups={existingGroups}
/>
<LineItemDialog
open={addLineOpen}
onOpenChange={setAddLineOpen}
onSubmit={handleAddLineItem}
rateCards={rateCards}
defaultTasks={[
"Matchmove / Tracking", "3D Camera Tracking", "Object Tracking", "Roto",
"Paint / Cleanup", "Keying", "Compositing", "FX Simulation", "Lighting",
"Rendering", "Animation", "Lookdev", "Crowd Simulation", "CG Integration",
"Environment Creation", "Matte Painting", "Rig Removal", "Wire Removal",
"Beauty Cleanup", "Screen Replacement", "Set Extension", "QC / Review",
"Supervision", "Editorial Support",
]}
/>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useForm, useWatch } from "react-hook-form";
import type { Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { shotSchema, type ShotFormValues } from "@/lib/validations";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
onSubmit: (data: ShotFormValues) => Promise<void>;
initialData?: Partial<ShotFormValues>;
showId: string;
jobType: "STANDARD" | "EPISODIC";
nextShotNumber: number;
existingGroups?: string[];
}
export function ShotDialog({ open, onOpenChange, onSubmit, initialData, showId, jobType, nextShotNumber, existingGroups = [] }: Props) {
const {
register,
handleSubmit,
reset,
control,
formState: { errors, isSubmitting },
} = useForm<ShotFormValues>({
resolver: zodResolver(shotSchema) as Resolver<ShotFormValues>,
defaultValues: { complexityMultiplier: 1, ...initialData },
});
const scene = useWatch({ control, name: "scene", defaultValue: "" });
const episode = useWatch({ control, name: "episode", defaultValue: "" });
const paddedNumber = String(nextShotNumber).padStart(4, "0");
const sid = (showId || "UNK").toUpperCase();
const sc = (scene || "SCENE").toUpperCase();
const ep = (episode || "").toUpperCase();
const previewCode =
jobType === "EPISODIC" && ep
? `${sid}_${ep}_${sc}_${paddedNumber}`
: `${sid}_${sc}_${paddedNumber}`;
const handleClose = () => {
reset();
onOpenChange(false);
};
const submit = async (data: ShotFormValues) => {
await onSubmit(data);
reset();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-lg">
<DialogHeader>
<DialogTitle>{initialData ? "Edit Shot" : "Add Shot"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
{/* Shot code — editable in edit mode, preview-only when adding */}
{initialData ? (
<div className="space-y-1.5">
<Label>Shot Code</Label>
<Input
{...register("shotCode")}
className="bg-zinc-800 border-zinc-700 font-mono uppercase text-amber-400"
placeholder={previewCode}
onChange={e => { e.target.value = e.target.value.toUpperCase(); register("shotCode").onChange(e); }}
/>
{errors.shotCode && <p className="text-red-400 text-xs">{errors.shotCode.message}</p>}
</div>
) : (
<div className="bg-zinc-800/60 border border-zinc-700 rounded-lg px-4 py-3">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Shot ID preview</p>
<p className="font-mono font-semibold text-amber-400 text-sm">{previewCode}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{jobType === "EPISODIC" && (
<div className="space-y-1.5">
<Label>Episode</Label>
<Input
{...register("episode")}
className="bg-zinc-800 border-zinc-700 uppercase"
placeholder="e.g. EP01"
onChange={e => { e.target.value = e.target.value.toUpperCase(); register("episode").onChange(e); }}
/>
{errors.episode && <p className="text-red-400 text-xs">{errors.episode.message}</p>}
</div>
)}
<div className="space-y-1.5">
<Label>Scene *</Label>
<Input
{...register("scene")}
className="bg-zinc-800 border-zinc-700 uppercase"
placeholder="e.g. SQ010"
onChange={e => { e.target.value = e.target.value.toUpperCase(); register("scene").onChange(e); }}
/>
{errors.scene && <p className="text-red-400 text-xs">{errors.scene.message}</p>}
</div>
{jobType === "STANDARD" && (
<div className="space-y-1.5 col-span-2">
<Label>Group / Package <span className="text-zinc-500 font-normal text-xs ml-1">optional — used for quote subtotals</span></Label>
<input
{...register("shotGroup")}
list="shot-groups"
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
placeholder="e.g. Title Sequence, VFX Pack, Episode 1…"
/>
{existingGroups.length > 0 && (
<datalist id="shot-groups">
{existingGroups.map(g => <option key={g} value={g} />)}
</datalist>
)}
</div>
)}
<div className="space-y-1.5">
<Label>Duration (seconds)</Label>
<Input {...register("durationSeconds")} type="number" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Complexity Multiplier</Label>
<Input {...register("complexityMultiplier")} type="number" step="0.1" min="0.1" max="10" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Input {...register("description")} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
<div className="flex gap-3 justify-end pt-2">
<Button type="button" variant="outline" className="border-zinc-700" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{initialData ? "Save" : "Add Shot"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useState, useMemo } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Plus, FolderDown, Upload } from "lucide-react";
import { createShot, deleteShot, duplicateShot, reorderShots } from "@/actions/shots-assets";
import { ShotCard } from "./shot-card";
import { ShotDialog } from "./shot-dialog";
import { ImportShotsDialog } from "./import-shots-dialog";
import { ImportCsvShotsDialog } from "./import-csv-shots-dialog";
import { calcLineItem, sumLineItems, formatCurrency } from "@/lib/calculations";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import type { Prisma } from "@prisma/client";
type JobWithRelations = Prisma.JobGetPayload<{
include: {
client: true;
shots: { include: { lineItems: { include: { rateCard: true } } } };
assets: { include: { lineItems: { include: { rateCard: true } } } };
comments: { include: { user: true } };
history: true;
};
}>;
function shotTotal(shot: any): number {
const results = (shot.lineItems ?? []).map((li: any) =>
calcLineItem({
quantityHours: li.quantityHours?.toString() ?? "0",
dailyRate: li.dailyRate?.toString() ?? "0",
hoursPerDay: li.hoursPerDay?.toString() ?? "8",
numArtists: li.numArtists?.toString() ?? "1",
numDays: li.numDays?.toString() ?? "0",
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent?.toString() ?? "0",
discountPercent: li.discountPercent?.toString() ?? "0",
taxPercent: li.taxPercent?.toString() ?? "0",
}, shot.complexityMultiplier?.toString() ?? "1")
);
return sumLineItems(results).total.toNumber();
}
export function ShotsEditor({ job, rateCards, taskTemplates }: { job: JobWithRelations; rateCards: any[]; taskTemplates: any[] }) {
const [shots, setShots] = useState(job.shots);
const [dialogOpen, setDialogOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importCsvDialogOpen, setImportCsvDialogOpen] = useState(false);
const jobType = (job.jobType as "STANDARD" | "EPISODIC") ?? "STANDARD";
const showId = job.showId || "";
const sensors = useSensors(useSensor(PointerSensor));
// Collect unique group keys for autocomplete
const existingGroups = useMemo<string[]>(() => {
const keys = new Set<string>();
shots.forEach((s: any) => {
const g = jobType === "EPISODIC" ? s.episode : s.shotGroup;
if (g) keys.add(g);
});
return Array.from(keys).sort();
}, [shots, jobType]);
// Group shots for display: episodic → by episode, standard → by shotGroup
const grouped = useMemo(() => {
const map = new Map<string, typeof shots>();
shots.forEach((s: any) => {
const key = jobType === "EPISODIC"
? (s.episode || "Ungrouped")
: (s.shotGroup || "Ungrouped");
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
});
// Sort groups alphabetically; put "Ungrouped" last
const result: { label: string; shots: typeof shots }[] = [];
const sortedKeys = Array.from(map.keys()).filter((k) => k !== "Ungrouped").sort();
sortedKeys.forEach((k) => result.push({ label: k, shots: map.get(k)! }));
const ungrouped = map.get("Ungrouped");
if (ungrouped?.length) result.push({ label: "Ungrouped", shots: ungrouped });
// Sort shots within each group alphabetically by shotCode
result.forEach((g) => g.shots.sort((a: any, b: any) => (a.shotCode ?? "").localeCompare(b.shotCode ?? "")));
return result;
}, [shots, jobType]);
const hasGroups = grouped.length > 1 || (grouped.length === 1 && grouped[0].label !== "Ungrouped");
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = shots.findIndex((s) => s.id === active.id);
const newIndex = shots.findIndex((s) => s.id === over.id);
const reordered = arrayMove(shots, oldIndex, newIndex);
setShots(reordered);
await reorderShots(job.id, reordered.map((s) => s.id));
};
const handleCreate = async (data: any) => {
try {
const result = await createShot(job.id, data);
setShots((prev) => [...prev, result.shot as any]);
setDialogOpen(false);
toast.success("Shot added");
} catch {
toast.error("Failed to add shot");
}
};
const handleImported = (importedShots: any[]) => {
setShots((prev) => [...prev, ...importedShots]);
};
const handleDelete = async (shotId: string) => {
try {
await deleteShot(shotId, job.id);
setShots((prev) => prev.filter((s) => s.id !== shotId));
toast.success("Shot deleted");
} catch {
toast.error("Failed to delete shot");
}
};
const handleDuplicate = async (shotId: string) => {
try {
const result = await duplicateShot(shotId, job.id);
setShots((prev) => [...prev, result.shot as any]);
toast.success("Shot duplicated");
} catch {
toast.error("Failed to duplicate shot");
}
};
const handleShotUpdate = (updatedShot: any) => {
setShots((prev) => prev.map((s) => (s.id === updatedShot.id ? updatedShot : s)));
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Shots ({shots.length})</h2>
<div className="flex items-center gap-2">
<Button
onClick={() => setImportCsvDialogOpen(true)}
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-white"
>
<Upload className="h-4 w-4 mr-1" /> Import CSV
</Button>
<Button
onClick={() => setImportDialogOpen(true)}
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-white"
>
<FolderDown className="h-4 w-4 mr-1" /> Import Shots
</Button>
<Button
onClick={() => setDialogOpen(true)}
size="sm"
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
<Plus className="h-4 w-4 mr-1" /> Add Shot
</Button>
</div>
</div>
{shots.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-dashed border-zinc-700 text-zinc-500">
<p>No shots yet. Add your first shot to get started.</p>
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={shots.map((s) => s.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-6">
{grouped.map(({ label, shots: groupShots }) => {
const groupTotal = groupShots.reduce((sum, s) => sum + shotTotal(s), 0);
return (
<div key={label}>
{hasGroups && (
<div className="flex items-center justify-between mb-2 px-1">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400">
{label}
</span>
<span className="text-xs text-zinc-600">· {groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}</span>
</div>
<span className="font-mono text-sm font-semibold text-amber-400">
{formatCurrency(groupTotal, "ZAR")}
</span>
</div>
)}
<div className="space-y-3">
{groupShots.map((shot) => (
<ShotCard
key={shot.id}
shot={shot as any}
jobId={job.id}
currency="ZAR"
rateCards={rateCards}
taskTemplates={taskTemplates}
showId={showId}
jobType={jobType}
existingGroups={existingGroups}
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onUpdate={handleShotUpdate}
/>
))}
</div>
</div>
);
})}
</div>
</SortableContext>
</DndContext>
)}
<ShotDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={handleCreate}
showId={showId}
jobType={jobType}
nextShotNumber={Math.max(0, ...shots.map((s: any) => s.shotNumber ?? 0)) + 10}
existingGroups={existingGroups}
/>
<ImportShotsDialog
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
jobId={job.id}
onImported={handleImported}
/>
<ImportCsvShotsDialog
open={importCsvDialogOpen}
onOpenChange={setImportCsvDialogOpen}
jobId={job.id}
onImported={handleImported}
/>
</div>
);
}
@@ -0,0 +1,263 @@
"use client";
import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { GripVertical, ChevronDown, ChevronRight, Trash2, Pencil, Plus, MapPin, Calendar, Clock } from "lucide-react";
import { updateSupervision, deleteLineItem, createLineItem } from "@/actions/shots-assets";
import { toast } from "sonner";
import { LineItemsTable } from "@/components/line-items/line-items-table";
import { SupervisionDialog } from "./supervision-dialog";
import { LineItemDialog } from "@/components/line-items/line-item-dialog";
import { calcLineItem, sumLineItems, formatCurrency } from "@/lib/calculations";
import { SUPERVISION_LINE_ITEM_FLAT } from "@/lib/constants";
interface Supervision {
id: string;
supervisionTitle: string;
shootName: string | null;
location: string | null;
shootDates: string | null;
numShootDays: number;
travelDays: number;
prepDays: number;
wrapDays: number;
description: string | null;
complexityMultiplier: any;
notes: string | null;
lineItems: any[];
}
interface Props {
supervision: Supervision;
jobId: string;
currency: string;
rateCards: any[];
onDelete: (id: string) => void;
onUpdate: (supervision: any) => void;
}
export function SupervisionCard({ supervision, jobId, currency, rateCards, onDelete, onUpdate }: Props) {
const [expanded, setExpanded] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [addLineOpen, setAddLineOpen] = useState(false);
const [lineItems, setLineItems] = useState(supervision.lineItems);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: supervision.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
const totals = sumLineItems(
lineItems.map((li) =>
calcLineItem({
quantityHours: li.quantityHours?.toString() ?? "0",
dailyRate: li.dailyRate?.toString() ?? "0",
hoursPerDay: li.hoursPerDay?.toString() ?? "8",
numArtists: li.numArtists?.toString() ?? "1",
numDays: li.numDays?.toString() ?? "0",
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent?.toString() ?? "0",
discountPercent: li.discountPercent?.toString() ?? "0",
taxPercent: li.taxPercent?.toString() ?? "0",
}, supervision.complexityMultiplier?.toString() ?? "1")
)
);
const totalDays = supervision.numShootDays + supervision.prepDays + supervision.travelDays + supervision.wrapDays;
const handleEdit = async (data: any) => {
try {
const result = await updateSupervision(supervision.id, jobId, data);
onUpdate(result.supervision);
setEditOpen(false);
toast.success("Supervision updated");
} catch {
toast.error("Failed to update supervision");
}
};
const handleAddLineItem = async (data: any) => {
try {
const result = await createLineItem(supervision.id, "supervision", jobId, data);
setLineItems((prev) => [...prev, result.lineItem]);
setAddLineOpen(false);
toast.success("Line item added");
} catch {
toast.error("Failed to add line item");
}
};
const handleDeleteLineItem = async (id: string) => {
try {
await deleteLineItem(id, jobId);
setLineItems((prev) => prev.filter((li) => li.id !== id));
toast.success("Line item removed");
} catch {
toast.error("Failed to delete line item");
}
};
const handleLineItemUpdate = (updated: any) => {
setLineItems((prev) => prev.map((li) => (li.id === updated.id ? updated : li)));
};
return (
<div ref={setNodeRef} style={style} className="border border-zinc-800 rounded-xl overflow-hidden bg-zinc-900">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-900">
<button {...attributes} {...listeners} className="text-zinc-600 hover:text-zinc-400 cursor-grab">
<GripVertical className="h-4 w-4" />
</button>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-white truncate">{supervision.supervisionTitle}</span>
{supervision.location && (
<span className="inline-flex items-center gap-0.5 text-xs text-zinc-400">
<MapPin className="h-3 w-3" />
{supervision.location}
</span>
)}
{supervision.shootDates && (
<span className="inline-flex items-center gap-0.5 text-xs text-zinc-500">
<Calendar className="h-3 w-3" />
{supervision.shootDates}
</span>
)}
{totalDays > 0 && (
<span className="inline-flex items-center gap-0.5 text-xs text-amber-500">
<Clock className="h-3 w-3" />
{totalDays}d total
</span>
)}
</div>
{supervision.description && (
<p className="text-xs text-zinc-500 truncate mt-0.5">{supervision.description}</p>
)}
</div>
</button>
<span className="text-sm font-mono font-semibold text-amber-400 shrink-0">
{formatCurrency(totals.total, currency)}
</span>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-zinc-400 hover:text-white"
onClick={() => setEditOpen(true)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-zinc-600 hover:text-red-400"
onClick={() => onDelete(supervision.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Expanded content */}
{expanded && (
<div className="border-t border-zinc-800">
{/* Day breakdown pills */}
{totalDays > 0 && (
<div className="flex items-center gap-2 px-4 py-2 bg-zinc-950/50 flex-wrap">
{supervision.numShootDays > 0 && (
<span className="text-xs bg-amber-900/30 text-amber-300 px-2 py-0.5 rounded-full border border-amber-800/50">
{supervision.numShootDays} shoot {supervision.numShootDays === 1 ? "day" : "days"}
</span>
)}
{supervision.prepDays > 0 && (
<span className="text-xs bg-zinc-800 text-zinc-300 px-2 py-0.5 rounded-full">
{supervision.prepDays} prep
</span>
)}
{supervision.travelDays > 0 && (
<span className="text-xs bg-zinc-800 text-zinc-300 px-2 py-0.5 rounded-full">
{supervision.travelDays} travel
</span>
)}
{supervision.wrapDays > 0 && (
<span className="text-xs bg-zinc-800 text-zinc-300 px-2 py-0.5 rounded-full">
{supervision.wrapDays} wrap
</span>
)}
</div>
)}
{/* Line items */}
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Line Items ({lineItems.length})
</span>
<Button
size="sm"
variant="ghost"
onClick={() => setAddLineOpen(true)}
className="h-7 text-xs text-amber-400 hover:text-amber-300"
>
<Plus className="h-3.5 w-3.5 mr-1" /> Add
</Button>
</div>
{lineItems.length > 0 ? (
<LineItemsTable
lineItems={lineItems}
complexityMultiplier={supervision.complexityMultiplier?.toString() ?? "1"}
currency={currency}
parentId={supervision.id}
parentType="supervision"
jobId={jobId}
rateCards={rateCards}
onDelete={handleDeleteLineItem}
onUpdate={handleLineItemUpdate}
/>
) : (
<p className="text-xs text-zinc-600 py-2">No line items yet.</p>
)}
</div>
</div>
)}
<SupervisionDialog
open={editOpen}
onOpenChange={setEditOpen}
onSubmit={handleEdit}
initialData={{
supervisionTitle: supervision.supervisionTitle,
shootName: supervision.shootName ?? undefined,
location: supervision.location ?? undefined,
shootDates: supervision.shootDates ?? undefined,
numShootDays: supervision.numShootDays,
travelDays: supervision.travelDays,
prepDays: supervision.prepDays,
wrapDays: supervision.wrapDays,
description: supervision.description ?? undefined,
complexityMultiplier: Number(supervision.complexityMultiplier),
notes: supervision.notes ?? undefined,
}}
/>
<LineItemDialog
open={addLineOpen}
onOpenChange={setAddLineOpen}
onSubmit={handleAddLineItem}
rateCards={rateCards}
defaultTasks={SUPERVISION_LINE_ITEM_FLAT as unknown as string[]}
/>
</div>
);
}
@@ -0,0 +1,144 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { supervisionSchema, type SupervisionFormValues } from "@/lib/validations";
import type { Resolver } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
onSubmit: (data: SupervisionFormValues) => Promise<void>;
initialData?: Partial<SupervisionFormValues>;
}
export function SupervisionDialog({ open, onOpenChange, onSubmit, initialData }: Props) {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<SupervisionFormValues>({
resolver: zodResolver(supervisionSchema) as Resolver<SupervisionFormValues>,
defaultValues: {
complexityMultiplier: 1,
numShootDays: 0,
travelDays: 0,
prepDays: 0,
wrapDays: 0,
...initialData,
},
});
const handleClose = () => { reset(); onOpenChange(false); };
const submit = async (data: SupervisionFormValues) => { await onSubmit(data); reset(); };
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-2xl">
<DialogHeader>
<DialogTitle className="text-white">
{initialData?.supervisionTitle ? "Edit Supervision" : "Add Supervision"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
{/* Title */}
<div className="space-y-1.5">
<Label>Supervision Title *</Label>
<Input
{...register("supervisionTitle")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. Cape Town Unit Shoot"
/>
{errors.supervisionTitle && <p className="text-red-400 text-xs">{errors.supervisionTitle.message}</p>}
</div>
{/* Shoot name + location */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Production / Shoot Name</Label>
<Input {...register("shootName")} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Feature Film Unit B" />
</div>
<div className="space-y-1.5">
<Label>Location</Label>
<Input {...register("location")} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Cape Town" />
</div>
</div>
{/* Shoot dates + complexity */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Shoot Dates</Label>
<Input
{...register("shootDates")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. 15–20 Jan 2026"
/>
</div>
<div className="space-y-1.5">
<Label>Complexity Multiplier</Label>
<Input
{...register("complexityMultiplier")}
type="number"
step="0.1"
min="0.1"
max="10"
className="bg-zinc-800 border-zinc-700"
/>
</div>
</div>
{/* Days breakdown */}
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1.5">
<Label>Shoot Days</Label>
<Input {...register("numShootDays")} type="number" min="0" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Prep Days</Label>
<Input {...register("prepDays")} type="number" min="0" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Travel Days</Label>
<Input {...register("travelDays")} type="number" min="0" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Wrap Days</Label>
<Input {...register("wrapDays")} type="number" min="0" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
{/* Description + Notes */}
<div className="space-y-1.5">
<Label>Description</Label>
<Input {...register("description")} className="bg-zinc-800 border-zinc-700" placeholder="Brief overview" />
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
<div className="flex gap-3 justify-end pt-2">
<Button type="button" variant="outline" className="border-zinc-700" onClick={handleClose}>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{initialData?.supervisionTitle ? "Save Changes" : "Add Supervision"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,121 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { createSupervision, deleteSupervision, reorderSupervisions } from "@/actions/shots-assets";
import { SupervisionCard } from "./supervision-card";
import { SupervisionDialog } from "./supervision-dialog";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
interface Props {
job: {
id: string;
supervisions: any[];
[key: string]: any;
};
rateCards: any[];
}
export function SupervisionEditor({ job, rateCards }: Props) {
const [supervisions, setSupervisions] = useState(job.supervisions ?? []);
const [dialogOpen, setDialogOpen] = useState(false);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = supervisions.findIndex((s) => s.id === active.id);
const newIndex = supervisions.findIndex((s) => s.id === over.id);
const reordered = arrayMove(supervisions, oldIndex, newIndex);
setSupervisions(reordered);
await reorderSupervisions(job.id, reordered.map((s) => s.id));
};
const handleCreate = async (data: any) => {
try {
const result = await createSupervision(job.id, data);
setSupervisions((prev) => [...prev, result.supervision]);
setDialogOpen(false);
toast.success("Supervision added");
} catch {
toast.error("Failed to add supervision");
}
};
const handleDelete = async (supervisionId: string) => {
try {
await deleteSupervision(supervisionId, job.id);
setSupervisions((prev) => prev.filter((s) => s.id !== supervisionId));
toast.success("Supervision deleted");
} catch {
toast.error("Failed to delete supervision");
}
};
const handleUpdate = (updated: any) => {
setSupervisions((prev) => prev.map((s) => (s.id === updated.id ? updated : s)));
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-white">On-Set Supervision ({supervisions.length})</h2>
<p className="text-xs text-zinc-500 mt-0.5">Shoots, tech scouts, data wrangling, VP support</p>
</div>
<Button
onClick={() => setDialogOpen(true)}
size="sm"
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
<Plus className="h-4 w-4 mr-1" /> Add Supervision
</Button>
</div>
{supervisions.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-dashed border-zinc-700 text-zinc-500">
<p className="mb-1">No supervision entries yet.</p>
<p className="text-sm">Add on-set supervision, tech scouts, or VP support services.</p>
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={supervisions.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{supervisions.map((sv) => (
<SupervisionCard
key={sv.id}
supervision={sv}
jobId={job.id}
currency="ZAR"
rateCards={rateCards}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
<SupervisionDialog open={dialogOpen} onOpenChange={setDialogOpen} onSubmit={handleCreate} />
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+221
View File
@@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+21
View File
@@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+196
View File
@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+268
View File
@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+90
View File
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
+83
View File
@@ -0,0 +1,83 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+138
View File
@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

Some files were not shown because too many files have changed in this diff Show More