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:
parent
df7ddbfb0d
commit
18ae429924
15 changed files with 2169 additions and 5 deletions
526
API.md
Normal file
526
API.md
Normal 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
468
HOWTO.md
Normal 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).
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
671
src/app/(app)/resources/page.tsx
Normal file
671
src/app/(app)/resources/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/api/resources/bookings/[bookingId]/route.ts
Normal file
23
src/app/api/resources/bookings/[bookingId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
73
src/app/api/resources/bookings/route.ts
Normal file
73
src/app/api/resources/bookings/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
src/app/api/resources/job-numbers/route.ts
Normal file
19
src/app/api/resources/job-numbers/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
73
src/hooks/use-bookings.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
128
src/lib/services/booking-service.ts
Normal file
128
src/lib/services/booking-service.ts
Normal 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 }));
|
||||
}
|
||||
36
src/lib/validators/booking.ts
Normal file
36
src/lib/validators/booking.ts
Normal 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>;
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue