Dow-ify project form + seed placeholder roster + Resource Manager + docs

You flagged three concrete gaps after the deploy went live — all
addressed in this commit, plus the API + how-to docs you asked for.

A) Create Project dialog was still HP-centric
   Placeholders like "HP Envy x360 Renders" / "HP-2026-001" / "NPI /
   Refresh" / "Form Factor" etc. bore no relation to Dow's actual XLSX
   columns and the form had no ClientTeam selector — so any
   admin-created project was orphaned from the visibility layer.
   - src/lib/validators/project.ts: added clientTeamId + omgJobNumber;
     status enum now includes PIPELINE and CANCELED
   - src/components/projects/project-form-dialog.tsx: rewritten around
     the Dow XLSX schema. Three tabs (Details / Dates / References)
     instead of four. Placeholders reference real Dow values
     (Celena / Yzabella etc. for Owner, 2337959 for OMG #, Brand / Events
     etc. for Team, Copywriting/Display/... for Category). ClientTeam
     selector populated from /api/client-teams with a "no teams — add
     one in Settings" fallback. Category is a typed enum dropdown with
     the 8 XLSX values. Risk/Priority wording mirrors the XLSX labels
     (Priority = URGENT). Dropped HP-only fields from the UI
     (formFactor, codeName, npiOrRefresh, businessUnit placeholder,
     agency, Financial tab, Workfront ID placeholder). Legacy fields
     are still in the Zod schema for back-compat but not rendered.

B) Users invisible because only the admin was seeded
   The plan flagged "real Dow/Oliver roster — open question" and we
   never got the list, so the seed only created admin@dowjones.com.
   prisma/seed-dow.ts now also creates the 9 placeholder resources
   from the Resources.html prototype (Alice Chen, Ben Marsh, Cara Wu,
   Dan Koch, Eva Stone, Frank Osei, Grace Lee, Hiro Tanaka, Isla Reeve),
   distributed round-robin across the three placeholder pods. Each has
   role + department + maxCapacity set but no passwordHash, so they
   show up in the UI immediately but can't log in until an admin
   invites them via Settings → Team (which issues a reset link).
   Swap for the real roster whenever Zia delivers it — the emails are
   @example.com so they're safe to delete.

C) Resource Manager page (matching Resources.html)
   New capacity planner UI — daily hours-per-job grid.
   - Schema: new ResourceBooking model { userId, date, jobNumber,
     hours, note, organizationId, createdById }. Migration at
     prisma/migrations/20260421000000_resource_bookings.
   - Validator (src/lib/validators/booking.ts): create + list schemas
     with date-only coercion.
   - Service (src/lib/services/booking-service.ts): week window
     helpers, create/list/delete + known-job-numbers lookup for the
     popover autocomplete.
   - API: GET/POST /api/resources/bookings, DELETE
     /api/resources/bookings/[id], GET /api/resources/job-numbers.
     Writes gated to ADMIN + PRODUCER; reads open to any signed-in
     member of the org (capacity view is a shared studio-level thing,
     not per-team visibility).
   - Hook (src/hooks/use-bookings.ts) with TanStack Query wiring +
     week-scoped cache keys.
   - Page (src/app/(app)/resources/page.tsx) ports the Resources.html
     design to the app's Tailwind + shadcn primitives: Resource × Day
     grid grouped by department, week navigator, click-to-assign
     popover with job-number autocomplete + hour chips (1/2/3/4/6/8 +
     custom), capacity bar per cell, week total column with over-cap
     warning, collapsible role bands. Matches the prototype's
     color-hashed job chips so the same job number gets a consistent
     color across the grid.
   - Sidebar nav: added "Resources" entry next to Workload.

D) Docs — full README + API reference + how-to
   - API.md: complete REST + webhook reference. Three auth modes
     documented (session cookie / X-API-Key / OMG HMAC). XLSX upload
     header map with the Dow XLSX column correspondences. OMG webhook
     has the speculative payload shape + a working bash example that
     signs + sends a request. Common flows at the bottom: bootstrap
     from zero, OMG publishes a status change, update a job from an
     external script.
   - HOWTO.md: end-to-end runbook. Mental model, local dev, prod
     deploy pointers, first-login ritual, add-users flow (UI + API),
     client teams + pods config, XLSX ingest (UI + curl + idempotency
     notes), OMG webhook wiring (secret gen through verification),
     producer daily workflow, client-viewer experience, resource
     planning walk-through, RBAC matrix, common-problems table, and
     "change the model" pointer map for future edits.
   - README.md: top intro now points at API.md / HOWTO.md / DEPLOY.md.

Verified: npx tsc --noEmit ✓ zero errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-20 22:31:19 -04:00
parent df7ddbfb0d
commit 18ae429924
15 changed files with 2169 additions and 5 deletions

526
API.md Normal file
View file

