Add full project files
This commit is contained in:
@@ -39,3 +39,5 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
+32
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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
@@ -0,0 +1,378 @@
|
||||
# xBid Design System & Style Guide
|
||||
|
||||
A reference for the visual language, component patterns, and design principles used in xBid so that future apps can maintain the same look and feel.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Principles
|
||||
|
||||
- **Dark-first**: The app is permanently dark mode (`className="dark"` on `<html>`). There is no light/dark toggle.
|
||||
- **Minimal chrome**: Surfaces are dark zinc grays with amber as the single accent colour. No gradients, no heavy decoration.
|
||||
- **Dense but readable**: Data-heavy UI uses compact sizing (`text-xs`, `text-sm`) with generous whitespace in page-level padding.
|
||||
- **Amber accent, zinc foundation**: Brand colour is amber (`#f59e0b` / `amber-500`). All structural surfaces use the zinc palette.
|
||||
|
||||
---
|
||||
|
||||
## 2. Typography
|
||||
|
||||
### Font Family
|
||||
- **Primary (body & UI)**: **Inter** — loaded via `next/font/google`, subset `latin`.
|
||||
- **Mono**: Geist Mono (via `--font-geist-mono` CSS variable from shadcn).
|
||||
- Applied globally: `font-sans` on `<html>`.
|
||||
|
||||
### Scale (Tailwind defaults, used in practice)
|
||||
|
||||
| Usage | Class(es) | Notes |
|
||||
|---|---|---|
|
||||
| Page title | `text-3xl font-bold text-white` | e.g. Dashboard h1 |
|
||||
| Section title | `text-xl font-semibold text-white` | e.g. "Recent Jobs" h2 |
|
||||
| Detail page title | `text-xl font-bold text-white` | Truncated with `truncate` |
|
||||
| Card title | `text-base font-medium` (via `CardTitle`) | |
|
||||
| Body / default | `text-sm` | |
|
||||
| Captions / meta | `text-sm text-zinc-400` or `text-zinc-500` | |
|
||||
| Table headers | `text-xs uppercase tracking-wider text-zinc-500` | |
|
||||
| Table cells | `text-xs` or `text-sm` | |
|
||||
| Error messages | `text-xs text-red-400` | Form validation |
|
||||
| Stat value | `text-3xl font-bold text-white` | Dashboard stat cards |
|
||||
|
||||
---
|
||||
|
||||
## 3. Colour Palette
|
||||
|
||||
The app uses **Tailwind CSS v4 with OKLCH CSS variables**. All semantic tokens map to zinc grays in dark mode.
|
||||
|
||||
### Brand / Accent
|
||||
|
||||
| Token | Tailwind class | Hex approx | Usage |
|
||||
|---|---|---|---|
|
||||
| Brand amber | `amber-500` | `#f59e0b` | Logo bg, icon colour |
|
||||
| Active nav highlight | `amber-500/10` bg + `amber-400` text | — | Active nav item |
|
||||
| Amber text | `text-amber-400` | `#fbbf24` | Active nav, stat icons |
|
||||
| Amber text bold | `text-amber-500` | `#f59e0b` | Logo "Bid" wordmark |
|
||||
|
||||
### Surface / Layout
|
||||
|
||||
| Purpose | Tailwind class | Notes |
|
||||
|---|---|---|
|
||||
| Page background | `bg-zinc-950` | Top bar, outermost bg |
|
||||
| Sidebar & card bg | `bg-zinc-900` | Primary surface |
|
||||
| Hover / secondary surface | `bg-zinc-800` | Hover states, secondary card |
|
||||
| Input fields | `bg-zinc-900 border-zinc-700` | Form inputs override |
|
||||
| Borders | `border-zinc-800` | Dividers, card outlines |
|
||||
| Input borders | `border-zinc-700` | Slightly lighter than surface borders |
|
||||
|
||||
### Text
|
||||
|
||||
| Purpose | Tailwind class | Hex approx |
|
||||
|---|---|---|
|
||||
| Primary / headings | `text-white` | `#ffffff` |
|
||||
| Secondary / labels | `text-zinc-400` | `#a1a1aa` |
|
||||
| Tertiary / placeholders | `text-zinc-500` | `#71717a` |
|
||||
| Disabled / subtle | `text-zinc-600` | `#52525b` |
|
||||
| Bright secondary | `text-zinc-300` | `#d4d4d8` |
|
||||
|
||||
### Semantic / Status Badges
|
||||
|
||||
These appear on `Job` and `Invoice` status badges. Pattern: coloured bg at low opacity + matching light text.
|
||||
|
||||
| Status | Classes |
|
||||
|---|---|
|
||||
| DRAFT | `bg-zinc-700 text-zinc-300` |
|
||||
| SENT | `bg-blue-900/60 text-blue-300` |
|
||||
| APPROVED / PAID | `bg-green-900/60 text-green-300` |
|
||||
| REJECTED / OVERDUE | `bg-red-900/60 text-red-300` |
|
||||
| ARCHIVED | `bg-yellow-900/60 text-yellow-300` |
|
||||
| VIEWED | `bg-purple-900/60 text-purple-300` |
|
||||
| PARTIALLY_PAID | `bg-orange-900/60 text-orange-300` |
|
||||
| CANCELLED | `bg-zinc-800 text-zinc-500` |
|
||||
|
||||
### Icon Colours (Dashboard Stats)
|
||||
|
||||
| Metric | Icon colour |
|
||||
|---|---|
|
||||
| Jobs / Total | `text-amber-400` |
|
||||
| Clients | `text-blue-400` |
|
||||
| Drafts | `text-zinc-400` |
|
||||
| Sent / Pending | `text-yellow-400` |
|
||||
| Approved | `text-green-400` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Spacing & Layout
|
||||
|
||||
### App Shell
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Sidebar (w-60 / w-16 collapsed) │ Main content (flex-1 overflow-y-auto)
|
||||
│ bg-zinc-900 │ p-8 on page content
|
||||
│ border-r border-zinc-800 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Root: `flex h-full overflow-hidden`
|
||||
- Sidebar: `flex flex-col bg-zinc-900 border-r border-zinc-800 transition-all duration-200`
|
||||
- Main: `flex-1 overflow-y-auto`
|
||||
- Page padding: `p-8`
|
||||
|
||||
### Page Header pattern
|
||||
|
||||
```tsx
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white">Page Title</h1>
|
||||
<p className="text-zinc-400 mt-1">Subtitle / description.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Detail Page Top Bar
|
||||
|
||||
```tsx
|
||||
<div className="border-b border-zinc-800 px-8 py-4 flex items-center gap-4 bg-zinc-950">
|
||||
{/* back arrow + title + meta + actions */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid layouts
|
||||
|
||||
| Pattern | Class |
|
||||
|---|---|
|
||||
| Stat cards | `grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5` |
|
||||
| Form fields | `grid grid-cols-1 sm:grid-cols-2 gap-5` |
|
||||
| Form field spacing | `space-y-1.5` (label + input) |
|
||||
| Form section spacing | `space-y-6` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Border Radius
|
||||
|
||||
Defined by CSS variable `--radius: 0.625rem` (10px).
|
||||
|
||||
| Token | Size | Tailwind class |
|
||||
|---|---|---|
|
||||
| sm | ~6px | `rounded-sm` / `rounded` |
|
||||
| md | ~8px | `rounded-md` |
|
||||
| lg | 10px (base) | `rounded-lg` |
|
||||
| xl | ~14px | `rounded-xl` |
|
||||
| 4xl (badges) | ~26px | `rounded-4xl` |
|
||||
|
||||
**In practice**: `rounded-lg` is the default for buttons, inputs, cards, nav items. Cards use `rounded-xl`. Badges use `rounded-4xl` (pill shape).
|
||||
|
||||
---
|
||||
|
||||
## 6. Component Patterns
|
||||
|
||||
### Sidebar Navigation
|
||||
|
||||
- Container: `bg-zinc-900 border-r border-zinc-800`
|
||||
- Logo area: `px-4 py-5 border-b border-zinc-800`
|
||||
- Icon: `w-8 h-8 rounded-lg bg-amber-500 flex items-center justify-center` with black icon inside
|
||||
- Wordmark: `font-bold text-lg tracking-tight text-white` — "x" white, "Bid" amber
|
||||
- Nav item inactive: `text-zinc-400 hover:text-white hover:bg-zinc-800 px-3 py-2.5 rounded-lg text-sm font-medium`
|
||||
- Nav item active: `bg-amber-500/10 text-amber-400`
|
||||
- Icon size: `h-4 w-4`
|
||||
|
||||
### Cards
|
||||
|
||||
```tsx
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle>…</CardTitle>
|
||||
<CardDescription>…</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>…</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
- Card has `ring-1 ring-foreground/10` and `rounded-xl` by default via shadcn base-nova.
|
||||
- Override bg/border on dark surfaces: `bg-zinc-900 border-zinc-800`.
|
||||
|
||||
### Buttons
|
||||
|
||||
Variants from shadcn `buttonVariants`:
|
||||
|
||||
| Variant | Appearance |
|
||||
|---|---|
|
||||
| `default` | Dark bg (`primary`), light text — effectively `bg-zinc-900 text-white` in dark mode |
|
||||
| `outline` | Border, transparent bg, hover muted |
|
||||
| `secondary` | Muted bg |
|
||||
| `ghost` | No border, hover muted |
|
||||
| `destructive` | Red tinted bg, red text |
|
||||
| `link` | Underline on hover |
|
||||
|
||||
Sizes: `xs`, `sm`, `default` (h-8), `lg`, `icon`, `icon-xs`, `icon-sm`, `icon-lg`.
|
||||
|
||||
### Badges / Status Pills
|
||||
|
||||
```tsx
|
||||
<Badge className="bg-green-900/60 text-green-300">Approved</Badge>
|
||||
```
|
||||
|
||||
- Custom status colours applied via `className` override (see §3 Semantic/Status Badges).
|
||||
- Default shape: pill (`rounded-4xl`), `h-5`, `text-xs`.
|
||||
|
||||
### Form Fields
|
||||
|
||||
```tsx
|
||||
<div className="space-y-1.5">
|
||||
<Label>Field Label *</Label>
|
||||
<Input className="bg-zinc-900 border-zinc-700" placeholder="…" />
|
||||
{error && <p className="text-red-400 text-xs">{error.message}</p>}
|
||||
</div>
|
||||
```
|
||||
|
||||
- Input base: `h-8 rounded-lg border border-input bg-transparent` — always override with `bg-zinc-900 border-zinc-700` for dark surface visibility.
|
||||
- Textarea, Select follow the same pattern.
|
||||
|
||||
### Tables
|
||||
|
||||
Line-item tables are custom `<table>` elements styled with:
|
||||
- `text-xs w-full`
|
||||
- Header row: `border-b border-zinc-800 text-zinc-500 uppercase tracking-wider`
|
||||
- Cells: `px-4 py-2 text-left`
|
||||
- Alternating/hover rows: use `hover:bg-zinc-800/50`
|
||||
|
||||
shadcn `<Table>` component uses `text-sm` and standard `border-b` between rows.
|
||||
|
||||
### Tabs
|
||||
|
||||
```tsx
|
||||
<Tabs defaultValue="…">
|
||||
<TabsList>
|
||||
<TabsTrigger value="…">Label</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="…">…</TabsContent>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
- `TabsList` bg: `bg-muted` (zinc-800 in dark), `rounded-lg`.
|
||||
- `TabsTrigger` active: white text, slight bg lift.
|
||||
|
||||
### Dialogs / Sheets
|
||||
|
||||
Standard shadcn `Dialog` / `Sheet`. Content uses `bg-zinc-900` / card surface. Destructive actions use red confirm buttons.
|
||||
|
||||
### Toasts / Notifications
|
||||
|
||||
- Library: **Sonner** (`<Toaster richColors position="top-right" />`)
|
||||
- `toast.success(…)` — green
|
||||
- `toast.error(…)` — red
|
||||
- Always called after async actions complete.
|
||||
|
||||
### Dropdown Menus
|
||||
|
||||
Standard shadcn `DropdownMenu`. Destructive actions get `text-destructive` styling on the `DropdownMenuItem`.
|
||||
|
||||
### Collapsible / Expandable Rows
|
||||
|
||||
Shot and asset cards use a chevron toggle (`ChevronDown` / `ChevronRight`) to expand inline line-item tables. Pattern:
|
||||
|
||||
```tsx
|
||||
<button onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? <ChevronDown /> : <ChevronRight />}
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Icons
|
||||
|
||||
Library: **Lucide React** (`lucide-react`)
|
||||
|
||||
Standard icon size: `h-4 w-4` (16px). Larger decorative: `h-5 w-5`.
|
||||
|
||||
Common icons used:
|
||||
|
||||
| Purpose | Icon |
|
||||
|---|---|
|
||||
| Dashboard | `LayoutDashboard` |
|
||||
| Clients | `Users` |
|
||||
| Jobs / Quotes | `Briefcase` |
|
||||
| Invoices | `FileText` |
|
||||
| Rate Cards | `CreditCard` |
|
||||
| Settings | `Settings` |
|
||||
| Back | `ArrowLeft` |
|
||||
| Download PDF | `Download` |
|
||||
| Duplicate | `Copy` |
|
||||
| Delete | `Trash2` |
|
||||
| Edit | `Pencil` |
|
||||
| Add | `Plus` |
|
||||
| More options | `MoreHorizontal` |
|
||||
| Search | `Search` |
|
||||
| Loading | `Loader2` (with `animate-spin`) |
|
||||
| Drag handle | `GripVertical` |
|
||||
| App logo | `Film` |
|
||||
| Send | `Send` |
|
||||
| Approve / Mark paid | `Check` / `CheckCircle` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Motion & Transitions
|
||||
|
||||
- Sidebar collapse: `transition-all duration-200`
|
||||
- Nav item colour: `transition-colors`
|
||||
- Buttons: `transition-all`
|
||||
- Drag-and-drop (DnD Kit): opacity `0.5` while dragging.
|
||||
- Animation library: `tw-animate-css` (imported in globals.css).
|
||||
|
||||
---
|
||||
|
||||
## 9. Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Framework | Next.js 15 (App Router, React Server Components) |
|
||||
| Styling | Tailwind CSS v4 |
|
||||
| Component system | shadcn/ui (`base-nova` style) + `@base-ui/react` primitives |
|
||||
| Icon library | Lucide React |
|
||||
| Font | Inter (Google Fonts via `next/font`) |
|
||||
| Notifications | Sonner |
|
||||
| Forms | React Hook Form + Zod |
|
||||
| Drag & Drop | `@dnd-kit/core` + `@dnd-kit/sortable` |
|
||||
| ORM | Prisma |
|
||||
| DB | PostgreSQL |
|
||||
| Color space | OKLCH (CSS variables) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick Reference — Recurring Class Combinations
|
||||
|
||||
```
|
||||
# Page wrapper
|
||||
p-8
|
||||
|
||||
# Page title
|
||||
text-3xl font-bold text-white
|
||||
|
||||
# Page subtitle
|
||||
text-zinc-400 mt-1
|
||||
|
||||
# Section heading
|
||||
text-xl font-semibold text-white mb-4
|
||||
|
||||
# Top bar (detail pages)
|
||||
border-b border-zinc-800 px-8 py-4 flex items-center gap-4 bg-zinc-950
|
||||
|
||||
# Card (dark surface)
|
||||
bg-zinc-900 border-zinc-800
|
||||
|
||||
# Card text hierarchy
|
||||
text-white (value) | text-zinc-400 (label) | text-zinc-500 (meta)
|
||||
|
||||
# Form input (dark)
|
||||
bg-zinc-900 border-zinc-700
|
||||
|
||||
# Form error
|
||||
text-red-400 text-xs
|
||||
|
||||
# Table header
|
||||
border-b border-zinc-800 text-zinc-500 uppercase tracking-wider text-xs
|
||||
|
||||
# Empty state text
|
||||
text-center text-sm text-zinc-500
|
||||
|
||||
# Active nav
|
||||
bg-amber-500/10 text-amber-400
|
||||
|
||||
# Inactive nav
|
||||
text-zinc-400 hover:text-white hover:bg-zinc-800
|
||||
```
|
||||
@@ -0,0 +1,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": {}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
Generated
+6537
-105
File diff suppressed because it is too large
Load Diff
+42
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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
@@ -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(); });
|
||||
@@ -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());
|
||||
@@ -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())
|
||||
;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 & 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+117
-13
@@ -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;
|
||||
}
|
||||
--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);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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))} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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()} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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. 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
Reference in New Issue
Block a user