Compare commits
64 Commits
docs/dedup
...
9d7decfc5b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d7decfc5b | ||
|
|
c685c9fada | ||
|
|
71d7daf1ae | ||
|
|
1fd05a886d | ||
|
|
bcf4c1f797 | ||
|
|
f9cb8003b5 | ||
|
|
3b0421aa81 | ||
|
|
a14dc8143c | ||
|
|
b75834ab7e | ||
|
|
4c171848fc | ||
|
|
a6d6647bb2 | ||
|
|
367fc9800e | ||
|
|
ddcffe9f6f | ||
|
|
3c5267f5e9 | ||
|
|
2111bb8b60 | ||
|
|
64d7b5c765 | ||
|
|
4e448dd06e | ||
|
|
29a7fc8857 | ||
|
|
5d76a8a1cf | ||
|
|
d6743ed52c | ||
|
|
ba86b7a897 | ||
|
|
4f56c2bdfd | ||
|
|
508518b6c8 | ||
|
|
f64a52b995 | ||
|
|
76d2348873 | ||
|
|
a604223c17 | ||
|
|
d4f58abb9c | ||
|
|
727e323288 | ||
|
|
7abbdd4913 | ||
|
|
94f8b76a03 | ||
|
|
a78f653f5a | ||
|
|
aca45fb1b2 | ||
|
|
183ff1ff9e | ||
|
|
90463269ce | ||
|
|
a5036c6358 | ||
|
|
f743169354 | ||
|
|
b053a6388e | ||
|
|
b1133c4e87 | ||
|
|
15a79e7990 | ||
|
|
037f2544e8 | ||
|
|
7c408cf975 | ||
|
|
8a5cd1ef0e | ||
|
|
d0ab4b8102 | ||
|
|
aaf4847fc2 | ||
|
|
feacb8c7ac | ||
|
|
2f2ad4452f | ||
|
|
27d438929b | ||
|
|
899e588a0c | ||
|
|
7a6e95c87a | ||
|
|
077ba5bf6b | ||
|
|
14dac2f3e1 | ||
|
|
117cfae52e | ||
|
|
d43298a74e | ||
|
|
88a87afa77 | ||
|
|
299e893e2b | ||
|
|
51523e6768 | ||
|
|
11969c0d8a | ||
|
|
1c0a16fd59 | ||
|
|
b6996f9a31 | ||
|
|
46bd8aaef1 | ||
|
|
b5d8e1ecb8 | ||
|
|
ed40662b99 | ||
|
|
9d815c4dcc | ||
|
|
b9b3f942a6 |
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}
|
||||||
30
.gitattributes
vendored
Normal file
30
.gitattributes
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Normalize line endings on commit; check out LF on every OS.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Binary files — never touch line endings.
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.gz binary
|
||||||
|
*.tar binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
|
*.eot binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.mov binary
|
||||||
|
*.wasm binary
|
||||||
|
|
||||||
|
# Shell scripts must stay LF regardless.
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Windows batch / PowerShell must stay CRLF.
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,3 +17,5 @@ playwright-report/
|
|||||||
nginx/certs/
|
nginx/certs/
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
docker-compose.override.yml
|
||||||
|
.remember/
|
||||||
|
|||||||
21
PROGRESS.md
21
PROGRESS.md
@@ -1,12 +1,22 @@
|
|||||||
# Port Nimara CRM - Project Progress
|
# Port Nimara CRM - Project Progress
|
||||||
|
|
||||||
**Last updated:** 2026-03-26
|
**Last updated:** 2026-04-22
|
||||||
**Repo:** https://code.letsbe.solutions/letsbe/pn-new-crm
|
**Repo:** https://code.letsbe.solutions/letsbe/pn-new-crm
|
||||||
**Domain:** pn.letsbe.solutions
|
**Domain:** pn.letsbe.solutions
|
||||||
**Stack:** Next.js 15 + TypeScript + Tailwind + Drizzle ORM + PostgreSQL + Redis + BullMQ + MinIO + Socket.io
|
**Stack:** Next.js 15 + TypeScript + Tailwind + Drizzle ORM + PostgreSQL + Redis + BullMQ + MinIO + Socket.io
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Since 2026-03-26
|
||||||
|
|
||||||
|
- **Admin surface expanded** — full admin users + roles management, admin ports + system settings management, user settings, expanded audit log, and berth CRUD completions.
|
||||||
|
- **Reminders system** — promoted from "pages only" to full CRUD with background processors.
|
||||||
|
- **Multi-address clients** — new `client_addresses` table with a partial unique index enforcing one primary address per client.
|
||||||
|
- **Inquiry notifications feature (end-to-end)** — public interest form now fires: (a) confirmation email to the inquiring client, (b) in-app notifications to CRM users with `interests.view`, (c) optional email to configured sales recipients. Public schema expanded with first/last name split, address block, and berth mooring lookup. `sendEmail` gained a plain-text fallback. Admin settings UI exposes `inquiry_contact_email` and `inquiry_notification_recipients`. Plan: `docs/superpowers/plans/2026-04-14-inquiry-notifications.md`.
|
||||||
|
- **Build/infra cleanup** — Next.js 15 static-prerender bugs fixed (Suspense boundaries around `useSearchParams` on `/portal/verify` and `/set-password`), `.gitattributes` added to enforce LF in the index across Windows/macOS checkouts, Docker production build fixes, CI trimmed to build+push (deploy job removed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What's Been Built (Layers 0-4 Complete)
|
## What's Been Built (Layers 0-4 Complete)
|
||||||
|
|
||||||
### Layer 0: Foundation (DONE)
|
### Layer 0: Foundation (DONE)
|
||||||
@@ -80,8 +90,10 @@
|
|||||||
- API: `/api/v1/notifications/...` (CRUD, preferences, read-all, unread-count)
|
- API: `/api/v1/notifications/...` (CRUD, preferences, read-all, unread-count)
|
||||||
- Service: `notifications.service.ts`
|
- Service: `notifications.service.ts`
|
||||||
- Components: `src/components/notifications/`
|
- Components: `src/components/notifications/`
|
||||||
- [x] **Reminders** - Reminder pages
|
- [x] **Reminders** - Full CRUD with background processors (dispatcher, reminder workers)
|
||||||
- Pages: `/reminders`
|
- Pages: `/reminders`
|
||||||
|
- API: `/api/v1/reminders/...` (CRUD, my, overdue, upcoming, complete, dismiss, snooze)
|
||||||
|
- Service: `reminders.service.ts`
|
||||||
- [x] **Search** - Global search (inline in topbar), saved views
|
- [x] **Search** - Global search (inline in topbar), saved views
|
||||||
- API: `/api/v1/search/...`, `/api/v1/saved-views/...`
|
- API: `/api/v1/search/...`, `/api/v1/saved-views/...`
|
||||||
- Service: `search.service.ts`, `saved-views.service.ts`
|
- Service: `search.service.ts`, `saved-views.service.ts`
|
||||||
@@ -178,11 +190,12 @@
|
|||||||
|
|
||||||
### Priority 1: Deployment & Go-Live
|
### Priority 1: Deployment & Go-Live
|
||||||
|
|
||||||
- [ ] Push to Gitea and verify CI/CD pipeline builds
|
- [x] Push to Gitea (origin/main at `9d815c4` as of 2026-04-22)
|
||||||
|
- [ ] Verify CI/CD pipeline builds the latest image and pushes to the Gitea container registry
|
||||||
- [ ] Set up server: install Docker, nginx, configure DNS for `pn.letsbe.solutions`
|
- [ ] Set up server: install Docker, nginx, configure DNS for `pn.letsbe.solutions`
|
||||||
- [ ] Run `certbot --nginx -d pn.letsbe.solutions` for SSL
|
- [ ] Run `certbot --nginx -d pn.letsbe.solutions` for SSL
|
||||||
- [ ] Configure production `.env` on server
|
- [ ] Configure production `.env` on server
|
||||||
- [ ] Run database migrations (`pnpm db:push`)
|
- [ ] Run database migrations (`drizzle-kit migrate` against prod DB — `0000` + `0001` need to apply)
|
||||||
- [ ] Run seed data (`pnpm db:seed`)
|
- [ ] Run seed data (`pnpm db:seed`)
|
||||||
- [ ] Verify all services start and health check passes
|
- [ ] Verify all services start and health check passes
|
||||||
|
|
||||||
|
|||||||
Submodule client-portal updated: e2d31815cf...84f89f9409
2678
docs/superpowers/plans/2026-04-23-data-model-refactor.md
Normal file
2678
docs/superpowers/plans/2026-04-23-data-model-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
663
docs/superpowers/specs/2026-04-23-data-model-refactor-design.md
Normal file
663
docs/superpowers/specs/2026-04-23-data-model-refactor-design.md
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
# Data-Model Refactor: Yachts and Companies as First-Class Entities
|
||||||
|
|
||||||
|
**Status:** Draft — awaiting final review
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Spec position:** 1 of 3 (Spec 2 = NocoDB+MinIO importer; Spec 3 = client merge endpoint)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This spec delivers a refactor of the core client / yacht / company data model to support real-world ownership relationships that the current schema cannot express.
|
||||||
|
|
||||||
|
The current `clients` table holds yacht dimensions and company name as columns directly on the person row. This enforces a one-person = one-yacht = one-company assumption that breaks the moment:
|
||||||
|
|
||||||
|
- A client owns multiple yachts (a common marina scenario)
|
||||||
|
- A person is a broker or director of multiple companies
|
||||||
|
- A yacht is legally owned by a shell company (common for tax / liability reasons) rather than by the human on the dock
|
||||||
|
- A yacht changes hands between owners and the marina needs chain-of-title
|
||||||
|
|
||||||
|
The refactor pulls yacht and company data into their own first-class tables, adds join tables for person↔company memberships, and introduces a proper `berth_reservations` table for exclusive-reservation lifecycle tracking.
|
||||||
|
|
||||||
|
This spec also fixes two existing schema gaps that surface during the refactor:
|
||||||
|
|
||||||
|
- `berths.status` tracks the state of a berth but there is no table recording which client/yacht exclusively reserves a berth
|
||||||
|
- `invoices.clientName` is a text field with no FK — there's no first-class link between invoices and billing entities
|
||||||
|
|
||||||
|
## Scope boundaries
|
||||||
|
|
||||||
|
### In scope (this spec)
|
||||||
|
|
||||||
|
- New `yachts`, `yacht_ownership_history`, `yacht_notes`, `yacht_tags` tables
|
||||||
|
- New `companies`, `company_memberships`, `company_addresses`, `company_notes`, `company_tags` tables
|
||||||
|
- New `berth_reservations` table with partial-unique-index exclusivity enforcement
|
||||||
|
- Updates to `interests`, `berth_waiting_list`, `invoices`, `files`, `documents` to add FKs to the new entities
|
||||||
|
- Removal of yacht, company, and proxy columns from `clients`
|
||||||
|
- New services, API routes, permissions, and socket/webhook events
|
||||||
|
- New UI pages for yachts, companies, and berth reservations; modifications to client, interest, berth, invoice forms
|
||||||
|
- Dual-path EOI generation (Documenso + in-app PDF template) with a shared payload builder
|
||||||
|
- Comprehensive test coverage: unit, integration, E2E, exhaustive click-through, template regression
|
||||||
|
- Seeder with realistic multi-cardinality dummy data
|
||||||
|
|
||||||
|
### Explicitly out of scope
|
||||||
|
|
||||||
|
- **Importing NocoDB records and MinIO documents** → Spec 2
|
||||||
|
- **Client merge endpoint** → Spec 3
|
||||||
|
- Yacht survey / class-cert document categorization
|
||||||
|
- Company hierarchy (holding → subsidiary)
|
||||||
|
- Line-item-level yacht references on invoices
|
||||||
|
- Auto-renewal flow for berth reservations
|
||||||
|
- Per-yacht row-level permissions
|
||||||
|
- Portal branding per company
|
||||||
|
|
||||||
|
## Decisions and rationale
|
||||||
|
|
||||||
|
| Topic | Decision | Why |
|
||||||
|
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Yacht scope | Full entity: own page, documents, ownership history, yacht-keyed interests / reservations / invoices | Marina domain cares about yachts as first-class objects (dimensions for berth fit, registration for port entry, ownership for liability) |
|
||||||
|
| Company scope | Full entity: memberships join, company-owned yachts, company billing | Yachts are frequently owned by shell companies for tax/liability reasons — the human on the dock is a director or broker. Lightweight/medium models can't route invoices to the correct legal entity |
|
||||||
|
| Ownership history | Dedicated `yacht_ownership_history` table + denormalized current-owner columns on `yachts` | Ownership change is exactly the kind of event that needs queryable history (chain of title, insurance, broker commission attribution). Denormalized current-owner keeps common reads fast |
|
||||||
|
| Proxy fields on clients (`isProxy`, `proxyType`, `actualOwnerName`, `relationshipNotes`) | Drop all four | Every real proxy scenario is expressible through `company_memberships` roles or `client_relationships`. Keeping the old fields creates two sources of truth and drift risk |
|
||||||
|
| Berth exclusive reservation | New `berth_reservations` table with partial unique index `WHERE status = 'active'` | Current schema tracks berth state via `berths.status` but does not record which client/yacht holds the reservation. Partial unique index enforces exclusivity at the DB level |
|
||||||
|
| Invoice billing entity | `billingEntityType` (`'client' \| 'company'`) + `billingEntityId`; `clientName` retained as an immutable snapshot | Companies become first-class payers. `clientName` as text is preserved on the invoice as a snapshot so invoices never retroactively rename themselves |
|
||||||
|
| Data state | Green-field with dummy seeder; real data arrives via Spec 2 | No production data lives in this Postgres DB yet. NocoDB holds the real records until Spec 2 imports them |
|
||||||
|
| Delivery | One cohesive spec covering both yacht + company refactor | Splitting doubles the migration/UI/test churn for no architectural gain; both sets of changes overlap heavily |
|
||||||
|
| EOI template strategy | Support both Documenso-template path and in-app PDF template path, both fully functional from day one | Handoff risk: client must not come back claiming "EOIs don't work." If Documenso breaks or is replaced, in-app path is the fallback. Both consume the same payload builder for data consistency |
|
||||||
|
| EOI UI picker | Dropdown at generation time (user picks Documenso or in-app explicitly) | Explicit beats automatic fallback for handoff — misconfiguration is visible, not silently masked |
|
||||||
|
| Testing | Unit, integration, full E2E scenarios, exhaustive Playwright click-through, template regression (including visual diff) | Explicit "test thoroughly" direction plus the handoff concern justify going heavier than normal on integration + E2E tiers |
|
||||||
|
|
||||||
|
## Schema design
|
||||||
|
|
||||||
|
### New tables
|
||||||
|
|
||||||
|
```
|
||||||
|
yachts
|
||||||
|
id text PK
|
||||||
|
portId text NOT NULL FK → ports.id
|
||||||
|
name text NOT NULL
|
||||||
|
hullNumber text
|
||||||
|
registration text
|
||||||
|
flag text
|
||||||
|
yearBuilt integer
|
||||||
|
builder text
|
||||||
|
model text
|
||||||
|
hullMaterial text
|
||||||
|
lengthFt numeric
|
||||||
|
widthFt numeric
|
||||||
|
draftFt numeric
|
||||||
|
lengthM numeric
|
||||||
|
widthM numeric
|
||||||
|
draftM numeric
|
||||||
|
currentOwnerType text NOT NULL -- 'client' | 'company'
|
||||||
|
currentOwnerId text NOT NULL
|
||||||
|
status text NOT NULL DEFAULT 'active' -- 'active' | 'retired' | 'sold_away'
|
||||||
|
notes text
|
||||||
|
archivedAt timestamptz
|
||||||
|
createdAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
Indexes:
|
||||||
|
idx_yachts_port on (portId)
|
||||||
|
idx_yachts_current_owner on (portId, currentOwnerType, currentOwnerId)
|
||||||
|
idx_yachts_name on (portId, name)
|
||||||
|
|
||||||
|
yacht_ownership_history
|
||||||
|
id text PK
|
||||||
|
yachtId text NOT NULL FK → yachts.id ON DELETE CASCADE
|
||||||
|
ownerType text NOT NULL -- 'client' | 'company'
|
||||||
|
ownerId text NOT NULL
|
||||||
|
startDate date NOT NULL
|
||||||
|
endDate date -- NULL = currently active
|
||||||
|
transferReason text -- 'sale' | 'inheritance' | 'gift' | 'company_restructure' | 'other'
|
||||||
|
transferNotes text
|
||||||
|
createdBy text NOT NULL
|
||||||
|
createdAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
Indexes:
|
||||||
|
idx_yoh_yacht on (yachtId)
|
||||||
|
idx_yoh_active (partial) on (yachtId) WHERE endDate IS NULL
|
||||||
|
|
||||||
|
yacht_notes -- mirrors client_notes shape
|
||||||
|
id, yachtId (FK CASCADE), authorId, content, mentions text[], isLocked, createdAt, updatedAt
|
||||||
|
|
||||||
|
yacht_tags
|
||||||
|
yachtId, tagId composite PK; tagId references system.tags.id
|
||||||
|
|
||||||
|
companies
|
||||||
|
id text PK
|
||||||
|
portId text NOT NULL FK → ports.id
|
||||||
|
name text NOT NULL
|
||||||
|
legalName text
|
||||||
|
taxId text
|
||||||
|
registrationNumber text
|
||||||
|
incorporationCountry text
|
||||||
|
incorporationDate date
|
||||||
|
status text NOT NULL DEFAULT 'active' -- 'active' | 'dissolved'
|
||||||
|
billingEmail text
|
||||||
|
notes text
|
||||||
|
archivedAt timestamptz
|
||||||
|
createdAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
Indexes:
|
||||||
|
idx_companies_port on (portId)
|
||||||
|
idx_companies_name_unique UNIQUE on (portId, lower(name)) -- case-insensitive
|
||||||
|
idx_companies_taxid on (portId, taxId) WHERE taxId IS NOT NULL
|
||||||
|
|
||||||
|
company_memberships
|
||||||
|
id text PK
|
||||||
|
companyId text NOT NULL FK → companies.id ON DELETE CASCADE
|
||||||
|
clientId text NOT NULL FK → clients.id ON DELETE CASCADE
|
||||||
|
role text NOT NULL -- 'director' | 'officer' | 'broker' | 'representative' | 'legal_counsel' | 'employee' | 'shareholder' | 'other'
|
||||||
|
roleDetail text -- free-text qualifier: "Managing Director", "Exclusive Broker"
|
||||||
|
startDate date NOT NULL
|
||||||
|
endDate date -- NULL = active
|
||||||
|
isPrimary boolean NOT NULL DEFAULT false
|
||||||
|
notes text
|
||||||
|
createdAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
Indexes:
|
||||||
|
idx_cm_company on (companyId)
|
||||||
|
idx_cm_client on (clientId)
|
||||||
|
idx_cm_active (partial) on (companyId, clientId) WHERE endDate IS NULL
|
||||||
|
unique_cm_exact UNIQUE on (companyId, clientId, role, startDate)
|
||||||
|
|
||||||
|
company_addresses -- mirrors client_addresses shape with companyId FK
|
||||||
|
company_notes -- mirrors client_notes shape with companyId FK
|
||||||
|
company_tags
|
||||||
|
companyId, tagId composite PK
|
||||||
|
|
||||||
|
berth_reservations
|
||||||
|
id text PK
|
||||||
|
berthId text NOT NULL FK → berths.id
|
||||||
|
portId text NOT NULL FK → ports.id
|
||||||
|
clientId text NOT NULL FK → clients.id -- contract holder
|
||||||
|
yachtId text NOT NULL FK → yachts.id -- which yacht occupies the slip
|
||||||
|
interestId text FK → interests.id -- nullable link back to originating interest
|
||||||
|
status text NOT NULL -- 'pending' | 'active' | 'ended' | 'cancelled'
|
||||||
|
startDate date NOT NULL
|
||||||
|
endDate date -- NULL = open-ended
|
||||||
|
tenureType text NOT NULL DEFAULT 'permanent' -- 'permanent' | 'fixed_term' | 'seasonal'
|
||||||
|
contractFileId text FK → files.id
|
||||||
|
createdBy text NOT NULL
|
||||||
|
createdAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
updatedAt timestamptz NOT NULL DEFAULT now()
|
||||||
|
Indexes:
|
||||||
|
idx_br_berth on (berthId)
|
||||||
|
idx_br_client on (clientId)
|
||||||
|
idx_br_yacht on (yachtId)
|
||||||
|
idx_br_active (partial) UNIQUE on (berthId) WHERE status = 'active'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified tables
|
||||||
|
|
||||||
|
```
|
||||||
|
clients
|
||||||
|
DROP COLUMN yachtName, yachtLengthFt, yachtWidthFt, yachtDraftFt,
|
||||||
|
yachtLengthM, yachtWidthM, yachtDraftM, berthSizeDesired
|
||||||
|
DROP COLUMN companyName
|
||||||
|
DROP COLUMN isProxy, proxyType, actualOwnerName, relationshipNotes
|
||||||
|
(retains: fullName, nationality, preferredContactMethod, preferredLanguage,
|
||||||
|
timezone, source, sourceDetails, archivedAt, createdAt, updatedAt)
|
||||||
|
|
||||||
|
interests
|
||||||
|
ADD COLUMN yachtId text FK → yachts.id -- nullable initially; enforced non-null before pipeline_stage leaves 'open'
|
||||||
|
ADD INDEX idx_interests_yacht on (yachtId)
|
||||||
|
|
||||||
|
berth_waiting_list
|
||||||
|
ADD COLUMN yachtId text FK → yachts.id
|
||||||
|
|
||||||
|
invoices
|
||||||
|
ADD COLUMN billingEntityType text NOT NULL -- 'client' | 'company'
|
||||||
|
ADD COLUMN billingEntityId text NOT NULL
|
||||||
|
(clientName column kept as immutable snapshot — must never auto-update)
|
||||||
|
ADD INDEX idx_invoices_billing_entity on (portId, billingEntityType, billingEntityId)
|
||||||
|
|
||||||
|
files
|
||||||
|
ADD COLUMN yachtId text FK → yachts.id -- nullable
|
||||||
|
ADD COLUMN companyId text FK → companies.id -- nullable
|
||||||
|
(existing clientId stays nullable; a file links to one of: client, yacht, or company)
|
||||||
|
|
||||||
|
documents
|
||||||
|
ADD COLUMN yachtId text FK → yachts.id -- nullable
|
||||||
|
ADD COLUMN companyId text FK → companies.id -- nullable
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB-level invariants
|
||||||
|
|
||||||
|
| # | Invariant | Enforced by |
|
||||||
|
| --- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | One active ownership row per yacht | Partial unique index on `yacht_ownership_history(yachtId) WHERE endDate IS NULL` |
|
||||||
|
| 2 | One active reservation per berth | Partial unique index on `berth_reservations(berthId) WHERE status = 'active'` |
|
||||||
|
| 3 | Yacht always has a current owner | Both `currentOwnerType` and `currentOwnerId` NOT NULL; ownership row inserted atomically with yacht creation inside service transaction |
|
||||||
|
| 4 | Company names unique per port (case-insensitive) | Unique index on `(portId, lower(name))` |
|
||||||
|
| 5 | Exact-duplicate memberships blocked | Unique index on `(companyId, clientId, role, startDate)` |
|
||||||
|
|
||||||
|
### Service-layer invariants (not DB-enforceable due to polymorphic columns)
|
||||||
|
|
||||||
|
| # | Invariant | Enforced by |
|
||||||
|
| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||||
|
| 6 | `yacht.currentOwnerType='client'` ↔ `currentOwnerId` references an existing row in `clients`; same for `'company'` ↔ `companies` | Zod validator + service-layer lookup before insert/update |
|
||||||
|
| 7 | `yacht_ownership_history.ownerType/ownerId` consistent with the corresponding entity table | Same as #6 |
|
||||||
|
| 8 | `invoices.billingEntityType` + `billingEntityId` consistent with entity table | Same as #6 |
|
||||||
|
| 9 | `files.clientId`, `files.yachtId`, `files.companyId` — exactly one of the three must be non-null if the file is entity-scoped | Service-layer validation on insert/update |
|
||||||
|
|
||||||
|
### Drizzle relations (`relations.ts`)
|
||||||
|
|
||||||
|
All new tables wire into the relations map. Notable additions:
|
||||||
|
|
||||||
|
- `clientsRelations`: `companyMemberships` (many), `ownedYachts` (many, via polymorphic query), `berthReservations` (many)
|
||||||
|
- `yachtsRelations`: `port` (one), `ownershipHistory` (many), `notes` (many), `tags` (many), `interests` (many), `reservations` (many), `documents` (many)
|
||||||
|
- `companiesRelations`: `port` (one), `memberships` (many), `addresses` (many), `notes` (many), `tags` (many), `documents` (many)
|
||||||
|
- `berthReservationsRelations`: `berth`, `port`, `client`, `yacht`, `interest`, `contractFile`
|
||||||
|
|
||||||
|
## Service layer and API
|
||||||
|
|
||||||
|
### New services (`src/lib/services/`)
|
||||||
|
|
||||||
|
| File | Key functions |
|
||||||
|
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `yachts.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `transferOwnership(yachtId, newOwnerType, newOwnerId, effectiveDate, reason, notes)` — atomic: closes current history row, opens new row, updates denormalized `currentOwner*` columns |
|
||||||
|
| `companies.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `upsertByName(portId, name)` (case-insensitive, for autocomplete) |
|
||||||
|
| `company-memberships.service.ts` | `addMembership`, `endMembership(id, endDate)`, `updateMembership`, `listByCompany`, `listByClient`, `setPrimary` |
|
||||||
|
| `berth-reservations.service.ts` | `createPending`, `activate(id)` (gates on partial unique index), `end(id, endDate)`, `cancel(id)`, `listByBerth`, `listByClient`, `listByYacht` |
|
||||||
|
|
||||||
|
### Modified services
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `clients.service.ts` | Strip yacht/company/proxy field handling from create/update paths |
|
||||||
|
| `interests.service.ts` | Accept `yachtId`; validate yacht is owned by the interest's client OR by a company the client actively represents. Promote-to-stage helpers require `yachtId` non-null before leaving `'open'` |
|
||||||
|
| `berths.service.ts` | Read reservation state via `berth_reservations` instead of deriving from `berths.status`. Reservation state changes also update `berths.status` via trigger-in-service-layer |
|
||||||
|
| `invoices.service.ts` | Accept `billingEntityType` + `billingEntityId`; snapshot the entity's current display name into `clientName` at creation (immutable afterward) |
|
||||||
|
| `search.service.ts` | Extend to yachts and companies; include yacht name, hull number, registration in search index; include company name, legal name, taxId |
|
||||||
|
| `recommendations.ts` (berth matcher) | Pull yacht dimensions from `yachts` table via `interest.yachtId` instead of from `clients.yacht*` |
|
||||||
|
| `document-templates.ts` | Update `MERGE_FIELDS` catalog: deprecate `{{client.yachtName}}`, `{{client.companyName}}` and old yacht dimension tokens; add `{{yacht.*}}`, `{{company.*}}`, `{{owner.*}}` scopes. Update `resolveTemplate()` to resolve new scopes |
|
||||||
|
| `portal.service.ts` | Portal user dashboards surface their yachts (owned + represented via memberships), their active memberships, and their active berth reservations |
|
||||||
|
|
||||||
|
### New REST endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
# Yachts
|
||||||
|
GET /api/v1/yachts
|
||||||
|
POST /api/v1/yachts
|
||||||
|
GET /api/v1/yachts/:id
|
||||||
|
PATCH /api/v1/yachts/:id
|
||||||
|
DELETE /api/v1/yachts/:id — archive (soft delete)
|
||||||
|
POST /api/v1/yachts/:id/transfer — ownership transfer
|
||||||
|
GET /api/v1/yachts/:id/ownership-history
|
||||||
|
GET /api/v1/yachts/autocomplete?q=…
|
||||||
|
|
||||||
|
# Companies
|
||||||
|
GET /api/v1/companies
|
||||||
|
POST /api/v1/companies
|
||||||
|
GET /api/v1/companies/:id
|
||||||
|
PATCH /api/v1/companies/:id
|
||||||
|
DELETE /api/v1/companies/:id — archive
|
||||||
|
GET /api/v1/companies/autocomplete?q=…
|
||||||
|
|
||||||
|
# Company memberships
|
||||||
|
GET /api/v1/companies/:id/members
|
||||||
|
POST /api/v1/companies/:id/members
|
||||||
|
PATCH /api/v1/companies/:id/members/:mid
|
||||||
|
DELETE /api/v1/companies/:id/members/:mid — sets endDate
|
||||||
|
|
||||||
|
# Berth reservations
|
||||||
|
GET /api/v1/berths/:id/reservations
|
||||||
|
POST /api/v1/berths/:id/reservations — create pending
|
||||||
|
PATCH /api/v1/berth-reservations/:id — state transitions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified endpoints
|
||||||
|
|
||||||
|
- `GET /api/v1/clients/:id` — response now includes nested `yachts` (owned + represented), `companies` (via active memberships), `activeReservations`
|
||||||
|
- `POST /api/v1/clients` — no longer accepts yacht/company/proxy fields
|
||||||
|
- `POST /api/v1/interests` — requires `yachtId`
|
||||||
|
- `POST /api/v1/invoices` — requires `billingEntityType` + `billingEntityId`
|
||||||
|
- `POST /api/public/interests` — creates new `client` + `yacht` + optional `company` + `membership` + `interest` in one transaction, all marked `source: 'public_submission'`. No dedup against existing records (anonymous trust boundary).
|
||||||
|
|
||||||
|
### Permissions (new keys)
|
||||||
|
|
||||||
|
```
|
||||||
|
yachts:view
|
||||||
|
yachts:write
|
||||||
|
yachts:transfer — higher-stakes operation, separate from :write
|
||||||
|
yachts:delete — archive permission
|
||||||
|
|
||||||
|
companies:view
|
||||||
|
companies:write
|
||||||
|
companies:delete
|
||||||
|
|
||||||
|
memberships:write — covers both directions of company_memberships
|
||||||
|
|
||||||
|
reservations:view
|
||||||
|
reservations:write
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing role updates:
|
||||||
|
|
||||||
|
- `admin` — all new keys
|
||||||
|
- `team_lead` — `yachts:view`, `yachts:write`, `companies:view`, `companies:write`, `memberships:write`, `reservations:view`; NOT `yachts:transfer` or `reservations:write`
|
||||||
|
- `front_desk` — all `:view` keys
|
||||||
|
|
||||||
|
### Socket / webhook events (new)
|
||||||
|
|
||||||
|
```
|
||||||
|
yacht.created
|
||||||
|
yacht.updated
|
||||||
|
yacht.ownership_transferred
|
||||||
|
yacht.archived
|
||||||
|
company.created
|
||||||
|
company.updated
|
||||||
|
company.archived
|
||||||
|
company_membership.added
|
||||||
|
company_membership.ended
|
||||||
|
berth_reservation.created
|
||||||
|
berth_reservation.activated
|
||||||
|
berth_reservation.ended
|
||||||
|
berth_reservation.cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
Webhook event map in `src/lib/services/webhooks.ts` gains the same list.
|
||||||
|
|
||||||
|
## EOI template strategy (dual-path)
|
||||||
|
|
||||||
|
Both paths fully supported from day one. Required to mitigate handoff risk — if Documenso breaks or is replaced, the in-app path is the fallback.
|
||||||
|
|
||||||
|
### Shared payload builder
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/services/eoi-context.ts
|
||||||
|
export async function buildEoiContext(interestId: string): Promise<EoiContext>
|
||||||
|
|
||||||
|
type EoiContext = {
|
||||||
|
client: { fullName; nationality; primaryEmail; primaryPhone; address; … }
|
||||||
|
yacht: { name; lengthFt; widthFt; draftFt; hullNumber; flag; yearBuilt; … } // via interest.yachtId
|
||||||
|
company: { name; legalName; taxId; billingAddress } | null // if yacht owner is a company
|
||||||
|
owner: { type: 'client' | 'company'; name; … } // polymorphic current owner
|
||||||
|
berth: { mooringNumber; area; lengthFt; price; priceCurrency; tenureType; … }
|
||||||
|
interest: { stage; leadCategory; dateFirstContact; notes; … }
|
||||||
|
port: { name; defaultCurrency; legalEntity; … }
|
||||||
|
date: { today; year }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both paths consume this. Guarantees the two rendering engines see the same data and stay in sync as schema evolves.
|
||||||
|
|
||||||
|
### Path A — Documenso template
|
||||||
|
|
||||||
|
- Documenso hosts the template, referenced by ID via env var `DOCUMENSO_TEMPLATE_ID` (matches the old system's `NUXT_DOCUMENSO_TEMPLATE_ID` pattern — a single global template ID; per-port templates are a future extension if needed)
|
||||||
|
- Payload builder flattens `EoiContext` into Documenso's field-name format, POSTs to `/api/v1/templates/{id}/generate-document`
|
||||||
|
- Signing flow unchanged: Documenso emails signers, webhook updates status in our DB
|
||||||
|
- Mitigation for "Documenso's template expects specific field names": one-time audit mapping every field name expected by `templateId=8` (from the old system) to a source in the new schema
|
||||||
|
|
||||||
|
### Path B — In-app PDF template
|
||||||
|
|
||||||
|
- Seed a "Standard EOI" HTML template into `document_templates` table on first boot. Template references tokens: `{{client.fullName}}`, `{{yacht.name}}`, `{{yacht.lengthFt}}`, `{{company.name}}`, `{{berth.mooringNumber}}`, `{{interest.dateFirstContact}}`, etc.
|
||||||
|
- `resolveTemplate()` substitutes tokens from `EoiContext`
|
||||||
|
- `pdfme` renders the resolved HTML to PDF
|
||||||
|
- **Signing**: generated PDF is uploaded to Documenso via existing `documensoCreate` + `documensoSend` — Documenso supports signing ad-hoc PDFs (not just its own templates). Signing experience identical to Path A from the signer's perspective.
|
||||||
|
- **Fallback**: if Documenso is unavailable, the PDF can be emailed to the signer via `nodemailer` as a manual fallback (flag in UI, not auto-fallback)
|
||||||
|
|
||||||
|
### UI picker
|
||||||
|
|
||||||
|
Generate-EOI dialog adds a Template dropdown:
|
||||||
|
|
||||||
|
```
|
||||||
|
Template: [ Documenso — Standard EOI v ]
|
||||||
|
[ Documenso — Standard EOI ]
|
||||||
|
[ In-app — Standard EOI ]
|
||||||
|
[ In-app — (any custom template user authored) ]
|
||||||
|
```
|
||||||
|
|
||||||
|
Explicit picker chosen over automatic fallback: misconfiguration is visible, not silently masked — important for handoff.
|
||||||
|
|
||||||
|
## UI impact
|
||||||
|
|
||||||
|
### New pages
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| `/[portSlug]/yachts` | List view: name, dimensions, current owner, status. Filters by owner type, size, status |
|
||||||
|
| `/[portSlug]/yachts/[yachtId]` | Detail — Tabs: Overview, Ownership History, Interests, Reservations, Documents, Notes, Tags |
|
||||||
|
| `/[portSlug]/companies` | List view: name, legal name, # members, # owned yachts |
|
||||||
|
| `/[portSlug]/companies/[companyId]` | Detail — Tabs: Overview, Members, Owned Yachts, Addresses, Documents, Notes, Tags |
|
||||||
|
|
||||||
|
### Modified pages
|
||||||
|
|
||||||
|
| Page | Change |
|
||||||
|
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `client-form` | Remove yacht / companyName / proxy fields. Becomes a clean "person" form. Yacht and company associations managed from detail page, not here |
|
||||||
|
| `client-detail` | Add tabs: Yachts (owned + represented), Companies (active memberships), Reservations |
|
||||||
|
| `client-columns` | Replace yacht/company text columns with "# yachts" and "Primary company" (from active memberships marked `isPrimary`) |
|
||||||
|
| `interest-form` | New required field: yacht picker, constrained to client's yachts (with inline "Add new yacht" option) |
|
||||||
|
| `interest-detail` | Display yacht prominently; berth recommendations match against yacht dimensions |
|
||||||
|
| `berth-detail` | New tab: Reservations. Shows active reservation + history. "Reserve this berth" button opens reservation dialog |
|
||||||
|
| `invoice-form` | New billing-entity picker (client or company toggle + autocomplete); `clientName` snapshot populates automatically |
|
||||||
|
| `eoi-generate-dialog` | New template-picker dropdown (per dual-path strategy) |
|
||||||
|
| Global search | Extended to yachts and companies |
|
||||||
|
| Sidebar | Adds "Yachts" and "Companies" entries. Reservations lives inside the Berths page |
|
||||||
|
| `/api/public/interest` form (new interest submission) | Captures yacht + company sub-forms; creates new trio on submission |
|
||||||
|
|
||||||
|
### Portal pages
|
||||||
|
|
||||||
|
- Dashboard: shows owned + represented yachts, active memberships, active reservations
|
||||||
|
- New "My Yachts" tab — read-only yacht detail scoped to ones user owns or represents
|
||||||
|
- New "My Reservations" tab
|
||||||
|
- Authenticated interest submissions create yacht row linked to the portal user (not anonymous)
|
||||||
|
|
||||||
|
### New components (`src/components/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
yachts/
|
||||||
|
yacht-form.tsx
|
||||||
|
yacht-detail.tsx
|
||||||
|
yacht-detail-header.tsx
|
||||||
|
yacht-tabs.tsx
|
||||||
|
yacht-columns.tsx
|
||||||
|
yacht-picker.tsx
|
||||||
|
yacht-ownership-history.tsx
|
||||||
|
yacht-transfer-dialog.tsx
|
||||||
|
companies/
|
||||||
|
company-form.tsx
|
||||||
|
company-detail.tsx
|
||||||
|
company-detail-header.tsx
|
||||||
|
company-tabs.tsx
|
||||||
|
company-columns.tsx
|
||||||
|
company-picker.tsx
|
||||||
|
company-members-tab.tsx
|
||||||
|
company-owned-yachts-tab.tsx
|
||||||
|
add-membership-dialog.tsx
|
||||||
|
reservations/
|
||||||
|
reservation-form.tsx
|
||||||
|
reservation-list.tsx
|
||||||
|
berth-reserve-dialog.tsx
|
||||||
|
shared/
|
||||||
|
owner-picker.tsx — polymorphic client|company autocomplete
|
||||||
|
billing-entity-picker.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
All follow existing `shadcn/ui` + CVA + react-hook-form + zod pattern.
|
||||||
|
|
||||||
|
### Seeder (`src/lib/db/seed.ts`) — rewrite
|
||||||
|
|
||||||
|
Produces realistic multi-cardinality fixtures:
|
||||||
|
|
||||||
|
- 3 companies (two with multiple members, one dissolved with an `endDate` on all memberships)
|
||||||
|
- 8 clients (some personal-only, some with company memberships, at least one representing multiple companies)
|
||||||
|
- 12 yachts (mix of client-owned and company-owned; 2-3 with ownership-transfer history)
|
||||||
|
- Interests linking clients ↔ yachts ↔ berths with realistic pipeline-stage distribution
|
||||||
|
- A handful of active berth reservations + a few ended/cancelled ones
|
||||||
|
- Rich contact / address / membership / ownership-history data covering every test scenario
|
||||||
|
|
||||||
|
Seeder shares factory helpers with tests (`tests/helpers/factories.ts`).
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
### Coverage targets (CI-enforced)
|
||||||
|
|
||||||
|
| Tier | Target |
|
||||||
|
| ------------- | ------------------- |
|
||||||
|
| Service layer | ≥ 90% line coverage |
|
||||||
|
| Validators | 100% line coverage |
|
||||||
|
| API routes | ≥ 85% line coverage |
|
||||||
|
| Overall | ≥ 85% line coverage |
|
||||||
|
|
||||||
|
Hard rules: no skipped tests on `main`; no PR merge without green CI on all tiers.
|
||||||
|
|
||||||
|
### Tier 1 — Unit tests (Vitest)
|
||||||
|
|
||||||
|
- Every new service function: happy path, each validation failure, each precondition failure, tenant-scoping
|
||||||
|
- Merge-field resolver: every new token resolves correctly across each context shape
|
||||||
|
- Validators: every zod schema tested for pass + fail on each field
|
||||||
|
|
||||||
|
### Tier 2 — Integration tests (Vitest + Postgres via docker-compose test DB)
|
||||||
|
|
||||||
|
- Migration up/down correctness
|
||||||
|
- Partial unique indexes (`berth_reservations(berthId) WHERE status='active'`, `yacht_ownership_history(yachtId) WHERE endDate IS NULL`) reject duplicate inserts
|
||||||
|
- FK cascades: deleting a client cascades contacts/addresses; yacht-with-this-owner is BLOCKED from being lost
|
||||||
|
- Atomic `transferOwnership`: concurrent retries result in consistent state
|
||||||
|
- Polymorphic integrity checks: `yacht.currentOwnerType='client'` with a companyId is rejected by service-layer validation
|
||||||
|
- Company name case-insensitive uniqueness
|
||||||
|
- Every new API route: auth → permission → service → DB → response shape
|
||||||
|
|
||||||
|
### Tier 3 — E2E scenario tests (Playwright)
|
||||||
|
|
||||||
|
Full-lifecycle flows:
|
||||||
|
|
||||||
|
1. Create client → add yacht → create interest → generate EOI (Documenso path) → PDF in MinIO
|
||||||
|
2. Same, in-app template path → verify PDF content contains expected yacht name
|
||||||
|
3. Create company → add two clients as members → create yacht owned by company → generate invoice billed to company
|
||||||
|
4. Yacht transfer: client-owned → company-owned; verify history + denormalized column + UI
|
||||||
|
5. Reserve berth: create → verify visible → attempt duplicate reservation → blocked
|
||||||
|
6. Public interest form → admin sees new client+yacht+company+interest trio
|
||||||
|
7. (Spec 3 stub): merge flow tested end-to-end in Spec 3
|
||||||
|
|
||||||
|
Multi-cardinality flows (the core justification for this refactor):
|
||||||
|
|
||||||
|
8. One client with 3 yachts, 3 interests, 3 different berths — all representable
|
||||||
|
9. One person as broker for 2 companies, each owning 1 yacht — memberships + owned yachts visible from client detail
|
||||||
|
|
||||||
|
Portal flows:
|
||||||
|
|
||||||
|
10. Portal user views "my yachts" — sees only owned/represented
|
||||||
|
11. Portal user submits interest — new yacht linked to their identity
|
||||||
|
|
||||||
|
### Tier 3.5 — Exhaustive Playwright click-through suite
|
||||||
|
|
||||||
|
Location: `tests/e2e/exhaustive/`. Separate CI job (15-20 min, runs in parallel with other tiers, blocks merge if failing).
|
||||||
|
|
||||||
|
Spec files: `yachts`, `companies`, `reservations`, `client-detail-refactored`, `eoi-generate`, `invoice-form`, `berths-with-reservations`, `portal`, `navigation`.
|
||||||
|
|
||||||
|
Per-page logic:
|
||||||
|
|
||||||
|
1. Navigate to page
|
||||||
|
2. Enumerate every interactive element (`button`, `a`, `[role="button"]`, `[data-testid]`, form inputs)
|
||||||
|
3. Click/fill each; post-click: assert no console errors, no 4xx/5xx network responses, UI returns to stable state
|
||||||
|
4. Coverage assertion: elements clicked ≥ total elements on page (minus declared destructive-action allowlist)
|
||||||
|
|
||||||
|
Helper: `tests/helpers/click-everything.ts` exports `clickEverythingOnPage(page, opts)`.
|
||||||
|
|
||||||
|
Destructive actions allowlist (tested separately with create-then-destroy isolation):
|
||||||
|
|
||||||
|
```
|
||||||
|
yachts.delete, yachts.archive, yachts.transferOwnership
|
||||||
|
companies.delete, companies.archive
|
||||||
|
companyMemberships.end
|
||||||
|
berthReservations.cancel, berthReservations.end
|
||||||
|
invoices.delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptance criteria for Spec 1 completion:
|
||||||
|
|
||||||
|
- Every new or changed page has 100% coverage in the exhaustive suite (minus allowlist)
|
||||||
|
- Every allowlist entry has its own narrow destructive test
|
||||||
|
- Zero console errors across the full suite
|
||||||
|
- Zero unexpected 4xx/5xx responses
|
||||||
|
|
||||||
|
### Tier 4 — EOI template regression
|
||||||
|
|
||||||
|
- **Documenso payload snapshot test**: mock Documenso API; assert POST body contains every expected field name with correct value sourced from new schema
|
||||||
|
- **In-app template rendering test**: render seeded template against each scenario's context; assert resolved HTML contains expected substrings; assert `pdfme` produces a non-empty PDF
|
||||||
|
- **Visual diff**: render in-app EOI to PDF, compare against committed golden-image PDFs per scenario; regressions surface as image diffs in PR
|
||||||
|
- **Error paths**: missing yacht, missing company with company-owned yacht reference, missing config (Documenso API key missing) — all produce explicit errors, not silent blanks
|
||||||
|
|
||||||
|
### Tier 5 — Security tests
|
||||||
|
|
||||||
|
- Cross-tenant isolation: yacht/company/reservation in port A invisible/unmodifiable from port B
|
||||||
|
- Permission enforcement: user without `yachts:write` cannot `POST /yachts`; `yachts:transfer` required for transfer endpoint
|
||||||
|
- Portal authorization: portal user cannot see yachts they don't own/represent
|
||||||
|
- Public interest endpoint: anonymous submitter cannot read existing records
|
||||||
|
|
||||||
|
### Test infrastructure
|
||||||
|
|
||||||
|
Fixture factories in `tests/helpers/factories.ts`:
|
||||||
|
|
||||||
|
```
|
||||||
|
makeYacht({ owner: client|company, ...overrides })
|
||||||
|
makeCompany({ overrides })
|
||||||
|
makeMembership({ client, company, role, ...overrides })
|
||||||
|
makeOwnershipHistoryRow({ yacht, owner, startDate, endDate })
|
||||||
|
makeReservation({ berth, client, yacht, status })
|
||||||
|
```
|
||||||
|
|
||||||
|
Scenario builders produce Tier 3 multi-cardinality setups in a single call.
|
||||||
|
|
||||||
|
Integration tests run against a fresh migrated DB; each test file wraps in a transaction that rolls back OR uses per-file schema isolation.
|
||||||
|
|
||||||
|
## Rollout plan
|
||||||
|
|
||||||
|
Green-field Postgres DB — no dual-write, no phased migration needed. Concern is only sequencing so the working tree never enters a broken half-migrated state.
|
||||||
|
|
||||||
|
### PR sequence (≈ 15 PRs, feature branch `refactor/data-model`)
|
||||||
|
|
||||||
|
| # | PR | Depends on |
|
||||||
|
| --- | --------------------------------------------------------------------------------------------------- | ------------ |
|
||||||
|
| 1 | Schema migration: add all new tables, leave old client columns in place | — |
|
||||||
|
| 2 | Service layer: new services (yachts, companies, memberships, reservations) | 1 |
|
||||||
|
| 3 | API routes for new services + new permissions | 2 |
|
||||||
|
| 4 | Seeder rewrite with multi-cardinality fixtures | 2 |
|
||||||
|
| 5 | UI: yacht list + detail + form + picker + ownership-history + transfer-dialog | 3 |
|
||||||
|
| 6 | UI: company list + detail + form + picker + memberships tab + add-membership dialog | 3 |
|
||||||
|
| 7 | UI: berth reservations tab + reserve dialog + ownership-transfer wiring | 3 |
|
||||||
|
| 8 | Client form refactor: strip yacht/company/proxy fields, add nav links to yachts/companies | 5, 6 |
|
||||||
|
| 9 | Interest form: require `yachtId` + public interest form creates trio | 5 |
|
||||||
|
| 10 | Invoice billing-entity support (client or company) | 6 |
|
||||||
|
| 11 | EOI shared payload builder + seed in-app Standard EOI template + dual-path dialog | 5, 6 |
|
||||||
|
| 12 | Merge-field catalog update + resolver extension for `{{yacht.*}}` / `{{company.*}}` / `{{owner.*}}` | 11 |
|
||||||
|
| 13 | Drop old columns from `clients` (`yacht*`, `companyName`, proxy fields) | 8, 9, 10, 11 |
|
||||||
|
| 14 | Exhaustive Playwright click-through suite (Tier 3.5) | 13 |
|
||||||
|
| 15 | Documentation updates (CLAUDE.md, numbered spec files 01-15, API catalog) | 13 |
|
||||||
|
|
||||||
|
After PR 15, merge the feature branch into `main` as one final PR.
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Severity | Mitigation |
|
||||||
|
| -------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Spec 2 (importer) depends on final schema; mid-development schema churn → rework | High | Schema freeze after PR 1 lands; amendments require deliberate spec update |
|
||||||
|
| Polymorphic owner columns have no DB-level FK — service-layer bug could insert inconsistent owner | Medium | Service-layer validation + integration test for every create/update path; runtime assertion in `buildEoiContext` |
|
||||||
|
| EOI dual-template drift (two engines produce subtly different output) | Medium | Golden-image visual-diff tests in Tier 4, CI-gated |
|
||||||
|
| Documenso template at `templateId=8` expects specific field names — new payload builder must match | Medium | One-time audit: document every field the existing template expects; map each to a source in new schema; Spec 2's importer uses same mapping |
|
||||||
|
| Old `client-portal/` sub-repo coordination during Spec 2 cutover | Low | Confirm old client-portal is decommissioned at Spec 2 cutover (not running concurrently against shared data) |
|
||||||
|
| Seeder becomes dev-onboarding bottleneck | Low | Seeder uses same factory helpers as tests — code path shared + tested |
|
||||||
|
| Documentation rot in numbered spec files | Low | PR 15 updates them before the feature branch merges to `main` |
|
||||||
|
| Exhaustive-click-suite runtime (15-20 min per PR) | Low | Separate CI job, runs in parallel with other tiers |
|
||||||
|
| Handoff quality — "EOIs don't work" / "I can't see my yachts" | Addressed | Dual template paths + exhaustive click coverage + golden-image diff + template regression tests collectively mitigate |
|
||||||
|
|
||||||
|
## Open questions / deferred items
|
||||||
|
|
||||||
|
Explicitly out of scope for this spec:
|
||||||
|
|
||||||
|
- Yacht survey / class-cert document categorization (requires taxonomy work)
|
||||||
|
- Multi-level company hierarchy (holding → subsidiary) — additive later
|
||||||
|
- Invoice line items referencing specific yacht
|
||||||
|
- Berth reservation auto-renewal flow
|
||||||
|
- Per-yacht row-level permissions (e.g., "broker can only see yachts they represent")
|
||||||
|
- Portal branding per company
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
Spec 1 is complete when:
|
||||||
|
|
||||||
|
1. All PRs in the sequence are merged to `main`
|
||||||
|
2. CI is green: all coverage gates met, zero skipped tests, exhaustive click-through suite passes
|
||||||
|
3. Manual verification: developer walks through every multi-cardinality scenario in Tier 3 E2E list against a dev build
|
||||||
|
4. Both EOI paths produce documents that match the current system's outputs (visual verification + golden images committed)
|
||||||
|
5. Documentation (CLAUDE.md + numbered spec files) updated
|
||||||
|
6. Spec 2 (NocoDB+MinIO importer) can begin against a frozen schema
|
||||||
@@ -1,564 +0,0 @@
|
|||||||
# Client Deduplication and NocoDB Migration Design
|
|
||||||
|
|
||||||
**Status**: Design draft 2026-05-03 — pending approval.
|
|
||||||
**Plan decomposition**: Three implementation plans stack from this design — (P1) normalization + dedup core library; (P2) admin settings + at-create + interest-level guards (runtime); (P3) NocoDB migration script + review queue UI. P1 unblocks P2 and P3.
|
|
||||||
**Branch base**: stacks on `feat/mobile-foundation` once it merges to `main`.
|
|
||||||
**Out of scope**: live merge of two clients across ports (cross-tenant), automated AI-judged matches, profile-photo / face-match dedup, web-of-trust referrer relationships.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Background
|
|
||||||
|
|
||||||
### 1.1 Why this exists
|
|
||||||
|
|
||||||
The legacy CRM lives in a NocoDB base whose `Interests` table conflates _the human_ with _the deal_. A row contains `Full Name`, `Email Address`, `Phone Number`, `Address`, `Place of Residence` _and_ the sales-pipeline state for one specific berth. A single human pursuing two berths becomes two rows with semi-duplicated personal data. A 2026-05-03 read-only audit confirmed:
|
|
||||||
|
|
||||||
- **252 Interests rows** in NocoDB, against an estimated ~190–200 unique humans (~20–25% duplication rate).
|
|
||||||
- **35 Residential Interests rows** in a parallel residential pathway with the same conflation.
|
|
||||||
- **64 Website Interest Submissions + 47 Website Contact Form Submissions + 1 EOI Supplemental Form** as inbound capture surfaces.
|
|
||||||
- **No Clients table.** The conflated structure is structural, not accidental.
|
|
||||||
|
|
||||||
The new CRM (`src/lib/db/schema/clients.ts`) splits this into `clients` (people) ↔ `interests` (deals), with `clientContacts` (multi-channel), `clientAddresses` (multi-address), and a pre-existing `clientMergeLog` table that anticipates merge with undo. The design has been ready; what's missing is (a) a normalization + matching library, (b) the at-create / at-import surfaces that use it, and (c) the migration of the existing 252+35 records.
|
|
||||||
|
|
||||||
### 1.2 Real duplicate patterns observed in the live data
|
|
||||||
|
|
||||||
Sampled 200 of the 252 NocoDB Interests rows. Confirmed duplicate clusters fall into six patterns:
|
|
||||||
|
|
||||||
| Pattern | Example rows | Signature |
|
|
||||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
||||||
| **A. Pure double-submit** | Deepak Ramchandani #624/#625; John Lynch #716/#725 | All fields identical; created same day |
|
|
||||||
| **B. Phone format variance** | Howard Wiarda #236/#536 (`574-274-0548` vs `+15742740548`); Christophe Zasso #701/#702 (`0651381036` vs `0033651381036`) | Same email, normalize-equal phone |
|
|
||||||
| **C. Name capitalization** | Nicolas Ruiz #681/#682/#683; Jean-Charles Miege/MIEGE #37/#163; John Farmer/FARMER #35/#161 | Same email or empty; surname case differs |
|
|
||||||
| **D. Name shortening** | Chris vs Christopher Allen #700/#534; Emma c vs Emma Cauchefer #661/#673 | Same email + phone; given-name truncated |
|
|
||||||
| **E. Resubmit with typo** | Christopher Camazou #649/#650 (phone last 4 digits typo); Gianfranco Di Constanzo/Costanzo #585/#336 (surname typo, **different yacht** — should be ONE client + TWO interests) | Score-on-everything-else high, one field has small-edit-distance noise |
|
|
||||||
| **F. Hard cases** | Etiennette Clamouze #188/#717 (same name, different country phone + email); Bruno Joyerot #18 with email belonging to Bruce Hearn #19 (couple sharing contact) | Cannot resolve without a human |
|
|
||||||
|
|
||||||
This dataset will be the fixture for the dedup library's tests — every pattern above must be either auto-detected or flagged for review, and the false-positive bar must be high enough that Pattern F doesn't get force-merged.
|
|
||||||
|
|
||||||
### 1.3 Dirty data inventory
|
|
||||||
|
|
||||||
The migration normalizer must survive these real values from production:
|
|
||||||
|
|
||||||
**Phone fields**: `+1-264-235-8840\r` (with carriage return), `'+1.214.603.4235` (apostrophe + dots), `0677580750/0690511494` (two numbers in one field), `00447956657022` (00 prefix), `+447000000000` (placeholder all-zeros), `+4901637039672` (impossible — stripped 0 + country prefix), various unprefixed local formats, dashed US numbers without country code.
|
|
||||||
|
|
||||||
**Email fields**: mixed case rampant (`Arthur@laser-align.com` vs `arthur@laser-align.com`); ALL-CAPS local parts; trailing whitespace.
|
|
||||||
|
|
||||||
**Name fields**: ALL-CAPS surnames mixed with title-case given names; embedded `\n` and `\r`; double spaces; lowercase-only entries; slash-with-company variants (`Daniel Wainstein / 7 Knots, LLC`, `Bruno Joyerot / SAS TIKI`); placeholder `Mr DADER`, `TBC`.
|
|
||||||
|
|
||||||
**Place of Residence (free text)**: `Saint barthelemy`, `St Barth`, `Saint-Barthélemy` (same place, three forms); `anguilla`, `United States `, `USA`, `Kansas City` (city without country), `Sag Harbor Y` (typo).
|
|
||||||
|
|
||||||
### 1.4 Existing battle-tested algorithm
|
|
||||||
|
|
||||||
`client-portal/server/utils/duplicate-detection.ts` already implements blocking + weighted-rules dedup against this same NocoDB. It runs in production today. We **port it forward** (don't reinvent), then add: soundex/metaphone for surname matching, compounded-confidence when multiple rules match, and negative evidence (same email + different country phone reduces confidence).
|
|
||||||
|
|
||||||
### 1.5 Why the website is no longer the source of new dirty data
|
|
||||||
|
|
||||||
The website forms (`website/components/pn/specific/website/{berths-item,register,form}/form.vue`) use `<v-phone-input>` with a country picker (`prefer-countries: ['US', 'GB', 'DE', 'FR']`) and `[(value) => !!value || 'Phone number is required']` validation. Output is E.164-shaped. The 252 dirty rows are legacy — pre-form-redesign submissions, sales-rep manual entries, and external CSV imports. Future inbound is clean.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Approach
|
|
||||||
|
|
||||||
Three artifacts, layered:
|
|
||||||
|
|
||||||
1. **A pure-logic normalization + matching library** at `src/lib/dedup/`. JSX-free, vitest-native (proven pattern: `realtime-invalidation-core.ts`). Tested against the dirty-data fixture corpus drawn from §1.2.
|
|
||||||
2. **Three runtime surfaces** that use the library: at-create suggestion in client/interest forms; interest-level same-berth guard; admin review queue powered by a nightly background scoring job.
|
|
||||||
3. **A one-shot migration script** that pulls NocoDB → normalizes → dedupes → writes new schema → produces a CSV report with auto-merge log + flagged-for-review pile.
|
|
||||||
|
|
||||||
**Configurability via admin settings** (`system_settings` per port) so the team can tune sensitivity without code changes. Defaults err on the safe side — a flagged review is cheaper than a wrongly-merged record.
|
|
||||||
|
|
||||||
**Reversibility**: every merge writes a `client_merge_log` row containing the loser's full pre-state JSON. A 7-day undo window lets a wrong merge be reversed without engineering involvement. After 7 days the snapshot is purged for GDPR; merges become permanent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Normalization library
|
|
||||||
|
|
||||||
Lives at `src/lib/dedup/normalize.ts`. Pure functions, no DB, vitest-tested. Used by the dedup algorithm AND by all create-paths so what gets stored is already normalized.
|
|
||||||
|
|
||||||
### 3.1 `normalizeName(raw: string)`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export function normalizeName(raw: string): {
|
|
||||||
display: string; // human-readable, kept for UI
|
|
||||||
normalized: string; // for matching
|
|
||||||
surnameToken?: string; // for surname-based blocking
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
- Trim leading/trailing whitespace
|
|
||||||
- Replace `\r`, `\n`, tabs with single space
|
|
||||||
- Collapse consecutive whitespace to single space
|
|
||||||
- Smart title-case: keep particles (`van`, `de`, `del`, `O'`, `di`, `le`, `da`) lowercase except as first token
|
|
||||||
- `display` preserves user's intent (slash-with-company stays intact)
|
|
||||||
- `normalized` is `display.toLowerCase()` for comparison
|
|
||||||
- `surnameToken` is the last non-particle token for blocking
|
|
||||||
|
|
||||||
### 3.2 `normalizeEmail(raw: string)`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export function normalizeEmail(raw: string): string | null;
|
|
||||||
```
|
|
||||||
|
|
||||||
- Trim + lowercase
|
|
||||||
- Validate via `zod.email()` schema
|
|
||||||
- Returns `null` for empty / invalid (caller decides what to do)
|
|
||||||
- **Does NOT strip plus-aliases** (`user+tag@domain.com`) — both intentional (real distinct addresses) and malicious-prevention apply. Compare by full localpart.
|
|
||||||
|
|
||||||
### 3.3 `normalizePhone(raw: string, defaultCountry: string)`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export function normalizePhone(
|
|
||||||
raw: string,
|
|
||||||
defaultCountry: string,
|
|
||||||
): {
|
|
||||||
e164: string | null; // canonical, e.g. '+15742740548'
|
|
||||||
country: string | null; // ISO-3166-1 alpha-2
|
|
||||||
display: string | null; // user-facing pretty
|
|
||||||
flagged?: 'multi_number' | 'placeholder' | 'unparseable';
|
|
||||||
} | null;
|
|
||||||
```
|
|
||||||
|
|
||||||
Pipeline:
|
|
||||||
|
|
||||||
1. Strip `\r`, `\n`, tabs, single quotes, dots, dashes, parens, spaces
|
|
||||||
2. If contains `/` or `;` or `,` → flag `multi_number`, take first segment
|
|
||||||
3. If matches `+\d{2}0+$` (e.g., `+447000000000`) → flag `placeholder`, return null
|
|
||||||
4. If starts with `00` → replace with `+`
|
|
||||||
5. If starts with `+` → parse as E.164
|
|
||||||
6. Else if `defaultCountry` provided → parse against that country
|
|
||||||
7. Else return null (caller's problem)
|
|
||||||
|
|
||||||
Backed by `libphonenumber-js` (already in deps via `tests/integration/factories.ts` usage if not, will add). The hostile cases above all need explicit handling — naïve regex won't survive.
|
|
||||||
|
|
||||||
### 3.4 `resolveCountry(text: string)`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export function resolveCountry(text: string): {
|
|
||||||
iso: string | null; // ISO-3166-1 alpha-2
|
|
||||||
confidence: 'exact' | 'fuzzy' | 'city' | null;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Reuses `src/lib/i18n/countries.ts`. Pipeline:
|
|
||||||
|
|
||||||
1. Lowercase + strip diacritics
|
|
||||||
2. Exact match against country names (any locale we ship)
|
|
||||||
3. Fuzzy match (Levenshtein ≤ 2 against canonical English names)
|
|
||||||
4. City fallback — small in-package mapping for high-frequency cities seen in legacy data (`Sag Harbor → US`, `Kansas City → US`, `St Barth → BL`, etc.). Order: exact → city → fuzzy.
|
|
||||||
|
|
||||||
The mapping is opinionated and small (~30 entries covering the actual values seen in the 252-row dataset). Anything that fails to resolve returns `null` and lands in the migration's flagged pile.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Dedup algorithm
|
|
||||||
|
|
||||||
Lives at `src/lib/dedup/find-matches.ts`. Pure function. Vitest-tested against the §1.2 cluster fixtures.
|
|
||||||
|
|
||||||
### 4.1 Public API
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export interface MatchCandidate {
|
|
||||||
id: string;
|
|
||||||
fullName: string | null;
|
|
||||||
emails: string[]; // already normalized
|
|
||||||
phonesE164: string[]; // already normalized E.164
|
|
||||||
countryIso: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatchResult {
|
|
||||||
candidate: MatchCandidate;
|
|
||||||
score: number; // 0–100
|
|
||||||
reasons: string[]; // human-readable, e.g. ["email match", "phone match"]
|
|
||||||
confidence: 'high' | 'medium' | 'low';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findClientMatches(
|
|
||||||
input: MatchCandidate,
|
|
||||||
pool: MatchCandidate[],
|
|
||||||
thresholds: DedupThresholds,
|
|
||||||
): MatchResult[];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Scoring rules (compound)
|
|
||||||
|
|
||||||
Each rule produces a score addition. **Compounding**: when two strong rules match (e.g., email AND phone), the result is ~95+ rather than max(50, 50). Negative evidence subtracts.
|
|
||||||
|
|
||||||
| Rule | Score | Notes |
|
|
||||||
| --------------------------------------------------------------- | ----- | ------------------------------------------------------ |
|
|
||||||
| Exact email match (case-insensitive, normalized) | +60 | One match suffices |
|
|
||||||
| Exact phone E.164 match (≥ 8 significant digits) | +50 | Excludes placeholder all-zeros |
|
|
||||||
| Exact normalized full-name match | +20 | Many "John Smith"s exist |
|
|
||||||
| Surname soundex match + given-name fuzzy match (Lev ≤ 1) | +15 | Catches `Constanzo/Costanzo`, `Christophe/Christopher` |
|
|
||||||
| Same address (normalized fuzzy ≥ 0.8) | +10 | Bonus signal |
|
|
||||||
| **Negative**: Same email but different country code on phone | −15 | Suggests spouse / coworker / shared inbox |
|
|
||||||
| **Negative**: Same name but DIFFERENT email AND DIFFERENT phone | −20 | Two distinct people with the same name |
|
|
||||||
|
|
||||||
### 4.3 Confidence tiers (post-compound)
|
|
||||||
|
|
||||||
- **score ≥ 90 — `high`** — email AND phone match, or email + name + address. Block-create suggest "Use existing." Auto-link on public-form submit by default.
|
|
||||||
- **score 50–89 — `medium`** — single strong signal (email or phone alone), or email + same-name + different country (Etiennette case). Soft-warn but allow.
|
|
||||||
- **score < 50 — `low`** — weak signals only. Don't surface in UI; only relevant in background-job review queue.
|
|
||||||
|
|
||||||
### 4.4 Blocking strategy
|
|
||||||
|
|
||||||
For O(n) scan over a pool of N existing clients, build three lookup maps once per scan:
|
|
||||||
|
|
||||||
- `byEmail: Map<string, MatchCandidate[]>` — keyed by normalized email
|
|
||||||
- `byPhoneE164: Map<string, MatchCandidate[]>` — keyed by E.164
|
|
||||||
- `bySurnameToken: Map<string, MatchCandidate[]>` — keyed by `normalizeName(...).surnameToken`
|
|
||||||
|
|
||||||
For an incoming `MatchCandidate`, the candidate set to compare is the union of pool entries reachable through any of its emails/phones/surname-token. Typically 0–5 candidates per query, regardless of N.
|
|
||||||
|
|
||||||
### 4.5 Performance budget
|
|
||||||
|
|
||||||
For migration: 252 rows compared pairwise once. ~30k comparisons after blocking — a few seconds.
|
|
||||||
|
|
||||||
For runtime at-create: incoming candidate against existing pool of N clients per port. Expected pool size at maturity: 1k–10k. With blocking: <10 comparisons, <1ms target. No DB query needed beyond the initial pool fetch (which itself uses the indexed columns).
|
|
||||||
|
|
||||||
For background nightly job: full pairwise within port, blocked. 10k clients → ~50k pairwise checks per port → <30s. Fine for a nightly cron.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Configurable thresholds (admin settings)
|
|
||||||
|
|
||||||
New rows in `system_settings` per port. Default values err safe (more confirmation, less auto-action).
|
|
||||||
|
|
||||||
| Key | Default | Effect |
|
|
||||||
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `dedup_block_create_threshold` | `90` | Score above which the client-create form interrupts: "Use existing client?" |
|
|
||||||
| `dedup_soft_warn_threshold` | `50` | Score above which a soft-warn panel surfaces below the form |
|
|
||||||
| `dedup_review_queue_threshold` | `40` | Background job lands pairs ≥ this score in `/admin/duplicates` |
|
|
||||||
| `dedup_public_form_auto_link` | `true` | When a public-form submission scores ≥ block-threshold against existing client, attach the new interest to that client without prompting. **Safe**: no merge, just attaching a deal. |
|
|
||||||
| `dedup_auto_merge_threshold` | `null` (disabled) | If non-null, merges happen automatically at this threshold without human confirmation. Recommend leaving null until the team is comfortable; `95` is a reasonable cautious value. |
|
|
||||||
| `dedup_undo_window_days` | `7` | How long the loser's pre-state JSON is retained for merge-undo. After this, the snapshot is purged (GDPR) and merges are permanent. |
|
|
||||||
|
|
||||||
Each setting is a row in `system_settings`. UI surface in `/[portSlug]/admin/dedup` (a new admin page) with an "Advanced" toggle to expose the thresholds and brief explanations.
|
|
||||||
|
|
||||||
If the sales team complains the safer mode is too click-heavy, an admin flips `dedup_auto_merge_threshold` to `95` without any code change.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Merge service contract
|
|
||||||
|
|
||||||
### 6.1 Data flow
|
|
||||||
|
|
||||||
`mergeClients(winnerId, loserId, fieldChoices, ctx)` does, in a single transaction:
|
|
||||||
|
|
||||||
1. **Snapshot loser** — full row + all attached `clientContacts`, `clientAddresses`, `clientNotes`, `clientTags`, plus a count of dependent rows about to be moved (interests, yacht-memberships, etc.). Stored as `mergeDetails` JSONB in `clientMergeLog`.
|
|
||||||
2. **Reattach** — every row pointing at `loserId` updates to point at `winnerId`:
|
|
||||||
- `interests.clientId`
|
|
||||||
- `clientContacts.clientId` — with conflict handling: if winner already has the same email, keep winner's; flag the duplicate for the user
|
|
||||||
- `clientAddresses.clientId` — same conflict handling
|
|
||||||
- `clientNotes.clientId` — preserve `authorId` + `createdAt` (never overwrite)
|
|
||||||
- `clientTags.clientId`
|
|
||||||
- `clientYachtMembership.clientId` (or whatever the table is called)
|
|
||||||
- `auditLogs.entityId` — annotate, don't move (audit truth)
|
|
||||||
3. **Apply fieldChoices** — for each field where the user picked the loser's value, copy that into the winner row.
|
|
||||||
4. **Soft-archive loser** — `loser.archivedAt = now()`, `loser.mergedIntoClientId = winnerId`. Row stays in DB so the merge is reversible.
|
|
||||||
5. **Write `clientMergeLog`** — `{ winnerId, loserId, mergedBy, mergedAt, mergeDetails: <snapshot>, fieldChoices }`.
|
|
||||||
6. **Audit log** — top-level `auditLogs` row: `{ action: 'merge', entityType: 'client', entityId: winnerId, metadata: { loserId, score, reasons } }`.
|
|
||||||
|
|
||||||
### 6.2 Schema additions (migration)
|
|
||||||
|
|
||||||
`clients` table gets a new column:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
mergedIntoClientId: text('merged_into_client_id').references(() => clients.id),
|
|
||||||
```
|
|
||||||
|
|
||||||
The existing `clientMergeLog` table is reused. Add a partial index for the undo-window query:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE INDEX idx_cml_recent ON client_merge_log (port_id, created_at DESC) WHERE created_at > NOW() - INTERVAL '7 days';
|
|
||||||
```
|
|
||||||
|
|
||||||
A daily maintenance job (using the existing `maintenance-cleanup.test.ts` infrastructure) purges `mergeDetails` JSONB older than `dedup_undo_window_days` setting.
|
|
||||||
|
|
||||||
### 6.3 Undo
|
|
||||||
|
|
||||||
`unmergeClients(mergeLogId, ctx)`:
|
|
||||||
|
|
||||||
1. Within the undo window, look up the snapshot
|
|
||||||
2. Restore loser: clear `archivedAt`, `mergedIntoClientId`
|
|
||||||
3. Restore loser's contacts/addresses/notes/tags from snapshot
|
|
||||||
4. Detach reattached rows: `interests` etc. that were touching `winnerId` and originally belonged to loser go back. The snapshot stores the original `(rowType, rowId)` list explicitly so this is deterministic.
|
|
||||||
5. Mark log row `undoneAt = now()`, `undoneBy = userId`
|
|
||||||
|
|
||||||
After 7 days the snapshot is gone and unmerge returns `410 Gone`.
|
|
||||||
|
|
||||||
### 6.4 Concurrency
|
|
||||||
|
|
||||||
Both merge and unmerge wrap in a single transaction with `SELECT … FOR UPDATE` on `clients.id` of both winner and loser. A second merge attempt against the same loser sees `mergedIntoClientId` already set and refuses (clear error: "Already merged into …").
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Runtime surfaces
|
|
||||||
|
|
||||||
### 7.1 Layer 1 — At-create suggestion
|
|
||||||
|
|
||||||
In `ClientForm` (and the public `register` form once that hits the new system):
|
|
||||||
|
|
||||||
- Debounced 300ms after email or phone field changes
|
|
||||||
- Calls `findClientMatches` against current port's clients
|
|
||||||
- Renders top-1 match if score ≥ `dedup_soft_warn_threshold`:
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ This looks like an existing client │
|
|
||||||
│ ML Marcus Laurent │
|
|
||||||
│ marcus@… +33 6 12 34 56 78 │
|
|
||||||
│ 2 interests · last 9d ago │
|
|
||||||
│ [ Use this client ] [ Create new ] │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
- "Use this client" → form switches to "create new interest under existing client" mode (preserves whatever other fields the user typed)
|
|
||||||
- "Create new" → audit-log `dedup_override` with the candidate's id and reasons (so we have data on false positives)
|
|
||||||
|
|
||||||
### 7.2 Layer 2 — Interest-level same-berth guard
|
|
||||||
|
|
||||||
Cheap one-liner in `createInterest` service:
|
|
||||||
|
|
||||||
- Check `(clientId, berthId)` against existing non-archived interests
|
|
||||||
- If hit, throw `BerthDuplicateError` with the existing interest details
|
|
||||||
- UI catches and prompts: "Update existing or create separate?"
|
|
||||||
|
|
||||||
This is NOT the same as client-level dedup. Same client legitimately can pursue the same berth a second time after it falls through. But the prompt-before-create catches the accidental double-submit case.
|
|
||||||
|
|
||||||
### 7.3 Layer 3 — Background scoring + review queue
|
|
||||||
|
|
||||||
- A nightly cron (using existing BullMQ infrastructure — search for `scheduled-tasks` in repo) runs `findClientMatches` over each port's full client pool
|
|
||||||
- Pairs scoring ≥ `dedup_review_queue_threshold` land in a `client_merge_candidates` table:
|
|
||||||
```ts
|
|
||||||
export const clientMergeCandidates = pgTable('client_merge_candidates', {
|
|
||||||
id: text('id').primaryKey()...,
|
|
||||||
portId: text('port_id').notNull()...,
|
|
||||||
clientAId: text('client_a_id').notNull()...,
|
|
||||||
clientBId: text('client_b_id').notNull()...,
|
|
||||||
score: integer('score').notNull(),
|
|
||||||
reasons: jsonb('reasons').notNull(),
|
|
||||||
status: text('status').notNull().default('pending'), // pending | dismissed | merged
|
|
||||||
createdAt: timestamp('created_at')...,
|
|
||||||
resolvedAt: timestamp('resolved_at'),
|
|
||||||
resolvedBy: text('resolved_by'),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
- `/[portSlug]/admin/duplicates` lists pending candidates sorted by score desc, with `[Review →]` opening a side-by-side merge dialog
|
|
||||||
- Dismissing a candidate marks it `status=dismissed` so the job doesn't re-surface the same pair tomorrow (a future score increase re-creates it).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. NocoDB → new system field mapping
|
|
||||||
|
|
||||||
This is the explicit mapping the migration script applies. One NocoDB Interest row produces multiple new rows.
|
|
||||||
|
|
||||||
### 8.1 Top-level transform
|
|
||||||
|
|
||||||
```
|
|
||||||
NocoDB Interests row
|
|
||||||
─→ 0–1 client (deduped against existing pool)
|
|
||||||
─→ 0–1 client_address
|
|
||||||
─→ 0–2 client_contacts (email, phone)
|
|
||||||
─→ exactly 1 interest
|
|
||||||
─→ 0–1 yacht (when Yacht Name present and not "TBC"/"Na"/empty placeholders)
|
|
||||||
─→ 0–1 document (when documensoID present)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 Field map
|
|
||||||
|
|
||||||
| NocoDB field | Target | Transform |
|
|
||||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
|
||||||
| `Full Name` | `clients.fullName` | `normalizeName().display` |
|
|
||||||
| `Email Address` | `clientContacts(channel='email', value=...)` | `normalizeEmail()` |
|
|
||||||
| `Phone Number` | `clientContacts(channel='phone', valueE164=..., valueCountry=...)` | `normalizePhone(raw, defaultCountry)` |
|
|
||||||
| `Address` | `clientAddresses.streetAddress` (LongText preserved) | trim |
|
|
||||||
| `Place of Residence` | `clientAddresses.countryIso` AND `clients.nationalityIso` | `resolveCountry()` |
|
|
||||||
| `Contact Method Preferred` | `clients.preferredContactMethod` | lowercase, mapped: Email→email, Phone→phone |
|
|
||||||
| `Source` | `clients.source` | mapped: portal→website, Form→website, External→manual; null → manual |
|
|
||||||
| `Date Added` | `interests.createdAt` (fallback to NocoDB `Created At` then now) | parse: try `DD-MM-YYYY`, then `YYYY-MM-DD`, then ISO |
|
|
||||||
| `Sales Process Level` | `interests.pipelineStage` | see §8.3 |
|
|
||||||
| `Lead Category` | `interests.leadCategory` | General→general_interest, Friends and Family→general_interest with tag |
|
|
||||||
| `Berth` (FK) | `interests.berthId` | resolve via `Berths` table by `Mooring Number` |
|
|
||||||
| `Berth Size Desired` | `interests.notes` (appended) | preserve |
|
|
||||||
| `Yacht Name`, `Length`, `Width`, `Depth` | `yachts.name`, `lengthM`, `widthM`, `draughtM` | skip if name in {`TBC`, `Na`, ``, null}; ft→m via `\* 0.3048` |
|
|
||||||
| `EOI Status` | `interests.eoiStatus` | Awaiting Further Details→pending; Waiting for Signatures→sent; Signed→signed |
|
|
||||||
| `Deposit 10% Status` | `interests.depositStatus` | Pending→pending; Received→received |
|
|
||||||
| `Contract Status` | `interests.contractStatus` | Pending→pending; 40% Received→partial; Complete→complete |
|
|
||||||
| `EOI Time Sent` | `interests.dateEoiSent` | parse |
|
|
||||||
| `clientSignTime` / `developerSignTime` / `all_signed_notified_at` | `interests.dateEoiSigned` (use latest) | parse |
|
|
||||||
| `Time LOI Sent` | `interests.dateContractSent` | parse |
|
|
||||||
| `Internal Notes` + `Extra Comments` | `clientNotes` (one row, system author) | concatenate with section markers |
|
|
||||||
| `documensoID` | `documents.documensoId` (when present, type='eoi') | preserve |
|
|
||||||
| `Signature Link Client/CC/Developer`, `EmbeddedSignature*` | `documents.signers[]` | one row per non-null signer |
|
|
||||||
| `reminder_enabled`, `last_reminder_sent`, etc. | `interests.reminderEnabled`, `interests.reminderLastFired` | parse, default true |
|
|
||||||
|
|
||||||
### 8.3 Sales-stage mapping (8 → 9)
|
|
||||||
|
|
||||||
| NocoDB | New (PIPELINE_STAGES) |
|
|
||||||
| ------------------------------- | ------------------------------------------------------------------------ |
|
|
||||||
| General Qualified Interest | `open` |
|
|
||||||
| Specific Qualified Interest | `details_sent` |
|
|
||||||
| EOI and NDA Sent | `eoi_sent` |
|
|
||||||
| Signed EOI and NDA | `eoi_signed` |
|
|
||||||
| Made Reservation | `deposit_10pct` |
|
|
||||||
| Contract Negotiation | `contract_sent` |
|
|
||||||
| Contract Negotiations Finalized | `contract_sent` (with audit-note: legacy "negotiations finalized") |
|
|
||||||
| Contract Signed | `contract_signed` (or `completed` when deposit + contract both complete) |
|
|
||||||
|
|
||||||
### 8.4 Other tables
|
|
||||||
|
|
||||||
- **Residential Interests** (35 rows) — same shape as Interests but maps to `residentialClients` + `residentialInterests`. Smaller and cleaner. Same dedup runs within this pool independently.
|
|
||||||
- **Website - Interest Submissions** (64 rows) — these are **inbound capture, not yet a client**. Treat as if each row is a fresh public-form submission today: run dedup against the migrated client pool. Auto-link if `dedup_public_form_auto_link` setting allows.
|
|
||||||
- **Website - Contact Form Submissions** (47 rows) — sparse data (just name + email + interest type). Skip migration; export as CSV for manual triage. Not the source of truth for any deal.
|
|
||||||
- **Website - Berth EOI Details Supplements** (1 row) — single record, preserved as a one-off attached to the matching Interest.
|
|
||||||
- **Newsletter Sending** (69 rows) — out of scope; that's a marketing surface, not CRM.
|
|
||||||
- **Interests Backup, Interests copy** — historical artifacts. Skipped by default. A `--include-backups` flag attaches them as audit-note entries on the corresponding live Interest if the user wants the history.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Migration script
|
|
||||||
|
|
||||||
Located at `scripts/migrate-from-nocodb.ts`. Idempotent: safe to re-run. Three main flags:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug X]
|
|
||||||
Pulls everything, transforms, runs dedup, writes CSV report to .migration/<timestamp>/. No DB writes.
|
|
||||||
|
|
||||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<timestamp>/
|
|
||||||
Reads the report, performs the writes the dry-run promised. Refuses if the source data has changed since the report was generated (hash mismatch).
|
|
||||||
|
|
||||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --rollback --apply-id <id>
|
|
||||||
Reads the apply log, undoes the writes (only valid within the undo window).
|
|
||||||
```
|
|
||||||
|
|
||||||
Reuses the `client-portal/server/utils/nocodb.ts` adapter for the NocoDB API client (no need to rebuild). Writes to the new system via Drizzle (re-using the existing services like `createClient`, `createInterest`, etc., so all the same validation runs).
|
|
||||||
|
|
||||||
### 9.1 Dry-run report format
|
|
||||||
|
|
||||||
`.migration/<timestamp>/report.csv`:
|
|
||||||
|
|
||||||
```csv
|
|
||||||
op,reason,nocodb_row_id,target_table,target_value,confidence,manual_review_required
|
|
||||||
create_client,new,624,clients.fullName,Deepak Ramchandani,N/A,false
|
|
||||||
create_contact,new,624,clientContacts.email,dannyrams8888@gmail.com,N/A,false
|
|
||||||
create_contact,new,624,clientContacts.phone,+17215868888,N/A,false
|
|
||||||
create_interest,new,624,interests.berthId,a1b2c3...,N/A,false
|
|
||||||
auto_link,score=98 (email+phone),625,clients.id,<existing client UUID from row 624>,high,false
|
|
||||||
flag_for_review,score=72 (same name diff country),188,client.id,<existing client UUID from row 717>,medium,true
|
|
||||||
country_unresolved,fallback to AI (port country),198,clientAddresses.countryIso,AI,low,true
|
|
||||||
phone_unparseable,placeholder all-zeros,641,clientContacts.phone,<skipped>,N/A,true
|
|
||||||
```
|
|
||||||
|
|
||||||
Plus `.migration/<timestamp>/summary.md`:
|
|
||||||
|
|
||||||
```
|
|
||||||
# Migration Dry-Run — 2026-05-03 14:23 UTC
|
|
||||||
|
|
||||||
NocoDB: 252 Interests + 35 Residences + 64 Website Submissions
|
|
||||||
Outcome: 198 clients, 287 interests (incl. residences), 91 yachts, 412 contacts
|
|
||||||
|
|
||||||
Auto-linked (high confidence, no human action needed):
|
|
||||||
- Nicolas Ruiz: rows 681,682,683 → 1 client + 3 interests
|
|
||||||
- John Lynch: rows 716,725 → 1 client + 2 interests
|
|
||||||
- Deepak Ramchandani: rows 624,625 → 1 client + 2 interests
|
|
||||||
- [12 more]
|
|
||||||
|
|
||||||
Flagged for manual review (medium confidence):
|
|
||||||
- Etiennette Clamouze (rows 188,717): same name, different country phone + email
|
|
||||||
- Bruno Joyerot #18 + Bruce Hearn #19: shared household contact
|
|
||||||
- [4 more]
|
|
||||||
|
|
||||||
Country resolution failed for 7 rows. All defaulted to port country (AI). Review:
|
|
||||||
- Row 239: "Sag Harbor Y" → AI (likely US)
|
|
||||||
- [6 more]
|
|
||||||
|
|
||||||
Phone parsing failed for 3 rows. All flagged, no contact created:
|
|
||||||
- Row 178: empty
|
|
||||||
- Row 641: placeholder "+447000000000"
|
|
||||||
- Row 175: empty
|
|
||||||
|
|
||||||
Run `--apply` to commit these changes.
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.2 Apply phase
|
|
||||||
|
|
||||||
`--apply` reads the report, re-fetches the source rows (via NocoDB MCP / API), recomputes the hash, fails fast if NocoDB changed since dry-run. Then performs the writes within a single PostgreSQL transaction per port (commit at end). On any error mid-transaction, full rollback.
|
|
||||||
|
|
||||||
After successful apply, an `apply_id` is generated and an audit-log row written. The `apply_id` is the handle used for `--rollback`.
|
|
||||||
|
|
||||||
### 9.3 Idempotency
|
|
||||||
|
|
||||||
The script tracks NocoDB row IDs in a `migration_source_links` table:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const migrationSourceLinks = pgTable('migration_source_links', {
|
|
||||||
id: text('id').primaryKey()...,
|
|
||||||
sourceSystem: text('source_system').notNull(), // 'nocodb_interests' | 'nocodb_residences' | …
|
|
||||||
sourceId: text('source_id').notNull(), // NocoDB row id as string
|
|
||||||
targetEntityType: text('target_entity_type').notNull(), // client | interest | yacht | …
|
|
||||||
targetEntityId: text('target_entity_id').notNull(),
|
|
||||||
appliedAt: timestamp('applied_at')...,
|
|
||||||
appliedBy: text('applied_by'),
|
|
||||||
}, (table) => [
|
|
||||||
uniqueIndex('idx_msl_source').on(table.sourceSystem, table.sourceId, table.targetEntityType),
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-running `--apply` against the same report skips rows already in this table. Useful for partial-failure resumption.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Test plan
|
|
||||||
|
|
||||||
### 10.1 Library-level (vitest unit)
|
|
||||||
|
|
||||||
- `tests/unit/dedup/normalize.test.ts` — every dirty-data pattern from §1.3 has a fixture asserting the expected normalized output.
|
|
||||||
- `tests/unit/dedup/find-matches.test.ts` — every duplicate cluster from §1.2 has a fixture asserting score + confidence tier. Hard cases (Pattern F) assert "medium" not "high" — false-positive guard.
|
|
||||||
|
|
||||||
### 10.2 Service-level (vitest integration)
|
|
||||||
|
|
||||||
- `tests/integration/dedup/client-merge.test.ts` — merge service exercised: full reattach, clientMergeLog written, undo within window restores, undo after window returns 410, concurrent merge of same loser fails the second.
|
|
||||||
- `tests/integration/dedup/at-create-suggestion.test.ts` — `findClientMatches` against a seeded pool returns expected matches + reasons.
|
|
||||||
|
|
||||||
### 10.3 Migration script (vitest integration with NocoDB mock)
|
|
||||||
|
|
||||||
- `tests/integration/dedup/migration-dry-run.test.ts` — feed the script a fixture NocoDB dump (the 252 rows, frozen as a JSON snapshot in fixtures), assert the resulting CSV matches a golden file. Catch any future regression in the transform pipeline.
|
|
||||||
- `tests/integration/dedup/migration-apply.test.ts` — apply the dry-run output to a clean test DB, assert all expected rows exist, assert idempotency (re-apply is a no-op).
|
|
||||||
|
|
||||||
### 10.4 E2E (Playwright)
|
|
||||||
|
|
||||||
- `tests/e2e/smoke/30-dedup-create.spec.ts` — type into ClientForm with an email matching seeded client; assert suggestion card appears; click "Use this client"; assert form switches to interest-create mode.
|
|
||||||
- `tests/e2e/smoke/31-admin-duplicates.spec.ts` — admin views review queue, opens a candidate, side-by-side merge UI works, merge succeeds, undo within window works.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Rollback plan
|
|
||||||
|
|
||||||
Three layers of safety, ordered by reversibility:
|
|
||||||
|
|
||||||
1. **Per-merge undo** — admin clicks Undo on a wrongly-merged pair, system rolls back from `clientMergeLog` snapshot. 7-day window. No engineering needed.
|
|
||||||
2. **Migration `--rollback` flag** — entire migration apply is reversed via the `apply_id` and `migration_source_links` table. Useful in the first 24h after `--apply`. Engineering-supervised.
|
|
||||||
3. **DB restore from backup** — the existing `docs/ops/backup-runbook.md` covers this. Last resort if both above are blocked.
|
|
||||||
|
|
||||||
Pre-migration, take a hot backup of the new DB (`pg_dump`). Pre-merge in production (before any human-facing surface ships), the `dedup_auto_merge_threshold` defaults to `null` so no automatic merges happen — every merge is human-confirmed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Open items
|
|
||||||
|
|
||||||
- **Soundex vs metaphone** — Soundex is simpler but English-leaning. Metaphone handles non-English surnames better (the dataset has French, German, Italian, Slavic names). Default to metaphone via the `natural` package; revisit if it adds significant install size.
|
|
||||||
- **Cross-port dedup** — not in scope. Each port's clients are deduped within that port. A future "shared address book" feature would need its own design.
|
|
||||||
- **Profile photo / face match** — out of scope.
|
|
||||||
- **AI-assisted match resolution** — out of scope. The Layer-3 review queue is human-only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation sequence
|
|
||||||
|
|
||||||
P1 (this design's library) → P2 (runtime surfaces) → P3 (migration). Each is a separate plan / PR.
|
|
||||||
|
|
||||||
**P1 deliverables**: `src/lib/dedup/{normalize,find-matches}.ts` + tests. No UI changes. No DB changes (except indexed lookups added to existing `clientContacts`). ~1.5 days.
|
|
||||||
|
|
||||||
**P2 deliverables**: at-create suggestion in `ClientForm` + interest-level guard in `createInterest` service + admin settings UI for thresholds + `clientMergeCandidates` table + nightly job + admin review queue page + merge service + side-by-side merge UI. ~5–7 days.
|
|
||||||
|
|
||||||
**P3 deliverables**: `scripts/migrate-from-nocodb.ts` + `migration_source_links` table + dry-run + apply + rollback. CSV report format frozen against fixture. ~3 days, including fixture creation from the live NocoDB snapshot.
|
|
||||||
|
|
||||||
Total: ~10–12 engineering days from approval. Can be split across three PRs landing independently — each is testable in isolation and the runtime surfaces (P2) work even without P3 being run.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { Suspense, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -44,7 +44,7 @@ const requirements: Requirement[] = [
|
|||||||
{ label: 'Special character', test: (v) => /[^A-Za-z0-9]/.test(v) },
|
{ label: 'Special character', test: (v) => /[^A-Za-z0-9]/.test(v) },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SetPasswordPage() {
|
function SetPasswordInner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
@@ -154,8 +154,7 @@ export default function SetPasswordPage() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={cn(
|
className={cn(
|
||||||
errors.confirmPassword &&
|
errors.confirmPassword && 'border-destructive focus-visible:ring-destructive',
|
||||||
'border-destructive focus-visible:ring-destructive',
|
|
||||||
)}
|
)}
|
||||||
{...register('confirmPassword')}
|
{...register('confirmPassword')}
|
||||||
/>
|
/>
|
||||||
@@ -174,3 +173,18 @@ export default function SetPasswordPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function SetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex items-center justify-center px-4"
|
||||||
|
style={{ backgroundColor: '#1e2844' }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SetPasswordInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { CompanyDetail } from '@/components/companies/company-detail';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
interface CompanyDetailPageProps {
|
||||||
|
params: Promise<{ companyId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CompanyDetailPage({ params }: CompanyDetailPageProps) {
|
||||||
|
const { companyId } = await params;
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const currentUserId = session?.user?.id;
|
||||||
|
|
||||||
|
return <CompanyDetail companyId={companyId} currentUserId={currentUserId} />;
|
||||||
|
}
|
||||||
5
src/app/(dashboard)/[portSlug]/companies/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/companies/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CompanyList } from '@/components/companies/company-list';
|
||||||
|
|
||||||
|
export default function CompaniesPage() {
|
||||||
|
return <CompanyList />;
|
||||||
|
}
|
||||||
@@ -55,7 +55,13 @@ export default function NewInvoicePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = methods;
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = methods;
|
||||||
|
|
||||||
const watchedValues = watch();
|
const watchedValues = watch();
|
||||||
const lineItems = watchedValues.lineItems ?? [];
|
const lineItems = watchedValues.lineItems ?? [];
|
||||||
@@ -87,7 +93,7 @@ export default function NewInvoicePage() {
|
|||||||
async function goNext() {
|
async function goNext() {
|
||||||
if (step === 1) {
|
if (step === 1) {
|
||||||
const valid = await methods.trigger([
|
const valid = await methods.trigger([
|
||||||
'clientName',
|
'billingEntity',
|
||||||
'billingEmail',
|
'billingEmail',
|
||||||
'billingAddress',
|
'billingAddress',
|
||||||
'dueDate',
|
'dueDate',
|
||||||
@@ -112,11 +118,7 @@ export default function NewInvoicePage() {
|
|||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => router.push(`/${portSlug}/invoices`)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-semibold">New Invoice</h1>
|
<h1 className="text-xl font-semibold">New Invoice</h1>
|
||||||
@@ -131,22 +133,16 @@ export default function NewInvoicePage() {
|
|||||||
step > s.id
|
step > s.id
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: step === s.id
|
: step === s.id
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'bg-muted text-muted-foreground'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id}
|
{step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className={`text-sm ${step === s.id ? 'font-medium' : 'text-muted-foreground'}`}>
|
||||||
className={`text-sm ${
|
|
||||||
step === s.id ? 'font-medium' : 'text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{s.label}
|
{s.label}
|
||||||
</span>
|
</span>
|
||||||
{idx < STEPS.length - 1 && (
|
{idx < STEPS.length - 1 && <div className="w-8 h-px bg-border mx-1" />}
|
||||||
<div className="w-8 h-px bg-border mx-1" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,17 +157,36 @@ export default function NewInvoicePage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="clientName">
|
<Label htmlFor="billingEntityType">
|
||||||
Client Name <span className="text-destructive">*</span>
|
Billing Entity <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="grid grid-cols-2 gap-2">
|
||||||
id="clientName"
|
<Select
|
||||||
{...register('clientName')}
|
defaultValue="client"
|
||||||
placeholder="Client or company name"
|
onValueChange={(v) =>
|
||||||
/>
|
setValue('billingEntity.type', v as 'client' | 'company')
|
||||||
{errors.clientName && (
|
}
|
||||||
<p className="text-xs text-destructive">{errors.clientName.message}</p>
|
>
|
||||||
|
<SelectTrigger id="billingEntityType">
|
||||||
|
<SelectValue placeholder="Type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="client">Client</SelectItem>
|
||||||
|
<SelectItem value="company">Company</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input {...register('billingEntity.id')} placeholder="Entity ID" />
|
||||||
|
</div>
|
||||||
|
{errors.billingEntity && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.billingEntity.message ??
|
||||||
|
errors.billingEntity.id?.message ??
|
||||||
|
errors.billingEntity.type?.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Picker UI is coming in Task 10.2 — for now paste the client or company ID.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -202,11 +217,7 @@ export default function NewInvoicePage() {
|
|||||||
<Label htmlFor="dueDate">
|
<Label htmlFor="dueDate">
|
||||||
Due Date <span className="text-destructive">*</span>
|
Due Date <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input id="dueDate" type="date" {...register('dueDate')} />
|
||||||
id="dueDate"
|
|
||||||
type="date"
|
|
||||||
{...register('dueDate')}
|
|
||||||
/>
|
|
||||||
{errors.dueDate && (
|
{errors.dueDate && (
|
||||||
<p className="text-xs text-destructive">{errors.dueDate.message}</p>
|
<p className="text-xs text-destructive">{errors.dueDate.message}</p>
|
||||||
)}
|
)}
|
||||||
@@ -216,7 +227,9 @@ export default function NewInvoicePage() {
|
|||||||
<Label>Payment Terms</Label>
|
<Label>Payment Terms</Label>
|
||||||
<Select
|
<Select
|
||||||
defaultValue="net30"
|
defaultValue="net30"
|
||||||
onValueChange={(v) => setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])}
|
onValueChange={(v) =>
|
||||||
|
setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select terms" />
|
<SelectValue placeholder="Select terms" />
|
||||||
@@ -284,8 +297,10 @@ export default function NewInvoicePage() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Client</span>
|
<span className="text-muted-foreground">Billing Entity</span>
|
||||||
<p className="font-medium mt-0.5">{watchedValues.clientName}</p>
|
<p className="font-medium mt-0.5">
|
||||||
|
{watchedValues.billingEntity?.type}: {watchedValues.billingEntity?.id}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Due Date</span>
|
<span className="text-muted-foreground">Due Date</span>
|
||||||
@@ -293,9 +308,7 @@ export default function NewInvoicePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Payment Terms</span>
|
<span className="text-muted-foreground">Payment Terms</span>
|
||||||
<p className="font-medium mt-0.5 capitalize">
|
<p className="font-medium mt-0.5 capitalize">{watchedValues.paymentTerms}</p>
|
||||||
{watchedValues.paymentTerms}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Currency</span>
|
<span className="text-muted-foreground">Currency</span>
|
||||||
@@ -354,12 +367,7 @@ export default function NewInvoicePage() {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={goBack} disabled={step === 1}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={goBack}
|
|
||||||
disabled={step === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="mr-1.5 h-4 w-4" />
|
<ChevronLeft className="mr-1.5 h-4 w-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
16
src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { YachtDetail } from '@/components/yachts/yacht-detail';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
interface YachtDetailPageProps {
|
||||||
|
params: Promise<{ yachtId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function YachtDetailPage({ params }: YachtDetailPageProps) {
|
||||||
|
const { yachtId } = await params;
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const currentUserId = session?.user?.id;
|
||||||
|
|
||||||
|
return <YachtDetail yachtId={yachtId} currentUserId={currentUserId} />;
|
||||||
|
}
|
||||||
5
src/app/(dashboard)/[portSlug]/yachts/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/yachts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { YachtList } from '@/components/yachts/yacht-list';
|
||||||
|
|
||||||
|
export default function YachtsPage() {
|
||||||
|
return <YachtList />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { Anchor, FileText, Receipt } from 'lucide-react';
|
import { Anchor, FileText, Receipt, Sailboat, Building2, CalendarCheck } from 'lucide-react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { getPortalSession } from '@/lib/portal/auth';
|
import { getPortalSession } from '@/lib/portal/auth';
|
||||||
@@ -21,15 +21,12 @@ export default async function PortalDashboardPage() {
|
|||||||
<h1 className="text-2xl font-semibold text-gray-900">
|
<h1 className="text-2xl font-semibold text-gray-900">
|
||||||
Welcome back, {dashboard.client.fullName.split(' ')[0]}
|
Welcome back, {dashboard.client.fullName.split(' ')[0]}
|
||||||
</h1>
|
</h1>
|
||||||
{dashboard.client.companyName && (
|
{dashboard.client.nationality && (
|
||||||
<p className="text-gray-500 mt-0.5">{dashboard.client.companyName}</p>
|
<p className="text-sm text-gray-400 mt-0.5">{dashboard.client.nationality}</p>
|
||||||
)}
|
|
||||||
{dashboard.client.yachtName && (
|
|
||||||
<p className="text-sm text-gray-400 mt-0.5">Vessel: {dashboard.client.yachtName}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<PortalCard
|
<PortalCard
|
||||||
title="Berth Interests"
|
title="Berth Interests"
|
||||||
value={dashboard.counts.interests}
|
value={dashboard.counts.interests}
|
||||||
@@ -51,13 +48,33 @@ export default async function PortalDashboardPage() {
|
|||||||
icon={Receipt}
|
icon={Receipt}
|
||||||
href="/portal/invoices"
|
href="/portal/invoices"
|
||||||
/>
|
/>
|
||||||
|
<PortalCard
|
||||||
|
title="My Yachts"
|
||||||
|
value={dashboard.counts.yachts}
|
||||||
|
description="Vessels you own directly or through a company"
|
||||||
|
icon={Sailboat}
|
||||||
|
href="/portal/my-yachts"
|
||||||
|
/>
|
||||||
|
<PortalCard
|
||||||
|
title="My Memberships"
|
||||||
|
value={dashboard.counts.memberships}
|
||||||
|
description="Companies where you hold an active role"
|
||||||
|
icon={Building2}
|
||||||
|
/>
|
||||||
|
<PortalCard
|
||||||
|
title="My Active Reservations"
|
||||||
|
value={dashboard.counts.activeReservations}
|
||||||
|
description="Current and pending berth reservations"
|
||||||
|
icon={CalendarCheck}
|
||||||
|
href="/portal/my-reservations"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border p-6">
|
<div className="bg-white rounded-lg border p-6">
|
||||||
<h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2>
|
<h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Contact the {dashboard.port.name} team directly. This portal provides a read-only view
|
Contact the {dashboard.port.name} team directly. This portal provides a read-only view of
|
||||||
of your account. All changes must be made through your port contact.
|
your account. All changes must be made through your port contact.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
83
src/app/(portal)/portal/my-reservations/page.tsx
Normal file
83
src/app/(portal)/portal/my-reservations/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { CalendarCheck } from 'lucide-react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { getPortalSession } from '@/lib/portal/auth';
|
||||||
|
import { getPortalUserReservations } from '@/lib/services/portal.service';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'My Reservations' };
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
|
pending: 'secondary',
|
||||||
|
active: 'default',
|
||||||
|
ended: 'outline',
|
||||||
|
cancelled: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TENURE_LABELS: Record<string, string> = {
|
||||||
|
permanent: 'Permanent',
|
||||||
|
fixed_term: 'Fixed term',
|
||||||
|
seasonal: 'Seasonal',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(d: Date | string): string {
|
||||||
|
return new Date(d).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PortalMyReservationsPage() {
|
||||||
|
const session = await getPortalSession();
|
||||||
|
if (!session) redirect('/portal/login');
|
||||||
|
|
||||||
|
const reservations = await getPortalUserReservations(session.clientId, session.portId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">My Reservations</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Your current and pending berth reservations</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reservations.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg border p-12 text-center">
|
||||||
|
<CalendarCheck className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500 font-medium">No active reservations</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Contact your port representative to discuss reservations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{reservations.map((r) => (
|
||||||
|
<div key={r.id} className="bg-white rounded-lg border p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
|
||||||
|
{r.berthMooringNumber && (
|
||||||
|
<span className="text-sm text-gray-400">— Berth {r.berthMooringNumber}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{TENURE_LABELS[r.tenureType] ?? r.tenureType}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-400">
|
||||||
|
<span>
|
||||||
|
From {formatDate(r.startDate)}
|
||||||
|
{r.endDate ? ` to ${formatDate(r.endDate)}` : ' · ongoing'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={STATUS_COLORS[r.status] ?? 'default'}>{r.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/app/(portal)/portal/my-yachts/page.tsx
Normal file
77
src/app/(portal)/portal/my-yachts/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { Sailboat } from 'lucide-react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { getPortalSession } from '@/lib/portal/auth';
|
||||||
|
import { getPortalUserYachts } from '@/lib/services/portal.service';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'My Yachts' };
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
|
active: 'default',
|
||||||
|
retired: 'secondary',
|
||||||
|
sold_away: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PortalMyYachtsPage() {
|
||||||
|
const session = await getPortalSession();
|
||||||
|
if (!session) redirect('/portal/login');
|
||||||
|
|
||||||
|
const yachts = await getPortalUserYachts(session.clientId, session.portId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">My Yachts</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Vessels you own directly or through a company</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{yachts.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg border p-12 text-center">
|
||||||
|
<Sailboat className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500 font-medium">No yachts on file</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Yachts owned by you or a company you are a member of will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{yachts.map((y) => (
|
||||||
|
<div key={y.id} className="bg-white rounded-lg border p-5">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Sailboat className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-gray-900 truncate">{y.name}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
{y.hullNumber ? `Hull ${y.hullNumber}` : 'No hull number'}
|
||||||
|
{y.flag ? ` · ${y.flag}` : ''}
|
||||||
|
{y.yearBuilt ? ` · ${y.yearBuilt}` : ''}
|
||||||
|
</p>
|
||||||
|
{y.ownerContext === 'company' && y.ownerCompanyName && (
|
||||||
|
<p className="text-xs text-[#1e2844] mt-2">Owned by {y.ownerCompanyName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge variant={STATUS_COLORS[y.status] ?? 'default'}>
|
||||||
|
{y.status.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(y.lengthFt || y.widthFt || y.registration) && (
|
||||||
|
<div className="flex flex-wrap gap-3 mt-3 text-xs text-gray-400">
|
||||||
|
{y.registration && <span>Reg: {y.registration}</span>}
|
||||||
|
{y.lengthFt && <span>Length: {y.lengthFt}ft</span>}
|
||||||
|
{y.widthFt && <span>Beam: {y.widthFt}ft</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { Suspense, useEffect, useRef } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function PortalVerifyPage() {
|
function PortalVerifyInner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const calledRef = useRef(false);
|
const calledRef = useRef(false);
|
||||||
@@ -33,3 +33,17 @@ export default function PortalVerifyPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function PortalVerifyPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-[#1e2844]" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PortalVerifyInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { withTransaction } from '@/lib/db/utils';
|
||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
|
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
|
||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
|
||||||
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
import { errorResponse, RateLimitError } from '@/lib/errors';
|
import { errorResponse, RateLimitError } from '@/lib/errors';
|
||||||
import { publicInterestSchema } from '@/lib/validators/interests';
|
import { publicInterestSchema } from '@/lib/validators/interests';
|
||||||
@@ -35,7 +39,14 @@ function checkRateLimit(ip: string): void {
|
|||||||
entry.count += 1;
|
entry.count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/public/interests — unauthenticated public interest registration
|
type PublicInterestData = z.infer<typeof publicInterestSchema>;
|
||||||
|
// `withTransaction` exposes its tx argument as `typeof db` (see lib/db/utils.ts).
|
||||||
|
// Keep the helper aligned with that.
|
||||||
|
type Tx = typeof db;
|
||||||
|
|
||||||
|
// POST /api/public/interests — unauthenticated public interest registration.
|
||||||
|
// Creates the trio (client + yacht + interest) plus an optional company +
|
||||||
|
// membership, all inside a single transaction.
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||||
@@ -50,7 +61,6 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
|
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the full name
|
|
||||||
const fullName =
|
const fullName =
|
||||||
data.firstName && data.lastName
|
data.firstName && data.lastName
|
||||||
? `${data.firstName} ${data.lastName}`
|
? `${data.firstName} ${data.lastName}`
|
||||||
@@ -58,10 +68,10 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
||||||
|
|
||||||
// Resolve berth by mooring number (if provided)
|
// Resolve berth by mooring number (if provided). Read-only lookup — safe
|
||||||
|
// to do outside the transaction.
|
||||||
let berthId: string | null = null;
|
let berthId: string | null = null;
|
||||||
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
||||||
|
|
||||||
if (data.mooringNumber) {
|
if (data.mooringNumber) {
|
||||||
const berth = await db.query.berths.findFirst({
|
const berth = await db.query.berths.findFirst({
|
||||||
where: and(eq(berths.mooringNumber, data.mooringNumber), eq(berths.portId, portId)),
|
where: and(eq(berths.mooringNumber, data.mooringNumber), eq(berths.portId, portId)),
|
||||||
@@ -72,74 +82,172 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create client by email
|
// ─── Transactional trio creation ────────────────────────────────────────
|
||||||
let clientId: string;
|
const result = await withTransaction(async (tx) => {
|
||||||
|
// 1. Find or create client by email (case-sensitive contact match, same
|
||||||
const existingContact = await db.query.clientContacts.findFirst({
|
// behavior as before the refactor).
|
||||||
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
|
let clientId: string;
|
||||||
});
|
const existingContact = await tx.query.clientContacts.findFirst({
|
||||||
|
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
|
||||||
if (existingContact) {
|
|
||||||
const existingClient = await db.query.clients.findFirst({
|
|
||||||
where: eq(clients.id, existingContact.clientId),
|
|
||||||
});
|
});
|
||||||
if (existingClient && existingClient.portId === portId) {
|
if (existingContact) {
|
||||||
clientId = existingClient.id;
|
const existingClient = await tx.query.clients.findFirst({
|
||||||
// Update preferred contact method if provided
|
where: eq(clients.id, existingContact.clientId),
|
||||||
if (data.preferredContactMethod) {
|
});
|
||||||
await db
|
if (existingClient && existingClient.portId === portId) {
|
||||||
.update(clients)
|
clientId = existingClient.id;
|
||||||
.set({ preferredContactMethod: data.preferredContactMethod })
|
if (data.preferredContactMethod) {
|
||||||
.where(eq(clients.id, clientId));
|
await tx
|
||||||
|
.update(clients)
|
||||||
|
.set({ preferredContactMethod: data.preferredContactMethod })
|
||||||
|
.where(eq(clients.id, clientId));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clientId = await createNewClient(portId, fullName, data);
|
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
clientId = await createNewClient(portId, fullName, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store address if provided
|
// 2. Optional: upsert company + add membership
|
||||||
if (data.address && Object.values(data.address).some(Boolean)) {
|
let companyId: string | null = null;
|
||||||
await db.insert(clientAddresses).values({
|
if (data.company) {
|
||||||
clientId,
|
const existingCompany = await tx.query.companies.findFirst({
|
||||||
portId,
|
where: and(
|
||||||
label: 'Primary',
|
eq(companies.portId, portId),
|
||||||
streetAddress: data.address.street ?? null,
|
sql`lower(${companies.name}) = lower(${data.company.name})`,
|
||||||
city: data.address.city ?? null,
|
),
|
||||||
stateProvince: data.address.stateProvince ?? null,
|
});
|
||||||
postalCode: data.address.postalCode ?? null,
|
if (existingCompany) {
|
||||||
country: data.address.country ?? null,
|
companyId = existingCompany.id;
|
||||||
isPrimary: true,
|
} else {
|
||||||
|
const [newCompany] = await tx
|
||||||
|
.insert(companies)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
name: data.company.name,
|
||||||
|
legalName: data.company.legalName ?? null,
|
||||||
|
taxId: data.company.taxId ?? null,
|
||||||
|
incorporationCountry: data.company.incorporationCountry ?? null,
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
companyId = newCompany!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add active membership only if one doesn't already exist (open row).
|
||||||
|
const existingMembership = await tx.query.companyMemberships.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(companyMemberships.companyId, companyId),
|
||||||
|
eq(companyMemberships.clientId, clientId),
|
||||||
|
isNull(companyMemberships.endDate),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!existingMembership) {
|
||||||
|
await tx.insert(companyMemberships).values({
|
||||||
|
companyId,
|
||||||
|
clientId,
|
||||||
|
role: data.company.role ?? 'representative',
|
||||||
|
startDate: new Date(),
|
||||||
|
isPrimary: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create yacht. Owner is the company when provided, else the client.
|
||||||
|
const ownerType: 'client' | 'company' = companyId ? 'company' : 'client';
|
||||||
|
const ownerId = companyId ?? clientId;
|
||||||
|
const [newYacht] = await tx
|
||||||
|
.insert(yachts)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
name: data.yacht.name,
|
||||||
|
hullNumber: data.yacht.hullNumber ?? null,
|
||||||
|
registration: data.yacht.registration ?? null,
|
||||||
|
flag: data.yacht.flag ?? null,
|
||||||
|
yearBuilt: data.yacht.yearBuilt ?? null,
|
||||||
|
lengthFt: data.yacht.lengthFt != null ? String(data.yacht.lengthFt) : null,
|
||||||
|
widthFt: data.yacht.widthFt != null ? String(data.yacht.widthFt) : null,
|
||||||
|
draftFt: data.yacht.draftFt != null ? String(data.yacht.draftFt) : null,
|
||||||
|
currentOwnerType: ownerType,
|
||||||
|
currentOwnerId: ownerId,
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const yachtId = newYacht!.id;
|
||||||
|
|
||||||
|
// 3a. Open ownership_history row for the new yacht.
|
||||||
|
await tx.insert(yachtOwnershipHistory).values({
|
||||||
|
yachtId,
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
startDate: new Date(),
|
||||||
|
endDate: null,
|
||||||
|
createdBy: 'public-submission',
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Create the interest
|
// 4. Store address if provided AND no primary address exists yet.
|
||||||
const [interest] = await db
|
if (data.address && Object.values(data.address).some(Boolean)) {
|
||||||
.insert(interests)
|
const existingAddr = await tx.query.clientAddresses.findFirst({
|
||||||
.values({
|
where: and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)),
|
||||||
portId,
|
});
|
||||||
|
if (!existingAddr) {
|
||||||
|
await tx.insert(clientAddresses).values({
|
||||||
|
clientId,
|
||||||
|
portId,
|
||||||
|
label: 'Primary',
|
||||||
|
streetAddress: data.address.street ?? null,
|
||||||
|
city: data.address.city ?? null,
|
||||||
|
stateProvince: data.address.stateProvince ?? null,
|
||||||
|
postalCode: data.address.postalCode ?? null,
|
||||||
|
country: data.address.country ?? null,
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Create interest with yachtId wired up.
|
||||||
|
const [newInterest] = await tx
|
||||||
|
.insert(interests)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
clientId,
|
||||||
|
berthId,
|
||||||
|
yachtId,
|
||||||
|
source: 'website',
|
||||||
|
pipelineStage: 'open',
|
||||||
|
notes: data.notes,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return {
|
||||||
|
interestId: newInterest!.id,
|
||||||
clientId,
|
clientId,
|
||||||
berthId,
|
yachtId,
|
||||||
source: 'website',
|
companyId,
|
||||||
pipelineStage: 'open',
|
};
|
||||||
notes: data.notes,
|
});
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
|
// ─── Post-commit side-effects (fire-and-forget) ─────────────────────────
|
||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
userId: null as unknown as string,
|
userId: null as unknown as string,
|
||||||
portId,
|
portId,
|
||||||
action: 'create',
|
action: 'create',
|
||||||
entityType: 'interest',
|
entityType: 'interest',
|
||||||
entityId: interest!.id,
|
entityId: result.interestId,
|
||||||
newValue: { clientId, source: 'website', pipelineStage: 'open', berthId },
|
newValue: {
|
||||||
|
clientId: result.clientId,
|
||||||
|
yachtId: result.yachtId,
|
||||||
|
companyId: result.companyId,
|
||||||
|
source: 'website',
|
||||||
|
pipelineStage: 'open',
|
||||||
|
berthId,
|
||||||
|
},
|
||||||
metadata: { type: 'public_registration', ip },
|
metadata: { type: 'public_registration', ip },
|
||||||
ipAddress: ip,
|
ipAddress: ip,
|
||||||
userAgent: req.headers.get('user-agent') ?? 'unknown',
|
userAgent: req.headers.get('user-agent') ?? 'unknown',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fire notifications asynchronously (non-blocking)
|
|
||||||
const port = await db.query.ports.findFirst({
|
const port = await db.query.ports.findFirst({
|
||||||
where: eq(ports.id, portId),
|
where: eq(ports.id, portId),
|
||||||
columns: { slug: true },
|
columns: { slug: true },
|
||||||
@@ -148,7 +256,7 @@ export async function POST(req: NextRequest) {
|
|||||||
void sendInquiryNotifications({
|
void sendInquiryNotifications({
|
||||||
portId,
|
portId,
|
||||||
portSlug: port?.slug ?? portId,
|
portSlug: port?.slug ?? portId,
|
||||||
interestId: interest!.id,
|
interestId: result.interestId,
|
||||||
clientFullName: fullName,
|
clientFullName: fullName,
|
||||||
clientEmail: data.email,
|
clientEmail: data.email,
|
||||||
clientPhone: data.phone,
|
clientPhone: data.phone,
|
||||||
@@ -157,7 +265,7 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ data: { id: interest!.id, message: 'Interest registered successfully' } },
|
{ data: { id: result.interestId, message: 'Interest registered successfully' } },
|
||||||
{ status: 201 },
|
{ status: 201 },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -165,46 +273,33 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createNewClient(
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function createClientInTx(
|
||||||
|
tx: Tx,
|
||||||
portId: string,
|
portId: string,
|
||||||
fullName: string,
|
fullName: string,
|
||||||
data: {
|
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod'>,
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
companyName?: string;
|
|
||||||
yachtName?: string;
|
|
||||||
yachtLengthFt?: number;
|
|
||||||
yachtWidthFt?: number;
|
|
||||||
yachtDraftFt?: number;
|
|
||||||
preferredBerthSize?: string;
|
|
||||||
preferredContactMethod?: string;
|
|
||||||
},
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const [newClient] = await db
|
const [newClient] = await tx
|
||||||
.insert(clients)
|
.insert(clients)
|
||||||
.values({
|
.values({
|
||||||
portId,
|
portId,
|
||||||
fullName,
|
fullName,
|
||||||
companyName: data.companyName,
|
|
||||||
yachtName: data.yachtName,
|
|
||||||
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
|
|
||||||
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
|
|
||||||
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
|
|
||||||
berthSizeDesired: data.preferredBerthSize,
|
|
||||||
preferredContactMethod: data.preferredContactMethod,
|
preferredContactMethod: data.preferredContactMethod,
|
||||||
source: 'website',
|
source: 'website',
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
const clientId = newClient!.id;
|
const clientId = newClient!.id;
|
||||||
|
|
||||||
await db.insert(clientContacts).values({
|
await tx.insert(clientContacts).values({
|
||||||
clientId,
|
clientId,
|
||||||
channel: 'email',
|
channel: 'email',
|
||||||
value: data.email,
|
value: data.email,
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.insert(clientContacts).values({
|
await tx.insert(clientContacts).values({
|
||||||
clientId,
|
clientId,
|
||||||
channel: 'phone',
|
channel: 'phone',
|
||||||
value: data.phone,
|
value: data.phone,
|
||||||
|
|||||||
114
src/app/api/v1/berth-reservations/[id]/route.ts
Normal file
114
src/app/api/v1/berth-reservations/[id]/route.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { requirePermission } from '@/lib/auth/permissions';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import {
|
||||||
|
activate,
|
||||||
|
cancel,
|
||||||
|
endReservation,
|
||||||
|
getById,
|
||||||
|
} from '@/lib/services/berth-reservations.service';
|
||||||
|
|
||||||
|
// ─── PATCH body schema (action-based discriminated union) ────────────────────
|
||||||
|
|
||||||
|
const patchBodySchema = z.discriminatedUnion('action', [
|
||||||
|
z.object({
|
||||||
|
action: z.literal('activate'),
|
||||||
|
contractFileId: z.string().optional(),
|
||||||
|
effectiveDate: z.coerce.date().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('end'),
|
||||||
|
endDate: z.coerce.date(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal('cancel'),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ─── Handlers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const getHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const reservation = await getById(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: reservation });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, patchBodySchema);
|
||||||
|
const meta = {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.action === 'activate') {
|
||||||
|
requirePermission(ctx, 'reservations', 'activate');
|
||||||
|
const result = await activate(
|
||||||
|
params.id!,
|
||||||
|
ctx.portId,
|
||||||
|
{
|
||||||
|
contractFileId: body.contractFileId,
|
||||||
|
effectiveDate: body.effectiveDate,
|
||||||
|
},
|
||||||
|
meta,
|
||||||
|
);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.action === 'end') {
|
||||||
|
// `end` is lifecycle progression; same privilege as activate.
|
||||||
|
requirePermission(ctx, 'reservations', 'activate');
|
||||||
|
const result = await endReservation(
|
||||||
|
params.id!,
|
||||||
|
ctx.portId,
|
||||||
|
{ endDate: body.endDate, notes: body.notes },
|
||||||
|
meta,
|
||||||
|
);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
}
|
||||||
|
|
||||||
|
// action === 'cancel'
|
||||||
|
requirePermission(ctx, 'reservations', 'cancel');
|
||||||
|
const result = await cancel(params.id!, ctx.portId, { reason: body.reason }, meta);
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await cancel(
|
||||||
|
params.id!,
|
||||||
|
ctx.portId,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
|
||||||
|
// PATCH cannot use `withPermission` wrapper — the required permission depends
|
||||||
|
// on the `action` field in the body. `requirePermission` is called inside the
|
||||||
|
// handler after the body is parsed.
|
||||||
|
export const PATCH = withAuth(patchHandler);
|
||||||
|
export const DELETE = withAuth(withPermission('reservations', 'cancel', deleteHandler));
|
||||||
72
src/app/api/v1/berths/[id]/reservations/route.ts
Normal file
72
src/app/api/v1/berths/[id]/reservations/route.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
|
import { NotFoundError, errorResponse } from '@/lib/errors';
|
||||||
|
import { createPending, listReservations } from '@/lib/services/berth-reservations.service';
|
||||||
|
import { createPendingSchema, listReservationsSchema } from '@/lib/validators/reservations';
|
||||||
|
|
||||||
|
// URL berthId is authoritative; make body berthId optional (ignored anyway).
|
||||||
|
const createPendingBodySchema = createPendingSchema
|
||||||
|
.omit({ berthId: true })
|
||||||
|
.extend({ berthId: createPendingSchema.shape.berthId.optional() });
|
||||||
|
|
||||||
|
async function assertBerthInPort(berthId: string, portId: string): Promise<void> {
|
||||||
|
const berth = await db.query.berths.findFirst({
|
||||||
|
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
|
||||||
|
});
|
||||||
|
if (!berth) throw new NotFoundError('Berth');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await assertBerthInPort(params.id!, ctx.portId);
|
||||||
|
|
||||||
|
const query = parseQuery(req, listReservationsSchema);
|
||||||
|
// URL berthId is authoritative; override any client-supplied value.
|
||||||
|
const result = await listReservations(ctx.portId, { ...query, berthId: params.id! });
|
||||||
|
const { page, limit } = query;
|
||||||
|
const totalPages = Math.ceil(result.total / limit);
|
||||||
|
return NextResponse.json({
|
||||||
|
data: result.data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize: limit,
|
||||||
|
total: result.total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await assertBerthInPort(params.id!, ctx.portId);
|
||||||
|
|
||||||
|
const body = await parseBody(req, createPendingBodySchema);
|
||||||
|
// URL berthId is authoritative; any body-supplied berthId is ignored.
|
||||||
|
const reservation = await createPending(
|
||||||
|
ctx.portId,
|
||||||
|
{ ...body, berthId: params.id! },
|
||||||
|
{
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return NextResponse.json({ data: reservation }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
|
||||||
|
export const POST = withAuth(withPermission('reservations', 'create', createHandler));
|
||||||
50
src/app/api/v1/companies/[id]/members/[mid]/route.ts
Normal file
50
src/app/api/v1/companies/[id]/members/[mid]/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { endMembership, updateMembership } from '@/lib/services/company-memberships.service';
|
||||||
|
import { endMembershipSchema, updateMembershipSchema } from '@/lib/validators/company-memberships';
|
||||||
|
|
||||||
|
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, updateMembershipSchema);
|
||||||
|
const updated = await updateMembership(params.mid!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: updated });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
let endDate = new Date();
|
||||||
|
const text = await req.text();
|
||||||
|
if (text.length > 0) {
|
||||||
|
const parsed = endMembershipSchema.parse(JSON.parse(text));
|
||||||
|
endDate = parsed.endDate;
|
||||||
|
}
|
||||||
|
await endMembership(
|
||||||
|
params.mid!,
|
||||||
|
ctx.portId,
|
||||||
|
{ endDate },
|
||||||
|
{
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATCH = withAuth(withPermission('memberships', 'manage', patchHandler));
|
||||||
|
export const DELETE = withAuth(withPermission('memberships', 'manage', deleteHandler));
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { setPrimary } from '@/lib/services/company-memberships.service';
|
||||||
|
|
||||||
|
export const setPrimaryHandler: RouteHandler = async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const membership = await setPrimary(params.mid!, ctx.portId, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: membership });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = withAuth(withPermission('memberships', 'manage', setPrimaryHandler));
|
||||||
43
src/app/api/v1/companies/[id]/members/route.ts
Normal file
43
src/app/api/v1/companies/[id]/members/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { addMembership, listByCompany } from '@/lib/services/company-memberships.service';
|
||||||
|
import { addMembershipSchema } from '@/lib/validators/company-memberships';
|
||||||
|
|
||||||
|
const listQuerySchema = z.object({
|
||||||
|
activeOnly: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.transform((v) => v === 'true')
|
||||||
|
.default('true'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const { activeOnly } = parseQuery(req, listQuerySchema);
|
||||||
|
const memberships = await listByCompany(params.id!, ctx.portId, { activeOnly });
|
||||||
|
return NextResponse.json({ data: memberships });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, addMembershipSchema);
|
||||||
|
const membership = await addMembership(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: membership }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('memberships', 'view', listHandler));
|
||||||
|
export const POST = withAuth(withPermission('memberships', 'manage', createHandler));
|
||||||
49
src/app/api/v1/companies/[id]/route.ts
Normal file
49
src/app/api/v1/companies/[id]/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { getCompanyById, updateCompany, archiveCompany } from '@/lib/services/companies.service';
|
||||||
|
import { updateCompanySchema } from '@/lib/validators/companies';
|
||||||
|
|
||||||
|
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const company = await getCompanyById(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: company });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, updateCompanySchema);
|
||||||
|
const updated = await updateCompany(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: updated });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await archiveCompany(params.id!, ctx.portId, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('companies', 'view', getHandler));
|
||||||
|
export const PATCH = withAuth(withPermission('companies', 'edit', patchHandler));
|
||||||
|
export const DELETE = withAuth(withPermission('companies', 'delete', deleteHandler));
|
||||||
20
src/app/api/v1/companies/autocomplete/route.ts
Normal file
20
src/app/api/v1/companies/autocomplete/route.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { autocomplete } from '@/lib/services/companies.service';
|
||||||
|
|
||||||
|
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const q = req.nextUrl.searchParams.get('q');
|
||||||
|
if (!q) {
|
||||||
|
return NextResponse.json({ data: [] });
|
||||||
|
}
|
||||||
|
const companies = await autocomplete(ctx.portId, q);
|
||||||
|
return NextResponse.json({ data: companies });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('companies', 'view', autocompleteHandler));
|
||||||
47
src/app/api/v1/companies/route.ts
Normal file
47
src/app/api/v1/companies/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listCompanies, createCompany } from '@/lib/services/companies.service';
|
||||||
|
import { listCompaniesSchema, createCompanySchema } from '@/lib/validators/companies';
|
||||||
|
|
||||||
|
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(req, listCompaniesSchema);
|
||||||
|
const result = await listCompanies(ctx.portId, query);
|
||||||
|
const { page, limit } = query;
|
||||||
|
const totalPages = Math.ceil(result.total / limit);
|
||||||
|
return NextResponse.json({
|
||||||
|
data: result.data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize: limit,
|
||||||
|
total: result.total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, createCompanySchema);
|
||||||
|
const company = await createCompany(ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: company }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('companies', 'view', listHandler));
|
||||||
|
export const POST = withAuth(withPermission('companies', 'create', createHandler));
|
||||||
16
src/app/api/v1/yachts/[id]/ownership-history/route.ts
Normal file
16
src/app/api/v1/yachts/[id]/ownership-history/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listOwnershipHistory } from '@/lib/services/yachts.service';
|
||||||
|
|
||||||
|
export const historyHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const history = await listOwnershipHistory(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: history });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('yachts', 'view', historyHandler));
|
||||||
49
src/app/api/v1/yachts/[id]/route.ts
Normal file
49
src/app/api/v1/yachts/[id]/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { getYachtById, updateYacht, archiveYacht } from '@/lib/services/yachts.service';
|
||||||
|
import { updateYachtSchema } from '@/lib/validators/yachts';
|
||||||
|
|
||||||
|
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const yacht = await getYachtById(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: yacht });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, updateYachtSchema);
|
||||||
|
const updated = await updateYacht(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: updated });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await archiveYacht(params.id!, ctx.portId, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
|
||||||
|
export const PATCH = withAuth(withPermission('yachts', 'edit', patchHandler));
|
||||||
|
export const DELETE = withAuth(withPermission('yachts', 'delete', deleteHandler));
|
||||||
24
src/app/api/v1/yachts/[id]/transfer/route.ts
Normal file
24
src/app/api/v1/yachts/[id]/transfer/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { transferOwnership } from '@/lib/services/yachts.service';
|
||||||
|
import { transferOwnershipSchema } from '@/lib/validators/yachts';
|
||||||
|
|
||||||
|
export const transferHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, transferOwnershipSchema);
|
||||||
|
const yacht = await transferOwnership(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: yacht });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = withAuth(withPermission('yachts', 'transfer', transferHandler));
|
||||||
20
src/app/api/v1/yachts/autocomplete/route.ts
Normal file
20
src/app/api/v1/yachts/autocomplete/route.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { autocomplete } from '@/lib/services/yachts.service';
|
||||||
|
|
||||||
|
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const q = req.nextUrl.searchParams.get('q');
|
||||||
|
if (!q) {
|
||||||
|
return NextResponse.json({ data: [] });
|
||||||
|
}
|
||||||
|
const yachts = await autocomplete(ctx.portId, q);
|
||||||
|
return NextResponse.json({ data: yachts });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('yachts', 'view', autocompleteHandler));
|
||||||
47
src/app/api/v1/yachts/route.ts
Normal file
47
src/app/api/v1/yachts/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listYachts, createYacht } from '@/lib/services/yachts.service';
|
||||||
|
import { listYachtsSchema, createYachtSchema } from '@/lib/validators/yachts';
|
||||||
|
|
||||||
|
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(req, listYachtsSchema);
|
||||||
|
const result = await listYachts(ctx.portId, query);
|
||||||
|
const { page, limit } = query;
|
||||||
|
const totalPages = Math.ceil(result.total / limit);
|
||||||
|
return NextResponse.json({
|
||||||
|
data: result.data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize: limit,
|
||||||
|
total: result.total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, createYachtSchema);
|
||||||
|
const yacht = await createYacht(ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: yacht }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('yachts', 'view', listHandler));
|
||||||
|
export const POST = withAuth(withPermission('yachts', 'create', createHandler));
|
||||||
90
src/components/berths/berth-reservations-tab.tsx
Normal file
90
src/components/berths/berth-reservations-tab.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
|
||||||
|
import { BerthReserveDialog } from '@/components/reservations/berth-reserve-dialog';
|
||||||
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface BerthReservationsTabProps {
|
||||||
|
berthId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BerthReservationsTab({ berthId }: BerthReservationsTabProps) {
|
||||||
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = routeParams?.portSlug ?? '';
|
||||||
|
const [reserveOpen, setReserveOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ data: ReservationRow[]; pagination?: unknown }>({
|
||||||
|
queryKey: ['berths', berthId, 'reservations'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch(
|
||||||
|
`/api/v1/berths/${berthId}/reservations?page=1&limit=50&order=desc&includeArchived=false`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
useRealtimeInvalidation({
|
||||||
|
'berth_reservation:created': [['berths', berthId, 'reservations']],
|
||||||
|
'berth_reservation:activated': [['berths', berthId, 'reservations']],
|
||||||
|
'berth_reservation:ended': [['berths', berthId, 'reservations']],
|
||||||
|
'berth_reservation:cancelled': [['berths', berthId, 'reservations']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const reservations = data?.data ?? [];
|
||||||
|
const active = reservations.find((r) => r.status === 'active');
|
||||||
|
const history = reservations.filter((r) => r.status !== 'active');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Reservations</h3>
|
||||||
|
<PermissionGate resource="reservations" action="create">
|
||||||
|
<Button size="sm" onClick={() => setReserveOpen(true)}>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
Reserve this berth
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active reservation card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Active reservation</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{active ? (
|
||||||
|
<ReservationList reservations={[active]} portSlug={portSlug} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No active reservation.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">History</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : history.length === 0 ? (
|
||||||
|
<EmptyState title="No past reservations" description="Nothing here yet." />
|
||||||
|
) : (
|
||||||
|
<ReservationList reservations={history} portSlug={portSlug} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<BerthReserveDialog open={reserveOpen} onOpenChange={setReserveOpen} berthId={berthId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { type DetailTab } from '@/components/shared/detail-layout';
|
import { type DetailTab } from '@/components/shared/detail-layout';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
|
import { BerthReservationsTab } from './berth-reservations-tab';
|
||||||
|
|
||||||
type BerthData = {
|
type BerthData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -87,7 +88,10 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
||||||
<SpecRow label="Nominal Boat Size" value={berth.nominalBoatSize || berth.nominalBoatSizeM} />
|
<SpecRow
|
||||||
|
label="Nominal Boat Size"
|
||||||
|
value={berth.nominalBoatSize || berth.nominalBoatSizeM}
|
||||||
|
/>
|
||||||
<SpecRow
|
<SpecRow
|
||||||
label="Water Depth"
|
label="Water Depth"
|
||||||
value={
|
value={
|
||||||
@@ -179,6 +183,11 @@ export function buildBerthTabs(berth: BerthData): DetailTab[] {
|
|||||||
label: 'Interests',
|
label: 'Interests',
|
||||||
content: <StubTab label="Interests" />,
|
content: <StubTab label="Interests" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'reservations',
|
||||||
|
label: 'Reservations',
|
||||||
|
content: <BerthReservationsTab berthId={berth.id} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'waiting-list',
|
id: 'waiting-list',
|
||||||
label: 'Waiting List',
|
label: 'Waiting List',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { TagBadge } from '@/components/shared/tag-badge';
|
|||||||
export interface ClientRow {
|
export interface ClientRow {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
companyName: string | null;
|
nationality: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -39,6 +39,10 @@ interface GetColumnsOptions {
|
|||||||
onArchive: (client: ClientRow) => void;
|
onArchive: (client: ClientRow) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Add "Yachts" (count) and "Primary company" columns once the
|
||||||
|
// GET /api/v1/clients list endpoint joins owned-yachts and primary-company
|
||||||
|
// data into the row shape. Until then, the columns are omitted rather than
|
||||||
|
// shown as empty placeholders.
|
||||||
export function getClientColumns({
|
export function getClientColumns({
|
||||||
portSlug,
|
portSlug,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -59,14 +63,6 @@ export function getClientColumns({
|
|||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'companyName',
|
|
||||||
accessorKey: 'companyName',
|
|
||||||
header: 'Company',
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'primaryContact',
|
id: 'primaryContact',
|
||||||
header: 'Primary Contact',
|
header: 'Primary Contact',
|
||||||
@@ -82,6 +78,14 @@ export function getClientColumns({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'nationality',
|
||||||
|
accessorKey: 'nationality',
|
||||||
|
header: 'Nationality',
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'source',
|
id: 'source',
|
||||||
accessorKey: 'source',
|
accessorKey: 'source',
|
||||||
@@ -149,10 +153,7 @@ export function getClientColumns({
|
|||||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => onArchive(row.original)}
|
|
||||||
>
|
|
||||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||||
Archive
|
Archive
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
103
src/components/clients/client-companies-tab.tsx
Normal file
103
src/components/clients/client-companies-tab.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
|
||||||
|
interface ClientCompaniesTabProps {
|
||||||
|
clientId: string;
|
||||||
|
companies: Array<{
|
||||||
|
membershipId: string;
|
||||||
|
role: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
startDate: string | Date;
|
||||||
|
company: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSince(startDate: string | Date): string {
|
||||||
|
const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
|
||||||
|
if (Number.isNaN(d.getTime())) return '—';
|
||||||
|
return format(d, 'MMM d, yyyy');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCompaniesTabProps) {
|
||||||
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = routeParams?.portSlug ?? '';
|
||||||
|
|
||||||
|
if (companies.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No company memberships"
|
||||||
|
description="This client is not affiliated with any companies yet. Add a membership from a company's detail page."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium">Company affiliations</h3>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Company</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Primary</TableHead>
|
||||||
|
<TableHead>Since</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{companies.map((m) => (
|
||||||
|
<TableRow key={m.membershipId}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/companies/${m.company.id}` as any}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{m.company.name}
|
||||||
|
</Link>
|
||||||
|
{m.company.legalName && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
({m.company.legalName})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="capitalize">{m.role.replace('_', ' ')}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{m.isPrimary ? (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Primary
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{formatSince(m.startDate)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,19 +12,7 @@ interface ClientData {
|
|||||||
id: string;
|
id: string;
|
||||||
portId: string;
|
portId: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
companyName: string | null;
|
|
||||||
nationality: string | null;
|
nationality: string | null;
|
||||||
isProxy: boolean;
|
|
||||||
proxyType: string | null;
|
|
||||||
actualOwnerName: string | null;
|
|
||||||
yachtName: string | null;
|
|
||||||
yachtLengthFt: string | null;
|
|
||||||
yachtWidthFt: string | null;
|
|
||||||
yachtDraftFt: string | null;
|
|
||||||
yachtLengthM: string | null;
|
|
||||||
yachtWidthM: string | null;
|
|
||||||
yachtDraftM: string | null;
|
|
||||||
berthSizeDesired: string | null;
|
|
||||||
preferredContactMethod: string | null;
|
preferredContactMethod: string | null;
|
||||||
preferredLanguage: string | null;
|
preferredLanguage: string | null;
|
||||||
timezone: string | null;
|
timezone: string | null;
|
||||||
@@ -46,6 +34,35 @@ interface ClientData {
|
|||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
}>;
|
}>;
|
||||||
|
yachts: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
companies: Array<{
|
||||||
|
membershipId: string;
|
||||||
|
role: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
startDate: string | Date;
|
||||||
|
company: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
activeReservations: Array<{
|
||||||
|
id: string;
|
||||||
|
berthId: string;
|
||||||
|
yachtId: string;
|
||||||
|
startDate: string | Date;
|
||||||
|
tenureType: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientDetailProps {
|
interface ClientDetailProps {
|
||||||
@@ -64,11 +81,15 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
|||||||
'client:updated': [['clients', clientId]],
|
'client:updated': [['clients', clientId]],
|
||||||
'client:archived': [['clients', clientId]],
|
'client:archived': [['clients', clientId]],
|
||||||
'client:restored': [['clients', clientId]],
|
'client:restored': [['clients', clientId]],
|
||||||
|
'yacht:ownership_transferred': [['clients', clientId]],
|
||||||
|
'company_membership:added': [['clients', clientId]],
|
||||||
|
'company_membership:ended': [['clients', clientId]],
|
||||||
|
'berth_reservation:activated': [['clients', clientId]],
|
||||||
|
'berth_reservation:ended': [['clients', clientId]],
|
||||||
|
'berth_reservation:cancelled': [['clients', clientId]],
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabs = data
|
const tabs = data ? getClientTabs({ clientId, currentUserId, client: data }) : [];
|
||||||
? getClientTabs({ clientId, currentUserId, client: data })
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailLayout
|
<DetailLayout
|
||||||
|
|||||||
@@ -24,11 +24,6 @@ export const clientFilterDefinitions: FilterDefinition[] = [
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
placeholder: 'Filter by nationality...',
|
placeholder: 'Filter by nationality...',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'isProxy',
|
|
||||||
label: 'Proxy Client',
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'includeArchived',
|
key: 'includeArchived',
|
||||||
label: 'Include Archived',
|
label: 'Include Archived',
|
||||||
|
|||||||
@@ -16,13 +16,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetFooter,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { TagPicker } from '@/components/shared/tag-picker';
|
import { TagPicker } from '@/components/shared/tag-picker';
|
||||||
@@ -36,13 +30,7 @@ interface ClientFormProps {
|
|||||||
client?: {
|
client?: {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
companyName?: string | null;
|
|
||||||
nationality?: string | null;
|
nationality?: string | null;
|
||||||
isProxy?: boolean;
|
|
||||||
proxyType?: string | null;
|
|
||||||
actualOwnerName?: string | null;
|
|
||||||
yachtName?: string | null;
|
|
||||||
berthSizeDesired?: string | null;
|
|
||||||
preferredContactMethod?: string | null;
|
preferredContactMethod?: string | null;
|
||||||
preferredLanguage?: string | null;
|
preferredLanguage?: string | null;
|
||||||
timezone?: string | null;
|
timezone?: string | null;
|
||||||
@@ -53,6 +41,7 @@ interface ClientFormProps {
|
|||||||
value: string;
|
value: string;
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
}>;
|
}>;
|
||||||
tags?: Array<{ id: string }>;
|
tags?: Array<{ id: string }>;
|
||||||
};
|
};
|
||||||
@@ -75,13 +64,11 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
fullName: '',
|
fullName: '',
|
||||||
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
||||||
isProxy: false,
|
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
||||||
const isProxy = watch('isProxy');
|
|
||||||
const tagIds = watch('tagIds') ?? [];
|
const tagIds = watch('tagIds') ?? [];
|
||||||
|
|
||||||
// Populate form when editing
|
// Populate form when editing
|
||||||
@@ -89,14 +76,10 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
if (client && open) {
|
if (client && open) {
|
||||||
reset({
|
reset({
|
||||||
fullName: client.fullName,
|
fullName: client.fullName,
|
||||||
companyName: client.companyName ?? undefined,
|
|
||||||
nationality: client.nationality ?? undefined,
|
nationality: client.nationality ?? undefined,
|
||||||
isProxy: client.isProxy ?? false,
|
preferredContactMethod:
|
||||||
proxyType: client.proxyType ?? undefined,
|
(client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ??
|
||||||
actualOwnerName: client.actualOwnerName ?? undefined,
|
undefined,
|
||||||
yachtName: client.yachtName ?? undefined,
|
|
||||||
berthSizeDesired: client.berthSizeDesired ?? undefined,
|
|
||||||
preferredContactMethod: (client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ?? undefined,
|
|
||||||
preferredLanguage: client.preferredLanguage ?? undefined,
|
preferredLanguage: client.preferredLanguage ?? undefined,
|
||||||
timezone: client.timezone ?? undefined,
|
timezone: client.timezone ?? undefined,
|
||||||
source: (client.source as CreateClientInput['source']) ?? undefined,
|
source: (client.source as CreateClientInput['source']) ?? undefined,
|
||||||
@@ -108,6 +91,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
value: c.value,
|
value: c.value,
|
||||||
label: c.label ?? undefined,
|
label: c.label ?? undefined,
|
||||||
isPrimary: c.isPrimary ?? false,
|
isPrimary: c.isPrimary ?? false,
|
||||||
|
notes: c.notes ?? undefined,
|
||||||
}))
|
}))
|
||||||
: [{ channel: 'email', value: '', isPrimary: true }],
|
: [{ channel: 'email', value: '', isPrimary: true }],
|
||||||
tagIds: client.tags?.map((t) => t.id) ?? [],
|
tagIds: client.tags?.map((t) => t.id) ?? [],
|
||||||
@@ -116,7 +100,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
reset({
|
reset({
|
||||||
fullName: '',
|
fullName: '',
|
||||||
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
||||||
isProxy: false,
|
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -151,10 +134,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
<SheetTitle>{isEdit ? 'Edit Client' : 'New Client'}</SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit Client' : 'New Client'}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form
|
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
||||||
onSubmit={handleSubmit((data) => mutation.mutate(data))}
|
|
||||||
className="space-y-6 py-6"
|
|
||||||
>
|
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
@@ -170,11 +150,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Company Name</Label>
|
|
||||||
<Input {...register('companyName')} placeholder="Acme Corp" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Nationality</Label>
|
<Label>Nationality</Label>
|
||||||
<Input {...register('nationality')} placeholder="British" />
|
<Input {...register('nationality')} placeholder="British" />
|
||||||
@@ -194,9 +169,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => append({ channel: 'email', value: '', isPrimary: false })}
|
||||||
append({ channel: 'email', value: '', isPrimary: false })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
Add Contact
|
Add Contact
|
||||||
@@ -218,7 +191,10 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
<Select
|
<Select
|
||||||
value={watch(`contacts.${index}.channel`)}
|
value={watch(`contacts.${index}.channel`)}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setValue(`contacts.${index}.channel`, v as 'email' | 'phone' | 'whatsapp' | 'other')
|
setValue(
|
||||||
|
`contacts.${index}.channel`,
|
||||||
|
v as 'email' | 'phone' | 'whatsapp' | 'other',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8">
|
<SelectTrigger className="h-8">
|
||||||
@@ -254,9 +230,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
<div className="col-span-1 flex items-center gap-1 pb-1">
|
<div className="col-span-1 flex items-center gap-1 pb-1">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={watch(`contacts.${index}.isPrimary`)}
|
checked={watch(`contacts.${index}.isPrimary`)}
|
||||||
onCheckedChange={(v) =>
|
onCheckedChange={(v) => setValue(`contacts.${index}.isPrimary`, !!v)}
|
||||||
setValue(`contacts.${index}.isPrimary`, !!v)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Label className="text-xs">Primary</Label>
|
<Label className="text-xs">Primary</Label>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,72 +255,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Proxy */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
||||||
Proxy Information
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="isProxy"
|
|
||||||
checked={watch('isProxy')}
|
|
||||||
onCheckedChange={(v) => setValue('isProxy', !!v)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="isProxy">This is a proxy client</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isProxy && (
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Proxy Type</Label>
|
|
||||||
<Select
|
|
||||||
value={watch('proxyType') ?? ''}
|
|
||||||
onValueChange={(v) => setValue('proxyType', v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="broker">Broker</SelectItem>
|
|
||||||
<SelectItem value="representative">Representative</SelectItem>
|
|
||||||
<SelectItem value="family_member">Family Member</SelectItem>
|
|
||||||
<SelectItem value="legal_counsel">Legal Counsel</SelectItem>
|
|
||||||
<SelectItem value="other">Other</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Actual Owner Name</Label>
|
|
||||||
<Input
|
|
||||||
{...register('actualOwnerName')}
|
|
||||||
placeholder="Actual owner"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Yacht Details */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
||||||
Yacht Details
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="col-span-2 space-y-1">
|
|
||||||
<Label>Yacht Name</Label>
|
|
||||||
<Input {...register('yachtName')} placeholder="My Yacht" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Berth Size Desired</Label>
|
|
||||||
<Input {...register('berthSizeDesired')} placeholder="e.g. 30m" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Source & Preferences */}
|
{/* Source & Preferences */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
@@ -357,7 +265,9 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
<Label>Source</Label>
|
<Label>Source</Label>
|
||||||
<Select
|
<Select
|
||||||
value={watch('source') ?? ''}
|
value={watch('source') ?? ''}
|
||||||
onValueChange={(v) => setValue('source', v as 'website' | 'manual' | 'referral' | 'broker')}
|
onValueChange={(v) =>
|
||||||
|
setValue('source', v as 'website' | 'manual' | 'referral' | 'broker')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select source" />
|
<SelectValue placeholder="Select source" />
|
||||||
@@ -374,7 +284,9 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
<Label>Preferred Contact Method</Label>
|
<Label>Preferred Contact Method</Label>
|
||||||
<Select
|
<Select
|
||||||
value={watch('preferredContactMethod') ?? ''}
|
value={watch('preferredContactMethod') ?? ''}
|
||||||
onValueChange={(v) => setValue('preferredContactMethod', v as 'email' | 'phone' | 'whatsapp')}
|
onValueChange={(v) =>
|
||||||
|
setValue('preferredContactMethod', v as 'email' | 'phone' | 'whatsapp')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select method" />
|
<SelectValue placeholder="Select method" />
|
||||||
@@ -396,10 +308,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 space-y-1">
|
<div className="col-span-2 space-y-1">
|
||||||
<Label>Source Details</Label>
|
<Label>Source Details</Label>
|
||||||
<Input
|
<Input {...register('sourceDetails')} placeholder="Referred by John Doe" />
|
||||||
{...register('sourceDetails')}
|
|
||||||
placeholder="Referred by John Doe"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,18 +318,11 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Tags</Label>
|
<Label>Tags</Label>
|
||||||
<TagPicker
|
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
|
||||||
selectedIds={tagIds}
|
|
||||||
onChange={(ids) => setValue('tagIds', ids)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||||
|
|||||||
51
src/components/clients/client-reservations-tab.tsx
Normal file
51
src/components/clients/client-reservations-tab.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
|
||||||
|
|
||||||
|
interface ClientReservationsTabProps {
|
||||||
|
clientId: string;
|
||||||
|
activeReservations: Array<{
|
||||||
|
id: string;
|
||||||
|
berthId: string;
|
||||||
|
yachtId: string;
|
||||||
|
startDate: string | Date;
|
||||||
|
tenureType: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientReservationsTab({
|
||||||
|
clientId,
|
||||||
|
activeReservations,
|
||||||
|
}: ClientReservationsTabProps) {
|
||||||
|
const rows: ReservationRow[] = activeReservations.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
berthId: r.berthId,
|
||||||
|
portId: '', // not rendered by ReservationList
|
||||||
|
clientId,
|
||||||
|
yachtId: r.yachtId,
|
||||||
|
status: r.status as ReservationRow['status'],
|
||||||
|
startDate: typeof r.startDate === 'string' ? r.startDate : r.startDate.toISOString(),
|
||||||
|
endDate: null,
|
||||||
|
tenureType: r.tenureType,
|
||||||
|
contractFileId: null,
|
||||||
|
notes: null,
|
||||||
|
createdAt: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">Active reservations</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Showing currently active reservations. History is coming soon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ReservationList
|
||||||
|
reservations={rows}
|
||||||
|
showBerth
|
||||||
|
emptyMessage="This client has no active reservations."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,22 +2,16 @@
|
|||||||
|
|
||||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
|
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
||||||
|
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
||||||
|
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
||||||
|
|
||||||
interface ClientTabsOptions {
|
interface ClientTabsOptions {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
client: {
|
client: {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
companyName?: string | null;
|
|
||||||
nationality?: string | null;
|
nationality?: string | null;
|
||||||
isProxy?: boolean;
|
|
||||||
proxyType?: string | null;
|
|
||||||
actualOwnerName?: string | null;
|
|
||||||
yachtName?: string | null;
|
|
||||||
yachtLengthFt?: string | null;
|
|
||||||
yachtWidthFt?: string | null;
|
|
||||||
yachtDraftFt?: string | null;
|
|
||||||
berthSizeDesired?: string | null;
|
|
||||||
preferredContactMethod?: string | null;
|
preferredContactMethod?: string | null;
|
||||||
preferredLanguage?: string | null;
|
preferredLanguage?: string | null;
|
||||||
timezone?: string | null;
|
timezone?: string | null;
|
||||||
@@ -30,6 +24,36 @@ interface ClientTabsOptions {
|
|||||||
label?: string | null;
|
label?: string | null;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
yachts: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
companies: Array<{
|
||||||
|
membershipId: string;
|
||||||
|
role: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
startDate: string | Date;
|
||||||
|
company: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
activeReservations: Array<{
|
||||||
|
id: string;
|
||||||
|
berthId: string;
|
||||||
|
yachtId: string;
|
||||||
|
startDate: string | Date;
|
||||||
|
tenureType: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,14 +75,10 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
|
|||||||
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<InfoRow label="Full Name" value={client.fullName} />
|
<InfoRow label="Full Name" value={client.fullName} />
|
||||||
<InfoRow label="Company" value={client.companyName} />
|
|
||||||
<InfoRow label="Nationality" value={client.nationality} />
|
<InfoRow label="Nationality" value={client.nationality} />
|
||||||
<InfoRow label="Preferred Language" value={client.preferredLanguage} />
|
<InfoRow label="Preferred Language" value={client.preferredLanguage} />
|
||||||
<InfoRow label="Timezone" value={client.timezone} />
|
<InfoRow label="Timezone" value={client.timezone} />
|
||||||
<InfoRow
|
<InfoRow label="Preferred Contact" value={client.preferredContactMethod} />
|
||||||
label="Preferred Contact"
|
|
||||||
value={client.preferredContactMethod}
|
|
||||||
/>
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,18 +92,12 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
|
|||||||
key={c.id}
|
key={c.id}
|
||||||
className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm"
|
className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm"
|
||||||
>
|
>
|
||||||
<span className="capitalize text-muted-foreground w-20 shrink-0">
|
<span className="capitalize text-muted-foreground w-20 shrink-0">{c.channel}</span>
|
||||||
{c.channel}
|
|
||||||
</span>
|
|
||||||
<span className="flex-1">{c.value}</span>
|
<span className="flex-1">{c.value}</span>
|
||||||
{c.label && (
|
{c.label && (
|
||||||
<span className="text-xs text-muted-foreground capitalize">
|
<span className="text-xs text-muted-foreground capitalize">{c.label}</span>
|
||||||
{c.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{c.isPrimary && (
|
|
||||||
<span className="text-xs font-medium text-primary">Primary</span>
|
|
||||||
)}
|
)}
|
||||||
|
{c.isPrimary && <span className="text-xs font-medium text-primary">Primary</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,41 +106,6 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Yacht Details */}
|
|
||||||
{(client.yachtName ||
|
|
||||||
client.yachtLengthFt ||
|
|
||||||
client.berthSizeDesired) && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-sm font-medium mb-2">Yacht Details</h3>
|
|
||||||
<dl>
|
|
||||||
<InfoRow label="Yacht Name" value={client.yachtName} />
|
|
||||||
<InfoRow
|
|
||||||
label="Length"
|
|
||||||
value={
|
|
||||||
client.yachtLengthFt
|
|
||||||
? `${client.yachtLengthFt} ft`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoRow
|
|
||||||
label="Width"
|
|
||||||
value={
|
|
||||||
client.yachtWidthFt ? `${client.yachtWidthFt} ft` : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoRow
|
|
||||||
label="Draft"
|
|
||||||
value={
|
|
||||||
client.yachtDraftFt
|
|
||||||
? `${client.yachtDraftFt} ft`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoRow label="Berth Size Desired" value={client.berthSizeDesired} />
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Source */}
|
{/* Source */}
|
||||||
{(client.source || client.sourceDetails) && (
|
{(client.source || client.sourceDetails) && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -138,34 +117,54 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Proxy Info */}
|
{/* Tags */}
|
||||||
{client.isProxy && (
|
{client.tags && client.tags.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Proxy Information</h3>
|
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||||
<dl>
|
<div className="flex flex-wrap gap-1">
|
||||||
<InfoRow
|
{client.tags.map((tag) => (
|
||||||
label="Proxy Type"
|
<span
|
||||||
value={client.proxyType?.replace('_', ' ')}
|
key={tag.id}
|
||||||
/>
|
className="inline-block rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
<InfoRow label="Actual Owner" value={client.actualOwnerName} />
|
style={{ backgroundColor: `${tag.color}20`, color: tag.color }}
|
||||||
</dl>
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClientTabs({
|
export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOptions): DetailTab[] {
|
||||||
clientId,
|
|
||||||
currentUserId,
|
|
||||||
client,
|
|
||||||
}: ClientTabsOptions): DetailTab[] {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'overview',
|
id: 'overview',
|
||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
content: <OverviewTab client={client} />,
|
content: <OverviewTab client={client} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'yachts',
|
||||||
|
label: 'Yachts',
|
||||||
|
badge: client.yachts.length,
|
||||||
|
content: <ClientYachtsTab clientId={clientId} yachts={client.yachts} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'companies',
|
||||||
|
label: 'Companies',
|
||||||
|
badge: client.companies.length,
|
||||||
|
content: <ClientCompaniesTab clientId={clientId} companies={client.companies} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reservations',
|
||||||
|
label: 'Reservations',
|
||||||
|
badge: client.activeReservations.length,
|
||||||
|
content: (
|
||||||
|
<ClientReservationsTab clientId={clientId} activeReservations={client.activeReservations} />
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'interests',
|
id: 'interests',
|
||||||
label: 'Interests',
|
label: 'Interests',
|
||||||
@@ -178,13 +177,7 @@ export function getClientTabs({
|
|||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
content: (
|
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
|
||||||
<NotesList
|
|
||||||
entityType="clients"
|
|
||||||
entityId={clientId}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'files',
|
id: 'files',
|
||||||
|
|||||||
97
src/components/clients/client-yachts-tab.tsx
Normal file
97
src/components/clients/client-yachts-tab.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||||
|
|
||||||
|
interface ClientYachtsTabProps {
|
||||||
|
clientId: string;
|
||||||
|
yachts: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTabProps) {
|
||||||
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = routeParams?.portSlug ?? '';
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium">Client-owned yachts</h3>
|
||||||
|
<PermissionGate resource="yachts" action="create">
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
Add yacht
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{yachts.length === 0 ? (
|
||||||
|
<EmptyState title="No yachts" description="No yachts owned by this client yet." />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Dimensions</TableHead>
|
||||||
|
<TableHead>Hull Number</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{yachts.map((y) => (
|
||||||
|
<TableRow key={y.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/yachts/${y.id}` as any}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{y.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{y.hullNumber ?? '—'}</TableCell>
|
||||||
|
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
TODO: YachtForm (Task 5.2) does not yet accept a preset owner prop.
|
||||||
|
When opened here, the user must manually pick this client in the owner
|
||||||
|
picker. Wire an `initialOwner` prop into YachtForm in a follow-up so
|
||||||
|
we can pre-select `{ type: 'client', id: clientId }`.
|
||||||
|
*/}
|
||||||
|
{createOpen && <YachtForm open={createOpen} onOpenChange={setCreateOpen} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
src/components/companies/add-membership-dialog.tsx
Normal file
220
src/components/companies/add-membership-dialog.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { ClientPicker } from '@/components/shared/client-picker';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { ROLES } from '@/lib/validators/company-memberships';
|
||||||
|
|
||||||
|
type RoleEnum = (typeof ROLES)[number];
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
clientId: string | null;
|
||||||
|
role: RoleEnum;
|
||||||
|
roleDetail?: string;
|
||||||
|
startDate: string; // YYYY-MM-DD
|
||||||
|
isPrimary: boolean;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AddMembershipDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
companyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayIso = (): string => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<RoleEnum, string> = {
|
||||||
|
director: 'Director',
|
||||||
|
officer: 'Officer',
|
||||||
|
broker: 'Broker',
|
||||||
|
representative: 'Representative',
|
||||||
|
legal_counsel: 'Legal counsel',
|
||||||
|
employee: 'Employee',
|
||||||
|
shareholder: 'Shareholder',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AddMembershipDialog({ open, onOpenChange, companyId }: AddMembershipDialogProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
clientId: null,
|
||||||
|
role: 'director',
|
||||||
|
roleDetail: '',
|
||||||
|
startDate: todayIso(),
|
||||||
|
isPrimary: false,
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFormError(null);
|
||||||
|
reset({
|
||||||
|
clientId: null,
|
||||||
|
role: 'director',
|
||||||
|
roleDetail: '',
|
||||||
|
startDate: todayIso(),
|
||||||
|
isPrimary: false,
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, reset]);
|
||||||
|
|
||||||
|
const clientId = watch('clientId');
|
||||||
|
const role = watch('role');
|
||||||
|
const isPrimary = watch('isPrimary');
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (data: FormValues) => {
|
||||||
|
if (!data.clientId) {
|
||||||
|
throw new Error('Please select a client');
|
||||||
|
}
|
||||||
|
await apiFetch(`/api/v1/companies/${companyId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
clientId: data.clientId,
|
||||||
|
role: data.role,
|
||||||
|
roleDetail: data.roleDetail?.trim() || undefined,
|
||||||
|
startDate: data.startDate,
|
||||||
|
isPrimary: data.isPrimary,
|
||||||
|
notes: data.notes?.trim() || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companies', companyId, 'members'] });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
let msg = err instanceof Error ? err.message : 'Failed to add membership';
|
||||||
|
// Detect 409 — service returns a "membership already exists" message
|
||||||
|
if (/already exists/i.test(msg)) {
|
||||||
|
msg = 'This membership already exists (same client + role + start date).';
|
||||||
|
}
|
||||||
|
setFormError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add member</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Associate a client with this company in a specific role.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data) => {
|
||||||
|
setFormError(null);
|
||||||
|
mutation.mutate(data);
|
||||||
|
})}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Client</Label>
|
||||||
|
<ClientPicker value={clientId} onChange={(id) => setValue('clientId', id)} />
|
||||||
|
{!clientId && errors.clientId && <p className="text-xs text-destructive">Required</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Role</Label>
|
||||||
|
<Select value={role} onValueChange={(v) => setValue('role', v as RoleEnum)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<SelectItem key={r} value={r}>
|
||||||
|
{ROLE_LABEL[r]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="roleDetail">Role detail (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="roleDetail"
|
||||||
|
{...register('roleDetail')}
|
||||||
|
placeholder="e.g. Chief Investment Officer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="startDate">Start date</Label>
|
||||||
|
<Input id="startDate" type="date" {...register('startDate', { required: true })} />
|
||||||
|
{errors.startDate && <p className="text-xs text-destructive">Required</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="isPrimary"
|
||||||
|
checked={isPrimary}
|
||||||
|
onCheckedChange={(v) => setValue('isPrimary', v === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isPrimary" className="cursor-pointer">
|
||||||
|
Set as primary contact
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="notes">Notes (optional)</Label>
|
||||||
|
<Textarea id="notes" rows={2} {...register('notes')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||||
|
{(isSubmitting || mutation.isPending) && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Add member
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/components/companies/company-columns.tsx
Normal file
148
src/components/companies/company-columns.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { MoreHorizontal, Pencil, Archive, Eye } from 'lucide-react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
// TODO: add member/yacht counts once the list endpoint returns them via a join.
|
||||||
|
export interface CompanyRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
registrationNumber: string | null;
|
||||||
|
incorporationCountry: string | null;
|
||||||
|
incorporationDate: string | null;
|
||||||
|
status: string;
|
||||||
|
billingEmail: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
dissolved: 'bg-red-100 text-red-800 border-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
dissolved: 'Dissolved',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetCompanyColumnsOptions {
|
||||||
|
portSlug: string;
|
||||||
|
onEdit: (company: CompanyRow) => void;
|
||||||
|
onArchive: (company: CompanyRow) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCompanyColumns({
|
||||||
|
portSlug,
|
||||||
|
onEdit,
|
||||||
|
onArchive,
|
||||||
|
}: GetCompanyColumnsOptions): ColumnDef<CompanyRow, unknown>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/companies/${row.original.id}` as any}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{row.original.name}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legalName',
|
||||||
|
accessorKey: 'legalName',
|
||||||
|
header: 'Legal Name',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue() as string | null;
|
||||||
|
if (!value) return <span className="text-muted-foreground">—</span>;
|
||||||
|
return <span className="text-sm">{value}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taxId',
|
||||||
|
accessorKey: 'taxId',
|
||||||
|
header: 'Tax ID',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue() as string | null;
|
||||||
|
if (!value) return <span className="text-muted-foreground">—</span>;
|
||||||
|
return <span className="text-sm">{value}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const label = STATUS_LABELS[status] ?? status;
|
||||||
|
const color = STATUS_COLORS[status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${color}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '',
|
||||||
|
enableSorting: false,
|
||||||
|
size: 48,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/companies/${row.original.id}` as any}
|
||||||
|
>
|
||||||
|
<Eye className="mr-2 h-3.5 w-3.5" />
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
|
||||||
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
153
src/components/companies/company-detail-header.tsx
Normal file
153
src/components/companies/company-detail-header.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Pencil, Archive } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { CompanyForm } from '@/components/companies/company-form';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface CompanyDetailHeaderCompany {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
registrationNumber: string | null;
|
||||||
|
incorporationCountry: string | null;
|
||||||
|
incorporationDate: string | null;
|
||||||
|
status: string;
|
||||||
|
billingEmail: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyDetailHeaderProps {
|
||||||
|
company: CompanyDetailHeaderCompany;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
dissolved: 'bg-red-100 text-red-800 border-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
dissolved: 'Dissolved',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
|
|
||||||
|
const isArchived = !!company.archivedAt;
|
||||||
|
const showLegalName = company.legalName && company.legalName !== company.name;
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: () => apiFetch(`/api/v1/companies/${company.id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companies', company.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companies'] });
|
||||||
|
toast.success('Company archived');
|
||||||
|
setArchiveOpen(false);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
router.push(`/${portSlug}/companies` as any);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast.error(err.message || 'Failed to archive company');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = STATUS_LABELS[company.status] ?? company.status;
|
||||||
|
const statusColor =
|
||||||
|
STATUS_COLORS[company.status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground truncate">{company.name}</h1>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
{isArchived && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Archived
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 space-y-0.5 text-sm text-muted-foreground">
|
||||||
|
{showLegalName && <p>{company.legalName}</p>}
|
||||||
|
{company.taxId && <p>Tax ID: {company.taxId}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PermissionGate resource="companies" action="edit">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||||
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
<PermissionGate resource="companies" action="delete">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setArchiveOpen(true)}
|
||||||
|
disabled={isArchived}
|
||||||
|
>
|
||||||
|
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CompanyForm
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
company={{
|
||||||
|
id: company.id,
|
||||||
|
name: company.name,
|
||||||
|
legalName: company.legalName,
|
||||||
|
taxId: company.taxId,
|
||||||
|
registrationNumber: company.registrationNumber,
|
||||||
|
incorporationCountry: company.incorporationCountry,
|
||||||
|
incorporationDate: company.incorporationDate,
|
||||||
|
status: company.status,
|
||||||
|
billingEmail: company.billingEmail,
|
||||||
|
notes: company.notes,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArchiveConfirmDialog
|
||||||
|
open={archiveOpen}
|
||||||
|
onOpenChange={setArchiveOpen}
|
||||||
|
entityName={company.name}
|
||||||
|
entityType="Company"
|
||||||
|
isArchived={isArchived}
|
||||||
|
onConfirm={() => {
|
||||||
|
archiveMutation.mutate();
|
||||||
|
}}
|
||||||
|
isLoading={archiveMutation.isPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/companies/company-detail.tsx
Normal file
62
src/components/companies/company-detail.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
|
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
|
||||||
|
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
||||||
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
export interface CompanyData {
|
||||||
|
id: string;
|
||||||
|
portId: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
registrationNumber: string | null;
|
||||||
|
incorporationCountry: string | null;
|
||||||
|
incorporationDate: string | null;
|
||||||
|
status: string;
|
||||||
|
billingEmail: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyDetailProps {
|
||||||
|
companyId: string;
|
||||||
|
currentUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<CompanyData>({
|
||||||
|
queryKey: ['companies', companyId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
useRealtimeInvalidation({
|
||||||
|
'company:updated': [['companies', companyId]],
|
||||||
|
'company:archived': [['companies', companyId]],
|
||||||
|
'company_membership:added': [['companies', companyId, 'members']],
|
||||||
|
'company_membership:updated': [['companies', companyId, 'members']],
|
||||||
|
'company_membership:ended': [['companies', companyId, 'members']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = data ? getCompanyTabs({ companyId, portSlug, currentUserId, company: data }) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailLayout
|
||||||
|
header={data ? <CompanyDetailHeader company={data} /> : null}
|
||||||
|
tabs={tabs}
|
||||||
|
defaultTab="overview"
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/companies/company-filters.tsx
Normal file
24
src/components/companies/company-filters.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { FilterDefinition } from '@/components/shared/filter-bar';
|
||||||
|
|
||||||
|
export const companyFilterDefinitions: FilterDefinition[] = [
|
||||||
|
{
|
||||||
|
key: 'search',
|
||||||
|
label: 'Search',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Search by name, legal name, tax ID...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'Active', value: 'active' },
|
||||||
|
{ label: 'Dissolved', value: 'dissolved' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'includeArchived',
|
||||||
|
label: 'Include Archived',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
];
|
||||||
262
src/components/companies/company-form.tsx
Normal file
262
src/components/companies/company-form.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { TagPicker } from '@/components/shared/tag-picker';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { createCompanySchema, type CreateCompanyInput } from '@/lib/validators/companies';
|
||||||
|
|
||||||
|
type CompanyStatus = 'active' | 'dissolved';
|
||||||
|
|
||||||
|
type CompanyFormValues = z.input<typeof createCompanySchema>;
|
||||||
|
|
||||||
|
interface CompanyFormProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** If provided, form is in edit mode */
|
||||||
|
company?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
registrationNumber: string | null;
|
||||||
|
incorporationCountry: string | null;
|
||||||
|
incorporationDate: string | null;
|
||||||
|
status: string;
|
||||||
|
billingEmail: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isEdit = !!company;
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<CompanyFormValues>({
|
||||||
|
resolver: zodResolver(createCompanySchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
status: 'active',
|
||||||
|
tagIds: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagIds = watch('tagIds') ?? [];
|
||||||
|
const status = watch('status') ?? 'active';
|
||||||
|
|
||||||
|
// Populate form when editing, or reset to defaults in create mode.
|
||||||
|
useEffect(() => {
|
||||||
|
if (company && open) {
|
||||||
|
reset({
|
||||||
|
name: company.name,
|
||||||
|
legalName: company.legalName ?? undefined,
|
||||||
|
taxId: company.taxId ?? undefined,
|
||||||
|
registrationNumber: company.registrationNumber ?? undefined,
|
||||||
|
incorporationCountry: company.incorporationCountry ?? undefined,
|
||||||
|
incorporationDate: company.incorporationDate
|
||||||
|
? new Date(company.incorporationDate)
|
||||||
|
: undefined,
|
||||||
|
status: (company.status as CompanyStatus) ?? 'active',
|
||||||
|
billingEmail: company.billingEmail ?? undefined,
|
||||||
|
notes: company.notes ?? undefined,
|
||||||
|
tagIds: [],
|
||||||
|
});
|
||||||
|
} else if (!company && open) {
|
||||||
|
reset({ name: '', status: 'active', tagIds: [] });
|
||||||
|
}
|
||||||
|
setFormError(null);
|
||||||
|
}, [company, open, reset]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (data: CreateCompanyInput) => {
|
||||||
|
if (isEdit) {
|
||||||
|
// updateCompanySchema omits tagIds — strip them from PATCH body.
|
||||||
|
const { tagIds: _tIds, ...rest } = data;
|
||||||
|
void _tIds;
|
||||||
|
await apiFetch(`/api/v1/companies/${company!.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: rest,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiFetch('/api/v1/companies', { method: 'POST', body: data });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companies'] });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to save company';
|
||||||
|
setFormError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{isEdit ? 'Edit Company' : 'New Company'}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data) => {
|
||||||
|
setFormError(null);
|
||||||
|
mutation.mutate(data as CreateCompanyInput);
|
||||||
|
})}
|
||||||
|
className="space-y-6 py-6"
|
||||||
|
>
|
||||||
|
{/* Basics */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Basics
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<Label>Name *</Label>
|
||||||
|
<Input {...register('name')} placeholder="Acme Holdings Ltd" />
|
||||||
|
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<Label>Legal Name</Label>
|
||||||
|
<Input {...register('legalName')} placeholder="Acme Holdings Limited" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Registration */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Registration
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Tax ID</Label>
|
||||||
|
<Input {...register('taxId')} placeholder="VAT / EIN" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Registration Number</Label>
|
||||||
|
<Input {...register('registrationNumber')} placeholder="Company #" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Incorporation Country</Label>
|
||||||
|
<Input {...register('incorporationCountry')} placeholder="e.g. MT" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Incorporation Date</Label>
|
||||||
|
<Input type="date" {...register('incorporationDate')} />
|
||||||
|
{errors.incorporationDate && (
|
||||||
|
<p className="text-xs text-destructive">{errors.incorporationDate.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Contact & Status */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Contact & Status
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Billing Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
{...register('billingEmail')}
|
||||||
|
placeholder="billing@example.com"
|
||||||
|
/>
|
||||||
|
{errors.billingEmail && (
|
||||||
|
<p className="text-xs text-destructive">{errors.billingEmail.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(v) => setValue('status', v as CompanyStatus)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="dissolved">Dissolved</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
{...register('notes')}
|
||||||
|
placeholder="Internal notes about this company"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||||
|
{(isSubmitting || mutation.isPending) && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{isEdit ? 'Save Changes' : 'Create Company'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
src/components/companies/company-list.tsx
Normal file
167
src/components/companies/company-list.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DataTable } from '@/components/shared/data-table';
|
||||||
|
import { FilterBar } from '@/components/shared/filter-bar';
|
||||||
|
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { CompanyForm } from '@/components/companies/company-form';
|
||||||
|
import { companyFilterDefinitions } from '@/components/companies/company-filters';
|
||||||
|
import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns';
|
||||||
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
export function CompanyList() {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editCompany, setEditCompany] = useState<CompanyRow | null>(null);
|
||||||
|
const [archiveCompany, setArchiveCompany] = useState<CompanyRow | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
pagination,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
sort,
|
||||||
|
setSort,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
filters,
|
||||||
|
setFilter,
|
||||||
|
clearFilters,
|
||||||
|
} = usePaginatedQuery<CompanyRow>({
|
||||||
|
queryKey: ['companies'],
|
||||||
|
endpoint: '/api/v1/companies',
|
||||||
|
filterDefinitions: companyFilterDefinitions,
|
||||||
|
});
|
||||||
|
|
||||||
|
useRealtimeInvalidation({
|
||||||
|
'company:created': [['companies']],
|
||||||
|
'company:updated': [['companies']],
|
||||||
|
'company:archived': [['companies']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiFetch(`/api/v1/companies/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companies'] });
|
||||||
|
setArchiveCompany(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = getCompanyColumns({
|
||||||
|
portSlug,
|
||||||
|
onEdit: (company) => setEditCompany(company),
|
||||||
|
onArchive: (company) => setArchiveCompany(company),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="Companies"
|
||||||
|
description="Manage company records"
|
||||||
|
actions={
|
||||||
|
<PermissionGate resource="companies" action="create">
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
New Company
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FilterBar
|
||||||
|
filters={companyFilterDefinitions}
|
||||||
|
values={filters}
|
||||||
|
onChange={setFilter}
|
||||||
|
onClear={clearFilters}
|
||||||
|
/>
|
||||||
|
<SavedViewsDropdown
|
||||||
|
entityType="companies"
|
||||||
|
currentFilters={filters}
|
||||||
|
currentSort={sort}
|
||||||
|
onApplyView={(savedFilters, _savedSort) => {
|
||||||
|
clearFilters();
|
||||||
|
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : !data.length ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No companies yet"
|
||||||
|
description="Create your first company to get started."
|
||||||
|
action={{ label: 'New Company', onClick: () => setCreateOpen(true) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
pagination={pagination}
|
||||||
|
onPaginationChange={(p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
}}
|
||||||
|
sort={sort}
|
||||||
|
onSortChange={setSort}
|
||||||
|
isLoading={isFetching && !isLoading}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
emptyState={
|
||||||
|
<EmptyState
|
||||||
|
title="No companies yet"
|
||||||
|
description="Create your first company to get started."
|
||||||
|
action={{ label: 'New Company', onClick: () => setCreateOpen(true) }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CompanyForm open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
|
|
||||||
|
{editCompany && (
|
||||||
|
<CompanyForm
|
||||||
|
open={!!editCompany}
|
||||||
|
onOpenChange={(open) => !open && setEditCompany(null)}
|
||||||
|
company={{
|
||||||
|
id: editCompany.id,
|
||||||
|
name: editCompany.name,
|
||||||
|
legalName: editCompany.legalName,
|
||||||
|
taxId: editCompany.taxId,
|
||||||
|
registrationNumber: editCompany.registrationNumber,
|
||||||
|
incorporationCountry: editCompany.incorporationCountry,
|
||||||
|
incorporationDate: editCompany.incorporationDate,
|
||||||
|
status: editCompany.status,
|
||||||
|
billingEmail: editCompany.billingEmail,
|
||||||
|
notes: editCompany.notes,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ArchiveConfirmDialog
|
||||||
|
open={!!archiveCompany}
|
||||||
|
onOpenChange={(open) => !open && setArchiveCompany(null)}
|
||||||
|
entityName={archiveCompany?.name ?? ''}
|
||||||
|
entityType="Company"
|
||||||
|
isArchived={false}
|
||||||
|
onConfirm={() => archiveCompany && archiveMutation.mutate(archiveCompany.id)}
|
||||||
|
isLoading={archiveMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
src/components/companies/company-members-tab.tsx
Normal file
266
src/components/companies/company-members-tab.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2, MoreHorizontal, Plus, Star, XCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { AddMembershipDialog } from './add-membership-dialog';
|
||||||
|
|
||||||
|
interface MembershipRow {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
clientId: string;
|
||||||
|
role: string;
|
||||||
|
roleDetail: string | null;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyMembersTabProps {
|
||||||
|
companyId: string;
|
||||||
|
portSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
director: 'Director',
|
||||||
|
officer: 'Officer',
|
||||||
|
broker: 'Broker',
|
||||||
|
representative: 'Representative',
|
||||||
|
legal_counsel: 'Legal counsel',
|
||||||
|
employee: 'Employee',
|
||||||
|
shareholder: 'Shareholder',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a client's name as a link by fetching the client record.
|
||||||
|
* Memoization is handled via the TanStack Query cache, so repeat renders
|
||||||
|
* for the same clientId are free.
|
||||||
|
*/
|
||||||
|
function ClientName({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||||
|
const { data } = useQuery<{ fullName: string | null }>({
|
||||||
|
queryKey: ['clients', clientId, 'name-only'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: { fullName: string | null } }>(`/api/v1/clients/${clientId}`).then(
|
||||||
|
(r) => r.data,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/clients/${clientId}` as any}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{data?.fullName ?? `Client ${clientId.slice(0, 8)}`}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [activeOnly, setActiveOnly] = useState(true);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
|
||||||
|
const membersKey = ['companies', companyId, 'members', { activeOnly }];
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<MembershipRow[]>({
|
||||||
|
queryKey: membersKey,
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: MembershipRow[] }>(
|
||||||
|
`/api/v1/companies/${companyId}/members?activeOnly=${activeOnly ? 'true' : 'false'}`,
|
||||||
|
).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const endMutation = useMutation({
|
||||||
|
mutationFn: (membershipId: string) =>
|
||||||
|
apiFetch(`/api/v1/companies/${companyId}/members/${membershipId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companies', companyId, 'members'] });
|
||||||
|
toast.success('Membership ended');
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast.error(err.message || 'Failed to end membership');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setPrimaryMutation = useMutation({
|
||||||
|
mutationFn: (membershipId: string) =>
|
||||||
|
apiFetch(`/api/v1/companies/${companyId}/members/${membershipId}/set-primary`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['companies', companyId, 'members'] });
|
||||||
|
toast.success('Primary contact updated');
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast.error(err.message || 'Failed to set primary');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="inline-flex rounded-md border p-0.5 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveOnly(true)}
|
||||||
|
className={`px-3 py-1 rounded-sm transition-colors ${
|
||||||
|
activeOnly ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveOnly(false)}
|
||||||
|
className={`px-3 py-1 rounded-sm transition-colors ${
|
||||||
|
!activeOnly ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PermissionGate resource="memberships" action="manage">
|
||||||
|
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
Add Member
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={activeOnly ? 'No active members' : 'No members yet'}
|
||||||
|
description={
|
||||||
|
activeOnly
|
||||||
|
? 'This company has no active memberships. Switch to "All" to see past members.'
|
||||||
|
: 'Add the first member to this company to get started.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Role Detail</TableHead>
|
||||||
|
<TableHead>Start Date</TableHead>
|
||||||
|
<TableHead>End Date</TableHead>
|
||||||
|
<TableHead>Primary</TableHead>
|
||||||
|
<TableHead className="w-[48px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{members.map((m) => {
|
||||||
|
const isActive = !m.endDate;
|
||||||
|
return (
|
||||||
|
<TableRow key={m.id}>
|
||||||
|
<TableCell>
|
||||||
|
<ClientName clientId={m.clientId} portSlug={portSlug} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
|
||||||
|
{m.roleDetail ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(m.startDate)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{m.endDate ? (
|
||||||
|
formatDate(m.endDate)
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{m.isPrimary ? (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Primary
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<PermissionGate resource="memberships" action="manage">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{isActive && !m.isPrimary && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setPrimaryMutation.mutate(m.id)}
|
||||||
|
disabled={setPrimaryMutation.isPending}
|
||||||
|
>
|
||||||
|
<Star className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Set Primary
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => endMutation.mutate(m.id)}
|
||||||
|
disabled={endMutation.isPending}
|
||||||
|
>
|
||||||
|
<XCircle className="mr-2 h-3.5 w-3.5" />
|
||||||
|
End Membership
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</PermissionGate>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddMembershipDialog open={addOpen} onOpenChange={setAddOpen} companyId={companyId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
src/components/companies/company-owned-yachts-tab.tsx
Normal file
156
src/components/companies/company-owned-yachts-tab.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface OwnedYachtRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
lengthM: string | null;
|
||||||
|
widthM: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YachtListResponse {
|
||||||
|
data: OwnedYachtRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyOwnedYachtsTabProps {
|
||||||
|
companyId: string;
|
||||||
|
portSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
retired: 'bg-gray-100 text-gray-800 border-gray-300',
|
||||||
|
sold_away: 'bg-amber-100 text-amber-800 border-amber-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
retired: 'Retired',
|
||||||
|
sold_away: 'Sold Away',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDimensions(y: OwnedYachtRow): string | null {
|
||||||
|
if (y.lengthFt || y.widthFt) {
|
||||||
|
const length = y.lengthFt ?? '—';
|
||||||
|
const width = y.widthFt ?? '—';
|
||||||
|
return `${length} × ${width} ft`;
|
||||||
|
}
|
||||||
|
if (y.lengthM || y.widthM) {
|
||||||
|
const length = y.lengthM ?? '—';
|
||||||
|
const width = y.widthM ?? '—';
|
||||||
|
return `${length} × ${width} m`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyOwnedYachtsTab({ companyId, portSlug }: CompanyOwnedYachtsTabProps) {
|
||||||
|
const { data, isLoading } = useQuery<OwnedYachtRow[]>({
|
||||||
|
queryKey: ['companies', companyId, 'owned-yachts'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ownerType: 'company',
|
||||||
|
ownerId: companyId,
|
||||||
|
page: '1',
|
||||||
|
limit: '50',
|
||||||
|
includeArchived: 'false',
|
||||||
|
order: 'desc',
|
||||||
|
});
|
||||||
|
const res = await apiFetch<YachtListResponse>(`/api/v1/yachts?${params.toString()}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const yachts = data ?? [];
|
||||||
|
|
||||||
|
if (yachts.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No yachts owned"
|
||||||
|
description="Yachts owned by this company will appear here."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Dimensions</TableHead>
|
||||||
|
<TableHead>Hull Number</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{yachts.map((y) => {
|
||||||
|
const dims = formatDimensions(y);
|
||||||
|
const statusLabel = STATUS_LABELS[y.status] ?? y.status;
|
||||||
|
const statusColor =
|
||||||
|
STATUS_COLORS[y.status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
return (
|
||||||
|
<TableRow key={y.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/yachts/${y.id}` as any}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{y.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dims ? (
|
||||||
|
<span className="text-sm">{dims}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{y.hullNumber ? (
|
||||||
|
<span className="text-sm">{y.hullNumber}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${statusColor}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/components/companies/company-picker.tsx
Normal file
103
src/components/companies/company-picker.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface CompanyOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyPickerProps {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (companyId: string | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Select company...',
|
||||||
|
disabled,
|
||||||
|
}: CompanyPickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const debounced = useDebounce(search, 300);
|
||||||
|
|
||||||
|
const { data } = useQuery<{ data: CompanyOption[] }>({
|
||||||
|
queryKey: ['company-picker', debounced],
|
||||||
|
queryFn: () => apiFetch(`/api/v1/companies/autocomplete?q=${encodeURIComponent(debounced)}`),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = data?.data ?? [];
|
||||||
|
|
||||||
|
const selectedLabel = (() => {
|
||||||
|
if (!value) return placeholder;
|
||||||
|
const match = options.find((o) => o.id === value);
|
||||||
|
return match?.name ?? `Company ${value.slice(0, 8)}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selectedLabel}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[320px] p-0" align="start">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput placeholder="Search companies…" value={search} onValueChange={setSearch} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No companies found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((c) => (
|
||||||
|
<CommandItem
|
||||||
|
key={c.id}
|
||||||
|
value={c.id}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(c.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn('mr-2 h-4 w-4', value === c.id ? 'opacity-100' : 'opacity-0')}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{c.name}
|
||||||
|
{c.legalName ? (
|
||||||
|
<span className="ml-2 text-xs opacity-60">{c.legalName}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
src/components/companies/company-tabs.tsx
Normal file
167
src/components/companies/company-tabs.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { CompanyMembersTab } from '@/components/companies/company-members-tab';
|
||||||
|
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab';
|
||||||
|
|
||||||
|
interface CompanyTabsCompany {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
registrationNumber: string | null;
|
||||||
|
incorporationCountry: string | null;
|
||||||
|
incorporationDate: string | null;
|
||||||
|
status: string;
|
||||||
|
billingEmail: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyTabsOptions {
|
||||||
|
companyId: string;
|
||||||
|
portSlug: string;
|
||||||
|
currentUserId?: string;
|
||||||
|
company: CompanyTabsCompany;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
dissolved: 'Dissolved',
|
||||||
|
};
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 py-1.5 border-b last:border-0">
|
||||||
|
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="text-sm">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewTab({ company }: { company: CompanyTabsCompany }) {
|
||||||
|
const incorporationDate = formatDate(company.incorporationDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Identity */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Name" value={company.name} />
|
||||||
|
<InfoRow label="Legal Name" value={company.legalName} />
|
||||||
|
<InfoRow label="Status" value={STATUS_LABELS[company.status] ?? company.status} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registration */}
|
||||||
|
{(company.taxId ||
|
||||||
|
company.registrationNumber ||
|
||||||
|
company.incorporationCountry ||
|
||||||
|
incorporationDate) && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Registration</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Tax ID" value={company.taxId} />
|
||||||
|
<InfoRow label="Registration Number" value={company.registrationNumber} />
|
||||||
|
<InfoRow label="Incorporation Country" value={company.incorporationCountry} />
|
||||||
|
<InfoRow label="Incorporation Date" value={incorporationDate} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
{company.billingEmail && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Billing Email" value={company.billingEmail} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{company.notes && (
|
||||||
|
<div className="space-y-1 md:col-span-2">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||||
|
<p className="text-sm whitespace-pre-wrap rounded-md border bg-muted/30 p-3">
|
||||||
|
{company.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCompanyTabs({
|
||||||
|
companyId,
|
||||||
|
portSlug,
|
||||||
|
// currentUserId reserved for when NotesList supports entityType='companies'.
|
||||||
|
currentUserId: _currentUserId,
|
||||||
|
company,
|
||||||
|
}: CompanyTabsOptions): DetailTab[] {
|
||||||
|
void _currentUserId;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: 'Overview',
|
||||||
|
content: <OverviewTab company={company} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'members',
|
||||||
|
label: 'Members',
|
||||||
|
content: <CompanyMembersTab companyId={companyId} portSlug={portSlug} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'owned-yachts',
|
||||||
|
label: 'Owned Yachts',
|
||||||
|
content: <CompanyOwnedYachtsTab companyId={companyId} portSlug={portSlug} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'addresses',
|
||||||
|
label: 'Addresses',
|
||||||
|
// TODO: wire to future company-addresses endpoint (see company-addresses schema).
|
||||||
|
content: (
|
||||||
|
<EmptyState
|
||||||
|
title="Addresses"
|
||||||
|
description="Company addresses coming soon — the addresses endpoint is pending wiring."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'documents',
|
||||||
|
label: 'Documents',
|
||||||
|
content: <EmptyState title="Documents" description="Coming soon" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notes',
|
||||||
|
label: 'Notes',
|
||||||
|
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
|
||||||
|
// Extend NotesList (or swap to a company-notes endpoint) in a follow-up.
|
||||||
|
content: (
|
||||||
|
<EmptyState
|
||||||
|
title="Notes"
|
||||||
|
description="Company notes coming soon — the notes endpoint is pending wiring."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tags',
|
||||||
|
label: 'Tags',
|
||||||
|
// TODO: replace with an inline tag editor once one exists; company tags
|
||||||
|
// can be edited via the Edit form in the meantime.
|
||||||
|
content: (
|
||||||
|
<EmptyState title="Tags" description="Manage tags from the Edit company form for now." />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -18,18 +18,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||||
Sheet,
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetFooter,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/popover';
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -41,6 +31,7 @@ import {
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { TagPicker } from '@/components/shared/tag-picker';
|
import { TagPicker } from '@/components/shared/tag-picker';
|
||||||
|
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { useEntityOptions } from '@/hooks/use-entity-options';
|
import { useEntityOptions } from '@/hooks/use-entity-options';
|
||||||
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
|
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
|
||||||
@@ -71,6 +62,7 @@ interface InterestFormProps {
|
|||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientName?: string | null;
|
clientName?: string | null;
|
||||||
|
yachtId?: string | null;
|
||||||
berthId?: string | null;
|
berthId?: string | null;
|
||||||
berthMooringNumber?: string | null;
|
berthMooringNumber?: string | null;
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
@@ -101,6 +93,7 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
|||||||
resolver: zodResolver(createInterestSchema),
|
resolver: zodResolver(createInterestSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
clientId: '',
|
clientId: '',
|
||||||
|
yachtId: undefined,
|
||||||
pipelineStage: 'open',
|
pipelineStage: 'open',
|
||||||
reminderEnabled: false,
|
reminderEnabled: false,
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
@@ -111,26 +104,34 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
|||||||
const reminderEnabled = watch('reminderEnabled');
|
const reminderEnabled = watch('reminderEnabled');
|
||||||
const selectedClientId = watch('clientId');
|
const selectedClientId = watch('clientId');
|
||||||
const selectedBerthId = watch('berthId');
|
const selectedBerthId = watch('berthId');
|
||||||
|
const selectedYachtId = watch('yachtId');
|
||||||
|
|
||||||
const { options: clientOptions, isLoading: clientsLoading, setSearch: setClientSearch } =
|
const {
|
||||||
useEntityOptions({
|
options: clientOptions,
|
||||||
endpoint: '/api/v1/clients/options',
|
isLoading: clientsLoading,
|
||||||
labelKey: 'fullName',
|
setSearch: setClientSearch,
|
||||||
});
|
} = useEntityOptions({
|
||||||
|
endpoint: '/api/v1/clients/options',
|
||||||
|
labelKey: 'fullName',
|
||||||
|
});
|
||||||
|
|
||||||
const { options: berthOptions, isLoading: berthsLoading, setSearch: setBerthSearch } =
|
const {
|
||||||
useEntityOptions({
|
options: berthOptions,
|
||||||
endpoint: '/api/v1/berths/options',
|
isLoading: berthsLoading,
|
||||||
labelKey: 'mooringNumber',
|
setSearch: setBerthSearch,
|
||||||
});
|
} = useEntityOptions({
|
||||||
|
endpoint: '/api/v1/berths/options',
|
||||||
|
labelKey: 'mooringNumber',
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (interest && open) {
|
if (interest && open) {
|
||||||
reset({
|
reset({
|
||||||
clientId: interest.clientId,
|
clientId: interest.clientId,
|
||||||
|
yachtId: interest.yachtId ?? undefined,
|
||||||
berthId: interest.berthId ?? undefined,
|
berthId: interest.berthId ?? undefined,
|
||||||
pipelineStage: interest.pipelineStage as typeof PIPELINE_STAGES[number],
|
pipelineStage: interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
|
||||||
leadCategory: interest.leadCategory as typeof LEAD_CATEGORIES[number] | undefined,
|
leadCategory: interest.leadCategory as (typeof LEAD_CATEGORIES)[number] | undefined,
|
||||||
source: interest.source ?? undefined,
|
source: interest.source ?? undefined,
|
||||||
notes: interest.notes ?? undefined,
|
notes: interest.notes ?? undefined,
|
||||||
reminderEnabled: interest.reminderEnabled ?? false,
|
reminderEnabled: interest.reminderEnabled ?? false,
|
||||||
@@ -140,6 +141,7 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
|||||||
} else if (!interest && open) {
|
} else if (!interest && open) {
|
||||||
reset({
|
reset({
|
||||||
clientId: '',
|
clientId: '',
|
||||||
|
yachtId: undefined,
|
||||||
pipelineStage: 'open',
|
pipelineStage: 'open',
|
||||||
reminderEnabled: false,
|
reminderEnabled: false,
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
@@ -178,10 +180,7 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
|||||||
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form
|
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
||||||
onSubmit={handleSubmit((data) => mutation.mutate(data))}
|
|
||||||
className="space-y-6 py-6"
|
|
||||||
>
|
|
||||||
{/* Client */}
|
{/* Client */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
@@ -202,16 +201,13 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
|||||||
)}
|
)}
|
||||||
disabled={isEdit}
|
disabled={isEdit}
|
||||||
>
|
>
|
||||||
{selectedClient?.label ?? (interest?.clientName ?? 'Select client...')}
|
{selectedClient?.label ?? interest?.clientName ?? 'Select client...'}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[400px] p-0">
|
<PopoverContent className="w-[400px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput placeholder="Search clients..." onValueChange={setClientSearch} />
|
||||||
placeholder="Search clients..."
|
|
||||||
onValueChange={setClientSearch}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
{clientsLoading ? 'Loading...' : 'No clients found.'}
|
{clientsLoading ? 'Loading...' : 'No clients found.'}
|
||||||
@@ -258,16 +254,13 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
|||||||
!selectedBerthId && 'text-muted-foreground',
|
!selectedBerthId && 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedBerth?.label ?? (interest?.berthMooringNumber ?? 'Select berth...')}
|
{selectedBerth?.label ?? interest?.berthMooringNumber ?? 'Select berth...'}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[400px] p-0">
|
<PopoverContent className="w-[400px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput placeholder="Search berths..." onValueChange={setBerthSearch} />
|
||||||
placeholder="Search berths..."
|
|
||||||
onValueChange={setBerthSearch}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
{berthsLoading ? 'Loading...' : 'No berths found.'}
|
{berthsLoading ? 'Loading...' : 'No berths found.'}
|
||||||
@@ -312,6 +305,24 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Yacht</Label>
|
||||||
|
<YachtPicker
|
||||||
|
value={selectedYachtId ?? null}
|
||||||
|
onChange={(id) => setValue('yachtId', id ?? undefined)}
|
||||||
|
ownerFilter={
|
||||||
|
selectedClientId ? { type: 'client', id: selectedClientId } : undefined
|
||||||
|
}
|
||||||
|
disabled={!selectedClientId}
|
||||||
|
placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Required before the interest can leave the "Open" stage.
|
||||||
|
</p>
|
||||||
|
{/* TODO: also include company-owned yachts where client is a member — requires autocomplete owner=any|company filter */}
|
||||||
|
{/* TODO: add "Add new yacht" inline shortcut (requires YachtForm integration) */}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -326,7 +337,9 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
|||||||
<Label>Stage</Label>
|
<Label>Stage</Label>
|
||||||
<Select
|
<Select
|
||||||
value={watch('pipelineStage') ?? 'open'}
|
value={watch('pipelineStage') ?? 'open'}
|
||||||
onValueChange={(v) => setValue('pipelineStage', v as typeof PIPELINE_STAGES[number])}
|
onValueChange={(v) =>
|
||||||
|
setValue('pipelineStage', v as (typeof PIPELINE_STAGES)[number])
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select stage" />
|
<SelectValue placeholder="Select stage" />
|
||||||
@@ -346,7 +359,10 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
|||||||
<Select
|
<Select
|
||||||
value={watch('leadCategory') ?? ''}
|
value={watch('leadCategory') ?? ''}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setValue('leadCategory', v ? v as typeof LEAD_CATEGORIES[number] : undefined)
|
setValue(
|
||||||
|
'leadCategory',
|
||||||
|
v ? (v as (typeof LEAD_CATEGORIES)[number]) : undefined,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -427,18 +443,11 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
|||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Tags</Label>
|
<Label>Tags</Label>
|
||||||
<TagPicker
|
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
|
||||||
selectedIds={tagIds}
|
|
||||||
onChange={(ids) => setValue('tagIds', ids)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Anchor,
|
Anchor,
|
||||||
|
Ship,
|
||||||
|
Building2,
|
||||||
Receipt,
|
Receipt,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -30,12 +32,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import {
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import type { UserPortRole } from '@/lib/db/schema/users';
|
import type { UserPortRole } from '@/lib/db/schema/users';
|
||||||
import type { Role } from '@/lib/db/schema/users';
|
import type { Role } from '@/lib/db/schema/users';
|
||||||
|
|
||||||
@@ -65,6 +62,8 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
items: [
|
items: [
|
||||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
||||||
|
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
||||||
|
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
||||||
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
||||||
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
||||||
],
|
],
|
||||||
@@ -280,7 +279,8 @@ export function Sidebar({ portRoles }: SidebarProps) {
|
|||||||
|
|
||||||
// Check for admin access based on role permissions
|
// Check for admin access based on role permissions
|
||||||
const hasAdminAccess = portRoles.some(
|
const hasAdminAccess = portRoles.some(
|
||||||
(pr) => pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
|
(pr) =>
|
||||||
|
pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { LayoutDashboard, Anchor, FileText, Receipt } from 'lucide-react';
|
import { LayoutDashboard, Anchor, FileText, Receipt, Sailboat, CalendarCheck } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: 'Dashboard', href: '/portal/dashboard', icon: LayoutDashboard },
|
{ label: 'Dashboard', href: '/portal/dashboard', icon: LayoutDashboard },
|
||||||
{ label: 'Interests', href: '/portal/interests', icon: Anchor },
|
{ label: 'Interests', href: '/portal/interests', icon: Anchor },
|
||||||
|
{ label: 'My Yachts', href: '/portal/my-yachts', icon: Sailboat },
|
||||||
|
{ label: 'Reservations', href: '/portal/my-reservations', icon: CalendarCheck },
|
||||||
{ label: 'Documents', href: '/portal/documents', icon: FileText },
|
{ label: 'Documents', href: '/portal/documents', icon: FileText },
|
||||||
{ label: 'Invoices', href: '/portal/invoices', icon: Receipt },
|
{ label: 'Invoices', href: '/portal/invoices', icon: Receipt },
|
||||||
];
|
];
|
||||||
|
|||||||
251
src/components/reservations/berth-reserve-dialog.tsx
Normal file
251
src/components/reservations/berth-reserve-dialog.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { ClientPicker } from '@/components/shared/client-picker';
|
||||||
|
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
type TenureType = 'permanent' | 'fixed_term' | 'seasonal';
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
clientId: string | null;
|
||||||
|
yachtId: string | null;
|
||||||
|
startDate: string; // YYYY-MM-DD
|
||||||
|
tenureType: TenureType;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BerthReserveDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
berthId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BerthReserveDialog({ open, onOpenChange, berthId }: BerthReserveDialogProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
clientId: null,
|
||||||
|
yachtId: null,
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
tenureType: 'permanent',
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFormError(null);
|
||||||
|
reset({
|
||||||
|
clientId: null,
|
||||||
|
yachtId: null,
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
tenureType: 'permanent',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, reset]);
|
||||||
|
|
||||||
|
const clientId = watch('clientId');
|
||||||
|
const yachtId = watch('yachtId');
|
||||||
|
const tenureType = watch('tenureType');
|
||||||
|
|
||||||
|
// When client changes, clear yacht (since yacht-picker is filtered to owner=client)
|
||||||
|
useEffect(() => {
|
||||||
|
setValue('yachtId', null);
|
||||||
|
}, [clientId, setValue]);
|
||||||
|
|
||||||
|
function validate(data: FormValues): string | null {
|
||||||
|
if (!data.clientId) return 'Please select a client';
|
||||||
|
if (!data.yachtId) return 'Please select a yacht';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPending(data: FormValues): Promise<{ id: string }> {
|
||||||
|
const res = await apiFetch<{ data: { id: string } }>(`/api/v1/berths/${berthId}/reservations`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
clientId: data.clientId!,
|
||||||
|
yachtId: data.yachtId!,
|
||||||
|
startDate: data.startDate,
|
||||||
|
tenureType: data.tenureType,
|
||||||
|
notes: data.notes?.trim() || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async (data: FormValues) => {
|
||||||
|
const err = validate(data);
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
await createPending(data);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['berths', berthId, 'reservations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['berth-reservations'] });
|
||||||
|
toast.success('Reservation created');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to create reservation';
|
||||||
|
setFormError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAndActivateMutation = useMutation({
|
||||||
|
mutationFn: async (data: FormValues) => {
|
||||||
|
const err = validate(data);
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
const pending = await createPending(data);
|
||||||
|
// Immediately activate
|
||||||
|
await apiFetch(`/api/v1/berth-reservations/${pending.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { action: 'activate' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['berths', berthId, 'reservations'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['berth-reservations'] });
|
||||||
|
toast.success('Reservation created and activated');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to activate';
|
||||||
|
if (/active reservation|conflict|409/i.test(msg)) {
|
||||||
|
setFormError(
|
||||||
|
'This berth already has an active reservation. The pending record was created — activate it manually once the other reservation ends.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setFormError(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPending = isSubmitting || createMutation.isPending || createAndActivateMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reserve this berth</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a pending reservation or activate it immediately.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Client</Label>
|
||||||
|
<ClientPicker value={clientId} onChange={(id) => setValue('clientId', id)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Yacht</Label>
|
||||||
|
<YachtPicker
|
||||||
|
value={yachtId}
|
||||||
|
onChange={(id) => setValue('yachtId', id)}
|
||||||
|
ownerFilter={clientId ? { type: 'client', id: clientId } : undefined}
|
||||||
|
disabled={!clientId}
|
||||||
|
placeholder={clientId ? 'Select yacht...' : 'Select a client first'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="startDate">Start date</Label>
|
||||||
|
<Input id="startDate" type="date" {...register('startDate', { required: true })} />
|
||||||
|
{errors.startDate && <p className="text-xs text-destructive">Required</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tenure</Label>
|
||||||
|
<Select
|
||||||
|
value={tenureType}
|
||||||
|
onValueChange={(v) => setValue('tenureType', v as TenureType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="permanent">Permanent</SelectItem>
|
||||||
|
<SelectItem value="fixed_term">Fixed term</SelectItem>
|
||||||
|
<SelectItem value="seasonal">Seasonal</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="notes">Notes (optional)</Label>
|
||||||
|
<Textarea id="notes" rows={2} {...register('notes')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||||
|
|
||||||
|
<DialogFooter className="flex-col-reverse sm:flex-row gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleSubmit((data) => {
|
||||||
|
setFormError(null);
|
||||||
|
createMutation.mutate(data);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Create reservation
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleSubmit((data) => {
|
||||||
|
setFormError(null);
|
||||||
|
createAndActivateMutation.mutate(data);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{createAndActivateMutation.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Create and activate
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/components/reservations/reservation-list.tsx
Normal file
215
src/components/reservations/reservation-list.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
export interface ReservationRow {
|
||||||
|
id: string;
|
||||||
|
berthId: string;
|
||||||
|
portId: string;
|
||||||
|
clientId: string;
|
||||||
|
yachtId: string;
|
||||||
|
status: 'pending' | 'active' | 'ended' | 'cancelled';
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
tenureType: string;
|
||||||
|
contractFileId: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservationListProps {
|
||||||
|
reservations: ReservationRow[];
|
||||||
|
showBerth?: boolean;
|
||||||
|
portSlug?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a client's name as a link by fetching the client record.
|
||||||
|
* Uses TanStack Query cache for memoization of repeated clientId queries.
|
||||||
|
*/
|
||||||
|
function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||||
|
const { data } = useQuery<{ fullName: string }>({
|
||||||
|
queryKey: ['clients', clientId, 'name-only'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: { fullName: string } }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/clients/${clientId}` as any}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{data?.fullName ?? `Client ${clientId.slice(0, 8)}`}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a yacht's name as a link by fetching the yacht record.
|
||||||
|
*/
|
||||||
|
function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
|
||||||
|
const { data } = useQuery<{ name: string }>({
|
||||||
|
queryKey: ['yachts', yachtId, 'name-only'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: { name: string } }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/yachts/${yachtId}` as any}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{data?.name ?? `Yacht ${yachtId.slice(0, 8)}`}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a berth's mooring number as a link by fetching the berth record.
|
||||||
|
*/
|
||||||
|
function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
|
||||||
|
const { data } = useQuery<{ mooringNumber: string }>({
|
||||||
|
queryKey: ['berths', berthId, 'name-only'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: { mooringNumber: string } }>(`/api/v1/berths/${berthId}`).then(
|
||||||
|
(r) => r.data,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/berths/${berthId}` as any}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{data?.mooringNumber ?? `Berth ${berthId.slice(0, 8)}`}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a status badge with appropriate color coding.
|
||||||
|
*/
|
||||||
|
function StatusBadge({ status }: { status: ReservationRow['status'] }) {
|
||||||
|
const colorMap: Record<ReservationRow['status'], string> = {
|
||||||
|
pending: 'bg-gray-100 text-gray-800',
|
||||||
|
active: 'bg-green-100 text-green-800',
|
||||||
|
ended: 'bg-blue-100 text-blue-800',
|
||||||
|
cancelled: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const color = colorMap[status];
|
||||||
|
const label = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pretty-prints tenure type for display.
|
||||||
|
*/
|
||||||
|
function prettyTenure(tenureType: string): string {
|
||||||
|
const tenureMap: Record<string, string> = {
|
||||||
|
permanent: 'Permanent',
|
||||||
|
fixed_term: 'Fixed term',
|
||||||
|
seasonal: 'Seasonal',
|
||||||
|
};
|
||||||
|
return tenureMap[tenureType] ?? tenureType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date range as "{startDate} → {endDate or 'ongoing'}".
|
||||||
|
*/
|
||||||
|
function formatDateRange(startDate: string, endDate: string | null): string {
|
||||||
|
const start = new Date(startDate).toLocaleDateString();
|
||||||
|
const end = endDate ? new Date(endDate).toLocaleDateString() : 'ongoing';
|
||||||
|
return `${start} → ${end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReservationList({
|
||||||
|
reservations,
|
||||||
|
showBerth = false,
|
||||||
|
portSlug: portSlugProp,
|
||||||
|
emptyMessage,
|
||||||
|
}: ReservationListProps) {
|
||||||
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = portSlugProp ?? routeParams?.portSlug ?? '';
|
||||||
|
|
||||||
|
if (reservations.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState title="No reservations" description={emptyMessage ?? 'No reservations yet.'} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{showBerth && <TableHead>Berth</TableHead>}
|
||||||
|
<TableHead>Client</TableHead>
|
||||||
|
<TableHead>Yacht</TableHead>
|
||||||
|
<TableHead>Dates</TableHead>
|
||||||
|
<TableHead>Tenure</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Contract</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{reservations.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
{showBerth && (
|
||||||
|
<TableCell>
|
||||||
|
<BerthLink berthId={r.berthId} portSlug={portSlug} />
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell>
|
||||||
|
<ClientLink clientId={r.clientId} portSlug={portSlug} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<YachtLink yachtId={r.yachtId} portSlug={portSlug} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDateRange(r.startDate, r.endDate)}</TableCell>
|
||||||
|
<TableCell>{prettyTenure(r.tenureType)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={r.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{r.contractFileId ? (
|
||||||
|
// TODO: Confirm final file-download endpoint URL when available
|
||||||
|
<a
|
||||||
|
href={`/api/v1/files/${r.contractFileId}/download`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View contract
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Search, Clock, User, TrendingUp, Anchor } from 'lucide-react';
|
import { Search, Clock, User, TrendingUp, Anchor, Ship, Building2 } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useSearch } from '@/hooks/use-search';
|
import { useSearch } from '@/hooks/use-search';
|
||||||
@@ -22,7 +22,11 @@ export function CommandSearch() {
|
|||||||
const hasQuery = query.length >= 2;
|
const hasQuery = query.length >= 2;
|
||||||
const hasResults =
|
const hasResults =
|
||||||
results &&
|
results &&
|
||||||
(results.clients.length > 0 || results.interests.length > 0 || results.berths.length > 0);
|
(results.clients.length > 0 ||
|
||||||
|
results.interests.length > 0 ||
|
||||||
|
results.berths.length > 0 ||
|
||||||
|
results.yachts.length > 0 ||
|
||||||
|
results.companies.length > 0);
|
||||||
|
|
||||||
// Cmd/Ctrl+K focuses the input
|
// Cmd/Ctrl+K focuses the input
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,7 +71,13 @@ export function CommandSearch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconMap = { client: User, interest: TrendingUp, berth: Anchor } as const;
|
const iconMap = {
|
||||||
|
client: User,
|
||||||
|
interest: TrendingUp,
|
||||||
|
berth: Anchor,
|
||||||
|
yacht: Ship,
|
||||||
|
company: Building2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="relative">
|
<div ref={wrapperRef} className="relative">
|
||||||
@@ -148,6 +158,32 @@ export function CommandSearch() {
|
|||||||
onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)}
|
onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{results.yachts.length > 0 && (
|
||||||
|
<ResultGroup
|
||||||
|
heading="Yachts"
|
||||||
|
items={results.yachts.map((y) => ({
|
||||||
|
id: y.id,
|
||||||
|
icon: 'yacht',
|
||||||
|
label: y.name,
|
||||||
|
sub: [y.hullNumber, y.registration].filter(Boolean).join(' · ') || null,
|
||||||
|
}))}
|
||||||
|
iconMap={iconMap}
|
||||||
|
onSelect={(id) => navigate(`/${portSlug}/yachts/${id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{results.companies.length > 0 && (
|
||||||
|
<ResultGroup
|
||||||
|
heading="Companies"
|
||||||
|
items={results.companies.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
icon: 'company',
|
||||||
|
label: c.name,
|
||||||
|
sub: [c.legalName, c.taxId].filter(Boolean).join(' · ') || null,
|
||||||
|
}))}
|
||||||
|
iconMap={iconMap}
|
||||||
|
onSelect={(id) => navigate(`/${portSlug}/companies/${id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{results.interests.length > 0 && (
|
{results.interests.length > 0 && (
|
||||||
<ResultGroup
|
<ResultGroup
|
||||||
heading="Interests"
|
heading="Interests"
|
||||||
@@ -190,7 +226,12 @@ function ResultGroup({
|
|||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
heading: string;
|
heading: string;
|
||||||
items: Array<{ id: string; icon: 'client' | 'interest' | 'berth'; label: string; sub?: string | null }>;
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
icon: 'client' | 'interest' | 'berth' | 'yacht' | 'company';
|
||||||
|
label: string;
|
||||||
|
sub?: string | null;
|
||||||
|
}>;
|
||||||
iconMap: Record<string, React.ElementType | undefined>;
|
iconMap: Record<string, React.ElementType | undefined>;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { User, Anchor, TrendingUp } from 'lucide-react';
|
import { User, Anchor, TrendingUp, Ship, Building2 } from 'lucide-react';
|
||||||
|
|
||||||
import { CommandItem } from '@/components/ui/command';
|
import { CommandItem } from '@/components/ui/command';
|
||||||
|
|
||||||
@@ -26,10 +26,26 @@ interface BerthItem {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface YachtItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
type SearchResultItemProps =
|
type SearchResultItemProps =
|
||||||
| { type: 'client'; item: ClientItem; onSelect: () => void }
|
| { type: 'client'; item: ClientItem; onSelect: () => void }
|
||||||
| { type: 'interest'; item: InterestItem; onSelect: () => void }
|
| { type: 'interest'; item: InterestItem; onSelect: () => void }
|
||||||
| { type: 'berth'; item: BerthItem; onSelect: () => void };
|
| { type: 'berth'; item: BerthItem; onSelect: () => void }
|
||||||
|
| { type: 'yacht'; item: YachtItem; onSelect: () => void }
|
||||||
|
| { type: 'company'; item: CompanyItem; onSelect: () => void };
|
||||||
|
|
||||||
// ─── Component ────────────────────────────────────────────────────────────────
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -63,6 +79,38 @@ export function SearchResultItem({ type, item, onSelect }: SearchResultItemProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'yacht') {
|
||||||
|
return (
|
||||||
|
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
|
||||||
|
<Ship className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{item.name}</span>
|
||||||
|
{(item.hullNumber || item.registration) && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{[item.hullNumber, item.registration].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'company') {
|
||||||
|
return (
|
||||||
|
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{item.name}</span>
|
||||||
|
{(item.legalName || item.taxId) && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{[item.legalName, item.taxId].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// berth
|
// berth
|
||||||
return (
|
return (
|
||||||
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
|
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
|
||||||
|
|||||||
106
src/components/shared/client-picker.tsx
Normal file
106
src/components/shared/client-picker.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ClientOption {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
companyName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientPickerProps {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (clientId: string | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Select client...',
|
||||||
|
disabled,
|
||||||
|
}: ClientPickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const debounced = useDebounce(search, 300);
|
||||||
|
|
||||||
|
const { data } = useQuery<{ data: ClientOption[] }>({
|
||||||
|
queryKey: ['client-picker', debounced],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch(
|
||||||
|
`/api/v1/clients?search=${encodeURIComponent(debounced)}&page=1&limit=10&order=desc&includeArchived=false`,
|
||||||
|
),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = data?.data ?? [];
|
||||||
|
|
||||||
|
const selectedLabel = (() => {
|
||||||
|
if (!value) return placeholder;
|
||||||
|
const match = options.find((o) => o.id === value);
|
||||||
|
return match?.fullName ?? `Client ${value.slice(0, 8)}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selectedLabel}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[320px] p-0" align="start">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput placeholder="Search clients…" value={search} onValueChange={setSearch} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No clients found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((c) => (
|
||||||
|
<CommandItem
|
||||||
|
key={c.id}
|
||||||
|
value={c.id}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(c.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn('mr-2 h-4 w-4', value === c.id ? 'opacity-100' : 'opacity-0')}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{c.fullName}
|
||||||
|
{c.companyName ? (
|
||||||
|
<span className="ml-2 text-xs opacity-60">{c.companyName}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/components/shared/owner-picker.tsx
Normal file
164
src/components/shared/owner-picker.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type OwnerRef = { type: 'client' | 'company'; id: string };
|
||||||
|
|
||||||
|
interface OwnerOption {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
fullName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnerPickerProps {
|
||||||
|
value: OwnerRef | null;
|
||||||
|
onChange: (value: OwnerRef | null) => void;
|
||||||
|
/** Optional placeholder when empty */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Disable the component */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OwnerPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Select owner...',
|
||||||
|
disabled,
|
||||||
|
}: OwnerPickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [type, setType] = useState<'client' | 'company'>(value?.type ?? 'client');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const debounced = useDebounce(search, 300);
|
||||||
|
|
||||||
|
// Keep local `type` in sync if value.type changes externally.
|
||||||
|
useEffect(() => {
|
||||||
|
if (value?.type) setType(value.type);
|
||||||
|
}, [value?.type]);
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
type === 'client'
|
||||||
|
? `/api/v1/clients/options?search=${encodeURIComponent(debounced)}`
|
||||||
|
: `/api/v1/companies/autocomplete?q=${encodeURIComponent(debounced)}`;
|
||||||
|
|
||||||
|
const { data } = useQuery<{ data: OwnerOption[] }>({
|
||||||
|
queryKey: ['owner-picker', type, debounced],
|
||||||
|
queryFn: () => apiFetch(endpoint),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = data?.data ?? [];
|
||||||
|
|
||||||
|
// Selected display label — show entity's name from current options if
|
||||||
|
// available, otherwise a truncated id fallback.
|
||||||
|
const selectedLabel = (() => {
|
||||||
|
if (!value) return placeholder;
|
||||||
|
const match = options.find((o) => o.id === value.id);
|
||||||
|
if (match) {
|
||||||
|
return type === 'client'
|
||||||
|
? (match.fullName ?? '(unnamed client)')
|
||||||
|
: (match.name ?? '(unnamed company)');
|
||||||
|
}
|
||||||
|
return value.type === 'client'
|
||||||
|
? `Client ${value.id.slice(0, 8)}`
|
||||||
|
: `Company ${value.id.slice(0, 8)}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{value && (
|
||||||
|
<span className="mr-2 text-xs opacity-60">
|
||||||
|
{value.type === 'client' ? 'Client:' : 'Company:'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedLabel}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[320px] p-0" align="start">
|
||||||
|
{/* Type toggle */}
|
||||||
|
<div className="flex border-b">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setType('client');
|
||||||
|
setSearch('');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-3 py-2 text-xs',
|
||||||
|
type === 'client' ? 'bg-accent font-medium' : 'hover:bg-accent/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Client
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setType('company');
|
||||||
|
setSearch('');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-3 py-2 text-xs',
|
||||||
|
type === 'company' ? 'bg-accent font-medium' : 'hover:bg-accent/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Company
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput placeholder={`Search ${type}s…`} value={search} onValueChange={setSearch} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const label =
|
||||||
|
type === 'client' ? (opt.fullName ?? '(unnamed)') : (opt.name ?? '(unnamed)');
|
||||||
|
const isSelected = value?.id === opt.id && value?.type === type;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={opt.id}
|
||||||
|
value={opt.id}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange({ type, id: opt.id });
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn('mr-2 h-4 w-4', isSelected ? 'opacity-100' : 'opacity-0')}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
src/components/yachts/yacht-columns.tsx
Normal file
176
src/components/yachts/yacht-columns.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { MoreHorizontal, Pencil, Archive, Eye } from 'lucide-react';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { OwnerLink } from '@/components/yachts/yacht-detail-header';
|
||||||
|
|
||||||
|
export interface YachtRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
currentOwnerType: 'client' | 'company';
|
||||||
|
currentOwnerId: string;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
draftFt: string | null;
|
||||||
|
lengthM: string | null;
|
||||||
|
widthM: string | null;
|
||||||
|
status: string;
|
||||||
|
archivedAt: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
retired: 'bg-gray-100 text-gray-800 border-gray-300',
|
||||||
|
sold_away: 'bg-amber-100 text-amber-800 border-amber-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
retired: 'Retired',
|
||||||
|
sold_away: 'Sold Away',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDimensions(yacht: YachtRow): string | null {
|
||||||
|
if (yacht.lengthFt || yacht.widthFt) {
|
||||||
|
const length = yacht.lengthFt ?? '—';
|
||||||
|
const width = yacht.widthFt ?? '—';
|
||||||
|
return `${length} × ${width} ft`;
|
||||||
|
}
|
||||||
|
if (yacht.lengthM || yacht.widthM) {
|
||||||
|
const length = yacht.lengthM ?? '—';
|
||||||
|
const width = yacht.widthM ?? '—';
|
||||||
|
return `${length} × ${width} m`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetYachtColumnsOptions {
|
||||||
|
portSlug: string;
|
||||||
|
onEdit: (yacht: YachtRow) => void;
|
||||||
|
onArchive: (yacht: YachtRow) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getYachtColumns({
|
||||||
|
portSlug,
|
||||||
|
onEdit,
|
||||||
|
onArchive,
|
||||||
|
}: GetYachtColumnsOptions): ColumnDef<YachtRow, unknown>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/yachts/${row.original.id}` as any}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{row.original.name}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'currentOwner',
|
||||||
|
header: 'Current Owner',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<OwnerLink
|
||||||
|
portSlug={portSlug}
|
||||||
|
type={row.original.currentOwnerType}
|
||||||
|
id={row.original.currentOwnerId}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dimensions',
|
||||||
|
header: 'Dimensions',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const dims = formatDimensions(row.original);
|
||||||
|
if (!dims) return <span className="text-muted-foreground">—</span>;
|
||||||
|
return <span className="text-sm">{dims}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hullNumber',
|
||||||
|
accessorKey: 'hullNumber',
|
||||||
|
header: 'Hull Number',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue() as string | null;
|
||||||
|
if (!value) return <span className="text-muted-foreground">—</span>;
|
||||||
|
return <span className="text-sm">{value}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const label = STATUS_LABELS[status] ?? status;
|
||||||
|
const color = STATUS_COLORS[status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${color}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '',
|
||||||
|
enableSorting: false,
|
||||||
|
size: 48,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/${portSlug}/yachts/${row.original.id}` as any}
|
||||||
|
>
|
||||||
|
<Eye className="mr-2 h-3.5 w-3.5" />
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||||
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
|
||||||
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
241
src/components/yachts/yacht-detail-header.tsx
Normal file
241
src/components/yachts/yacht-detail-header.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Pencil, Archive, ArrowRightLeft } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||||
|
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface YachtDetailHeaderYacht {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
flag: string | null;
|
||||||
|
yearBuilt: number | null;
|
||||||
|
builder: string | null;
|
||||||
|
model: string | null;
|
||||||
|
hullMaterial: string | null;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
draftFt: string | null;
|
||||||
|
lengthM: string | null;
|
||||||
|
widthM: string | null;
|
||||||
|
draftM: string | null;
|
||||||
|
currentOwnerType: 'client' | 'company';
|
||||||
|
currentOwnerId: string;
|
||||||
|
status: string;
|
||||||
|
notes: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YachtDetailHeaderProps {
|
||||||
|
yacht: YachtDetailHeaderYacht;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
retired: 'bg-gray-100 text-gray-800 border-gray-300',
|
||||||
|
sold_away: 'bg-amber-100 text-amber-800 border-amber-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
retired: 'Retired',
|
||||||
|
sold_away: 'Sold Away',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OwnerLink({
|
||||||
|
portSlug,
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
portSlug: string;
|
||||||
|
type: 'client' | 'company';
|
||||||
|
id: string;
|
||||||
|
}) {
|
||||||
|
const { data } = useQuery<{ fullName?: string; name?: string }>({
|
||||||
|
queryKey: [type === 'client' ? 'clients' : 'companies', id, 'name-only'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: { fullName?: string; name?: string } }>(
|
||||||
|
type === 'client' ? `/api/v1/clients/${id}` : `/api/v1/companies/${id}`,
|
||||||
|
).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = type === 'client' ? data?.fullName : data?.name;
|
||||||
|
const href = type === 'client' ? `/${portSlug}/clients/${id}` : `/${portSlug}/companies/${id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={href as any}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{label ?? `${type === 'client' ? 'Client' : 'Company'} ${id.slice(0, 8)}`}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDimensions(yacht: YachtDetailHeaderYacht): string | null {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (yacht.lengthFt) parts.push(`${yacht.lengthFt} ft`);
|
||||||
|
if (yacht.widthFt) parts.push(`${yacht.widthFt} ft`);
|
||||||
|
|
||||||
|
let summary: string | null = null;
|
||||||
|
if (parts.length > 0) {
|
||||||
|
summary = parts.join(' × ');
|
||||||
|
}
|
||||||
|
if (yacht.draftFt) {
|
||||||
|
summary = summary ? `${summary} (draft ${yacht.draftFt} ft)` : `draft ${yacht.draftFt} ft`;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
|
const [transferOpen, setTransferOpen] = useState(false);
|
||||||
|
|
||||||
|
const isArchived = !!yacht.archivedAt;
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: () => apiFetch(`/api/v1/yachts/${yacht.id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['yachts', yacht.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['yachts'] });
|
||||||
|
toast.success('Yacht archived');
|
||||||
|
setArchiveOpen(false);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
router.push(`/${portSlug}/yachts` as any);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast.error(err.message || 'Failed to archive yacht');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dimensions = formatDimensions(yacht);
|
||||||
|
const statusLabel = STATUS_LABELS[yacht.status] ?? yacht.status;
|
||||||
|
const statusColor = STATUS_COLORS[yacht.status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground truncate">{yacht.name}</h1>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
{isArchived && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Archived
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dimensions && <p className="text-muted-foreground mt-0.5 text-sm">{dimensions}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
||||||
|
<span>Owner:</span>
|
||||||
|
<OwnerLink
|
||||||
|
portSlug={portSlug}
|
||||||
|
type={yacht.currentOwnerType}
|
||||||
|
id={yacht.currentOwnerId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||||
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<PermissionGate resource="yachts" action="transfer">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTransferOpen(true)}
|
||||||
|
disabled={isArchived}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setArchiveOpen(true)}
|
||||||
|
disabled={isArchived}
|
||||||
|
>
|
||||||
|
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<YachtForm
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
yacht={{
|
||||||
|
id: yacht.id,
|
||||||
|
name: yacht.name,
|
||||||
|
hullNumber: yacht.hullNumber,
|
||||||
|
registration: yacht.registration,
|
||||||
|
flag: yacht.flag,
|
||||||
|
yearBuilt: yacht.yearBuilt,
|
||||||
|
builder: yacht.builder,
|
||||||
|
model: yacht.model,
|
||||||
|
hullMaterial: yacht.hullMaterial,
|
||||||
|
lengthFt: yacht.lengthFt,
|
||||||
|
widthFt: yacht.widthFt,
|
||||||
|
draftFt: yacht.draftFt,
|
||||||
|
lengthM: yacht.lengthM,
|
||||||
|
widthM: yacht.widthM,
|
||||||
|
draftM: yacht.draftM,
|
||||||
|
currentOwnerType: yacht.currentOwnerType,
|
||||||
|
currentOwnerId: yacht.currentOwnerId,
|
||||||
|
status: yacht.status,
|
||||||
|
notes: yacht.notes,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArchiveConfirmDialog
|
||||||
|
open={archiveOpen}
|
||||||
|
onOpenChange={setArchiveOpen}
|
||||||
|
entityName={yacht.name}
|
||||||
|
entityType="Yacht"
|
||||||
|
isArchived={isArchived}
|
||||||
|
onConfirm={() => {
|
||||||
|
archiveMutation.mutate();
|
||||||
|
}}
|
||||||
|
isLoading={archiveMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<YachtTransferDialog
|
||||||
|
open={transferOpen}
|
||||||
|
onOpenChange={setTransferOpen}
|
||||||
|
yachtId={yacht.id}
|
||||||
|
currentOwner={{ type: yacht.currentOwnerType, id: yacht.currentOwnerId }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/yachts/yacht-detail.tsx
Normal file
67
src/components/yachts/yacht-detail.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
|
import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
|
||||||
|
import { getYachtTabs } from '@/components/yachts/yacht-tabs';
|
||||||
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
export interface YachtData {
|
||||||
|
id: string;
|
||||||
|
portId: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
flag: string | null;
|
||||||
|
yearBuilt: number | null;
|
||||||
|
builder: string | null;
|
||||||
|
model: string | null;
|
||||||
|
hullMaterial: string | null;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
draftFt: string | null;
|
||||||
|
lengthM: string | null;
|
||||||
|
widthM: string | null;
|
||||||
|
draftM: string | null;
|
||||||
|
currentOwnerType: 'client' | 'company';
|
||||||
|
currentOwnerId: string;
|
||||||
|
status: string;
|
||||||
|
notes: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YachtDetailProps {
|
||||||
|
yachtId: string;
|
||||||
|
currentUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
|
||||||
|
const { data, isLoading } = useQuery<YachtData>({
|
||||||
|
queryKey: ['yachts', yachtId],
|
||||||
|
queryFn: () => apiFetch<{ data: YachtData }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
useRealtimeInvalidation({
|
||||||
|
'yacht:updated': [['yachts', yachtId]],
|
||||||
|
'yacht:archived': [['yachts', yachtId]],
|
||||||
|
'yacht:ownership_transferred': [
|
||||||
|
['yachts', yachtId],
|
||||||
|
['yachts', yachtId, 'ownership-history'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = data ? getYachtTabs({ yachtId, currentUserId, yacht: data }) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailLayout
|
||||||
|
header={data ? <YachtDetailHeader yacht={data} /> : null}
|
||||||
|
tabs={tabs}
|
||||||
|
defaultTab="overview"
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/yachts/yacht-filters.tsx
Normal file
34
src/components/yachts/yacht-filters.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { FilterDefinition } from '@/components/shared/filter-bar';
|
||||||
|
|
||||||
|
export const yachtFilterDefinitions: FilterDefinition[] = [
|
||||||
|
{
|
||||||
|
key: 'search',
|
||||||
|
label: 'Search',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Search by name, hull, registration...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'Active', value: 'active' },
|
||||||
|
{ label: 'Retired', value: 'retired' },
|
||||||
|
{ label: 'Sold Away', value: 'sold_away' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ownerType',
|
||||||
|
label: 'Owner Type',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'Client', value: 'client' },
|
||||||
|
{ label: 'Company', value: 'company' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'includeArchived',
|
||||||
|
label: 'Include Archived',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
];
|
||||||
356
src/components/yachts/yacht-form.tsx
Normal file
356
src/components/yachts/yacht-form.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { OwnerPicker, type OwnerRef } from '@/components/shared/owner-picker';
|
||||||
|
import { TagPicker } from '@/components/shared/tag-picker';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { createYachtSchema, type CreateYachtInput } from '@/lib/validators/yachts';
|
||||||
|
|
||||||
|
interface YachtFormProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** If provided, form is in edit mode */
|
||||||
|
yacht?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber?: string | null;
|
||||||
|
registration?: string | null;
|
||||||
|
flag?: string | null;
|
||||||
|
yearBuilt?: number | null;
|
||||||
|
builder?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
hullMaterial?: string | null;
|
||||||
|
lengthFt?: string | null;
|
||||||
|
widthFt?: string | null;
|
||||||
|
draftFt?: string | null;
|
||||||
|
lengthM?: string | null;
|
||||||
|
widthM?: string | null;
|
||||||
|
draftM?: string | null;
|
||||||
|
currentOwnerType: 'client' | 'company';
|
||||||
|
currentOwnerId: string;
|
||||||
|
status?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type YachtStatus = 'active' | 'retired' | 'sold_away';
|
||||||
|
|
||||||
|
export function YachtForm({ open, onOpenChange, yacht }: YachtFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isEdit = !!yacht;
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<CreateYachtInput>({
|
||||||
|
resolver: zodResolver(createYachtSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
status: 'active',
|
||||||
|
tagIds: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagIds = watch('tagIds') ?? [];
|
||||||
|
const owner = watch('owner') as OwnerRef | undefined;
|
||||||
|
const status = watch('status') ?? 'active';
|
||||||
|
|
||||||
|
// Populate form when editing, or reset to defaults in create mode.
|
||||||
|
useEffect(() => {
|
||||||
|
if (yacht && open) {
|
||||||
|
reset({
|
||||||
|
name: yacht.name,
|
||||||
|
hullNumber: yacht.hullNumber ?? undefined,
|
||||||
|
registration: yacht.registration ?? undefined,
|
||||||
|
flag: yacht.flag ?? undefined,
|
||||||
|
yearBuilt: yacht.yearBuilt ?? undefined,
|
||||||
|
builder: yacht.builder ?? undefined,
|
||||||
|
model: yacht.model ?? undefined,
|
||||||
|
hullMaterial: yacht.hullMaterial ?? undefined,
|
||||||
|
lengthFt: yacht.lengthFt ?? undefined,
|
||||||
|
widthFt: yacht.widthFt ?? undefined,
|
||||||
|
draftFt: yacht.draftFt ?? undefined,
|
||||||
|
lengthM: yacht.lengthM ?? undefined,
|
||||||
|
widthM: yacht.widthM ?? undefined,
|
||||||
|
draftM: yacht.draftM ?? undefined,
|
||||||
|
// Owner is required by the schema in create mode. In edit mode we
|
||||||
|
// strip it before PATCH, but we still satisfy the resolver by
|
||||||
|
// supplying the current owner.
|
||||||
|
owner: { type: yacht.currentOwnerType, id: yacht.currentOwnerId },
|
||||||
|
status: (yacht.status as YachtStatus | null) ?? 'active',
|
||||||
|
notes: yacht.notes ?? undefined,
|
||||||
|
tagIds: [],
|
||||||
|
});
|
||||||
|
} else if (!yacht && open) {
|
||||||
|
reset({
|
||||||
|
name: '',
|
||||||
|
status: 'active',
|
||||||
|
tagIds: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setFormError(null);
|
||||||
|
}, [yacht, open, reset]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (data: CreateYachtInput) => {
|
||||||
|
if (isEdit) {
|
||||||
|
// updateYachtSchema omits owner + tagIds — strip them from PATCH body.
|
||||||
|
const { owner: _owner, tagIds: _tIds, ...rest } = data;
|
||||||
|
void _owner;
|
||||||
|
void _tIds;
|
||||||
|
await apiFetch(`/api/v1/yachts/${yacht!.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: rest,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiFetch('/api/v1/yachts', { method: 'POST', body: data });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['yachts'] });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setFormError(err.message || 'Failed to save yacht');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{isEdit ? 'Edit Yacht' : 'New Yacht'}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data) => {
|
||||||
|
setFormError(null);
|
||||||
|
mutation.mutate(data);
|
||||||
|
})}
|
||||||
|
className="space-y-6 py-6"
|
||||||
|
>
|
||||||
|
{/* Basic */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Basic
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<Label>Name *</Label>
|
||||||
|
<Input {...register('name')} placeholder="Sea Breeze" />
|
||||||
|
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Hull Number</Label>
|
||||||
|
<Input {...register('hullNumber')} placeholder="HIN" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Registration</Label>
|
||||||
|
<Input {...register('registration')} placeholder="Registration #" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Flag</Label>
|
||||||
|
<Input {...register('flag')} placeholder="e.g. MT" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Year Built</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...register('yearBuilt', { valueAsNumber: true })}
|
||||||
|
placeholder="2015"
|
||||||
|
/>
|
||||||
|
{errors.yearBuilt && (
|
||||||
|
<p className="text-xs text-destructive">{errors.yearBuilt.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Build */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Build
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Builder</Label>
|
||||||
|
<Input {...register('builder')} placeholder="Benetti" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Model</Label>
|
||||||
|
<Input {...register('model')} placeholder="Classic 120" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<Label>Hull Material</Label>
|
||||||
|
<Input {...register('hullMaterial')} placeholder="Aluminium" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Dimensions (ft) */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Dimensions (ft)
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Length (ft)</Label>
|
||||||
|
<Input {...register('lengthFt')} placeholder="120" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Width (ft)</Label>
|
||||||
|
<Input {...register('widthFt')} placeholder="25" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Draft (ft)</Label>
|
||||||
|
<Input {...register('draftFt')} placeholder="8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Dimensions (m) */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Dimensions (m)
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Length (m)</Label>
|
||||||
|
<Input {...register('lengthM')} placeholder="36.5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Width (m)</Label>
|
||||||
|
<Input {...register('widthM')} placeholder="7.6" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Draft (m)</Label>
|
||||||
|
<Input {...register('draftM')} placeholder="2.4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Ownership */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Ownership
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isEdit ? (
|
||||||
|
<p className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
Ownership changes use the Transfer button.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Owner *</Label>
|
||||||
|
<OwnerPicker
|
||||||
|
value={owner ?? null}
|
||||||
|
onChange={(v) => {
|
||||||
|
if (v) {
|
||||||
|
setValue('owner', v, { shouldValidate: true });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errors.owner && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.owner.message ?? 'Owner is required'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select value={status} onValueChange={(v) => setValue('status', v as YachtStatus)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="retired">Retired</SelectItem>
|
||||||
|
<SelectItem value="sold_away">Sold away</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
{...register('notes')}
|
||||||
|
placeholder="Internal notes about this yacht"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||||
|
{(isSubmitting || mutation.isPending) && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{isEdit ? 'Save Changes' : 'Create Yacht'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
src/components/yachts/yacht-list.tsx
Normal file
170
src/components/yachts/yacht-list.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DataTable } from '@/components/shared/data-table';
|
||||||
|
import { FilterBar } from '@/components/shared/filter-bar';
|
||||||
|
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||||
|
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||||
|
import { yachtFilterDefinitions } from '@/components/yachts/yacht-filters';
|
||||||
|
import { getYachtColumns, type YachtRow } from '@/components/yachts/yacht-columns';
|
||||||
|
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||||
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
export function YachtList() {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editYacht, setEditYacht] = useState<YachtRow | null>(null);
|
||||||
|
const [archiveYacht, setArchiveYacht] = useState<YachtRow | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
pagination,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
sort,
|
||||||
|
setSort,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
filters,
|
||||||
|
setFilter,
|
||||||
|
clearFilters,
|
||||||
|
} = usePaginatedQuery<YachtRow>({
|
||||||
|
queryKey: ['yachts'],
|
||||||
|
endpoint: '/api/v1/yachts',
|
||||||
|
filterDefinitions: yachtFilterDefinitions,
|
||||||
|
});
|
||||||
|
|
||||||
|
useRealtimeInvalidation({
|
||||||
|
'yacht:created': [['yachts']],
|
||||||
|
'yacht:updated': [['yachts']],
|
||||||
|
'yacht:archived': [['yachts']],
|
||||||
|
'yacht:ownership_transferred': [['yachts']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiFetch(`/api/v1/yachts/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['yachts'] });
|
||||||
|
setArchiveYacht(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = getYachtColumns({
|
||||||
|
portSlug,
|
||||||
|
onEdit: (yacht) => setEditYacht(yacht),
|
||||||
|
onArchive: (yacht) => setArchiveYacht(yacht),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="Yachts"
|
||||||
|
description="Manage yacht records"
|
||||||
|
actions={
|
||||||
|
<PermissionGate resource="yachts" action="create">
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
New Yacht
|
||||||
|
</Button>
|
||||||
|
</PermissionGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FilterBar
|
||||||
|
filters={yachtFilterDefinitions}
|
||||||
|
values={filters}
|
||||||
|
onChange={setFilter}
|
||||||
|
onClear={clearFilters}
|
||||||
|
/>
|
||||||
|
<SavedViewsDropdown
|
||||||
|
entityType="yachts"
|
||||||
|
currentFilters={filters}
|
||||||
|
currentSort={sort}
|
||||||
|
onApplyView={(savedFilters, _savedSort) => {
|
||||||
|
clearFilters();
|
||||||
|
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : !data.length ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No yachts found"
|
||||||
|
description="Get started by adding your first yacht."
|
||||||
|
action={{ label: 'New Yacht', onClick: () => setCreateOpen(true) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
pagination={pagination}
|
||||||
|
onPaginationChange={(p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
}}
|
||||||
|
sort={sort}
|
||||||
|
onSortChange={setSort}
|
||||||
|
isLoading={isFetching && !isLoading}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
emptyState={
|
||||||
|
<EmptyState
|
||||||
|
title="No yachts found"
|
||||||
|
description="Get started by adding your first yacht."
|
||||||
|
action={{ label: 'New Yacht', onClick: () => setCreateOpen(true) }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<YachtForm open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
|
|
||||||
|
{editYacht && (
|
||||||
|
<YachtForm
|
||||||
|
open={!!editYacht}
|
||||||
|
onOpenChange={(open) => !open && setEditYacht(null)}
|
||||||
|
yacht={{
|
||||||
|
id: editYacht.id,
|
||||||
|
name: editYacht.name,
|
||||||
|
hullNumber: editYacht.hullNumber,
|
||||||
|
registration: editYacht.registration,
|
||||||
|
lengthFt: editYacht.lengthFt,
|
||||||
|
widthFt: editYacht.widthFt,
|
||||||
|
draftFt: editYacht.draftFt,
|
||||||
|
lengthM: editYacht.lengthM,
|
||||||
|
widthM: editYacht.widthM,
|
||||||
|
currentOwnerType: editYacht.currentOwnerType,
|
||||||
|
currentOwnerId: editYacht.currentOwnerId,
|
||||||
|
status: editYacht.status,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ArchiveConfirmDialog
|
||||||
|
open={!!archiveYacht}
|
||||||
|
onOpenChange={(open) => !open && setArchiveYacht(null)}
|
||||||
|
entityName={archiveYacht?.name ?? ''}
|
||||||
|
entityType="Yacht"
|
||||||
|
isArchived={false}
|
||||||
|
onConfirm={() => archiveYacht && archiveMutation.mutate(archiveYacht.id)}
|
||||||
|
isLoading={archiveMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/yachts/yacht-ownership-history.tsx
Normal file
123
src/components/yachts/yacht-ownership-history.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { OwnerLink } from '@/components/yachts/yacht-detail-header';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface OwnershipHistoryRow {
|
||||||
|
id: string;
|
||||||
|
yachtId: string;
|
||||||
|
ownerType: 'client' | 'company';
|
||||||
|
ownerId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
transferReason: string | null;
|
||||||
|
transferNotes: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YachtOwnershipHistoryProps {
|
||||||
|
yachtId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REASON_LABELS: Record<string, string> = {
|
||||||
|
sale: 'Sale',
|
||||||
|
inheritance: 'Inheritance',
|
||||||
|
gift: 'Gift',
|
||||||
|
company_restructure: 'Company restructure',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YachtOwnershipHistory({ yachtId }: YachtOwnershipHistoryProps) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<OwnershipHistoryRow[]>({
|
||||||
|
queryKey: ['yachts', yachtId, 'ownership-history'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: OwnershipHistoryRow[] }>(`/api/v1/yachts/${yachtId}/ownership-history`).then(
|
||||||
|
(r) => r.data,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No ownership history"
|
||||||
|
description="This yacht's ownership transfers will appear here."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Start Date</TableHead>
|
||||||
|
<TableHead>End Date</TableHead>
|
||||||
|
<TableHead>Owner</TableHead>
|
||||||
|
<TableHead>Reason</TableHead>
|
||||||
|
<TableHead>Notes</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell>{formatDate(row.startDate)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.endDate ? (
|
||||||
|
formatDate(row.endDate)
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Current
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<OwnerLink portSlug={portSlug} type={row.ownerType} id={row.ownerId} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{row.transferReason
|
||||||
|
? (REASON_LABELS[row.transferReason] ?? row.transferReason)
|
||||||
|
: '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[320px] truncate">
|
||||||
|
{row.transferNotes ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/yachts/yacht-picker.tsx
Normal file
114
src/components/yachts/yacht-picker.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface YachtOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber?: string | null;
|
||||||
|
registration?: string | null;
|
||||||
|
currentOwnerType?: 'client' | 'company';
|
||||||
|
currentOwnerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YachtPickerProps {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (yachtId: string | null) => void;
|
||||||
|
/** Optional filter to only show yachts owned by the given client or company. */
|
||||||
|
ownerFilter?: { type: 'client' | 'company'; id: string };
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YachtPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
ownerFilter,
|
||||||
|
placeholder = 'Select yacht...',
|
||||||
|
disabled,
|
||||||
|
}: YachtPickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const debounced = useDebounce(search, 300);
|
||||||
|
|
||||||
|
const { data } = useQuery<{ data: YachtOption[] }>({
|
||||||
|
queryKey: ['yacht-picker', debounced],
|
||||||
|
queryFn: () => apiFetch(`/api/v1/yachts/autocomplete?q=${encodeURIComponent(debounced)}`),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawOptions = data?.data ?? [];
|
||||||
|
const options = ownerFilter
|
||||||
|
? rawOptions.filter(
|
||||||
|
(y) => y.currentOwnerType === ownerFilter.type && y.currentOwnerId === ownerFilter.id,
|
||||||
|
)
|
||||||
|
: rawOptions;
|
||||||
|
|
||||||
|
const selectedLabel = (() => {
|
||||||
|
if (!value) return placeholder;
|
||||||
|
const match = rawOptions.find((o) => o.id === value);
|
||||||
|
return match?.name ?? `Yacht ${value.slice(0, 8)}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selectedLabel}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[320px] p-0" align="start">
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput placeholder="Search yachts…" value={search} onValueChange={setSearch} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No yachts found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((y) => (
|
||||||
|
<CommandItem
|
||||||
|
key={y.id}
|
||||||
|
value={y.id}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(y.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn('mr-2 h-4 w-4', value === y.id ? 'opacity-100' : 'opacity-0')}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{y.name}
|
||||||
|
{y.hullNumber ? (
|
||||||
|
<span className="ml-2 text-xs opacity-60">{y.hullNumber}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
src/components/yachts/yacht-tabs.tsx
Normal file
168
src/components/yachts/yacht-tabs.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
|
||||||
|
|
||||||
|
interface YachtTabsYacht {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
flag: string | null;
|
||||||
|
yearBuilt: number | null;
|
||||||
|
builder: string | null;
|
||||||
|
model: string | null;
|
||||||
|
hullMaterial: string | null;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
draftFt: string | null;
|
||||||
|
lengthM: string | null;
|
||||||
|
widthM: string | null;
|
||||||
|
draftM: string | null;
|
||||||
|
status: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YachtTabsOptions {
|
||||||
|
yachtId: string;
|
||||||
|
currentUserId?: string;
|
||||||
|
yacht: YachtTabsYacht;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 py-1.5 border-b last:border-0">
|
||||||
|
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="text-sm">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
retired: 'Retired',
|
||||||
|
sold_away: 'Sold away',
|
||||||
|
};
|
||||||
|
|
||||||
|
function OverviewTab({ yacht }: { yacht: YachtTabsYacht }) {
|
||||||
|
const hasFtDimensions = yacht.lengthFt || yacht.widthFt || yacht.draftFt;
|
||||||
|
const hasMDimensions = yacht.lengthM || yacht.widthM || yacht.draftM;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Identity */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Name" value={yacht.name} />
|
||||||
|
<InfoRow label="Hull Number" value={yacht.hullNumber} />
|
||||||
|
<InfoRow label="Registration" value={yacht.registration} />
|
||||||
|
<InfoRow label="Flag" value={yacht.flag} />
|
||||||
|
<InfoRow label="Year Built" value={yacht.yearBuilt} />
|
||||||
|
<InfoRow label="Status" value={STATUS_LABELS[yacht.status] ?? yacht.status} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Build */}
|
||||||
|
{(yacht.builder || yacht.model || yacht.hullMaterial) && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Build</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Builder" value={yacht.builder} />
|
||||||
|
<InfoRow label="Model" value={yacht.model} />
|
||||||
|
<InfoRow label="Hull Material" value={yacht.hullMaterial} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dimensions (ft) */}
|
||||||
|
{hasFtDimensions && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Length" value={yacht.lengthFt ? `${yacht.lengthFt} ft` : null} />
|
||||||
|
<InfoRow label="Width" value={yacht.widthFt ? `${yacht.widthFt} ft` : null} />
|
||||||
|
<InfoRow label="Draft" value={yacht.draftFt ? `${yacht.draftFt} ft` : null} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dimensions (m) */}
|
||||||
|
{hasMDimensions && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Length" value={yacht.lengthM ? `${yacht.lengthM} m` : null} />
|
||||||
|
<InfoRow label="Width" value={yacht.widthM ? `${yacht.widthM} m` : null} />
|
||||||
|
<InfoRow label="Draft" value={yacht.draftM ? `${yacht.draftM} m` : null} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{yacht.notes && (
|
||||||
|
<div className="space-y-1 md:col-span-2">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||||
|
<p className="text-sm whitespace-pre-wrap rounded-md border bg-muted/30 p-3">
|
||||||
|
{yacht.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getYachtTabs({
|
||||||
|
yachtId,
|
||||||
|
// currentUserId reserved for when NotesList supports entityType='yachts'.
|
||||||
|
currentUserId: _currentUserId,
|
||||||
|
yacht,
|
||||||
|
}: YachtTabsOptions): DetailTab[] {
|
||||||
|
void _currentUserId;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: 'Overview',
|
||||||
|
content: <OverviewTab yacht={yacht} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ownership-history',
|
||||||
|
label: 'Ownership History',
|
||||||
|
content: <YachtOwnershipHistory yachtId={yachtId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'interests',
|
||||||
|
label: 'Interests',
|
||||||
|
content: <EmptyState title="Interests" description="Coming soon" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reservations',
|
||||||
|
label: 'Reservations',
|
||||||
|
content: <EmptyState title="Reservations" description="Coming soon" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notes',
|
||||||
|
label: 'Notes',
|
||||||
|
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
|
||||||
|
// Extend NotesList (or swap to a yacht-notes endpoint) in a follow-up.
|
||||||
|
content: (
|
||||||
|
<EmptyState
|
||||||
|
title="Notes"
|
||||||
|
description="Yacht notes coming soon — the notes endpoint is pending wiring."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tags',
|
||||||
|
label: 'Tags',
|
||||||
|
// TODO: replace with an inline tag editor once one exists; yacht tags
|
||||||
|
// can be edited via the Edit form in the meantime.
|
||||||
|
content: (
|
||||||
|
<EmptyState title="Tags" description="Manage tags from the Edit yacht form for now." />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
202
src/components/yachts/yacht-transfer-dialog.tsx
Normal file
202
src/components/yachts/yacht-transfer-dialog.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { OwnerPicker, type OwnerRef } from '@/components/shared/owner-picker';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
type TransferReason = 'sale' | 'inheritance' | 'gift' | 'company_restructure' | 'other';
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
newOwner: OwnerRef | undefined;
|
||||||
|
effectiveDate: string; // ISO date string from <input type="date">
|
||||||
|
transferReason?: TransferReason;
|
||||||
|
transferNotes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface YachtTransferDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
yachtId: string;
|
||||||
|
/** Current owner shown for reference; used to guard against selecting the same owner. */
|
||||||
|
currentOwner?: OwnerRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayIso = (): string => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
export function YachtTransferDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
yachtId,
|
||||||
|
currentOwner,
|
||||||
|
}: YachtTransferDialogProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
newOwner: undefined,
|
||||||
|
effectiveDate: todayIso(),
|
||||||
|
transferReason: undefined,
|
||||||
|
transferNotes: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFormError(null);
|
||||||
|
reset({
|
||||||
|
newOwner: undefined,
|
||||||
|
effectiveDate: todayIso(),
|
||||||
|
transferReason: undefined,
|
||||||
|
transferNotes: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, reset]);
|
||||||
|
|
||||||
|
const newOwner = watch('newOwner');
|
||||||
|
const transferReason = watch('transferReason');
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (data: FormValues) => {
|
||||||
|
if (!data.newOwner) {
|
||||||
|
throw new Error('Please select a new owner');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
currentOwner &&
|
||||||
|
data.newOwner.type === currentOwner.type &&
|
||||||
|
data.newOwner.id === currentOwner.id
|
||||||
|
) {
|
||||||
|
throw new Error('New owner must be different from the current owner');
|
||||||
|
}
|
||||||
|
await apiFetch(`/api/v1/yachts/${yachtId}/transfer`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
newOwner: data.newOwner,
|
||||||
|
effectiveDate: data.effectiveDate,
|
||||||
|
transferReason: data.transferReason,
|
||||||
|
transferNotes: data.transferNotes?.trim() || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['yachts', yachtId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['yachts', yachtId, 'ownership-history'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['yachts'] });
|
||||||
|
toast.success('Ownership transferred');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to transfer ownership';
|
||||||
|
setFormError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Transfer ownership</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will close the current ownership record and open a new one. The change is auditable
|
||||||
|
and atomic.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data) => {
|
||||||
|
setFormError(null);
|
||||||
|
mutation.mutate(data);
|
||||||
|
})}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>New owner</Label>
|
||||||
|
<OwnerPicker
|
||||||
|
value={newOwner ?? null}
|
||||||
|
onChange={(v) => setValue('newOwner', v ?? undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="effectiveDate">Effective date</Label>
|
||||||
|
<Input
|
||||||
|
id="effectiveDate"
|
||||||
|
type="date"
|
||||||
|
{...register('effectiveDate', { required: true })}
|
||||||
|
/>
|
||||||
|
{errors.effectiveDate && <p className="text-xs text-destructive">Required</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Reason (optional)</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(v) => setValue('transferReason', v as TransferReason)}
|
||||||
|
value={transferReason ?? ''}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a reason..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sale">Sale</SelectItem>
|
||||||
|
<SelectItem value="inheritance">Inheritance</SelectItem>
|
||||||
|
<SelectItem value="gift">Gift</SelectItem>
|
||||||
|
<SelectItem value="company_restructure">Company restructure</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="transferNotes">Notes (optional)</Label>
|
||||||
|
<Textarea id="transferNotes" rows={3} {...register('transferNotes')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||||
|
{(isSubmitting || mutation.isPending) && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,18 @@ interface SearchResults {
|
|||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
}>;
|
}>;
|
||||||
berths: Array<{ id: string; mooringNumber: string; area: string | null; status: string }>;
|
berths: Array<{ id: string; mooringNumber: string; area: string | null; status: string }>;
|
||||||
|
yachts: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
}>;
|
||||||
|
companies: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import {
|
import { portRoleOverrides, ports, userPortRoles, userProfiles } from '@/lib/db/schema';
|
||||||
portRoleOverrides,
|
|
||||||
ports,
|
|
||||||
userPortRoles,
|
|
||||||
userProfiles,
|
|
||||||
} from '@/lib/db/schema';
|
|
||||||
import { type RolePermissions } from '@/lib/db/schema/users';
|
import { type RolePermissions } from '@/lib/db/schema/users';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
@@ -40,7 +35,7 @@ export interface AuthContext {
|
|||||||
userAgent: string;
|
userAgent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteHandler<T = unknown> = (
|
export type RouteHandler<T = unknown> = (
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
ctx: AuthContext,
|
ctx: AuthContext,
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
@@ -133,10 +128,7 @@ export function withAuth(
|
|||||||
|
|
||||||
if (!profile.isSuperAdmin && portId) {
|
if (!profile.isSuperAdmin && portId) {
|
||||||
const portRole = await db.query.userPortRoles.findFirst({
|
const portRole = await db.query.userPortRoles.findFirst({
|
||||||
where: and(
|
where: and(eq(userPortRoles.userId, profile.userId), eq(userPortRoles.portId, portId)),
|
||||||
eq(userPortRoles.userId, profile.userId),
|
|
||||||
eq(userPortRoles.portId, portId),
|
|
||||||
),
|
|
||||||
with: {
|
with: {
|
||||||
role: true,
|
role: true,
|
||||||
port: true,
|
port: true,
|
||||||
@@ -182,8 +174,7 @@ export function withAuth(
|
|||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
name: session.user.name,
|
name: session.user.name,
|
||||||
},
|
},
|
||||||
ipAddress:
|
ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown',
|
||||||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown',
|
|
||||||
userAgent: req.headers.get('user-agent') ?? 'unknown',
|
userAgent: req.headers.get('user-agent') ?? 'unknown',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -213,9 +204,7 @@ export function withPermission(
|
|||||||
): RouteHandler {
|
): RouteHandler {
|
||||||
return async (req, ctx, params) => {
|
return async (req, ctx, params) => {
|
||||||
if (!ctx.isSuperAdmin) {
|
if (!ctx.isSuperAdmin) {
|
||||||
const resourcePerms = ctx.permissions?.[resource] as
|
const resourcePerms = ctx.permissions?.[resource] as Record<string, boolean> | undefined;
|
||||||
| Record<string, boolean>
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!resourcePerms || !resourcePerms[action]) {
|
if (!resourcePerms || !resourcePerms[action]) {
|
||||||
logger.warn({ userId: ctx.userId, resource, action }, 'Permission denied');
|
logger.warn({ userId: ctx.userId, resource, action }, 'Permission denied');
|
||||||
|
|||||||
67
src/lib/db/migrations/0002_groovy_excalibur.sql
Normal file
67
src/lib/db/migrations/0002_groovy_excalibur.sql
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
CREATE TABLE "yacht_notes" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"yacht_id" text NOT NULL,
|
||||||
|
"author_id" text NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"mentions" text[],
|
||||||
|
"is_locked" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "yacht_ownership_history" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"yacht_id" text NOT NULL,
|
||||||
|
"owner_type" text NOT NULL,
|
||||||
|
"owner_id" text NOT NULL,
|
||||||
|
"start_date" timestamp with time zone NOT NULL,
|
||||||
|
"end_date" timestamp with time zone,
|
||||||
|
"transfer_reason" text,
|
||||||
|
"transfer_notes" text,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "yacht_tags" (
|
||||||
|
"yacht_id" text NOT NULL,
|
||||||
|
"tag_id" text NOT NULL,
|
||||||
|
CONSTRAINT "yacht_tags_yacht_id_tag_id_pk" PRIMARY KEY("yacht_id","tag_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "yachts" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"port_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"hull_number" text,
|
||||||
|
"registration" text,
|
||||||
|
"flag" text,
|
||||||
|
"year_built" integer,
|
||||||
|
"builder" text,
|
||||||
|
"model" text,
|
||||||
|
"hull_material" text,
|
||||||
|
"length_ft" numeric,
|
||||||
|
"width_ft" numeric,
|
||||||
|
"draft_ft" numeric,
|
||||||
|
"length_m" numeric,
|
||||||
|
"width_m" numeric,
|
||||||
|
"draft_m" numeric,
|
||||||
|
"current_owner_type" text NOT NULL,
|
||||||
|
"current_owner_id" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'active' NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "yacht_notes" ADD CONSTRAINT "yacht_notes_yacht_id_yachts_id_fk" FOREIGN KEY ("yacht_id") REFERENCES "public"."yachts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "yacht_ownership_history" ADD CONSTRAINT "yacht_ownership_history_yacht_id_yachts_id_fk" FOREIGN KEY ("yacht_id") REFERENCES "public"."yachts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "yacht_tags" ADD CONSTRAINT "yacht_tags_yacht_id_yachts_id_fk" FOREIGN KEY ("yacht_id") REFERENCES "public"."yachts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "yachts" ADD CONSTRAINT "yachts_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_yn_yacht" ON "yacht_notes" USING btree ("yacht_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_yoh_yacht" ON "yacht_ownership_history" USING btree ("yacht_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_yoh_active" ON "yacht_ownership_history" USING btree ("yacht_id") WHERE "yacht_ownership_history"."end_date" IS NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_yachts_port" ON "yachts" USING btree ("port_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_yachts_current_owner" ON "yachts" USING btree ("port_id","current_owner_type","current_owner_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_yachts_name" ON "yachts" USING btree ("port_id","name");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_yachts_archived" ON "yachts" USING btree ("port_id","archived_at");
|
||||||
80
src/lib/db/migrations/0003_opposite_lucky_pierre.sql
Normal file
80
src/lib/db/migrations/0003_opposite_lucky_pierre.sql
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
CREATE TABLE "companies" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"port_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"legal_name" text,
|
||||||
|
"tax_id" text,
|
||||||
|
"registration_number" text,
|
||||||
|
"incorporation_country" text,
|
||||||
|
"incorporation_date" timestamp with time zone,
|
||||||
|
"status" text DEFAULT 'active' NOT NULL,
|
||||||
|
"billing_email" text,
|
||||||
|
"notes" text,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "company_addresses" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"company_id" text NOT NULL,
|
||||||
|
"port_id" text NOT NULL,
|
||||||
|
"label" text DEFAULT 'Primary' NOT NULL,
|
||||||
|
"street_address" text,
|
||||||
|
"city" text,
|
||||||
|
"state_province" text,
|
||||||
|
"postal_code" text,
|
||||||
|
"country" text,
|
||||||
|
"is_primary" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "company_memberships" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"company_id" text NOT NULL,
|
||||||
|
"client_id" text NOT NULL,
|
||||||
|
"role" text NOT NULL,
|
||||||
|
"role_detail" text,
|
||||||
|
"start_date" timestamp with time zone NOT NULL,
|
||||||
|
"end_date" timestamp with time zone,
|
||||||
|
"is_primary" boolean DEFAULT false NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "company_notes" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"company_id" text NOT NULL,
|
||||||
|
"author_id" text NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"mentions" text[],
|
||||||
|
"is_locked" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "company_tags" (
|
||||||
|
"company_id" text NOT NULL,
|
||||||
|
"tag_id" text NOT NULL,
|
||||||
|
CONSTRAINT "company_tags_company_id_tag_id_pk" PRIMARY KEY("company_id","tag_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "companies" ADD CONSTRAINT "companies_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_addresses" ADD CONSTRAINT "company_addresses_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_addresses" ADD CONSTRAINT "company_addresses_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_memberships" ADD CONSTRAINT "company_memberships_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_memberships" ADD CONSTRAINT "company_memberships_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_notes" ADD CONSTRAINT "company_notes_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "company_tags" ADD CONSTRAINT "company_tags_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_companies_port" ON "companies" USING btree ("port_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_companies_name_unique" ON "companies" USING btree ("port_id",lower("name"));--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_companies_taxid" ON "companies" USING btree ("port_id","tax_id") WHERE "companies"."tax_id" IS NOT NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_compa_company" ON "company_addresses" USING btree ("company_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_compa_port" ON "company_addresses" USING btree ("port_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_compa_primary" ON "company_addresses" USING btree ("company_id") WHERE "company_addresses"."is_primary" = true;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_cm_company" ON "company_memberships" USING btree ("company_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_cm_client" ON "company_memberships" USING btree ("client_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_cm_active" ON "company_memberships" USING btree ("company_id","client_id") WHERE "company_memberships"."end_date" IS NULL;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "unique_cm_exact" ON "company_memberships" USING btree ("company_id","client_id","role","start_date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_compn_company" ON "company_notes" USING btree ("company_id");
|
||||||
29
src/lib/db/migrations/0004_nasty_warstar.sql
Normal file
29
src/lib/db/migrations/0004_nasty_warstar.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE "berth_reservations" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"berth_id" text NOT NULL,
|
||||||
|
"port_id" text NOT NULL,
|
||||||
|
"client_id" text NOT NULL,
|
||||||
|
"yacht_id" text NOT NULL,
|
||||||
|
"interest_id" text,
|
||||||
|
"status" text NOT NULL,
|
||||||
|
"start_date" timestamp with time zone NOT NULL,
|
||||||
|
"end_date" timestamp with time zone,
|
||||||
|
"tenure_type" text DEFAULT 'permanent' NOT NULL,
|
||||||
|
"contract_file_id" text,
|
||||||
|
"notes" text,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "berth_reservations" ADD CONSTRAINT "berth_reservations_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "berth_reservations" ADD CONSTRAINT "berth_reservations_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "berth_reservations" ADD CONSTRAINT "berth_reservations_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "berth_reservations" ADD CONSTRAINT "berth_reservations_yacht_id_yachts_id_fk" FOREIGN KEY ("yacht_id") REFERENCES "public"."yachts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "berth_reservations" ADD CONSTRAINT "berth_reservations_interest_id_interests_id_fk" FOREIGN KEY ("interest_id") REFERENCES "public"."interests"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "berth_reservations" ADD CONSTRAINT "berth_reservations_contract_file_id_files_id_fk" FOREIGN KEY ("contract_file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_br_berth" ON "berth_reservations" USING btree ("berth_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_br_client" ON "berth_reservations" USING btree ("client_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_br_yacht" ON "berth_reservations" USING btree ("yacht_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_br_port" ON "berth_reservations" USING btree ("port_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_br_active" ON "berth_reservations" USING btree ("berth_id") WHERE "berth_reservations"."status" = 'active';
|
||||||
3
src/lib/db/migrations/0005_stale_kronos.sql
Normal file
3
src/lib/db/migrations/0005_stale_kronos.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "berth_waiting_list" ADD COLUMN "yacht_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "interests" ADD COLUMN "yacht_id" text;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_interests_yacht" ON "interests" USING btree ("yacht_id");
|
||||||
3
src/lib/db/migrations/0006_great_pixie.sql
Normal file
3
src/lib/db/migrations/0006_great_pixie.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "invoices" ADD COLUMN "billing_entity_type" text DEFAULT 'client' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "invoices" ADD COLUMN "billing_entity_id" text DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_invoices_billing_entity" ON "invoices" USING btree ("port_id","billing_entity_type","billing_entity_id");
|
||||||
8
src/lib/db/migrations/0007_brainy_felicia_hardy.sql
Normal file
8
src/lib/db/migrations/0007_brainy_felicia_hardy.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE "documents" ADD COLUMN "yacht_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "documents" ADD COLUMN "company_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "files" ADD COLUMN "yacht_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "files" ADD COLUMN "company_id" text;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_documents_yacht" ON "documents" USING btree ("yacht_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_documents_company" ON "documents" USING btree ("company_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_files_yacht" ON "files" USING btree ("yacht_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_files_company" ON "files" USING btree ("company_id");
|
||||||
7585
src/lib/db/migrations/meta/0002_snapshot.json
Normal file
7585
src/lib/db/migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8219
src/lib/db/migrations/meta/0003_snapshot.json
Normal file
8219
src/lib/db/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8457
src/lib/db/migrations/meta/0004_snapshot.json
Normal file
8457
src/lib/db/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8484
src/lib/db/migrations/meta/0005_snapshot.json
Normal file
8484
src/lib/db/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8525
src/lib/db/migrations/meta/0006_snapshot.json
Normal file
8525
src/lib/db/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8609
src/lib/db/migrations/meta/0007_snapshot.json
Normal file
8609
src/lib/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,48 @@
|
|||||||
"when": 1776185487775,
|
"when": 1776185487775,
|
||||||
"tag": "0001_soft_ender_wiggin",
|
"tag": "0001_soft_ender_wiggin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776958500747,
|
||||||
|
"tag": "0002_groovy_excalibur",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776959610819,
|
||||||
|
"tag": "0003_opposite_lucky_pierre",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776959707066,
|
||||||
|
"tag": "0004_nasty_warstar",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776959832091,
|
||||||
|
"tag": "0005_stale_kronos",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776959911400,
|
||||||
|
"tag": "0006_great_pixie",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776959993173,
|
||||||
|
"tag": "0007_brainy_felicia_hardy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import { clients } from './clients';
|
|||||||
export const berths = pgTable(
|
export const berths = pgTable(
|
||||||
'berths',
|
'berths',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
portId: text('port_id')
|
portId: text('port_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ports.id),
|
.references(() => ports.id),
|
||||||
@@ -70,7 +72,9 @@ export const berths = pgTable(
|
|||||||
export const berthMapData = pgTable(
|
export const berthMapData = pgTable(
|
||||||
'berth_map_data',
|
'berth_map_data',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
berthId: text('berth_id')
|
berthId: text('berth_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.unique()
|
.unique()
|
||||||
@@ -89,7 +93,9 @@ export const berthMapData = pgTable(
|
|||||||
export const berthRecommendations = pgTable(
|
export const berthRecommendations = pgTable(
|
||||||
'berth_recommendations',
|
'berth_recommendations',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
interestId: text('interest_id').notNull(), // references interests.id
|
interestId: text('interest_id').notNull(), // references interests.id
|
||||||
berthId: text('berth_id')
|
berthId: text('berth_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -109,13 +115,16 @@ export const berthRecommendations = pgTable(
|
|||||||
export const berthWaitingList = pgTable(
|
export const berthWaitingList = pgTable(
|
||||||
'berth_waiting_list',
|
'berth_waiting_list',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
berthId: text('berth_id')
|
berthId: text('berth_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => berths.id, { onDelete: 'cascade' }),
|
.references(() => berths.id, { onDelete: 'cascade' }),
|
||||||
clientId: text('client_id')
|
clientId: text('client_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id, { onDelete: 'cascade' }),
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||||
|
yachtId: text('yacht_id'), // FK added via relation; nullable (waiting for this yacht)
|
||||||
position: integer('position').notNull(),
|
position: integer('position').notNull(),
|
||||||
priority: text('priority').notNull().default('normal'), // normal, high
|
priority: text('priority').notNull().default('normal'), // normal, high
|
||||||
notifyPref: text('notify_pref').default('email'), // email, in_app, both
|
notifyPref: text('notify_pref').default('email'), // email, in_app, both
|
||||||
@@ -131,7 +140,9 @@ export const berthWaitingList = pgTable(
|
|||||||
export const berthMaintenanceLog = pgTable(
|
export const berthMaintenanceLog = pgTable(
|
||||||
'berth_maintenance_log',
|
'berth_maintenance_log',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
berthId: text('berth_id')
|
berthId: text('berth_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => berths.id, { onDelete: 'cascade' }),
|
.references(() => berths.id, { onDelete: 'cascade' }),
|
||||||
@@ -149,10 +160,7 @@ export const berthMaintenanceLog = pgTable(
|
|||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [index('idx_bml_berth').on(table.berthId), index('idx_bml_port').on(table.portId)],
|
||||||
index('idx_bml_berth').on(table.berthId),
|
|
||||||
index('idx_bml_port').on(table.portId),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const berthTags = pgTable(
|
export const berthTags = pgTable(
|
||||||
|
|||||||
143
src/lib/db/schema/companies.ts
Normal file
143
src/lib/db/schema/companies.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
primaryKey,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { ports } from './ports';
|
||||||
|
import { clients } from './clients';
|
||||||
|
|
||||||
|
export const companies = pgTable(
|
||||||
|
'companies',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
legalName: text('legal_name'),
|
||||||
|
taxId: text('tax_id'),
|
||||||
|
registrationNumber: text('registration_number'),
|
||||||
|
incorporationCountry: text('incorporation_country'),
|
||||||
|
incorporationDate: timestamp('incorporation_date', { withTimezone: true, mode: 'date' }),
|
||||||
|
status: text('status').notNull().default('active'), // 'active' | 'dissolved'
|
||||||
|
billingEmail: text('billing_email'),
|
||||||
|
notes: text('notes'),
|
||||||
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('idx_companies_port').on(table.portId),
|
||||||
|
uniqueIndex('idx_companies_name_unique').on(table.portId, sql`lower(${table.name})`),
|
||||||
|
index('idx_companies_taxid')
|
||||||
|
.on(table.portId, table.taxId)
|
||||||
|
.where(sql`${table.taxId} IS NOT NULL`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const companyMemberships = pgTable(
|
||||||
|
'company_memberships',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
companyId: text('company_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||||
|
clientId: text('client_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||||
|
role: text('role').notNull(), // director | officer | broker | representative | legal_counsel | employee | shareholder | other
|
||||||
|
roleDetail: text('role_detail'),
|
||||||
|
startDate: timestamp('start_date', { withTimezone: true, mode: 'date' }).notNull(),
|
||||||
|
endDate: timestamp('end_date', { withTimezone: true, mode: 'date' }),
|
||||||
|
isPrimary: boolean('is_primary').notNull().default(false),
|
||||||
|
notes: text('notes'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('idx_cm_company').on(table.companyId),
|
||||||
|
index('idx_cm_client').on(table.clientId),
|
||||||
|
index('idx_cm_active')
|
||||||
|
.on(table.companyId, table.clientId)
|
||||||
|
.where(sql`${table.endDate} IS NULL`),
|
||||||
|
uniqueIndex('unique_cm_exact').on(table.companyId, table.clientId, table.role, table.startDate),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const companyAddresses = pgTable(
|
||||||
|
'company_addresses',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
companyId: text('company_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||||
|
label: text('label').notNull().default('Primary'),
|
||||||
|
streetAddress: text('street_address'),
|
||||||
|
city: text('city'),
|
||||||
|
stateProvince: text('state_province'),
|
||||||
|
postalCode: text('postal_code'),
|
||||||
|
country: text('country'),
|
||||||
|
isPrimary: boolean('is_primary').notNull().default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('idx_compa_company').on(table.companyId),
|
||||||
|
index('idx_compa_port').on(table.portId),
|
||||||
|
uniqueIndex('idx_compa_primary')
|
||||||
|
.on(table.companyId)
|
||||||
|
.where(sql`${table.isPrimary} = true`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const companyNotes = pgTable(
|
||||||
|
'company_notes',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
companyId: text('company_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||||
|
authorId: text('author_id').notNull(),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
mentions: text('mentions').array(),
|
||||||
|
isLocked: boolean('is_locked').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [index('idx_compn_company').on(table.companyId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const companyTags = pgTable(
|
||||||
|
'company_tags',
|
||||||
|
{
|
||||||
|
companyId: text('company_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||||
|
tagId: text('tag_id').notNull(),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.companyId, table.tagId] })],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Company = typeof companies.$inferSelect;
|
||||||
|
export type NewCompany = typeof companies.$inferInsert;
|
||||||
|
export type CompanyMembership = typeof companyMemberships.$inferSelect;
|
||||||
|
export type NewCompanyMembership = typeof companyMemberships.$inferInsert;
|
||||||
|
export type CompanyAddress = typeof companyAddresses.$inferSelect;
|
||||||
|
export type NewCompanyAddress = typeof companyAddresses.$inferInsert;
|
||||||
|
export type CompanyNote = typeof companyNotes.$inferSelect;
|
||||||
|
export type NewCompanyNote = typeof companyNotes.$inferInsert;
|
||||||
@@ -15,11 +15,15 @@ import { clients } from './clients';
|
|||||||
export const files = pgTable(
|
export const files = pgTable(
|
||||||
'files',
|
'files',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
portId: text('port_id')
|
portId: text('port_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ports.id),
|
.references(() => ports.id),
|
||||||
clientId: text('client_id').references(() => clients.id),
|
clientId: text('client_id').references(() => clients.id),
|
||||||
|
yachtId: text('yacht_id'), // FK wired in relations.ts
|
||||||
|
companyId: text('company_id'), // FK wired in relations.ts
|
||||||
filename: text('filename').notNull(),
|
filename: text('filename').notNull(),
|
||||||
originalName: text('original_name').notNull(),
|
originalName: text('original_name').notNull(),
|
||||||
mimeType: text('mime_type'),
|
mimeType: text('mime_type'),
|
||||||
@@ -33,18 +37,24 @@ export const files = pgTable(
|
|||||||
(table) => [
|
(table) => [
|
||||||
index('idx_files_port').on(table.portId),
|
index('idx_files_port').on(table.portId),
|
||||||
index('idx_files_client').on(table.clientId),
|
index('idx_files_client').on(table.clientId),
|
||||||
|
index('idx_files_yacht').on(table.yachtId),
|
||||||
|
index('idx_files_company').on(table.companyId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const documents = pgTable(
|
export const documents = pgTable(
|
||||||
'documents',
|
'documents',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
portId: text('port_id')
|
portId: text('port_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ports.id),
|
.references(() => ports.id),
|
||||||
interestId: text('interest_id'), // references interests.id
|
interestId: text('interest_id'), // references interests.id
|
||||||
clientId: text('client_id').references(() => clients.id),
|
clientId: text('client_id').references(() => clients.id),
|
||||||
|
yachtId: text('yacht_id'), // FK wired in relations.ts
|
||||||
|
companyId: text('company_id'), // FK wired in relations.ts
|
||||||
documentType: text('document_type').notNull(), // eoi, contract, nda, reservation_agreement, other
|
documentType: text('document_type').notNull(), // eoi, contract, nda, reservation_agreement, other
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
status: text('status').notNull().default('draft'), // draft, sent, partially_signed, completed, expired, cancelled
|
status: text('status').notNull().default('draft'), // draft, sent, partially_signed, completed, expired, cancelled
|
||||||
@@ -61,6 +71,8 @@ export const documents = pgTable(
|
|||||||
index('idx_docs_port').on(table.portId),
|
index('idx_docs_port').on(table.portId),
|
||||||
index('idx_docs_interest').on(table.interestId),
|
index('idx_docs_interest').on(table.interestId),
|
||||||
index('idx_docs_client').on(table.clientId),
|
index('idx_docs_client').on(table.clientId),
|
||||||
|
index('idx_documents_yacht').on(table.yachtId),
|
||||||
|
index('idx_documents_company').on(table.companyId),
|
||||||
index('idx_docs_type').on(table.portId, table.documentType),
|
index('idx_docs_type').on(table.portId, table.documentType),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -68,7 +80,9 @@ export const documents = pgTable(
|
|||||||
export const documentSigners = pgTable(
|
export const documentSigners = pgTable(
|
||||||
'document_signers',
|
'document_signers',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
documentId: text('document_id')
|
documentId: text('document_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => documents.id, { onDelete: 'cascade' }),
|
.references(() => documents.id, { onDelete: 'cascade' }),
|
||||||
@@ -88,7 +102,9 @@ export const documentSigners = pgTable(
|
|||||||
export const documentEvents = pgTable(
|
export const documentEvents = pgTable(
|
||||||
'document_events',
|
'document_events',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
documentId: text('document_id')
|
documentId: text('document_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => documents.id, { onDelete: 'cascade' }),
|
.references(() => documents.id, { onDelete: 'cascade' }),
|
||||||
@@ -100,16 +116,18 @@ export const documentEvents = pgTable(
|
|||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('idx_de_doc').on(table.documentId),
|
index('idx_de_doc').on(table.documentId),
|
||||||
uniqueIndex('idx_de_dedup').on(table.documentId, table.signatureHash).where(
|
uniqueIndex('idx_de_dedup')
|
||||||
sql`${table.signatureHash} IS NOT NULL`
|
.on(table.documentId, table.signatureHash)
|
||||||
),
|
.where(sql`${table.signatureHash} IS NOT NULL`),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const documentTemplates = pgTable(
|
export const documentTemplates = pgTable(
|
||||||
'document_templates',
|
'document_templates',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
portId: text('port_id')
|
portId: text('port_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ports.id),
|
.references(() => ports.id),
|
||||||
@@ -132,7 +150,9 @@ export const documentTemplates = pgTable(
|
|||||||
export const formTemplates = pgTable(
|
export const formTemplates = pgTable(
|
||||||
'form_templates',
|
'form_templates',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
portId: text('port_id')
|
portId: text('port_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ports.id),
|
.references(() => ports.id),
|
||||||
@@ -151,7 +171,9 @@ export const formTemplates = pgTable(
|
|||||||
export const formSubmissions = pgTable(
|
export const formSubmissions = pgTable(
|
||||||
'form_submissions',
|
'form_submissions',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
formTemplateId: text('form_template_id')
|
formTemplateId: text('form_template_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => formTemplates.id),
|
.references(() => formTemplates.id),
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import { files } from './documents';
|
|||||||
export const expenses = pgTable(
|
export const expenses = pgTable(
|
||||||
'expenses',
|
'expenses',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
portId: text('port_id')
|
portId: text('port_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ports.id),
|
.references(() => ports.id),
|
||||||
@@ -49,12 +51,16 @@ export const expenses = pgTable(
|
|||||||
export const invoices = pgTable(
|
export const invoices = pgTable(
|
||||||
'invoices',
|
'invoices',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
portId: text('port_id')
|
portId: text('port_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ports.id),
|
.references(() => ports.id),
|
||||||
invoiceNumber: text('invoice_number').notNull(), // INV-YYYYMM-### auto-generated
|
invoiceNumber: text('invoice_number').notNull(), // INV-YYYYMM-### auto-generated
|
||||||
clientName: text('client_name').notNull(),
|
clientName: text('client_name').notNull(),
|
||||||
|
billingEntityType: text('billing_entity_type').notNull().default('client'), // 'client' | 'company'
|
||||||
|
billingEntityId: text('billing_entity_id').notNull().default(''),
|
||||||
billingEmail: text('billing_email'),
|
billingEmail: text('billing_email'),
|
||||||
billingAddress: text('billing_address'),
|
billingAddress: text('billing_address'),
|
||||||
dueDate: date('due_date').notNull(),
|
dueDate: date('due_date').notNull(),
|
||||||
@@ -82,13 +88,20 @@ export const invoices = pgTable(
|
|||||||
uniqueIndex('idx_invoices_number').on(table.portId, table.invoiceNumber),
|
uniqueIndex('idx_invoices_number').on(table.portId, table.invoiceNumber),
|
||||||
index('idx_invoices_port').on(table.portId),
|
index('idx_invoices_port').on(table.portId),
|
||||||
index('idx_invoices_status').on(table.portId, table.status),
|
index('idx_invoices_status').on(table.portId, table.status),
|
||||||
|
index('idx_invoices_billing_entity').on(
|
||||||
|
table.portId,
|
||||||
|
table.billingEntityType,
|
||||||
|
table.billingEntityId,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const invoiceLineItems = pgTable(
|
export const invoiceLineItems = pgTable(
|
||||||
'invoice_line_items',
|
'invoice_line_items',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
invoiceId: text('invoice_id')
|
invoiceId: text('invoice_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => invoices.id, { onDelete: 'cascade' }),
|
.references(() => invoices.id, { onDelete: 'cascade' }),
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ export * from './users';
|
|||||||
// Clients
|
// Clients
|
||||||
export * from './clients';
|
export * from './clients';
|
||||||
|
|
||||||
|
// Companies
|
||||||
|
export * from './companies';
|
||||||
|
|
||||||
|
// Yachts
|
||||||
|
export * from './yachts';
|
||||||
|
|
||||||
// Interests
|
// Interests
|
||||||
export * from './interests';
|
export * from './interests';
|
||||||
|
|
||||||
// Berths
|
// Berths
|
||||||
export * from './berths';
|
export * from './berths';
|
||||||
|
|
||||||
|
// Reservations
|
||||||
|
export * from './reservations';
|
||||||
|
|
||||||
// Documents & Files
|
// Documents & Files
|
||||||
export * from './documents';
|
export * from './documents';
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { pgTable, text, boolean, integer, timestamp, primaryKey, index } from 'drizzle-orm/pg-core';
|
||||||
pgTable,
|
|
||||||
text,
|
|
||||||
boolean,
|
|
||||||
integer,
|
|
||||||
timestamp,
|
|
||||||
primaryKey,
|
|
||||||
index,
|
|
||||||
} from 'drizzle-orm/pg-core';
|
|
||||||
import { ports } from './ports';
|
import { ports } from './ports';
|
||||||
import { clients } from './clients';
|
import { clients } from './clients';
|
||||||
|
|
||||||
@@ -15,7 +7,9 @@ import { clients } from './clients';
|
|||||||
export const interests = pgTable(
|
export const interests = pgTable(
|
||||||
'interests',
|
'interests',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
portId: text('port_id')
|
portId: text('port_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ports.id),
|
.references(() => ports.id),
|
||||||
@@ -23,6 +17,7 @@ export const interests = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id),
|
.references(() => clients.id),
|
||||||
berthId: text('berth_id'), // nullable — FK to berths defined in berths.ts, added via relation
|
berthId: text('berth_id'), // nullable — FK to berths defined in berths.ts, added via relation
|
||||||
|
yachtId: text('yacht_id'), // FK added via relation; nullable until pipeline leaves 'open'
|
||||||
pipelineStage: text('pipeline_stage').notNull().default('open'),
|
pipelineStage: text('pipeline_stage').notNull().default('open'),
|
||||||
leadCategory: text('lead_category'), // general_interest, specific_qualified, hot_lead
|
leadCategory: text('lead_category'), // general_interest, specific_qualified, hot_lead
|
||||||
source: text('source'), // website, manual, referral, broker
|
source: text('source'), // website, manual, referral, broker
|
||||||
@@ -50,6 +45,7 @@ export const interests = pgTable(
|
|||||||
index('idx_interests_port').on(table.portId),
|
index('idx_interests_port').on(table.portId),
|
||||||
index('idx_interests_client').on(table.clientId),
|
index('idx_interests_client').on(table.clientId),
|
||||||
index('idx_interests_berth').on(table.berthId),
|
index('idx_interests_berth').on(table.berthId),
|
||||||
|
index('idx_interests_yacht').on(table.yachtId),
|
||||||
index('idx_interests_stage').on(table.portId, table.pipelineStage),
|
index('idx_interests_stage').on(table.portId, table.pipelineStage),
|
||||||
index('idx_interests_archived').on(table.portId, table.archivedAt),
|
index('idx_interests_archived').on(table.portId, table.archivedAt),
|
||||||
],
|
],
|
||||||
@@ -58,7 +54,9 @@ export const interests = pgTable(
|
|||||||
export const interestNotes = pgTable(
|
export const interestNotes = pgTable(
|
||||||
'interest_notes',
|
'interest_notes',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
interestId: text('interest_id')
|
interestId: text('interest_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => interests.id, { onDelete: 'cascade' }),
|
.references(() => interests.id, { onDelete: 'cascade' }),
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ import {
|
|||||||
// Interests
|
// Interests
|
||||||
import { interests, interestNotes, interestTags } from './interests';
|
import { interests, interestNotes, interestTags } from './interests';
|
||||||
|
|
||||||
|
// Yachts
|
||||||
|
import { yachts, yachtOwnershipHistory, yachtNotes, yachtTags } from './yachts';
|
||||||
|
|
||||||
|
// Companies
|
||||||
|
import {
|
||||||
|
companies,
|
||||||
|
companyMemberships,
|
||||||
|
companyAddresses,
|
||||||
|
companyNotes,
|
||||||
|
companyTags,
|
||||||
|
} from './companies';
|
||||||
|
|
||||||
// Berths
|
// Berths
|
||||||
import {
|
import {
|
||||||
berths,
|
berths,
|
||||||
@@ -30,6 +42,9 @@ import {
|
|||||||
berthTags,
|
berthTags,
|
||||||
} from './berths';
|
} from './berths';
|
||||||
|
|
||||||
|
// Reservations
|
||||||
|
import { berthReservations } from './reservations';
|
||||||
|
|
||||||
// Documents
|
// Documents
|
||||||
import {
|
import {
|
||||||
files,
|
files,
|
||||||
@@ -79,7 +94,10 @@ export const portsRelations = relations(ports, ({ many }) => ({
|
|||||||
portRoleOverrides: many(portRoleOverrides),
|
portRoleOverrides: many(portRoleOverrides),
|
||||||
clients: many(clients),
|
clients: many(clients),
|
||||||
interests: many(interests),
|
interests: many(interests),
|
||||||
|
yachts: many(yachts),
|
||||||
|
companies: many(companies),
|
||||||
berths: many(berths),
|
berths: many(berths),
|
||||||
|
berthReservations: many(berthReservations),
|
||||||
documents: many(documents),
|
documents: many(documents),
|
||||||
documentTemplates: many(documentTemplates),
|
documentTemplates: many(documentTemplates),
|
||||||
formTemplates: many(formTemplates),
|
formTemplates: many(formTemplates),
|
||||||
@@ -159,6 +177,8 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
|
|||||||
scratchpadNotes: many(scratchpadNotes),
|
scratchpadNotes: many(scratchpadNotes),
|
||||||
formSubmissions: many(formSubmissions),
|
formSubmissions: many(formSubmissions),
|
||||||
addresses: many(clientAddresses),
|
addresses: many(clientAddresses),
|
||||||
|
companyMemberships: many(companyMemberships),
|
||||||
|
berthReservations: many(berthReservations),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const clientContactsRelations = relations(clientContacts, ({ one }) => ({
|
export const clientContactsRelations = relations(clientContacts, ({ one }) => ({
|
||||||
@@ -240,6 +260,10 @@ export const interestsRelations = relations(interests, ({ one, many }) => ({
|
|||||||
fields: [interests.berthId],
|
fields: [interests.berthId],
|
||||||
references: [berths.id],
|
references: [berths.id],
|
||||||
}),
|
}),
|
||||||
|
yacht: one(yachts, {
|
||||||
|
fields: [interests.yachtId],
|
||||||
|
references: [yachts.id],
|
||||||
|
}),
|
||||||
notes: many(interestNotes),
|
notes: many(interestNotes),
|
||||||
tags: many(interestTags),
|
tags: many(interestTags),
|
||||||
documents: many(documents),
|
documents: many(documents),
|
||||||
@@ -266,6 +290,101 @@ export const interestTagsRelations = relations(interestTags, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ─── Yachts ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const yachtsRelations = relations(yachts, ({ one, many }) => ({
|
||||||
|
port: one(ports, {
|
||||||
|
fields: [yachts.portId],
|
||||||
|
references: [ports.id],
|
||||||
|
}),
|
||||||
|
ownershipHistory: many(yachtOwnershipHistory),
|
||||||
|
notes: many(yachtNotes),
|
||||||
|
tags: many(yachtTags),
|
||||||
|
interests: many(interests),
|
||||||
|
reservations: many(berthReservations),
|
||||||
|
documents: many(documents),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const yachtOwnershipHistoryRelations = relations(yachtOwnershipHistory, ({ one }) => ({
|
||||||
|
yacht: one(yachts, {
|
||||||
|
fields: [yachtOwnershipHistory.yachtId],
|
||||||
|
references: [yachts.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const yachtNotesRelations = relations(yachtNotes, ({ one }) => ({
|
||||||
|
yacht: one(yachts, {
|
||||||
|
fields: [yachtNotes.yachtId],
|
||||||
|
references: [yachts.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const yachtTagsRelations = relations(yachtTags, ({ one }) => ({
|
||||||
|
yacht: one(yachts, {
|
||||||
|
fields: [yachtTags.yachtId],
|
||||||
|
references: [yachts.id],
|
||||||
|
}),
|
||||||
|
tag: one(tags, {
|
||||||
|
fields: [yachtTags.tagId],
|
||||||
|
references: [tags.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── Companies ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const companiesRelations = relations(companies, ({ one, many }) => ({
|
||||||
|
port: one(ports, {
|
||||||
|
fields: [companies.portId],
|
||||||
|
references: [ports.id],
|
||||||
|
}),
|
||||||
|
memberships: many(companyMemberships),
|
||||||
|
addresses: many(companyAddresses),
|
||||||
|
notes: many(companyNotes),
|
||||||
|
tags: many(companyTags),
|
||||||
|
documents: many(documents),
|
||||||
|
files: many(files),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const companyMembershipsRelations = relations(companyMemberships, ({ one }) => ({
|
||||||
|
company: one(companies, {
|
||||||
|
fields: [companyMemberships.companyId],
|
||||||
|
references: [companies.id],
|
||||||
|
}),
|
||||||
|
client: one(clients, {
|
||||||
|
fields: [companyMemberships.clientId],
|
||||||
|
references: [clients.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const companyAddressesRelations = relations(companyAddresses, ({ one }) => ({
|
||||||
|
company: one(companies, {
|
||||||
|
fields: [companyAddresses.companyId],
|
||||||
|
references: [companies.id],
|
||||||
|
}),
|
||||||
|
port: one(ports, {
|
||||||
|
fields: [companyAddresses.portId],
|
||||||
|
references: [ports.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const companyNotesRelations = relations(companyNotes, ({ one }) => ({
|
||||||
|
company: one(companies, {
|
||||||
|
fields: [companyNotes.companyId],
|
||||||
|
references: [companies.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const companyTagsRelations = relations(companyTags, ({ one }) => ({
|
||||||
|
company: one(companies, {
|
||||||
|
fields: [companyTags.companyId],
|
||||||
|
references: [companies.id],
|
||||||
|
}),
|
||||||
|
tag: one(tags, {
|
||||||
|
fields: [companyTags.tagId],
|
||||||
|
references: [tags.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// ─── Berths ───────────────────────────────────────────────────────────────────
|
// ─── Berths ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const berthsRelations = relations(berths, ({ one, many }) => ({
|
export const berthsRelations = relations(berths, ({ one, many }) => ({
|
||||||
@@ -333,6 +452,35 @@ export const berthTagsRelations = relations(berthTags, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ─── Berth Reservations ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const berthReservationsRelations = relations(berthReservations, ({ one }) => ({
|
||||||
|
berth: one(berths, {
|
||||||
|
fields: [berthReservations.berthId],
|
||||||
|
references: [berths.id],
|
||||||
|
}),
|
||||||
|
port: one(ports, {
|
||||||
|
fields: [berthReservations.portId],
|
||||||
|
references: [ports.id],
|
||||||
|
}),
|
||||||
|
client: one(clients, {
|
||||||
|
fields: [berthReservations.clientId],
|
||||||
|
references: [clients.id],
|
||||||
|
}),
|
||||||
|
yacht: one(yachts, {
|
||||||
|
fields: [berthReservations.yachtId],
|
||||||
|
references: [yachts.id],
|
||||||
|
}),
|
||||||
|
interest: one(interests, {
|
||||||
|
fields: [berthReservations.interestId],
|
||||||
|
references: [interests.id],
|
||||||
|
}),
|
||||||
|
contractFile: one(files, {
|
||||||
|
fields: [berthReservations.contractFileId],
|
||||||
|
references: [files.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// ─── Documents ────────────────────────────────────────────────────────────────
|
// ─── Documents ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const filesRelations = relations(files, ({ one, many }) => ({
|
export const filesRelations = relations(files, ({ one, many }) => ({
|
||||||
@@ -344,6 +492,14 @@ export const filesRelations = relations(files, ({ one, many }) => ({
|
|||||||
fields: [files.clientId],
|
fields: [files.clientId],
|
||||||
references: [clients.id],
|
references: [clients.id],
|
||||||
}),
|
}),
|
||||||
|
yacht: one(yachts, {
|
||||||
|
fields: [files.yachtId],
|
||||||
|
references: [yachts.id],
|
||||||
|
}),
|
||||||
|
company: one(companies, {
|
||||||
|
fields: [files.companyId],
|
||||||
|
references: [companies.id],
|
||||||
|
}),
|
||||||
documentAsFile: many(documents, { relationName: 'file' }),
|
documentAsFile: many(documents, { relationName: 'file' }),
|
||||||
documentAsSignedFile: many(documents, { relationName: 'signed_file' }),
|
documentAsSignedFile: many(documents, { relationName: 'signed_file' }),
|
||||||
}));
|
}));
|
||||||
@@ -371,6 +527,14 @@ export const documentsRelations = relations(documents, ({ one, many }) => ({
|
|||||||
references: [files.id],
|
references: [files.id],
|
||||||
relationName: 'signed_file',
|
relationName: 'signed_file',
|
||||||
}),
|
}),
|
||||||
|
yacht: one(yachts, {
|
||||||
|
fields: [documents.yachtId],
|
||||||
|
references: [yachts.id],
|
||||||
|
}),
|
||||||
|
company: one(companies, {
|
||||||
|
fields: [documents.companyId],
|
||||||
|
references: [companies.id],
|
||||||
|
}),
|
||||||
signers: many(documentSigners),
|
signers: many(documentSigners),
|
||||||
events: many(documentEvents),
|
events: many(documentEvents),
|
||||||
}));
|
}));
|
||||||
|
|||||||
51
src/lib/db/schema/reservations.ts
Normal file
51
src/lib/db/schema/reservations.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { pgTable, text, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { ports } from './ports';
|
||||||
|
import { berths } from './berths';
|
||||||
|
import { clients } from './clients';
|
||||||
|
import { yachts } from './yachts';
|
||||||
|
import { interests } from './interests';
|
||||||
|
import { files } from './documents';
|
||||||
|
|
||||||
|
export const berthReservations = pgTable(
|
||||||
|
'berth_reservations',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
berthId: text('berth_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => berths.id),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id),
|
||||||
|
clientId: text('client_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id),
|
||||||
|
yachtId: text('yacht_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => yachts.id),
|
||||||
|
interestId: text('interest_id').references(() => interests.id),
|
||||||
|
status: text('status').notNull(), // 'pending' | 'active' | 'ended' | 'cancelled'
|
||||||
|
startDate: timestamp('start_date', { withTimezone: true, mode: 'date' }).notNull(),
|
||||||
|
endDate: timestamp('end_date', { withTimezone: true, mode: 'date' }),
|
||||||
|
tenureType: text('tenure_type').notNull().default('permanent'), // 'permanent' | 'fixed_term' | 'seasonal'
|
||||||
|
contractFileId: text('contract_file_id').references(() => files.id),
|
||||||
|
notes: text('notes'),
|
||||||
|
createdBy: text('created_by').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('idx_br_berth').on(table.berthId),
|
||||||
|
index('idx_br_client').on(table.clientId),
|
||||||
|
index('idx_br_yacht').on(table.yachtId),
|
||||||
|
index('idx_br_port').on(table.portId),
|
||||||
|
uniqueIndex('idx_br_active')
|
||||||
|
.on(table.berthId)
|
||||||
|
.where(sql`${table.status} = 'active'`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type BerthReservation = typeof berthReservations.$inferSelect;
|
||||||
|
export type NewBerthReservation = typeof berthReservations.$inferInsert;
|
||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { pgTable, text, boolean, timestamp, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
pgTable,
|
|
||||||
text,
|
|
||||||
boolean,
|
|
||||||
timestamp,
|
|
||||||
jsonb,
|
|
||||||
index,
|
|
||||||
uniqueIndex,
|
|
||||||
} from 'drizzle-orm/pg-core';
|
|
||||||
import { ports } from './ports';
|
import { ports } from './ports';
|
||||||
|
|
||||||
// ─── Permission Types ─────────────────────────────────────────────────────────
|
// ─── Permission Types ─────────────────────────────────────────────────────────
|
||||||
@@ -92,6 +84,29 @@ export type RolePermissions = {
|
|||||||
generate: boolean;
|
generate: boolean;
|
||||||
manage: boolean;
|
manage: boolean;
|
||||||
};
|
};
|
||||||
|
yachts: {
|
||||||
|
view: boolean;
|
||||||
|
create: boolean;
|
||||||
|
edit: boolean;
|
||||||
|
delete: boolean;
|
||||||
|
transfer: boolean;
|
||||||
|
};
|
||||||
|
companies: {
|
||||||
|
view: boolean;
|
||||||
|
create: boolean;
|
||||||
|
edit: boolean;
|
||||||
|
delete: boolean;
|
||||||
|
};
|
||||||
|
memberships: {
|
||||||
|
view: boolean;
|
||||||
|
manage: boolean;
|
||||||
|
};
|
||||||
|
reservations: {
|
||||||
|
view: boolean;
|
||||||
|
create: boolean;
|
||||||
|
activate: boolean;
|
||||||
|
cancel: boolean;
|
||||||
|
};
|
||||||
admin: {
|
admin: {
|
||||||
manage_users: boolean;
|
manage_users: boolean;
|
||||||
view_audit_log: boolean;
|
view_audit_log: boolean;
|
||||||
@@ -132,7 +147,9 @@ export const account = pgTable('account', {
|
|||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
accountId: text('account_id').notNull(),
|
accountId: text('account_id').notNull(),
|
||||||
providerId: text('provider_id').notNull(),
|
providerId: text('provider_id').notNull(),
|
||||||
userId: text('user_id').notNull().references(() => user.id),
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id),
|
||||||
accessToken: text('access_token'),
|
accessToken: text('access_token'),
|
||||||
refreshToken: text('refresh_token'),
|
refreshToken: text('refresh_token'),
|
||||||
idToken: text('id_token'),
|
idToken: text('id_token'),
|
||||||
@@ -163,7 +180,9 @@ export const verification = pgTable('verification', {
|
|||||||
export const userProfiles = pgTable(
|
export const userProfiles = pgTable(
|
||||||
'user_profiles',
|
'user_profiles',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
userId: text('user_id').notNull().unique(), // references Better Auth user ID
|
userId: text('user_id').notNull().unique(), // references Better Auth user ID
|
||||||
displayName: text('display_name').notNull(),
|
displayName: text('display_name').notNull(),
|
||||||
avatarUrl: text('avatar_url'),
|
avatarUrl: text('avatar_url'),
|
||||||
@@ -179,10 +198,15 @@ export const userProfiles = pgTable(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const roles = pgTable('roles', {
|
export const roles = pgTable('roles', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
permissions: jsonb('permissions').$type<RolePermissions>().notNull().default({} as RolePermissions),
|
permissions: jsonb('permissions')
|
||||||
|
.$type<RolePermissions>()
|
||||||
|
.notNull()
|
||||||
|
.default({} as RolePermissions),
|
||||||
isGlobal: boolean('is_global').notNull().default(true),
|
isGlobal: boolean('is_global').notNull().default(true),
|
||||||
isSystem: boolean('is_system').notNull().default(false),
|
isSystem: boolean('is_system').notNull().default(false),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
@@ -192,7 +216,9 @@ export const roles = pgTable('roles', {
|
|||||||
export const portRoleOverrides = pgTable(
|
export const portRoleOverrides = pgTable(
|
||||||
'port_role_overrides',
|
'port_role_overrides',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
portId: text('port_id')
|
portId: text('port_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||||
@@ -215,7 +241,9 @@ export const portRoleOverrides = pgTable(
|
|||||||
export const userPortRoles = pgTable(
|
export const userPortRoles = pgTable(
|
||||||
'user_port_roles',
|
'user_port_roles',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
userId: text('user_id').notNull(), // references Better Auth user ID
|
userId: text('user_id').notNull(), // references Better Auth user ID
|
||||||
portId: text('port_id')
|
portId: text('port_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
119
src/lib/db/schema/yachts.ts
Normal file
119
src/lib/db/schema/yachts.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
numeric,
|
||||||
|
timestamp,
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
primaryKey,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { ports } from './ports';
|
||||||
|
|
||||||
|
export const yachts = pgTable(
|
||||||
|
'yachts',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
hullNumber: text('hull_number'),
|
||||||
|
registration: text('registration'),
|
||||||
|
flag: text('flag'),
|
||||||
|
yearBuilt: integer('year_built'),
|
||||||
|
builder: text('builder'),
|
||||||
|
model: text('model'),
|
||||||
|
hullMaterial: text('hull_material'),
|
||||||
|
lengthFt: numeric('length_ft'),
|
||||||
|
widthFt: numeric('width_ft'),
|
||||||
|
draftFt: numeric('draft_ft'),
|
||||||
|
lengthM: numeric('length_m'),
|
||||||
|
widthM: numeric('width_m'),
|
||||||
|
draftM: numeric('draft_m'),
|
||||||
|
currentOwnerType: text('current_owner_type').notNull(), // 'client' | 'company'
|
||||||
|
currentOwnerId: text('current_owner_id').notNull(),
|
||||||
|
status: text('status').notNull().default('active'), // 'active' | 'retired' | 'sold_away'
|
||||||
|
notes: text('notes'),
|
||||||
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('idx_yachts_port').on(table.portId),
|
||||||
|
index('idx_yachts_current_owner').on(
|
||||||
|
table.portId,
|
||||||
|
table.currentOwnerType,
|
||||||
|
table.currentOwnerId,
|
||||||
|
),
|
||||||
|
index('idx_yachts_name').on(table.portId, table.name),
|
||||||
|
index('idx_yachts_archived').on(table.portId, table.archivedAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const yachtOwnershipHistory = pgTable(
|
||||||
|
'yacht_ownership_history',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
yachtId: text('yacht_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => yachts.id, { onDelete: 'cascade' }),
|
||||||
|
ownerType: text('owner_type').notNull(),
|
||||||
|
ownerId: text('owner_id').notNull(),
|
||||||
|
startDate: timestamp('start_date', { withTimezone: true, mode: 'date' }).notNull(),
|
||||||
|
endDate: timestamp('end_date', { withTimezone: true, mode: 'date' }),
|
||||||
|
transferReason: text('transfer_reason'),
|
||||||
|
transferNotes: text('transfer_notes'),
|
||||||
|
createdBy: text('created_by').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('idx_yoh_yacht').on(table.yachtId),
|
||||||
|
uniqueIndex('idx_yoh_active')
|
||||||
|
.on(table.yachtId)
|
||||||
|
.where(sql`${table.endDate} IS NULL`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const yachtNotes = pgTable(
|
||||||
|
'yacht_notes',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
yachtId: text('yacht_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => yachts.id, { onDelete: 'cascade' }),
|
||||||
|
authorId: text('author_id').notNull(),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
mentions: text('mentions').array(),
|
||||||
|
isLocked: boolean('is_locked').notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [index('idx_yn_yacht').on(table.yachtId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const yachtTags = pgTable(
|
||||||
|
'yacht_tags',
|
||||||
|
{
|
||||||
|
yachtId: text('yacht_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => yachts.id, { onDelete: 'cascade' }),
|
||||||
|
tagId: text('tag_id').notNull(),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.yachtId, table.tagId] })],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Yacht = typeof yachts.$inferSelect;
|
||||||
|
export type NewYacht = typeof yachts.$inferInsert;
|
||||||
|
export type YachtOwnershipHistoryRow = typeof yachtOwnershipHistory.$inferSelect;
|
||||||
|
export type NewYachtOwnershipHistoryRow = typeof yachtOwnershipHistory.$inferInsert;
|
||||||
|
export type YachtNote = typeof yachtNotes.$inferSelect;
|
||||||
|
export type NewYachtNote = typeof yachtNotes.$inferInsert;
|
||||||
1105
src/lib/db/seed-data.ts
Normal file
1105
src/lib/db/seed-data.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,130 +1,433 @@
|
|||||||
/**
|
/**
|
||||||
* Seed script for Port Nimara CRM.
|
* Seed script for Port Nimara CRM.
|
||||||
*
|
*
|
||||||
* Seeds:
|
* Top-level orchestrator:
|
||||||
* - 1 Port: Port Nimara
|
* 1. Create 3 ports (idempotent):
|
||||||
* - 5 System roles with full permission maps
|
* - Port Nimara
|
||||||
* - 1 Super admin user profile (matt@portnimara.com)
|
* - Marina Azzurra
|
||||||
|
* - Harbor Royale
|
||||||
|
* 2. Create 5 system roles with full permission maps
|
||||||
|
* 3. Create the super admin user profile placeholder (matt@portnimara.com)
|
||||||
|
* 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts
|
||||||
|
* to produce the realistic multi-cardinality fixture
|
||||||
|
* (berths, clients, companies, yachts, memberships, interests,
|
||||||
|
* reservations, ownership-transfer history).
|
||||||
|
* 5. Print a summary.
|
||||||
*
|
*
|
||||||
* Run with: npm run db:seed
|
* Run with: pnpm db:seed
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from './index';
|
import { db } from './index';
|
||||||
import { ports } from './schema/ports';
|
import { ports } from './schema/ports';
|
||||||
import { roles, userProfiles } from './schema/users';
|
import { roles, userProfiles } from './schema/users';
|
||||||
import type { RolePermissions } from './schema/users';
|
import type { RolePermissions } from './schema/users';
|
||||||
|
import { seedPortData, type SeedSummary } from './seed-data';
|
||||||
|
|
||||||
// ─── Permission Maps ─────────────────────────────────────────────────────────
|
// ─── Permission Maps ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ALL_PERMISSIONS: RolePermissions = {
|
const ALL_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
||||||
interests: { view: true, create: true, edit: true, delete: true, change_stage: true, generate_eoi: true, export: true },
|
interests: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: true,
|
||||||
|
change_stage: true,
|
||||||
|
generate_eoi: true,
|
||||||
|
export: true,
|
||||||
|
},
|
||||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||||
documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: true },
|
documents: {
|
||||||
expenses: { view: true, create: true, edit: true, delete: true, export: true, scan_receipt: true },
|
view: true,
|
||||||
invoices: { view: true, create: true, edit: true, delete: true, send: true, record_payment: true, export: true },
|
create: true,
|
||||||
|
send_for_signing: true,
|
||||||
|
upload_signed: true,
|
||||||
|
delete: true,
|
||||||
|
},
|
||||||
|
expenses: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: true,
|
||||||
|
export: true,
|
||||||
|
scan_receipt: true,
|
||||||
|
},
|
||||||
|
invoices: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: true,
|
||||||
|
send: true,
|
||||||
|
record_payment: true,
|
||||||
|
export: true,
|
||||||
|
},
|
||||||
files: { view: true, upload: true, delete: true, manage_folders: true },
|
files: { view: true, upload: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: { view_own: true, view_all: true, create: true, edit_own: true, edit_all: true, assign_others: true },
|
reminders: {
|
||||||
|
view_own: true,
|
||||||
|
view_all: true,
|
||||||
|
create: true,
|
||||||
|
edit_own: true,
|
||||||
|
edit_all: true,
|
||||||
|
assign_others: true,
|
||||||
|
},
|
||||||
calendar: { connect: true, view_events: true },
|
calendar: { connect: true, view_events: true },
|
||||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
reports: { view_dashboard: true, view_analytics: true, export: true },
|
||||||
document_templates: { view: true, generate: true, manage: true },
|
document_templates: { view: true, generate: true, manage: true },
|
||||||
admin: { manage_users: true, view_audit_log: true, manage_settings: true, manage_webhooks: true, manage_reports: true, manage_custom_fields: true, manage_forms: true, manage_tags: true, system_backup: true },
|
yachts: { view: true, create: true, edit: true, delete: true, transfer: true },
|
||||||
|
companies: { view: true, create: true, edit: true, delete: true },
|
||||||
|
memberships: { view: true, manage: true },
|
||||||
|
reservations: { view: true, create: true, activate: true, cancel: true },
|
||||||
|
admin: {
|
||||||
|
manage_users: true,
|
||||||
|
view_audit_log: true,
|
||||||
|
manage_settings: true,
|
||||||
|
manage_webhooks: true,
|
||||||
|
manage_reports: true,
|
||||||
|
manage_custom_fields: true,
|
||||||
|
manage_forms: true,
|
||||||
|
manage_tags: true,
|
||||||
|
system_backup: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const DIRECTOR_PERMISSIONS: RolePermissions = {
|
const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
||||||
interests: { view: true, create: true, edit: true, delete: true, change_stage: true, generate_eoi: true, export: true },
|
interests: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: true,
|
||||||
|
change_stage: true,
|
||||||
|
generate_eoi: true,
|
||||||
|
export: true,
|
||||||
|
},
|
||||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||||
documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: true },
|
documents: {
|
||||||
expenses: { view: true, create: true, edit: true, delete: true, export: true, scan_receipt: true },
|
view: true,
|
||||||
invoices: { view: true, create: true, edit: true, delete: true, send: true, record_payment: true, export: true },
|
create: true,
|
||||||
|
send_for_signing: true,
|
||||||
|
upload_signed: true,
|
||||||
|
delete: true,
|
||||||
|
},
|
||||||
|
expenses: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: true,
|
||||||
|
export: true,
|
||||||
|
scan_receipt: true,
|
||||||
|
},
|
||||||
|
invoices: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: true,
|
||||||
|
send: true,
|
||||||
|
record_payment: true,
|
||||||
|
export: true,
|
||||||
|
},
|
||||||
files: { view: true, upload: true, delete: true, manage_folders: true },
|
files: { view: true, upload: true, delete: true, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: { view_own: true, view_all: true, create: true, edit_own: true, edit_all: true, assign_others: true },
|
reminders: {
|
||||||
|
view_own: true,
|
||||||
|
view_all: true,
|
||||||
|
create: true,
|
||||||
|
edit_own: true,
|
||||||
|
edit_all: true,
|
||||||
|
assign_others: true,
|
||||||
|
},
|
||||||
calendar: { connect: true, view_events: true },
|
calendar: { connect: true, view_events: true },
|
||||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
reports: { view_dashboard: true, view_analytics: true, export: true },
|
||||||
document_templates: { view: true, generate: true, manage: true },
|
document_templates: { view: true, generate: true, manage: true },
|
||||||
admin: { manage_users: true, view_audit_log: true, manage_settings: true, manage_webhooks: true, manage_reports: true, manage_custom_fields: true, manage_forms: true, manage_tags: true, system_backup: false },
|
yachts: { view: true, create: true, edit: true, delete: true, transfer: true },
|
||||||
|
companies: { view: true, create: true, edit: true, delete: true },
|
||||||
|
memberships: { view: true, manage: true },
|
||||||
|
reservations: { view: true, create: true, activate: true, cancel: true },
|
||||||
|
admin: {
|
||||||
|
manage_users: true,
|
||||||
|
view_audit_log: true,
|
||||||
|
manage_settings: true,
|
||||||
|
manage_webhooks: true,
|
||||||
|
manage_reports: true,
|
||||||
|
manage_custom_fields: true,
|
||||||
|
manage_forms: true,
|
||||||
|
manage_tags: true,
|
||||||
|
system_backup: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true },
|
clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true },
|
||||||
interests: { view: true, create: true, edit: true, delete: false, change_stage: true, generate_eoi: true, export: true },
|
interests: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: false,
|
||||||
|
change_stage: true,
|
||||||
|
generate_eoi: true,
|
||||||
|
export: true,
|
||||||
|
},
|
||||||
berths: { view: true, edit: false, import: false, manage_waiting_list: true },
|
berths: { view: true, edit: false, import: false, manage_waiting_list: true },
|
||||||
documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: false },
|
documents: {
|
||||||
expenses: { view: true, create: true, edit: true, delete: false, export: true, scan_receipt: true },
|
view: true,
|
||||||
invoices: { view: true, create: true, edit: true, delete: false, send: true, record_payment: true, export: true },
|
create: true,
|
||||||
|
send_for_signing: true,
|
||||||
|
upload_signed: true,
|
||||||
|
delete: false,
|
||||||
|
},
|
||||||
|
expenses: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: false,
|
||||||
|
export: true,
|
||||||
|
scan_receipt: true,
|
||||||
|
},
|
||||||
|
invoices: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: false,
|
||||||
|
send: true,
|
||||||
|
record_payment: true,
|
||||||
|
export: true,
|
||||||
|
},
|
||||||
files: { view: true, upload: true, delete: false, manage_folders: true },
|
files: { view: true, upload: true, delete: false, manage_folders: true },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: { view_own: true, view_all: true, create: true, edit_own: true, edit_all: true, assign_others: true },
|
reminders: {
|
||||||
|
view_own: true,
|
||||||
|
view_all: true,
|
||||||
|
create: true,
|
||||||
|
edit_own: true,
|
||||||
|
edit_all: true,
|
||||||
|
assign_others: true,
|
||||||
|
},
|
||||||
calendar: { connect: true, view_events: true },
|
calendar: { connect: true, view_events: true },
|
||||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
reports: { view_dashboard: true, view_analytics: true, export: true },
|
||||||
document_templates: { view: true, generate: true, manage: false },
|
document_templates: { view: true, generate: true, manage: false },
|
||||||
admin: { manage_users: false, view_audit_log: false, manage_settings: false, manage_webhooks: false, manage_reports: false, manage_custom_fields: false, manage_forms: false, manage_tags: true, system_backup: false },
|
yachts: { view: true, create: true, edit: true, delete: false, transfer: true },
|
||||||
|
companies: { view: true, create: true, edit: true, delete: false },
|
||||||
|
memberships: { view: true, manage: true },
|
||||||
|
reservations: { view: true, create: true, activate: true, cancel: true },
|
||||||
|
admin: {
|
||||||
|
manage_users: false,
|
||||||
|
view_audit_log: false,
|
||||||
|
manage_settings: false,
|
||||||
|
manage_webhooks: false,
|
||||||
|
manage_reports: false,
|
||||||
|
manage_custom_fields: false,
|
||||||
|
manage_forms: false,
|
||||||
|
manage_tags: true,
|
||||||
|
system_backup: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true },
|
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true },
|
||||||
interests: { view: true, create: true, edit: true, delete: false, change_stage: true, generate_eoi: true, export: true },
|
interests: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: false,
|
||||||
|
change_stage: true,
|
||||||
|
generate_eoi: true,
|
||||||
|
export: true,
|
||||||
|
},
|
||||||
berths: { view: true, edit: false, import: false, manage_waiting_list: true },
|
berths: { view: true, edit: false, import: false, manage_waiting_list: true },
|
||||||
documents: { view: true, create: true, send_for_signing: true, upload_signed: true, delete: false },
|
documents: {
|
||||||
expenses: { view: true, create: true, edit: true, delete: false, export: true, scan_receipt: true },
|
view: true,
|
||||||
invoices: { view: true, create: true, edit: true, delete: false, send: true, record_payment: true, export: true },
|
create: true,
|
||||||
|
send_for_signing: true,
|
||||||
|
upload_signed: true,
|
||||||
|
delete: false,
|
||||||
|
},
|
||||||
|
expenses: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: false,
|
||||||
|
export: true,
|
||||||
|
scan_receipt: true,
|
||||||
|
},
|
||||||
|
invoices: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
edit: true,
|
||||||
|
delete: false,
|
||||||
|
send: true,
|
||||||
|
record_payment: true,
|
||||||
|
export: true,
|
||||||
|
},
|
||||||
files: { view: true, upload: true, delete: false, manage_folders: false },
|
files: { view: true, upload: true, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: true, configure_account: true },
|
email: { view: true, send: true, configure_account: true },
|
||||||
reminders: { view_own: true, view_all: false, create: true, edit_own: true, edit_all: false, assign_others: false },
|
reminders: {
|
||||||
|
view_own: true,
|
||||||
|
view_all: false,
|
||||||
|
create: true,
|
||||||
|
edit_own: true,
|
||||||
|
edit_all: false,
|
||||||
|
assign_others: false,
|
||||||
|
},
|
||||||
calendar: { connect: true, view_events: true },
|
calendar: { connect: true, view_events: true },
|
||||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
reports: { view_dashboard: true, view_analytics: true, export: true },
|
||||||
document_templates: { view: true, generate: true, manage: false },
|
document_templates: { view: true, generate: true, manage: false },
|
||||||
admin: { manage_users: false, view_audit_log: false, manage_settings: false, manage_webhooks: false, manage_reports: false, manage_custom_fields: false, manage_forms: false, manage_tags: true, system_backup: false },
|
yachts: { view: true, create: true, edit: true, delete: false, transfer: false },
|
||||||
|
companies: { view: true, create: true, edit: false, delete: false },
|
||||||
|
memberships: { view: true, manage: false },
|
||||||
|
reservations: { view: true, create: true, activate: true, cancel: false },
|
||||||
|
admin: {
|
||||||
|
manage_users: false,
|
||||||
|
view_audit_log: false,
|
||||||
|
manage_settings: false,
|
||||||
|
manage_webhooks: false,
|
||||||
|
manage_reports: false,
|
||||||
|
manage_custom_fields: false,
|
||||||
|
manage_forms: false,
|
||||||
|
manage_tags: true,
|
||||||
|
system_backup: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const VIEWER_PERMISSIONS: RolePermissions = {
|
const VIEWER_PERMISSIONS: RolePermissions = {
|
||||||
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false },
|
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false },
|
||||||
interests: { view: true, create: false, edit: false, delete: false, change_stage: false, generate_eoi: false, export: false },
|
interests: {
|
||||||
|
view: true,
|
||||||
|
create: false,
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
change_stage: false,
|
||||||
|
generate_eoi: false,
|
||||||
|
export: false,
|
||||||
|
},
|
||||||
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
||||||
documents: { view: true, create: false, send_for_signing: false, upload_signed: false, delete: false },
|
documents: {
|
||||||
expenses: { view: true, create: false, edit: false, delete: false, export: false, scan_receipt: false },
|
view: true,
|
||||||
invoices: { view: true, create: false, edit: false, delete: false, send: false, record_payment: false, export: false },
|
create: false,
|
||||||
|
send_for_signing: false,
|
||||||
|
upload_signed: false,
|
||||||
|
delete: false,
|
||||||
|
},
|
||||||
|
expenses: {
|
||||||
|
view: true,
|
||||||
|
create: false,
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
export: false,
|
||||||
|
scan_receipt: false,
|
||||||
|
},
|
||||||
|
invoices: {
|
||||||
|
view: true,
|
||||||
|
create: false,
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
send: false,
|
||||||
|
record_payment: false,
|
||||||
|
export: false,
|
||||||
|
},
|
||||||
files: { view: true, upload: false, delete: false, manage_folders: false },
|
files: { view: true, upload: false, delete: false, manage_folders: false },
|
||||||
email: { view: true, send: false, configure_account: false },
|
email: { view: true, send: false, configure_account: false },
|
||||||
reminders: { view_own: true, view_all: false, create: false, edit_own: false, edit_all: false, assign_others: false },
|
reminders: {
|
||||||
|
view_own: true,
|
||||||
|
view_all: false,
|
||||||
|
create: false,
|
||||||
|
edit_own: false,
|
||||||
|
edit_all: false,
|
||||||
|
assign_others: false,
|
||||||
|
},
|
||||||
calendar: { connect: false, view_events: true },
|
calendar: { connect: false, view_events: true },
|
||||||
reports: { view_dashboard: true, view_analytics: false, export: false },
|
reports: { view_dashboard: true, view_analytics: false, export: false },
|
||||||
document_templates: { view: true, generate: false, manage: false },
|
document_templates: { view: true, generate: false, manage: false },
|
||||||
admin: { manage_users: false, view_audit_log: false, manage_settings: false, manage_webhooks: false, manage_reports: false, manage_custom_fields: false, manage_forms: false, manage_tags: false, system_backup: false },
|
yachts: { view: true, create: false, edit: false, delete: false, transfer: false },
|
||||||
|
companies: { view: true, create: false, edit: false, delete: false },
|
||||||
|
memberships: { view: true, manage: false },
|
||||||
|
reservations: { view: true, create: false, activate: false, cancel: false },
|
||||||
|
admin: {
|
||||||
|
manage_users: false,
|
||||||
|
view_audit_log: false,
|
||||||
|
manage_settings: false,
|
||||||
|
manage_webhooks: false,
|
||||||
|
manage_reports: false,
|
||||||
|
manage_custom_fields: false,
|
||||||
|
manage_forms: false,
|
||||||
|
manage_tags: false,
|
||||||
|
system_backup: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Port Definitions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PORT_DEFINITIONS: Array<{
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
primaryColor: string;
|
||||||
|
defaultCurrency: string;
|
||||||
|
timezone: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'Port Nimara',
|
||||||
|
slug: 'port-nimara',
|
||||||
|
primaryColor: '#0F4C81',
|
||||||
|
defaultCurrency: 'USD',
|
||||||
|
timezone: 'America/Anguilla',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Marina Azzurra',
|
||||||
|
slug: 'marina-azzurra',
|
||||||
|
primaryColor: '#2E86AB',
|
||||||
|
defaultCurrency: 'EUR',
|
||||||
|
timezone: 'Europe/Rome',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Harbor Royale',
|
||||||
|
slug: 'harbor-royale',
|
||||||
|
primaryColor: '#8B1E3F',
|
||||||
|
defaultCurrency: 'GBP',
|
||||||
|
timezone: 'Europe/London',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// ─── Seed Function ────────────────────────────────────────────────────────────
|
// ─── Seed Function ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
console.log('Seeding Port Nimara CRM...');
|
console.log('Seeding Port Nimara CRM...');
|
||||||
|
|
||||||
// ── 1. Port ─────────────────────────────────────────────────────────────────
|
// ── 1. Ports ────────────────────────────────────────────────────────────────
|
||||||
console.log('Creating Port Nimara...');
|
console.log('Creating ports...');
|
||||||
const [port] = await db
|
const portIds: Array<{ id: string; name: string; slug: string }> = [];
|
||||||
.insert(ports)
|
|
||||||
.values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
name: 'Port Nimara',
|
|
||||||
slug: 'port-nimara',
|
|
||||||
logoUrl: null,
|
|
||||||
primaryColor: '#0F4C81',
|
|
||||||
defaultCurrency: 'USD',
|
|
||||||
timezone: 'America/Anguilla',
|
|
||||||
settings: {},
|
|
||||||
isActive: true,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const portId = port?.id;
|
for (const def of PORT_DEFINITIONS) {
|
||||||
if (!portId) {
|
const [inserted] = await db
|
||||||
console.log('Port already exists, skipping...');
|
.insert(ports)
|
||||||
} else {
|
.values({
|
||||||
console.log(`Port created: ${portId}`);
|
id: crypto.randomUUID(),
|
||||||
|
name: def.name,
|
||||||
|
slug: def.slug,
|
||||||
|
logoUrl: null,
|
||||||
|
primaryColor: def.primaryColor,
|
||||||
|
defaultCurrency: def.defaultCurrency,
|
||||||
|
timezone: def.timezone,
|
||||||
|
settings: {},
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (inserted) {
|
||||||
|
console.log(` Port created: ${def.name} (${inserted.id})`);
|
||||||
|
portIds.push({ id: inserted.id, name: def.name, slug: def.slug });
|
||||||
|
} else {
|
||||||
|
// Port already existed — look it up so we can still seed fixtures for it.
|
||||||
|
const [existing] = await db.select().from(ports).where(eq(ports.slug, def.slug)).limit(1);
|
||||||
|
if (existing) {
|
||||||
|
console.log(` Port exists: ${def.name} (${existing.id})`);
|
||||||
|
portIds.push({ id: existing.id, name: def.name, slug: def.slug });
|
||||||
|
} else {
|
||||||
|
console.warn(` Port insert conflict but lookup returned no row: ${def.slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. System Roles ─────────────────────────────────────────────────────────
|
// ── 2. System Roles ─────────────────────────────────────────────────────────
|
||||||
@@ -158,7 +461,8 @@ async function seed() {
|
|||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: 'sales_agent',
|
name: 'sales_agent',
|
||||||
description: 'Standard sales role. View/create/edit clients and interests, manage own reminders.',
|
description:
|
||||||
|
'Standard sales role. View/create/edit clients and interests, manage own reminders.',
|
||||||
permissions: SALES_AGENT_PERMISSIONS,
|
permissions: SALES_AGENT_PERMISSIONS,
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
@@ -175,7 +479,7 @@ async function seed() {
|
|||||||
|
|
||||||
for (const role of systemRoles) {
|
for (const role of systemRoles) {
|
||||||
await db.insert(roles).values(role).onConflictDoNothing();
|
await db.insert(roles).values(role).onConflictDoNothing();
|
||||||
console.log(`Role created: ${role.name}`);
|
console.log(` Role: ${role.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Super Admin User Profile ─────────────────────────────────────────────
|
// ── 3. Super Admin User Profile ─────────────────────────────────────────────
|
||||||
@@ -202,7 +506,32 @@ async function seed() {
|
|||||||
})
|
})
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
|
|
||||||
console.log(`Super admin profile created for user_id: ${superAdminUserId}`);
|
console.log(` Super admin profile for user_id: ${superAdminUserId}`);
|
||||||
|
|
||||||
|
// ── 4. Per-port fixtures ────────────────────────────────────────────────────
|
||||||
|
console.log('');
|
||||||
|
console.log('Seeding per-port fixtures...');
|
||||||
|
|
||||||
|
const summaries: Array<{ name: string; summary: SeedSummary | null }> = [];
|
||||||
|
for (const p of portIds) {
|
||||||
|
console.log(` [${p.slug}] seeding fixture data...`);
|
||||||
|
const summary = await seedPortData(p.id, p.slug);
|
||||||
|
summaries.push({ name: p.name, summary });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Summary ─────────────────────────────────────────────────────────────
|
||||||
|
console.log('');
|
||||||
|
console.log('─── Summary ───────────────────────────────────────────────');
|
||||||
|
for (const s of summaries) {
|
||||||
|
if (s.summary === null) {
|
||||||
|
console.log(` ✓ Port "${s.name}" — already seeded (skipped)`);
|
||||||
|
} else {
|
||||||
|
const x = s.summary;
|
||||||
|
console.log(
|
||||||
|
` ✓ Port "${s.name}" — ${x.berths} berths, ${x.clients} clients, ${x.companies} companies, ${x.yachts} yachts, ${x.interests} interests, ${x.reservations} reservations`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Seed complete!');
|
console.log('Seed complete!');
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|||||||
369
src/lib/services/berth-reservations.service.ts
Normal file
369
src/lib/services/berth-reservations.service.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { berthReservations, type BerthReservation } from '@/lib/db/schema/reservations';
|
||||||
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
|
import { companyMemberships } from '@/lib/db/schema/companies';
|
||||||
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
|
import { createAuditLog } from '@/lib/audit';
|
||||||
|
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
import type {
|
||||||
|
createPendingSchema,
|
||||||
|
ActivateInput,
|
||||||
|
EndReservationInput,
|
||||||
|
CancelInput,
|
||||||
|
ListReservationsInput,
|
||||||
|
} from '@/lib/validators/reservations';
|
||||||
|
|
||||||
|
type CreatePendingInput = z.input<typeof createPendingSchema>;
|
||||||
|
|
||||||
|
export type { BerthReservation };
|
||||||
|
|
||||||
|
interface AuditMeta {
|
||||||
|
userId: string;
|
||||||
|
portId: string;
|
||||||
|
ipAddress: string;
|
||||||
|
userAgent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the error is a Postgres unique-violation (SQLSTATE 23505)
|
||||||
|
* raised specifically on the `idx_br_active` partial unique index. Narrowing
|
||||||
|
* to this constraint name prevents us from swallowing unrelated unique
|
||||||
|
* violations.
|
||||||
|
*/
|
||||||
|
function isBerthActiveConflict(err: unknown): boolean {
|
||||||
|
if (!err || typeof err !== 'object') return false;
|
||||||
|
const e = err as {
|
||||||
|
code?: unknown;
|
||||||
|
constraint_name?: unknown;
|
||||||
|
constraint?: unknown;
|
||||||
|
cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown };
|
||||||
|
};
|
||||||
|
const code = e.code ?? e.cause?.code;
|
||||||
|
if (code !== '23505') return false;
|
||||||
|
const constraint =
|
||||||
|
e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint;
|
||||||
|
return constraint === 'idx_br_active';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-references the reservation's client against the yacht's current owner.
|
||||||
|
* Either the yacht is directly owned by the client, OR the client has an
|
||||||
|
* active (endDate IS NULL) company_membership on the owning company.
|
||||||
|
*/
|
||||||
|
async function assertClientOwnsOrRepresentsYacht(
|
||||||
|
yacht: { currentOwnerType: string; currentOwnerId: string },
|
||||||
|
clientId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (yacht.currentOwnerType === 'client' && yacht.currentOwnerId === clientId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yacht.currentOwnerType === 'company') {
|
||||||
|
const membership = await db.query.companyMemberships.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(companyMemberships.companyId, yacht.currentOwnerId),
|
||||||
|
eq(companyMemberships.clientId, clientId),
|
||||||
|
isNull(companyMemberships.endDate),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (membership) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ValidationError('yacht does not belong to reservation client');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScoped(id: string, portId: string): Promise<BerthReservation> {
|
||||||
|
const row = await db.query.berthReservations.findFirst({
|
||||||
|
where: and(eq(berthReservations.id, id), eq(berthReservations.portId, portId)),
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundError('Reservation');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Create (pending) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createPending(
|
||||||
|
portId: string,
|
||||||
|
data: CreatePendingInput,
|
||||||
|
meta: AuditMeta,
|
||||||
|
): Promise<BerthReservation> {
|
||||||
|
// Tenant-scoped existence checks (berth, client, yacht).
|
||||||
|
const berth = await db.query.berths.findFirst({
|
||||||
|
where: and(eq(berths.id, data.berthId), eq(berths.portId, portId)),
|
||||||
|
});
|
||||||
|
if (!berth) throw new ValidationError('berth not found');
|
||||||
|
|
||||||
|
const client = await db.query.clients.findFirst({
|
||||||
|
where: and(eq(clients.id, data.clientId), eq(clients.portId, portId)),
|
||||||
|
});
|
||||||
|
if (!client) throw new ValidationError('client not found');
|
||||||
|
|
||||||
|
const yacht = await db.query.yachts.findFirst({
|
||||||
|
where: and(eq(yachts.id, data.yachtId), eq(yachts.portId, portId)),
|
||||||
|
});
|
||||||
|
if (!yacht) throw new ValidationError('yacht not found');
|
||||||
|
|
||||||
|
// Client must own the yacht directly OR be an active member of the owning company.
|
||||||
|
await assertClientOwnsOrRepresentsYacht(
|
||||||
|
{ currentOwnerType: yacht.currentOwnerType, currentOwnerId: yacht.currentOwnerId },
|
||||||
|
data.clientId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [reservation] = await db
|
||||||
|
.insert(berthReservations)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
berthId: data.berthId,
|
||||||
|
clientId: data.clientId,
|
||||||
|
yachtId: data.yachtId,
|
||||||
|
interestId: data.interestId ?? null,
|
||||||
|
status: 'pending',
|
||||||
|
startDate: data.startDate,
|
||||||
|
tenureType: data.tenureType ?? 'permanent',
|
||||||
|
notes: data.notes ?? null,
|
||||||
|
createdBy: meta.userId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
void createAuditLog({
|
||||||
|
userId: meta.userId,
|
||||||
|
portId,
|
||||||
|
action: 'create',
|
||||||
|
entityType: 'berth_reservation',
|
||||||
|
entityId: reservation!.id,
|
||||||
|
newValue: {
|
||||||
|
berthId: reservation!.berthId,
|
||||||
|
clientId: reservation!.clientId,
|
||||||
|
yachtId: reservation!.yachtId,
|
||||||
|
status: reservation!.status,
|
||||||
|
startDate: reservation!.startDate,
|
||||||
|
},
|
||||||
|
ipAddress: meta.ipAddress,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitToRoom(`port:${portId}`, 'berth_reservation:created', {
|
||||||
|
reservationId: reservation!.id,
|
||||||
|
berthId: reservation!.berthId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reservation!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Activate (pending → active) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function activate(
|
||||||
|
reservationId: string,
|
||||||
|
portId: string,
|
||||||
|
data: ActivateInput,
|
||||||
|
meta: AuditMeta,
|
||||||
|
): Promise<BerthReservation> {
|
||||||
|
const existing = await loadScoped(reservationId, portId);
|
||||||
|
|
||||||
|
if (existing.status !== 'pending') {
|
||||||
|
throw new ValidationError(`invalid transition: ${existing.status} → active`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Partial<typeof berthReservations.$inferInsert> = {
|
||||||
|
status: 'active',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (data.contractFileId !== undefined) {
|
||||||
|
patch.contractFileId = data.contractFileId;
|
||||||
|
}
|
||||||
|
if (data.effectiveDate !== undefined) {
|
||||||
|
patch.startDate = data.effectiveDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated: BerthReservation | undefined;
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.update(berthReservations)
|
||||||
|
.set(patch)
|
||||||
|
.where(and(eq(berthReservations.id, reservationId), eq(berthReservations.portId, portId)))
|
||||||
|
.returning();
|
||||||
|
updated = rows[0];
|
||||||
|
} catch (err) {
|
||||||
|
if (isBerthActiveConflict(err)) {
|
||||||
|
const conflicting = await db.query.berthReservations.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(berthReservations.berthId, existing.berthId),
|
||||||
|
eq(berthReservations.status, 'active'),
|
||||||
|
eq(berthReservations.portId, portId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
throw new ConflictError(
|
||||||
|
conflicting
|
||||||
|
? `berth already has active reservation (conflictingReservationId: ${conflicting.id})`
|
||||||
|
: 'berth already has active reservation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
void createAuditLog({
|
||||||
|
userId: meta.userId,
|
||||||
|
portId,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'berth_reservation',
|
||||||
|
entityId: reservationId,
|
||||||
|
oldValue: { status: existing.status },
|
||||||
|
newValue: { status: 'active', contractFileId: updated!.contractFileId },
|
||||||
|
ipAddress: meta.ipAddress,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitToRoom(`port:${portId}`, 'berth_reservation:activated', {
|
||||||
|
reservationId,
|
||||||
|
berthId: updated!.berthId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── End (active → ended) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function endReservation(
|
||||||
|
reservationId: string,
|
||||||
|
portId: string,
|
||||||
|
data: EndReservationInput,
|
||||||
|
meta: AuditMeta,
|
||||||
|
): Promise<BerthReservation> {
|
||||||
|
const existing = await loadScoped(reservationId, portId);
|
||||||
|
|
||||||
|
if (existing.status !== 'active') {
|
||||||
|
throw new ValidationError(`invalid transition: ${existing.status} → ended`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.update(berthReservations)
|
||||||
|
.set({
|
||||||
|
status: 'ended',
|
||||||
|
endDate: data.endDate,
|
||||||
|
notes: data.notes ?? existing.notes,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(berthReservations.id, reservationId), eq(berthReservations.portId, portId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const updated = rows[0]!;
|
||||||
|
|
||||||
|
void createAuditLog({
|
||||||
|
userId: meta.userId,
|
||||||
|
portId,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'berth_reservation',
|
||||||
|
entityId: reservationId,
|
||||||
|
oldValue: { status: existing.status },
|
||||||
|
newValue: { status: 'ended', endDate: updated.endDate },
|
||||||
|
ipAddress: meta.ipAddress,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitToRoom(`port:${portId}`, 'berth_reservation:ended', {
|
||||||
|
reservationId,
|
||||||
|
berthId: updated.berthId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cancel (pending|active → cancelled) ─────────────────────────────────────
|
||||||
|
|
||||||
|
export async function cancel(
|
||||||
|
reservationId: string,
|
||||||
|
portId: string,
|
||||||
|
data: CancelInput,
|
||||||
|
meta: AuditMeta,
|
||||||
|
): Promise<BerthReservation> {
|
||||||
|
const existing = await loadScoped(reservationId, portId);
|
||||||
|
|
||||||
|
if (existing.status !== 'pending' && existing.status !== 'active') {
|
||||||
|
throw new ValidationError(`invalid transition: ${existing.status} → cancelled`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.update(berthReservations)
|
||||||
|
.set({
|
||||||
|
status: 'cancelled',
|
||||||
|
notes: data.reason
|
||||||
|
? `${existing.notes ? `${existing.notes}\n` : ''}Cancelled: ${data.reason}`
|
||||||
|
: existing.notes,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(berthReservations.id, reservationId), eq(berthReservations.portId, portId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const updated = rows[0]!;
|
||||||
|
|
||||||
|
void createAuditLog({
|
||||||
|
userId: meta.userId,
|
||||||
|
portId,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'berth_reservation',
|
||||||
|
entityId: reservationId,
|
||||||
|
oldValue: { status: existing.status },
|
||||||
|
newValue: { status: 'cancelled', reason: data.reason ?? null },
|
||||||
|
ipAddress: meta.ipAddress,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitToRoom(`port:${portId}`, 'berth_reservation:cancelled', {
|
||||||
|
reservationId,
|
||||||
|
berthId: updated.berthId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Get ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getById(id: string, portId: string): Promise<BerthReservation> {
|
||||||
|
return loadScoped(id, portId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listReservations(
|
||||||
|
portId: string,
|
||||||
|
query: ListReservationsInput,
|
||||||
|
): Promise<{ data: BerthReservation[]; total: number }> {
|
||||||
|
const { page, limit, sort, order, search, status, berthId, clientId, yachtId } = query;
|
||||||
|
|
||||||
|
const filters = [];
|
||||||
|
if (status) filters.push(eq(berthReservations.status, status));
|
||||||
|
if (berthId) filters.push(eq(berthReservations.berthId, berthId));
|
||||||
|
if (clientId) filters.push(eq(berthReservations.clientId, clientId));
|
||||||
|
if (yachtId) filters.push(eq(berthReservations.yachtId, yachtId));
|
||||||
|
|
||||||
|
let sortColumn:
|
||||||
|
| typeof berthReservations.startDate
|
||||||
|
| typeof berthReservations.createdAt
|
||||||
|
| typeof berthReservations.updatedAt = berthReservations.updatedAt;
|
||||||
|
if (sort === 'startDate') sortColumn = berthReservations.startDate;
|
||||||
|
else if (sort === 'createdAt') sortColumn = berthReservations.createdAt;
|
||||||
|
|
||||||
|
const result = await buildListQuery<BerthReservation>({
|
||||||
|
table: berthReservations,
|
||||||
|
portIdColumn: berthReservations.portId,
|
||||||
|
portId,
|
||||||
|
idColumn: berthReservations.id,
|
||||||
|
updatedAtColumn: berthReservations.updatedAt,
|
||||||
|
searchColumns: search ? [berthReservations.notes] : [],
|
||||||
|
searchTerm: search,
|
||||||
|
filters,
|
||||||
|
sort: sort ? { column: sortColumn, direction: order } : undefined,
|
||||||
|
page,
|
||||||
|
pageSize: limit,
|
||||||
|
includeArchived: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user