@ -0,0 +1,526 @@
# Dow Jones Studio Tracker — API Reference
All endpoints live under the app's base path **`/dow-prod-tracker`**. Examples
in this doc use `https://optical-dev.oliver.solutions/dow-prod-tracker` as the
origin — substitute your own when self-hosting.
**The canonical key for jobs is `omgJobNumber`.** XLSX uploads and the OMG
webhook both upsert projects on this field. The REST API exposes it on every
project payload.
---
## Authentication
Three ways to call the API.
### 1. Session cookie (browser / user-flow)
After a user signs in via `POST /api/local-auth/login`, the response sets an
`authjs.session-token` cookie. Subsequent requests in the same browser use
that cookie automatically. Session gets per-team visibility scoping
(admins see everything; others see only their `ClientTeam`'s projects).
### 2. API key (machine-to-machine)
Send `X-API-Key: <value>` where `<value>` matches the server's `API_KEY` env
var. The caller is authenticated as the first ADMIN in the resolved org.
The `X-Org-Id: <cuid>` header can narrow to a specific org (defaults to the
first one found).
### 3. HMAC (webhooks only — see the OMG section below)
Scoped to `/api/webhooks/*`, not the rest of the API.
---
## Data model — what is a "job"?
In Dow terms: a **job** ≈ one row in the Studio Tracker XLSX ≈ one `Project`
in the system. Key fields:
| App field | XLSX column | Notes |
|---|---|---|
| `omgJobNumber` | Number | **Canonical key.** String. Upsert primary. |
| `name` | Project Name | E.g. `"2337959 — BRND-CMKT-WSJ-March Education Media Pack"` |
| `status` | Status | Enum: `PIPELINE`, `ACTIVE`, `ON_HOLD`, `COMPLETED`, `CANCELED`, `ARCHIVED` |
| `priority` | Risk | Enum: `LOW`, `MEDIUM`, `HIGH`, `URGENT` (from Low / Medium / High / Priority) |
| `clientTeamId` | Team | FK → ClientTeam. Drives visibility. |
| `businessUnit` | Project Category | Copywriting / Display / GIF / PDF / Print / Social / Static / Video |
| `requestor` | Owner | Project manager name |
| `startDate` | Brief Acceptance Date | ISO 8601 |
| `dueDate` | External Deadline | ISO 8601 |
| `description` | Status Details | Free-form |
Under each project are **deliverables** (1:N), under each deliverable are
**pipeline stages** (N per template), which carry **revisions**, **comments**,
**annotations**.
---
## Endpoints
### Health
```
GET /api/health → 200, liveness only (DB + env vars)
GET /api/health?strict=1 → 200 only if seeded (org + pipeline templates present)
```
Return shape:
```json
{
"status": "healthy",
"mode": "liveness",
"checks": {
"database": { "status": "ok" },
"pgvector": { "status": "ok", "detail": "v0.8.2" },
"organization": { "status": "ok", "detail": "1 org(s)" },
"pipeline_templates": { "status": "ok", "detail": "1 template(s)" },
"dev_bypass": { "status": "ok" },
"auth_secret": { "status": "ok" }
},
"timestamp": "2026-04-21T..."
}
```
Use the liveness endpoint for Docker/Kubernetes health checks; `strict=1` is
for post-deploy verification.
---
### Projects (CRUD)
#### List
```
GET /api/projects
```
Scoped by visibility. Returns JSON array of:
```jsonc
[
{
"id": "cmo7...",
"omgJobNumber": "2337959",
"projectCode": "BRND-CMKT-WSJ-03",
"name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack",
"status": "ACTIVE",
"priority": "HIGH",
"startDate": "2026-03-10T00:00:00.000Z",
"dueDate": "2026-04-03T00:00:00.000Z",
"businessUnit": "PDF",
"requestor": "Celena",
"clientTeam": { "id": "...", "name": "Brand", "slug": "brand" },
"_count": { "deliverables": 42 },
"updatedAt": "2026-04-20T..."
}
]
```
#### Get one
```
GET /api/projects/:projectId
```
Returns the project with nested `deliverables[].stages[]` — enough data to
render the Kanban / deliverable detail page in one fetch.
#### Create
```
POST /api/projects
Content-Type: application/json
{
"projectCode": "BRND-CMKT-WSJ-03",
"name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack",
"omgJobNumber": "2337959",
"clientTeamId": "cmo7...",
"businessUnit": "PDF",
"priority": "HIGH",
"status": "ACTIVE",
"startDate": "2026-03-10",
"dueDate": "2026-04-03",
"requestor": "Celena",
"description": "Timeline notes go here"
}
```
Responses: `201` + project body, `400` on validation errors, `403` if caller
lacks `PROJECT_CREATE`. Caller needs an existing client team — fetch via
`GET /api/client-teams`.
#### Update
```
PATCH /api/projects/:projectId
```
Same body shape, all fields optional. Visibility pre-checked.
#### Delete
```
DELETE /api/projects/:projectId
```
Cascades deliverables + stages.
---
### XLSX upload — the "Job Tracker " sheet
The bulk-import endpoint parses the Dow Studio Tracker workbook and upserts
one project per row (keyed on `omgJobNumber`).
#### Preview (dry-run, nothing written)
```bash
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow Jones_Studio Tracker_Example.xlsx" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=false"
```
Response:
```json
{
"preview": true,
"totalRows": 27,
"validRows": 26,
"errors": [
{ "row": 21, "reason": "omgNumber: Invalid input" }
],
"rows": [
{
"omgNumber": "2337959",
"projectName": "2337959 - BRND-CMKT-WSJ-March Education Media Pack-26",
"clientTeamSlug": "brand",
"status": "ACTIVE",
"priority": "HIGH",
"externalDeadline": "2026-04-03T00:00:00.000Z"
}
]
}
```
The first 25 normalized rows ship in the response so you can show a
preview UI. Errors are row-scoped and don't abort the batch.
#### Commit (actually write)
```bash
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow Jones_Studio Tracker_Example.xlsx" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=true"
```
Response:
```json
{
"preview": false,
"totalRows": 27,
"imported": 26,
"created": 18,
"updated": 8,
"deliverablesCreated": 267,
"errors": [
{ "row": 21, "reason": "omgNumber: Invalid input" }
]
}
```
- Idempotent on `omgJobNumber` — re-upload the same file tomorrow and rows
with unchanged numbers update in place; rows with new numbers create.
- Rows that already exist: only fields that are set on the new row overwrite;
blank cells don't clobber producer-edited data.
- On create, one `Deliverable` is spawned per unit of `# of outputs` and the
default Dow pipeline stages are attached.
#### Expected XLSX structure
Sheet name: **`Job Tracker `** (trailing space is tolerated — parser
normalizes headers). Header row detected automatically within the first
5 rows. Row 2 (the example/instructions row) is skipped.
Column mapping is substring-matched on normalized headers:
| Header (any case / trailing space) | Maps to |
|---|---|
| `Owner` | `requestor` |
| `Risk` | `priority` (Priority→URGENT, High→HIGH, etc.) |
| `Creative Team Member…` | assignee (not persisted on Project; flagged in errors) |
| `Number` | `omgJobNumber` **(required)** |
| `Team` | `clientTeamId` (resolved by slug; auto-creates if missing) |
| `Status` | `status` (Brief in Review→PIPELINE, Amends/In production/Client Review→ACTIVE, On hold→ON_HOLD) |
| `Project Category` | `businessUnit` |
| `Project Name` | `name` **(required)** |
| `Brief Acceptance Date` | `startDate` |
| `External Deadline` | `dueDate` |
| `# of outputs` | count of deliverables created |
| `Status Details` | `description` |
---
### OMG webhook — near-real-time ingest
For the OMG platform to push job updates directly. Same upsert path as the
XLSX importer, so the two can never drift.
#### Endpoint
```
POST /api/webhooks/omg
Content-Type: application/json
X-OMG-Signature: sha256=<hex HMAC-SHA256 of the raw body>
```
HMAC is computed with the server's `OMG_WEBHOOK_SECRET` (a shared-secret
ops hands to the OMG team). Timing-safe compare.
**Dev bypass**: set `OMG_WEBHOOK_ALLOW_INSECURE=true` in `.env` to skip
signature verification. DO NOT use in production.
Route is exempt from session auth; middleware passes all `/api/webhooks/*`
through.
#### Payload shape (speculative — confirm with Shashank)
```jsonc
{
"event": "job.created", // or job.updated | job.assigned | job.status_changed | job.completed
"timestamp": "2026-04-21T12:34:56Z",
"job": {
"number": "2337959", // → omgJobNumber. REQUIRED.
"name": "2337959 — BRND-CMKT-WSJ-March Education Media Pack",
"client": "Dow Jones",
"team": "Brand", // → clientTeamId, resolved by name
"category": "PDF", // → businessUnit
"status": "In production", // → ProjectStatus, mapped
"priority": "High", // → Priority, mapped
"assignees": [
{ "email": "foo@dowjones.com", "role": "Creative" }
],
"dates": {
"accepted": "2026-03-10T00:00:00Z",
"externalDeadline": "2026-04-03T00:00:00Z"
},
"outputs": 42
},
"raw": {
// any OMG-native fields we haven't mapped — land on Project.customFields
}
}
```
#### Responses
- `200 { "ok": true, "projectId": "cmo7..." }` — upsert succeeded
- `401 { "error": "Invalid signature" }` — HMAC mismatch
- `400 { "error": "Invalid payload" }` — body didn't parse
- `500 { "error": "Internal server error" }` — something went wrong server-side
#### Replay safety
Idempotent: same `(omgJobNumber, timestamp)` replayed is effectively a noop
(the upsert just writes the same fields back). OMG can replay a queue
without worrying about duplicates.
#### Example — signing a payload in bash
```bash
SECRET="$(grep OMG_WEBHOOK_SECRET .env | cut -d= -f2- | tr -d '"')"
BODY='{"event":"job.updated","timestamp":"2026-04-21T12:00:00Z","job":{"number":"2337959","name":"Test","team":"Brand"}}'
SIG=$(printf "%s" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST \
-H "Content-Type: application/json" \
-H "X-OMG-Signature: sha256=$SIG" \
-d "$BODY" \
https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg
```
---
### Client Teams
```
GET /api/client-teams # list
POST /api/client-teams # admin only
PATCH /api/client-teams/:teamId # admin only
DELETE /api/client-teams/:teamId # admin only (blocks if team has projects)
POST /api/client-teams/:teamId/members # body: { userId, isPrimary? }
DELETE /api/client-teams/:teamId/members?userId=... # remove
```
Team slugs drive the XLSX/webhook resolution — keep them stable.
---
### Pods
```
GET /api/pods
POST /api/pods # { name, slug?, leadUserId? }
PATCH /api/pods/:podId
DELETE /api/pods/:podId
POST /api/pods/:podId/members # body: { userId } — sets homePodId
DELETE /api/pods/:podId/members?userId=...
```
Pods are orthogonal to client teams — they drive internal capacity
planning, not project visibility.
---
### Invitations (add users)
```
GET /api/org/invitations
POST /api/org/invitations # body: { email, role, isExternal? }
DELETE /api/org/invitations/:id
```
`POST` creates/upserts the placeholder User, issues a password-reset token,
and returns `acceptUrl` — the `/reset-password/<token>` link you hand to the
invitee.
---
### Resource bookings (capacity planner)
```
GET /api/resources/bookings?weekStart=YYYY-MM-DD
POST /api/resources/bookings # ADMIN or PRODUCER only
DELETE /api/resources/bookings/:id
GET /api/resources/job-numbers # autocomplete source for the UI
```
Body for POST:
```json
{
"userId": "cmo7...",
"date": "2026-04-21",
"jobNumber": "2337959",
"hours": 4,
"note": "Client review prep"
}
```
---
### Dashboards / reads
```
GET /api/dashboard/stats
GET /api/my-work
GET /api/workload?numWeeks=8
GET /api/calendar?startDate=...&endDate=...
GET /api/reports/weekly?date=2026-04-21
POST /api/search/semantic # { query }
```
All visibility-scoped — non-admins see only their client-team projects.
---
## Common flows
### Bootstrap a tenant from zero
```bash
# 1. Health
curl https://optical-dev.oliver.solutions/dow-prod-tracker/api/health
# 2. Invite the first producer (needs X-API-Key — you're the admin who ran seed)
curl -X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d '{"email":"producer@oliver.agency","role":"PRODUCER"}' \
https://optical-dev.oliver.solutions/dow-prod-tracker/api/org/invitations
# response: {"id":"...","email":"...","acceptUrl":".../reset-password/<token>"}
# hand that URL over for the producer to set a password.
# 3. Upload the current XLSX tracker
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow Jones_Studio Tracker_2026_04_20.xlsx" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/bulk-import?commit=true"
```
### OMG publishes a status change
OMG side (pseudocode):
```python
import hmac, hashlib, json, requests
secret = os.environ["DOW_TRACKER_SECRET"]
body = json.dumps({
"event": "job.status_changed",
"timestamp": datetime.utcnow().isoformat() + "Z",
"job": {
"number": "2337959",
"name": "…",
"team": "Brand",
"status": "Client Review",
}
}).encode("utf-8")
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
requests.post(
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg",
data=body,
headers={
"Content-Type": "application/json",
"X-OMG-Signature": f"sha256={sig}",
},
)
```
Returns `200 { "ok": true, "projectId": "..." }` — project updated in place
if the `number` already exists, created otherwise.
### Update a single job from an external script
```bash
JOB="2337959"
PROJECT_ID=$(curl -s -H "X-API-Key: $API_KEY" \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects" \
| jq -r ".[] | select(.omgJobNumber == \"$JOB\") | .id")
curl -X PATCH \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d '{"status":"COMPLETED","description":"Delivered 2026-04-21"}' \
"https://optical-dev.oliver.solutions/dow-prod-tracker/api/projects/$PROJECT_ID"
```
---
## Error conventions
All endpoints return JSON on error:
- `400 { "error": "<human message>" }` — validation / bad input
- `401 { "error": "Unauthorized" }` — no valid session / API key
- `403 { "error": "Forbidden" }` — authenticated but lacks the required permission
- `404 { "error": "Not found" }` — resource doesn't exist or isn't visible to this caller
(visibility-scoped endpoints return 404 instead of 403 so ACLs aren't leaked)
- `500 { "error": "Internal server error" }` — server-side bug; check logs
Permission errors surface the missing permission:
```json
{ "error": "Missing permission: PROJECT_CREATE" }
```

468
HOWTO.md Normal file
View file

@ -0,0 +1,468 @@
# Dow Jones Studio Tracker — How-to Guide
Everything you need to get, run, use, admin, integrate, and extend the tracker.
Pairs with [DEPLOY.md](./DEPLOY.md) (prod deploy) and [API.md](./API.md) (REST/webhook
reference).
---
## Table of contents
1. [Quick mental model](#quick-mental-model)
2. [Run it locally](#run-it-locally)
3. [Run it in production](#run-it-in-production)
4. [The first-login ritual](#the-first-login-ritual)
5. [Add users](#add-users)
6. [Configure client teams + pods](#configure-client-teams--pods)
7. [Get real data in (XLSX)](#get-real-data-in-xlsx)
8. [Wire the OMG webhook](#wire-the-omg-webhook)
9. [Day-to-day: producer workflow](#day-to-day-producer-workflow)
10. [Day-to-day: external client viewer](#day-to-day-external-client-viewer)
11. [Resource planning](#resource-planning)
12. [Who can do what (RBAC)](#who-can-do-what-rbac)
13. [Common problems + fixes](#common-problems--fixes)
14. [Change the model](#change-the-model)
---
## Quick mental model
**One tracker, one tenant.** Every row is a `Project` — equivalent to one row in
Dow's existing Studio Tracker XLSX. Projects belong to a **ClientTeam** (Brand,
Events, B2B, Content, Briefing Team, Performance) which drives *who can see
what*. Under each project are **deliverables**, and each deliverable runs through
the Dow 11-stage pipeline:
```
Pipeline → New → Copywriter → Client Review (Copy) → In Progress Creative
→ Internal Review → Client Feedback → Final Approval → Completed
± On Hold / Canceled (terminal parking states)
```
Orthogonal to visibility, everyone belongs to a **Pod** (Sergio's / Deborah's /
Shared) — pods are for *capacity planning*, not for project access.
**The `omgJobNumber` is the canonical key.** Both the XLSX importer and the OMG
webhook upsert on it, so if OMG publishes a job update while someone's editing
the same row in the UI, the next ingest correctly merges onto that row.
---
## Run it locally
Prereqs: Docker Desktop, Node 20+, SSH key on Bitbucket.
```bash
# 1. Clone
cd ~/Desktop/CODING-2024
git clone git@bitbucket.org:zlalani/dow-prod-tracker.git DOW-PROD-TRACKER/dow-prod-tracker
cd DOW-PROD-TRACKER/dow-prod-tracker
npm install
# 2. Env
cp .env.example .env
# Fill in AUTH_SECRET at minimum:
sed -i '' "s|^AUTH_SECRET=.*|AUTH_SECRET=\"$(openssl rand -base64 32)\"|" .env
# Set NEXT_PUBLIC_AUTH_ENTRA_ENABLED=false (default) so local auth works.
# 3. Start the DB (compose handles port clashes via the ${DB_HOST_PORT:-5492} default)
docker compose -p dow-prod-tracker up -d db
# If 5492 is busy on your Mac (e.g. another project's postgres), create a local
# override — gitignored:
cat > docker-compose.override.yml <<EOF
services:
db:
ports: !override ["5493:5432"]
EOF
# And update DATABASE_URL in .env to use :5493 instead of :5492.
# 4. Migrate + seed
npx prisma migrate deploy
npm run db:seed # prints admin email + temp password — SAVE THEM
# 5. Dev server
npm run dev
# → http://localhost:3000/dow-prod-tracker
# (or :3001 if Docker Desktop occupies :3000 on your Mac)
```
Sign in with the seed admin → forced password change → Dashboard.
---
## Run it in production
See [DEPLOY.md](./DEPLOY.md). One-liner summary:
```bash
cd /opt/dow-prod-tracker
git pull
./deploy.sh
docker compose -p dow-prod-tracker exec app npm run db:seed # first deploy only
```
`deploy.sh` auto-picks free host ports (3002 / 5492 preferred), renders the
Apache snippet, reloads the vhost, configures ufw. Idempotent.
---
## The first-login ritual
When the seed runs, it prints:
```
Email: admin@dowjones.com
Password: <16-char random>
```
Sign in with those. The app forces a password change on first login:
1. Go to `/login` → enter email + temp password
2. Land on `/change-password?first=1` → enter temp password + new password × 2
3. On success → redirected to the Dashboard
The `mustChangePassword` flag is DB-tracked per user. Invited users go through
the same flow via `/reset-password/<token>`.
---
## Add users
The "add user" flow is an **invitation** that creates both the User row AND a
password-reset token in one go. No separate "accept" step — the invitee just
sets their password via the reset link.
### Through the UI
1. Sign in as admin
2. **Settings → Team**
3. Type email, pick a role (Admin / Producer / Artist / **Client (read-only)**),
click **Send Invite**
4. A green banner appears with the accept URL. Click **Copy**, paste into
Teams / email / Slack to hand it to the user
5. User visits the URL → sets password → signs in
### Programmatically
```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d '{"email":"newuser@oliver.agency","role":"PRODUCER"}' \
https://your-host/dow-prod-tracker/api/org/invitations
# → response includes { "acceptUrl": ".../reset-password/<token>" }
```
### Notes
- **Role choice matters for what they see:**
- `ADMIN` — everything across all client teams.
- `PRODUCER` — the studio side. Can create/update projects and bookings.
- `ARTIST` — can update stage statuses / submit revisions but not create
projects.
- `CLIENT_VIEWER`*external* user. Read-only. Only sees the client teams
they're explicitly assigned to.
- **Invitation tokens expire in 7 days.** Just re-invite to re-issue.
- **Placeholder seed users exist but can't log in** until an admin invites them
(they have no passwordHash). Go to **Settings → Team** and invite any of them
— their role/pod/department are already set.
---
## Configure client teams + pods
### Client teams
Six teams are seeded: Brand, Events, B2B, Content, Briefing Team, Performance.
1. **Settings → Client Teams**
2. Click a team to see its members
3. Add users via the dropdown → they'll now see projects on this team
4. Admins see all teams by default regardless of membership
A user who belongs to zero client teams **sees zero projects** (fail-closed).
### Pods
1. **Settings → Pods**
2. Three placeholder pods seeded: Sergio's / Deborah's / Shared
3. Create new pods or rename existing ones
4. Each user has one `homePod`. The Resources page groups by `department` for
now — swap to pod-grouping when the roster stabilizes
---
## Get real data in (XLSX)
The fastest way to populate the tracker from Dow's existing spreadsheet.
### UI path
1. **Projects → Import XLSX**
2. Pick the file (expected: a Dow Studio Tracker workbook with a `Job Tracker `
sheet — trailing space tolerated, example/instructions row 2 auto-skipped)
3. Preview opens with normalized rows + per-row errors
4. Review errors. Hit **Commit** when you're happy
5. 18-ish projects land, ~250 deliverables are auto-created, pipeline stages
attach
### API path
```bash
# dry-run
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow_Studio_Tracker_2026_04_20.xlsx" \
"https://your-host/dow-prod-tracker/api/projects/bulk-import?commit=false"
# commit
curl -X POST \
-H "X-API-Key: $API_KEY" \
-F "file=@Dow_Studio_Tracker_2026_04_20.xlsx" \
"https://your-host/dow-prod-tracker/api/projects/bulk-import?commit=true"
```
### Idempotency
Re-upload tomorrow's snapshot of the same tracker:
- Projects with an **existing `omgJobNumber`** → fields merge into the existing
row (blanks don't clobber producer-edited data)
- Projects with a **new `omgJobNumber`** → freshly created + pipeline stages
spawned
- Dropped rows in the XLSX are NOT deleted (would be destructive). To kill a
project, do it in the UI or via `DELETE /api/projects/:id`.
See [API.md § XLSX upload](./API.md#xlsx-upload--the-job-tracker--sheet) for
the full column map and ingest edge cases.
---
## Wire the OMG webhook
The long-term ingest channel. Set it up once, OMG pushes updates in real time.
### 1. Pick a secret
```bash
openssl rand -hex 32
```
### 2. Set it on the server
```bash
# /opt/dow-prod-tracker/.env
OMG_WEBHOOK_SECRET="<the hex string>"
OMG_WEBHOOK_ALLOW_INSECURE="false" # ensure this is false in prod
```
Restart the app container to pick up the env:
```bash
docker compose -p dow-prod-tracker up -d --force-recreate app
```
### 3. Give the secret + endpoint to Shashank / OMG
- URL: `https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg`
- Method: `POST`
- Content-Type: `application/json`
- Signature header: `X-OMG-Signature: sha256=<hex HMAC-SHA256 of the raw body
using the shared secret>`
- Payload: see [API.md § OMG webhook](./API.md#omg-webhook--near-real-time-ingest)
### 4. Test with a stub payload
```bash
SECRET="$(grep OMG_WEBHOOK_SECRET /opt/dow-prod-tracker/.env | cut -d= -f2- | tr -d '"')"
BODY='{"event":"job.updated","timestamp":"2026-04-21T12:00:00Z","job":{"number":"TEST-001","name":"Stub job","team":"Brand","status":"In production"}}'
SIG=$(printf "%s" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -i -X POST \
-H "Content-Type: application/json" \
-H "X-OMG-Signature: sha256=$SIG" \
-d "$BODY" \
https://optical-dev.oliver.solutions/dow-prod-tracker/api/webhooks/omg
```
Expected: `200 { "ok": true, "projectId": "..." }`. A TEST-001 project should
now be visible on the Brand team.
### 5. Replay / idempotency
Same `(number, timestamp)` replayed = noop. OMG can safely replay queues.
### 6. Verification
Both paths write through **the same upsert function** (`upsertProjectFromDow`
in `src/lib/services/dow-excel-service.ts`). If XLSX works but the webhook
doesn't, it's the signature or the HTTP layer — not the data.
---
## Day-to-day: producer workflow
The happy path.
1. **Dashboard** — glance at the KPIs, overdue deliverables, recent activity
2. **Projects** — the Excel-shaped grid. Filter by team, search by OMG #, sort
by deadline. Click a project to open it
3. **Project detail** — see deliverables + their pipeline stages. Advance
stages as work progresses (click the stage status badge → pick the new
status). Transitions are gated: you can't jump to *In Progress Creative*
before *New* is accepted
4. **Upload XLSX / sync from OMG** — both are additive. Producer-edited data
survives re-ingest
5. **Client Feedback → CHANGES_REQUESTED** — automation rule kicks in,
re-opens *In Progress Creative* on the same deliverable and increments
its revision round. Notifies the assignee + PM
6. **Resources page** (admins + producers) — assign N hours of a job to a
person on a day. Capacity bars go amber at 85%, red over 100%
---
## Day-to-day: external client viewer
For `@dowjones.com` users you want to give read-only visibility.
1. **Admin invites them** with role `CLIENT_VIEWER` (Settings → Team →
Client (read-only))
2. **Admin adds them to exactly the client teams they should see** (Settings
→ Client Teams → Add member). Usually one team; could be multiple
3. User sets password via the reset link
4. On sign-in they see:
- Only projects on teams they're a member of
- Dashboard KPIs scoped to those same projects
- No edit buttons (the UI checks `session.user.role === "CLIENT_VIEWER"`
and hides mutations)
- Can still comment (that's intentional — client feedback is normal for
review flows and doesn't mutate state)
5. Any attempt to POST/PATCH/DELETE anything returns 403.
When you're ready for Entra guest-invite SSO instead of local accounts, set
`NEXT_PUBLIC_AUTH_ENTRA_ENABLED=true` and fill in the Azure env vars. No
code change.
---
## Resource planning
The **Resources** page (in the sidebar) is the daily-hours capacity view.
- Users grouped by department (swap to pod grouping when the real roster
lands)
- Each cell = one person × one weekday. Shows job chips + capacity bar
- Click **Assign** on any cell → job-number autocomplete (pulls from your
project `omgJobNumber` list) + hour picker → commit
- Job chips are color-coded by a hash of the job number — same number always
gets the same color across the grid
- Over-cap days get a red outline + a "8h/6h" red badge
ADMIN + PRODUCER can write; ARTIST / CLIENT_VIEWER see read-only.
---
## Who can do what (RBAC)
Defaults per role (Settings → Permissions to view / customize per org):
| Role | Key permissions |
|---|---|
| `ADMIN` | Everything. Managing users, teams, pods, pipelines, automations. |
| `PRODUCER` | Projects CRUD, deliverables, stages, revisions, comments, bookings. Can't manage users/teams/pods. |
| `ARTIST` | Read projects/deliverables/stages. Update stage status + submit revisions. Can comment. |
| `CLIENT_VIEWER` | Read-only on visible projects. Comments only (no state mutation). |
Per-team visibility is layered on top — an Artist on Team X only sees Team X's
projects, an Admin sees everything.
---
## Common problems + fixes
### "No organizations found" on `/api/health`
You haven't run the seed. `docker compose -p dow-prod-tracker exec app npm run db:seed`.
### Seed says `tsx: not found`
The runner image is missing the dev deps. Fixed in commit `df7ddbf` (installs
tsx globally). Rebuild with `--no-cache`:
```bash
docker compose -p dow-prod-tracker down
docker compose -p dow-prod-tracker build --no-cache app
docker compose -p dow-prod-tracker up -d
```
### Browser returns 404 at `/dow-prod-tracker/...`
Apache Include didn't land. Check:
```bash
grep dow-prod-tracker /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf
```
If absent, re-run deploy.sh (it auto-detects whether `sites-enabled` is a
symlink to `sites-available` or a separate file).
### Port 5492 is busy on the server
`deploy.sh` auto-picks the next free port. If you want to force a specific
one, `DB_HOST_PORT=5499 ./deploy.sh`.
### Password reset email isn't sent
SMTP isn't wired yet. `POST /api/local-auth/forgot-password` returns the
reset URL in the response when `NODE_ENV !== production` (dev path). In
prod, the URL is only server-logged. For now: admin reads the URL from the
create-invite response and hands it to the user out-of-band.
### "Missing permission: X" on an action
Check the role. Settings → Team → see everyone's role. Only ADMIN gets full
powers by default.
### Apache 502 after deploy
App container didn't start. `docker compose -p dow-prod-tracker logs app --tail 50`.
### XLSX upload rejects most rows
Usually means the Job Tracker sheet's headers drifted or Excel wrapped some
cells as hyperlinks. The importer handles the common cases; paste the
`?commit=false` preview errors and we'll iterate.
### OMG webhook returns 401
Shared secret mismatch. Double-check both sides have the same
`OMG_WEBHOOK_SECRET` and that the signing header is
`X-OMG-Signature: sha256=<hex>` (lowercase `sha256=`, no extra whitespace).
---
## Change the model
When the shape of "a job" changes, edits go here:
| Field of interest | Where to touch |
|---|---|
| Add a Project field | `prisma/schema.prisma` → new migration → `src/lib/validators/project.ts``src/components/projects/project-form-dialog.tsx` → optionally the Projects-page table column |
| Add/change an enum value | `schema.prisma` enum → new migration (Postgres: ALTER TYPE ADD VALUE) → update map/usage |
| Rename a ClientTeam | UI only — slugs are immutable (ingest depends on them) |
| Add a new pipeline stage | `prisma/seed-dow.ts``DOW_STAGES` + `DOW_DEPENDENCIES` → re-run seed |
| New XLSX column to ingest | `src/lib/validators/dow-import.ts` (schema) + `src/lib/services/dow-excel-service.ts` (`HEADER_MATCHERS` + `upsertProjectFromDow`) |
| New OMG webhook event | `src/app/api/webhooks/omg/route.ts` — the switch statement on `event` |
| New automation action | `src/lib/automation/action-executor.ts` — add a case + register it in `validateActions` |
Migrations: the repo is on Prisma 7 with a clean baseline plus one delta.
For a new schema change, generate a fresh migration against a running local
DB:
```bash
npx prisma migrate dev --name <short_description>
```
If a local DB isn't running, hand-write the SQL into
`prisma/migrations/<timestamp>_<name>/migration.sql` (idempotent, forward-only).

View file

@ -7,8 +7,13 @@ Progress Creative → Internal Review → Client Feedback → Final Approval →
Completed + On Hold + Canceled), and ingest from both the OMG webhook and the
Studio Tracker XLSX.
Forked from `hp-prod-tracker`, customized for Dow Jones. See [DEPLOY.md](./DEPLOY.md)
for deployment to `optical-dev.oliver.solutions/dow-prod-tracker`.
Forked from `hp-prod-tracker`, customized for Dow Jones.
**Docs:**
- [HOWTO.md](./HOWTO.md) — run it locally/prod, add users, configure teams + pods,
ingest XLSX, wire the OMG webhook, day-to-day usage, RBAC, common problems.
- [API.md](./API.md) — full REST + webhook reference. `omgJobNumber` is the key.
- [DEPLOY.md](./DEPLOY.md) — production deploy to `optical-dev.oliver.solutions`.
Built for producers and creative team members who need real-time visibility into
where every deliverable stands across every stage of the pipeline.

View file

@ -0,0 +1,33 @@
-- Dow resource bookings — daily hours-per-job grid for the capacity planner.
-- One row per (user, date, jobNumber). Multiple rows per (user, date) are
-- expected and encouraged (split days across jobs).
CREATE TABLE "resource_bookings" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"jobNumber" TEXT NOT NULL,
"hours" DOUBLE PRECISION NOT NULL,
"note" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "resource_bookings_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "resource_bookings_organizationId_date_idx" ON "resource_bookings"("organizationId", "date");
CREATE INDEX "resource_bookings_userId_date_idx" ON "resource_bookings"("userId", "date");
ALTER TABLE "resource_bookings" ADD CONSTRAINT "resource_bookings_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "organizations"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "resource_bookings" ADD CONSTRAINT "resource_bookings_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "resource_bookings" ADD CONSTRAINT "resource_bookings_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "users"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -116,6 +116,7 @@ model Organization {
notificationRules NotificationRule[]
clientTeams ClientTeam[]
pods Pod[]
resourceBookings ResourceBooking[]
@@map("organizations")
}
@ -162,6 +163,8 @@ model User {
annotations Annotation[]
clientTeams ClientTeamMembership[]
podsLed Pod[] @relation("PodLead")
bookings ResourceBooking[] @relation("BookingResource")
bookingsCreated ResourceBooking[] @relation("BookingCreator")
@@index([homePodId])
@@index([isExternal])
@ -807,3 +810,31 @@ model Pod {
@@index([organizationId])
@@map("pods")
}
// ─── Dow: Resource bookings (daily capacity grid) ──────
// One row per (user, date, jobNumber) — matches the Resources.html
// prototype's model: a producer assigns N hours of a given job to a
// person on a specific day. Multiple rows per (user, date) are expected
// (split days across jobs). jobNumber is a freeform string so it can
// hold Project.omgJobNumber, Project.projectCode, or an ad-hoc scratch
// label — the capacity planner doesn't care what project the hours are
// against, just that the total for the day/week is tracked.
model ResourceBooking {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
userId String
user User @relation("BookingResource", fields: [userId], references: [id], onDelete: Cascade)
date DateTime // date-only — we store 00:00:00 UTC
jobNumber String
hours Float
note String?
createdById String
createdBy User @relation("BookingCreator", fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, date])
@@index([userId, date])
@@map("resource_bookings")
}

View file

@ -46,6 +46,34 @@ const PODS = [
{ slug: "shared-pod", name: "Shared / Floating" },
];
/**
* Placeholder studio roster matches the 9 names in the Resources.html
* prototype. These are meant to be REPLACED with the real Dow/Oliver staff
* roster when Zia hands it over. Distributing round-robin across the 3 pods
* so the capacity planner has something to show out of the box.
*
* No passwordHash these users can't log in until an admin re-invites them
* from Settings Team (which issues a reset link).
*/
const PLACEHOLDER_TEAM: Array<{
email: string;
name: string;
role: "PRODUCER" | "ARTIST";
department: string;
maxCapacity: number;
podSlug: string;
}> = [
{ email: "alice.chen@example.com", name: "Alice Chen", role: "ARTIST", department: "Creative", maxCapacity: 8, podSlug: "sergio-pod" },
{ email: "ben.marsh@example.com", name: "Ben Marsh", role: "ARTIST", department: "Creative", maxCapacity: 8, podSlug: "deborah-pod" },
{ email: "cara.wu@example.com", name: "Cara Wu", role: "ARTIST", department: "Design", maxCapacity: 8, podSlug: "shared-pod" },
{ email: "dan.koch@example.com", name: "Dan Koch", role: "ARTIST", department: "Design", maxCapacity: 6, podSlug: "sergio-pod" },
{ email: "eva.stone@example.com", name: "Eva Stone", role: "ARTIST", department: "Production", maxCapacity: 8, podSlug: "deborah-pod" },
{ email: "frank.osei@example.com", name: "Frank Osei", role: "ARTIST", department: "Production", maxCapacity: 8, podSlug: "shared-pod" },
{ email: "grace.lee@example.com", name: "Grace Lee", role: "ARTIST", department: "Strategy", maxCapacity: 8, podSlug: "sergio-pod" },
{ email: "hiro.tanaka@example.com", name: "Hiro Tanaka", role: "PRODUCER", department: "PM", maxCapacity: 8, podSlug: "deborah-pod" },
{ email: "isla.reeve@example.com", name: "Isla Reeve", role: "PRODUCER", department: "PM", maxCapacity: 6, podSlug: "shared-pod" },
];
const DOW_PIPELINE_ID = "dow-pipeline-standard";
const DOW_STAGES: Array<{
@ -325,6 +353,47 @@ async function main() {
console.log(`✓ Admin user: ${admin.email} (id=${admin.id})`);
// Placeholder studio team + pod assignments
const podMap = new Map<string, string>();
for (const pod of PODS) {
const row = await prisma.pod.findUnique({
where: { organizationId_slug: { organizationId: org.id, slug: pod.slug } },
select: { id: true },
});
if (row) podMap.set(pod.slug, row.id);
}
let seededUserCount = 0;
for (const member of PLACEHOLDER_TEAM) {
const podId = podMap.get(member.podSlug) ?? null;
await prisma.user.upsert({
where: { email: member.email },
update: {
name: member.name,
role: member.role,
department: member.department,
maxCapacity: member.maxCapacity,
organizationId: org.id,
homePodId: podId,
},
create: {
email: member.email,
name: member.name,
role: member.role,
department: member.department,
maxCapacity: member.maxCapacity,
organizationId: org.id,
homePodId: podId,
// No passwordHash — admin re-sends them a reset link from the Team
// settings page when they're ready to activate this user.
mustChangePassword: true,
isExternal: false,
},
});
seededUserCount++;
}
console.log(`✓ Seeded ${seededUserCount} placeholder team members across ${PODS.length} pods`);
// Automation rule: Client Feedback CHANGES_REQUESTED → reopen creative
const rejectionRule = await prisma.automationRule.upsert({
where: { id: "dow-rule-client-feedback-rejection" },

View file

@ -0,0 +1,671 @@
"use client";
/**
* Resource Manager daily capacity planner.
*
* Grid of Resource × Weekday. Each cell holds job chips (job # + hours)
* and a capacity bar. Matches the Resources.html prototype's behaviour,
* re-implemented with the app's Tailwind + shadcn primitives and wired
* to the ResourceBooking API.
*
* Write path is ADMIN / PRODUCER only; ARTIST and CLIENT_VIEWER get
* read-only via hidden + disabled actions.
*/
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { format, addDays, startOfWeek, addWeeks, isSameDay } from "date-fns";
import {
ChevronLeft,
ChevronRight,
Plus,
X,
AlertCircle,
Users2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useQuery } from "@tanstack/react-query";
import { apiUrl } from "@/lib/api-client";
import { cn } from "@/lib/utils";
import {
useBookingsForWeek,
useCreateBooking,
useDeleteBooking,
useKnownJobNumbers,
type Booking,
} from "@/hooks/use-bookings";
import { toast } from "sonner";
// Stable color per job number — hashed palette, matches Resources.html.
const JOB_PALETTE = [
"#6C3FC5", "#0F7AE5", "#12A053", "#C97A10", "#D63B3B",
"#0891B2", "#7C3AED", "#059669", "#D97706", "#DC2626",
"#2563EB", "#9333EA", "#16A34A", "#EA580C", "#0284C7",
];
function jobColor(id: string): string {
let hash = 0;
for (const c of id) hash = (hash * 31 + c.charCodeAt(0)) & 0xffffffff;
return JOB_PALETTE[Math.abs(hash) % JOB_PALETTE.length];
}
const HOUR_CHIPS = [1, 2, 3, 4, 6, 8];
interface UserRow {
id: string;
name: string | null;
email: string;
role: string;
department: string | null;
maxCapacity: number;
}
function isoDateOnly(d: Date): string {
return format(d, "yyyy-MM-dd");
}
function initialsOf(name: string | null, email: string): string {
const base = name?.trim() || email.split("@")[0];
const parts = base.split(/[\s._-]+/).filter(Boolean);
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase() || "?";
}
export default function ResourcesPage() {
const [weekStart, setWeekStart] = useState<Date>(() =>
startOfWeek(new Date(), { weekStartsOn: 1 })
);
const weekStartIso = isoDateOnly(weekStart);
const weekDays = useMemo(
() => Array.from({ length: 5 }, (_, i) => addDays(weekStart, i)),
[weekStart]
);
const today = useMemo(() => new Date(), []);
const { data: users, isLoading: usersLoading } = useQuery({
queryKey: ["users"],
queryFn: async () => {
const res = await fetch(apiUrl("/api/users"));
if (!res.ok) throw new Error("Failed to fetch users");
return res.json() as Promise<UserRow[]>;
},
});
const { data: bookings, isLoading: bookingsLoading } =
useBookingsForWeek(weekStartIso);
const { data: knownJobs } = useKnownJobNumbers();
const createBooking = useCreateBooking(weekStartIso);
const deleteBooking = useDeleteBooking(weekStartIso);
const [popover, setPopover] = useState<
| { anchor: { x: number; y: number }; userId: string; date: Date }
| null
>(null);
const bookingsByCell = useMemo(() => {
const map = new Map<string, Booking[]>();
for (const b of bookings ?? []) {
const key = `${b.userId}_${b.date.slice(0, 10)}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(b);
}
return map;
}, [bookings]);
const cellBookings = (userId: string, date: Date): Booking[] =>
bookingsByCell.get(`${userId}_${isoDateOnly(date)}`) ?? [];
const cellHours = (userId: string, date: Date): number =>
cellBookings(userId, date).reduce((s, b) => s + b.hours, 0);
const weekHours = (userId: string): number =>
weekDays.reduce((s, d) => s + cellHours(userId, d), 0);
// Group users by department for the Resources.html-style role bands.
const userGroups = useMemo(() => {
const groups: Record<string, UserRow[]> = {};
for (const u of users ?? []) {
const bucket = u.department || "Unassigned";
if (!groups[bucket]) groups[bucket] = [];
groups[bucket].push(u);
}
return Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0]));
}, [users]);
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const handleCreate = useCallback(
async (userId: string, date: Date, jobNumber: string, hours: number) => {
try {
await createBooking.mutateAsync({
userId,
date: isoDateOnly(date),
jobNumber,
hours,
});
setPopover(null);
} catch (e: any) {
toast.error(e?.message ?? "Failed to create booking");
}
},
[createBooking]
);
const handleDelete = useCallback(
async (id: string) => {
try {
await deleteBooking.mutateAsync(id);
} catch (e: any) {
toast.error(e?.message ?? "Failed to delete booking");
}
},
[deleteBooking]
);
return (
<div className="flex h-full flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Users2 className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">Resources</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Daily capacity planner drag jobs onto the grid, track hours per person.
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setWeekStart((w) => addWeeks(w, -1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="min-w-[220px] rounded-md border bg-[var(--card)] px-3 py-1.5 text-center text-xs font-semibold tabular-nums">
{format(weekStart, "MMM d")} {format(addDays(weekStart, 4), "MMM d, yyyy")}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }))}
>
Today
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setWeekStart((w) => addWeeks(w, 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* Grid */}
<div className="flex-1 overflow-auto rounded-lg border bg-[var(--card)]">
<table className="w-full min-w-[1000px] border-collapse text-xs">
<colgroup>
<col style={{ width: 220 }} />
{weekDays.map((d, i) => (
<col key={i} />
))}
<col style={{ width: 90 }} />
</colgroup>
<thead className="sticky top-0 z-10 bg-[var(--muted)]/60">
<tr className="border-b-2 border-[var(--border)]">
<th className="px-3 py-2 text-left text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
Resource
</th>
{weekDays.map((d) => {
const isToday = isSameDay(d, today);
return (
<th
key={d.toISOString()}
className="px-2 py-2 text-center"
>
<div className="text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
{format(d, "EEE")}
</div>
<div
className={cn(
"mx-auto mt-0.5 flex h-7 w-7 items-center justify-center rounded-full text-sm font-bold",
isToday && "bg-[var(--primary)]/15 text-[var(--primary)]"
)}
>
{format(d, "d")}
</div>
</th>
);
})}
<th className="px-2 py-2 text-center text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
Week
</th>
</tr>
</thead>
<tbody>
{usersLoading || bookingsLoading ? (
Array.from({ length: 6 }).map((_, i) => (
<tr key={i} className="border-b">
{Array.from({ length: 7 }).map((_, j) => (
<td key={j} className="px-2 py-2">
<Skeleton className="h-10 w-full" />
</td>
))}
</tr>
))
) : !users || users.length === 0 ? (
<tr>
<td
colSpan={7}
className="py-12 text-center text-[var(--muted-foreground)]"
>
No users yet seed placeholders or invite team members in
Settings Team.
</td>
</tr>
) : (
userGroups.map(([group, members]) => {
const isCollapsed = collapsed[group];
return (
<FragmentRows
key={group}
group={group}
count={members.length}
collapsed={isCollapsed}
onToggle={() =>
setCollapsed((c) => ({ ...c, [group]: !c[group] }))
}
>
{!isCollapsed &&
members.map((user) => {
const weekTotal = weekHours(user.id);
const weekCap = user.maxCapacity * 5;
const overWeek = weekTotal > weekCap;
const warnWeek =
weekTotal >= weekCap * 0.85 && !overWeek;
const barColor = overWeek
? "bg-red-500"
: warnWeek
? "bg-amber-500"
: "bg-emerald-500";
return (
<tr
key={user.id}
className="border-b transition-colors hover:bg-[var(--muted)]/40"
>
<td className="border-r border-[var(--border)] px-3 py-2.5 align-top">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-[var(--primary)] to-[var(--primary)]/70 text-[11px] font-bold text-white">
{initialsOf(user.name, user.email)}
</div>
<div className="min-w-0">
<div className="truncate text-xs font-semibold">
{user.name ?? user.email}
</div>
<div className="text-[10px] text-[var(--muted-foreground)]">
{user.maxCapacity}h/day
</div>
</div>
</div>
</td>
{weekDays.map((day) => {
const dayBookings = cellBookings(user.id, day);
const booked = cellHours(user.id, day);
const over = booked > user.maxCapacity;
const isToday = isSameDay(day, today);
return (
<td
key={day.toISOString()}
className={cn(
"border-r border-[var(--border)] px-1.5 py-1.5 align-top",
isToday && "bg-[var(--primary)]/5",
over && "ring-2 ring-inset ring-red-400/40"
)}
>
<div className="flex min-h-[44px] flex-col gap-1">
{dayBookings.map((b) => (
<JobChip
key={b.id}
jobNumber={b.jobNumber}
hours={b.hours}
onRemove={() => handleDelete(b.id)}
/>
))}
<button
type="button"
onClick={(e) => {
const r =
e.currentTarget.getBoundingClientRect();
setPopover({
anchor: { x: r.left, y: r.bottom + 4 },
userId: user.id,
date: day,
});
}}
className="flex items-center gap-1 rounded border border-dashed border-[var(--border)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--muted-foreground)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
>
<Plus className="h-2.5 w-2.5" />
Assign
</button>
</div>
{booked > 0 && (
<CapBar
booked={booked}
capacity={user.maxCapacity}
/>
)}
</td>
);
})}
<td className="border-l border-[var(--border)] bg-[var(--muted)]/30 px-2 py-2.5 text-center align-top">
<div
className={cn(
"text-sm font-bold tabular-nums",
overWeek && "text-red-600",
warnWeek && "text-amber-600"
)}
>
{weekTotal}h
</div>
<div className="text-[10px] text-[var(--muted-foreground)]">
/ {weekCap}h
</div>
<div className="mt-1 h-1 overflow-hidden rounded bg-[var(--border)]">
<div
className={cn("h-full transition-all", barColor)}
style={{
width: `${Math.min((weekTotal / weekCap) * 100, 100)}%`,
}}
/>
</div>
</td>
</tr>
);
})}
</FragmentRows>
);
})
)}
</tbody>
</table>
</div>
{popover && (
<AssignPopover
anchor={popover.anchor}
onClose={() => setPopover(null)}
onAssign={(jobNumber, hours) =>
handleCreate(popover.userId, popover.date, jobNumber, hours)
}
knownJobs={knownJobs ?? []}
isPending={createBooking.isPending}
/>
)}
</div>
);
}
// ── Group header + member rows ────────────────────────────────
function FragmentRows({
group,
count,
collapsed,
onToggle,
children,
}: {
group: string;
count: number;
collapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<>
<tr
className="cursor-pointer select-none bg-[var(--muted)]/40 hover:bg-[var(--muted)]/60"
onClick={onToggle}
>
<td
colSpan={7}
className="border-b border-[var(--border)] px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]"
>
<span className="mr-2 opacity-60">{collapsed ? "▶" : "▼"}</span>
{group} · {count} {count === 1 ? "person" : "people"}
</td>
</tr>
{children}
</>
);
}
// ── Job chip (hover to delete) ────────────────────────────────
function JobChip({
jobNumber,
hours,
onRemove,
}: {
jobNumber: string;
hours: number;
onRemove: () => void;
}) {
const color = jobColor(jobNumber);
return (
<div
className="group flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-semibold tabular-nums transition-colors"
style={{
borderColor: `${color}55`,
background: `${color}15`,
color,
}}
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ background: color }}
/>
<span className="flex-1 truncate">{jobNumber}</span>
<span className="text-[var(--muted-foreground)]">{hours}h</span>
<button
type="button"
onClick={onRemove}
className="ml-1 hidden h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full text-white/80 group-hover:flex"
style={{ background: `${color}` }}
aria-label={`Remove ${jobNumber}`}
>
<X className="h-2 w-2" />
</button>
</div>
);
}
// ── Capacity bar under each cell ──────────────────────────────
function CapBar({ booked, capacity }: { booked: number; capacity: number }) {
const pct = Math.min((booked / capacity) * 100, 120);
const over = booked > capacity;
const warn = booked >= capacity * 0.85 && !over;
return (
<div className="mt-1">
<div className="h-0.5 overflow-hidden rounded bg-[var(--border)]">
<div
className={cn(
"h-full transition-all",
over ? "bg-red-500" : warn ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<div
className={cn(
"mt-0.5 text-[9px] font-bold tabular-nums",
over ? "text-red-600" : warn ? "text-amber-600" : "text-emerald-600"
)}
>
{booked}h / {capacity}h
</div>
</div>
);
}
// ── Assign-job popover ────────────────────────────────────────
function AssignPopover({
anchor,
onClose,
onAssign,
knownJobs,
isPending,
}: {
anchor: { x: number; y: number };
onClose: () => void;
onAssign: (jobNumber: string, hours: number) => void;
knownJobs: Array<{ jobNumber: string; name: string }>;
isPending: boolean;
}) {
const [jobNumber, setJobNumber] = useState("");
const [hours, setHours] = useState(4);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, [onClose]);
const suggestions = useMemo(() => {
const q = jobNumber.trim().toLowerCase();
if (!q) return knownJobs.slice(0, 8);
return knownJobs
.filter(
(j) =>
j.jobNumber.toLowerCase().startsWith(q) ||
j.name.toLowerCase().includes(q)
)
.slice(0, 8);
}, [jobNumber, knownJobs]);
const valid = jobNumber.trim().length > 0;
// Clamp popover to viewport
const left = Math.min(anchor.x, (typeof window !== "undefined" ? window.innerWidth : 1000) - 304);
const top = Math.min(anchor.y, (typeof window !== "undefined" ? window.innerHeight : 800) - 360);
return (
<div
ref={ref}
className="fixed z-50 w-[280px] overflow-hidden rounded-lg border bg-[var(--card)] shadow-[var(--shadow-lg)]"
style={{ left, top }}
>
<div className="border-b bg-[var(--muted)]/30 px-3 py-2 text-xs font-semibold">
Assign Job
</div>
<div className="space-y-3 p-3">
<div>
<label className="mb-1 block text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
Job Number
</label>
<Input
autoFocus
value={jobNumber}
onChange={(e) => setJobNumber(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && valid && onAssign(jobNumber.trim(), hours)}
placeholder="e.g. 2337959 or project name…"
className="h-8 text-xs"
/>
{suggestions.length > 0 && (
<div className="mt-1.5 flex max-h-[90px] flex-wrap gap-1 overflow-y-auto">
{suggestions.map((j) => (
<button
key={j.jobNumber}
type="button"
onClick={() => setJobNumber(j.jobNumber)}
className={cn(
"rounded-full border px-2 py-0.5 text-[10px] font-semibold transition-colors",
jobNumber === j.jobNumber
? "text-white"
: "hover:bg-[var(--muted)]/60"
)}
style={{
borderColor: `${jobColor(j.jobNumber)}55`,
background:
jobNumber === j.jobNumber
? jobColor(j.jobNumber)
: `${jobColor(j.jobNumber)}15`,
color:
jobNumber === j.jobNumber ? "#fff" : jobColor(j.jobNumber),
}}
title={j.name}
>
{j.jobNumber}
</button>
))}
</div>
)}
</div>
<div>
<label className="mb-1 block text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
Hours
</label>
<div className="flex gap-1">
{HOUR_CHIPS.map((h) => (
<button
key={h}
type="button"
onClick={() => setHours(h)}
className={cn(
"flex-1 rounded border px-1 py-1 text-xs font-bold transition-colors",
hours === h
? "border-[var(--primary)] bg-[var(--primary)] text-white"
: "bg-[var(--muted)]/30 text-[var(--muted-foreground)] hover:bg-[var(--muted)]/60"
)}
>
{h}h
</button>
))}
</div>
<div className="mt-1.5 flex items-center gap-1.5">
<span className="text-[10px] text-[var(--muted-foreground)]">Custom:</span>
<Input
type="number"
min={0.5}
max={24}
step={0.5}
value={HOUR_CHIPS.includes(hours) ? "" : hours}
onChange={(e) =>
setHours(parseFloat(e.target.value) || 0)
}
placeholder="—"
className="h-6 w-16 text-xs"
/>
<span className="text-[10px] text-[var(--muted-foreground)]">hrs</span>
</div>
</div>
{hours > 8 && (
<div className="flex items-center gap-1.5 rounded border border-amber-400/40 bg-amber-400/10 px-2 py-1 text-[10px] text-amber-700 dark:text-amber-400">
<AlertCircle className="h-3 w-3" />
{hours}h will likely push this person over their daily cap.
</div>
)}
<Button
type="button"
className="w-full h-8 text-xs"
disabled={!valid || isPending}
onClick={() => valid && onAssign(jobNumber.trim(), hours)}
>
{isPending ? "Saving…" : `Assign ${hours}h`}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { badRequest, serverError, forbidden } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { deleteBooking } from "@/lib/services/booking-service";
type Params = { params: Promise<{ bookingId: string }> };
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, error } = await requireAuth();
if (error) return error;
if (session.user.role !== "ADMIN" && session.user.role !== "PRODUCER") {
return forbidden("Only admins and producers can delete bookings");
}
try {
const { bookingId } = await params;
await deleteBooking(session.user.organizationId, bookingId);
return NextResponse.json({ ok: true });
} catch (e: any) {
if (e.message?.includes("not found")) return badRequest(e.message);
return serverError(e);
}
}

View file

@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { badRequest, serverError, forbidden } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import {
createBooking,
listBookingsForWeek,
} from "@/lib/services/booking-service";
import {
createBookingSchema,
listBookingsQuerySchema,
} from "@/lib/validators/booking";
// GET /api/resources/bookings?weekStart=YYYY-MM-DD[&userId=...]
// Any signed-in member of the org can list — capacity view is shared.
export async function GET(req: NextRequest) {
const { session, error } = await requireAuth();
if (error) return error;
try {
const url = new URL(req.url);
const parsed = listBookingsQuerySchema.safeParse({
weekStart: url.searchParams.get("weekStart") ?? undefined,
userId: url.searchParams.get("userId") ?? undefined,
});
if (!parsed.success) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
const weekStart = parsed.data.weekStart
? new Date(parsed.data.weekStart)
: new Date();
if (isNaN(weekStart.getTime())) {
return badRequest("Invalid weekStart — use YYYY-MM-DD");
}
const bookings = await listBookingsForWeek(
session.user.organizationId,
weekStart,
{ userId: parsed.data.userId }
);
return NextResponse.json(bookings);
} catch (e) {
return serverError(e);
}
}
// POST /api/resources/bookings — ADMIN or PRODUCER only.
// Assignees aren't allowed to book themselves — keeps the capacity picture
// under producer control. Easy to relax later.
export async function POST(req: NextRequest) {
const { session, error } = await requireAuth();
if (error) return error;
if (session.user.role !== "ADMIN" && session.user.role !== "PRODUCER") {
return forbidden("Only admins and producers can create bookings");
}
try {
const body = await req.json();
const parsed = createBookingSchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
const booking = await createBooking(
session.user.organizationId,
session.user.id,
parsed.data
);
return NextResponse.json(booking, { status: 201 });
} catch (e: any) {
if (e.message?.includes("not found")) return badRequest(e.message);
return serverError(e);
}
}

View file

@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { listKnownJobNumbers } from "@/lib/services/booking-service";
// GET /api/resources/job-numbers
// Powers the autocomplete in the Assign-Job popover on the Resources page.
// Org-scoped (all projects); doesn't apply per-team visibility because the
// capacity planner is internal studio-only and needs to see everything.
export async function GET() {
const { session, error } = await requireAuth();
if (error) return error;
try {
const rows = await listKnownJobNumbers(session.user.organizationId);
return NextResponse.json(rows);
} catch (e) {
return serverError(e);
}
}

View file

@ -9,6 +9,7 @@ import {
ClipboardList,
Bell,
Users,
Users2,
GanttChart,
Settings,
PanelLeftClose,
@ -31,6 +32,7 @@ const navItems = [
{ href: "/projects", label: "Projects", icon: FolderKanban },
{ href: "/my-work", label: "My Work", icon: ClipboardList },
{ href: "/workload", label: "Workload", icon: Users },
{ href: "/resources", label: "Resources", icon: Users2 },
{ href: "/timeline", label: "Timeline", icon: GanttChart },
{ href: "/calendar", label: "Calendar", icon: CalendarDays },
{ href: "/reports", label: "Reports", icon: FileBarChart },

73
src/hooks/use-bookings.ts Normal file
View file

@ -0,0 +1,73 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiUrl } from "@/lib/api-client";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(apiUrl(url), init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export interface Booking {
id: string;
userId: string;
date: string;
jobNumber: string;
hours: number;
note: string | null;
}
export function useBookingsForWeek(weekStartIso: string) {
return useQuery({
queryKey: ["bookings", weekStartIso],
queryFn: () =>
fetchJson<Booking[]>(
`/api/resources/bookings?weekStart=${encodeURIComponent(weekStartIso)}`
),
});
}
export function useCreateBooking(weekStartIso: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: {
userId: string;
date: string;
jobNumber: string;
hours: number;
note?: string;
}) =>
fetchJson<Booking>("/api/resources/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["bookings", weekStartIso] }),
});
}
export function useDeleteBooking(weekStartIso: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
fetchJson(`/api/resources/bookings/${id}`, { method: "DELETE" }),
onSuccess: () =>
qc.invalidateQueries({ queryKey: ["bookings", weekStartIso] }),
});
}
export function useKnownJobNumbers() {
return useQuery({
queryKey: ["resource-job-numbers"],
queryFn: () =>
fetchJson<Array<{ jobNumber: string; name: string }>>(
"/api/resources/job-numbers"
),
staleTime: 60_000,
});
}

View file

@ -0,0 +1,128 @@
import { prisma } from "@/lib/prisma";
import type { CreateBookingInput } from "@/lib/validators/booking";
/**
* Resource booking service daily capacity planner backend.
*
* Org-scoped. Write operations require ADMIN or PRODUCER role (checked in
* the API route). Reads are open to any signed-in member of the org the
* capacity view is a shared studio-level thing, not per-project.
*/
export function startOfWeekUtc(d: Date): Date {
// Monday as the start of the week — matches Resources.html + the Dow calendar.
const copy = new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
);
const day = copy.getUTCDay(); // 0=Sun, 1=Mon, ...
const diff = day === 0 ? -6 : 1 - day;
copy.setUTCDate(copy.getUTCDate() + diff);
return copy;
}
export function endOfWeekUtc(d: Date): Date {
const start = startOfWeekUtc(d);
const end = new Date(start);
end.setUTCDate(end.getUTCDate() + 6);
end.setUTCHours(23, 59, 59, 999);
return end;
}
/**
* List bookings for a given week. Returns bookings for all users in the org
* so the UI can build the full grid in one fetch.
*/
export async function listBookingsForWeek(
organizationId: string,
weekStart: Date,
opts: { userId?: string } = {}
) {
const from = startOfWeekUtc(weekStart);
const to = endOfWeekUtc(weekStart);
return prisma.resourceBooking.findMany({
where: {
organizationId,
date: { gte: from, lte: to },
...(opts.userId ? { userId: opts.userId } : {}),
},
orderBy: [{ date: "asc" }, { createdAt: "asc" }],
select: {
id: true,
userId: true,
date: true,
jobNumber: true,
hours: true,
note: true,
},
});
}
export async function createBooking(
organizationId: string,
createdById: string,
input: CreateBookingInput
) {
// Verify the user being booked belongs to this org.
const target = await prisma.user.findFirst({
where: { id: input.userId, organizationId },
select: { id: true },
});
if (!target) throw new Error("User not found in this organization");
// Normalize the date to UTC midnight so grouping by date is exact.
const dateUtc = new Date(
Date.UTC(
input.date.getUTCFullYear(),
input.date.getUTCMonth(),
input.date.getUTCDate()
)
);
return prisma.resourceBooking.create({
data: {
organizationId,
userId: input.userId,
date: dateUtc,
jobNumber: input.jobNumber,
hours: input.hours,
note: input.note,
createdById,
},
select: {
id: true,
userId: true,
date: true,
jobNumber: true,
hours: true,
note: true,
},
});
}
export async function deleteBooking(organizationId: string, bookingId: string) {
const existing = await prisma.resourceBooking.findFirst({
where: { id: bookingId, organizationId },
select: { id: true },
});
if (!existing) throw new Error("Booking not found");
await prisma.resourceBooking.delete({ where: { id: bookingId } });
return { ok: true };
}
/**
* Used by the Resource Manager UI to populate the job-number autocomplete.
* Returns distinct OMG job numbers + project names from all projects the
* caller can see (visibility is enforced upstream in the API route).
*/
export async function listKnownJobNumbers(organizationId: string) {
const rows = await prisma.project.findMany({
where: { organizationId, omgJobNumber: { not: null } },
select: { omgJobNumber: true, name: true },
orderBy: { updatedAt: "desc" },
take: 200,
});
return rows
.filter((r): r is { omgJobNumber: string; name: string } => !!r.omgJobNumber)
.map((r) => ({ jobNumber: r.omgJobNumber, name: r.name }));
}

View file

@ -0,0 +1,36 @@
import { z } from "zod/v4";
/**
* Resource booking validators.
*
* `date` is normalized to a date-only ISO string (YYYY-MM-DD) on both input
* and output. The DB stores it as a DateTime at 00:00:00 UTC; the Zod
* coerce-to-Date + `.toISOString().slice(0,10)` trick lets us accept both
* "2026-04-21" and "2026-04-21T00:00:00Z" from clients without fuss.
*/
const dateOnly = z
.union([z.string(), z.date()])
.transform((v) => {
if (v instanceof Date) return v;
const d = new Date(v);
if (isNaN(d.getTime())) throw new Error(`invalid date: ${v}`);
return d;
})
.pipe(z.date());
export const createBookingSchema = z.object({
userId: z.string().min(1, "userId is required"),
date: dateOnly,
jobNumber: z.string().trim().min(1).max(80),
hours: z.number().positive().max(24),
note: z.string().max(400).optional(),
});
export const listBookingsQuerySchema = z.object({
weekStart: z.string().optional(),
userId: z.string().optional(),
});
export type CreateBookingInput = z.infer<typeof createBookingSchema>;
export type ListBookingsQuery = z.infer<typeof listBookingsQuerySchema>;

View file

@ -7,16 +7,23 @@ export const createProjectSchema = z.object({
.max(50, "Project code must be 50 characters or less"),
name: z.string().min(1, "Name is required").max(200),
description: z.string().max(2000).optional(),
status: z.enum(["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"]),
status: z.enum(["PIPELINE", "ACTIVE", "ON_HOLD", "COMPLETED", "CANCELED", "ARCHIVED"]),
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]),
startDate: z.string().optional(),
dueDate: z.string().optional(),
// Dow fields
clientTeamId: z.string().optional(),
omgJobNumber: z.string().max(50).optional(),
// Category label (maps to Dow Project Category column from the XLSX:
// Copywriting / Display / GIF / PDF / Print / Social / Static / Video)
businessUnit: z.string().max(100).optional(),
// Freeform carry-overs that can still be handy (owner, notes, etc.)
quarter: z.string().max(20).optional(),
requestor: z.string().max(2000).optional(),
// Legacy HP fields kept in schema for back-compat but no longer in the form:
formFactor: z.string().max(100).optional(),
codeName: z.string().max(100).optional(),
npiOrRefresh: z.string().max(50).optional(),
quarter: z.string().max(20).optional(),
requestor: z.string().max(2000).optional(),
workfrontId: z.string().max(100).optional(),
omgCode: z.string().max(100).optional(),
bmtId: z.string().max(100).optional(),