Compare commits
1 Commits
9d7decfc5b
...
docs/dedup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b92eb827 |
@@ -1 +0,0 @@
|
||||
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}
|
||||
30
.gitattributes
vendored
30
.gitattributes
vendored
@@ -1,30 +0,0 @@
|
||||
# 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,5 +17,3 @@ playwright-report/
|
||||
nginx/certs/
|
||||
tsconfig.tsbuildinfo
|
||||
.playwright-mcp/
|
||||
docker-compose.override.yml
|
||||
.remember/
|
||||
|
||||
21
PROGRESS.md
21
PROGRESS.md
@@ -1,22 +1,12 @@
|
||||
# Port Nimara CRM - Project Progress
|
||||
|
||||
**Last updated:** 2026-04-22
|
||||
**Last updated:** 2026-03-26
|
||||
**Repo:** https://code.letsbe.solutions/letsbe/pn-new-crm
|
||||
**Domain:** pn.letsbe.solutions
|
||||
**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)
|
||||
|
||||
### Layer 0: Foundation (DONE)
|
||||
@@ -90,10 +80,8 @@
|
||||
- API: `/api/v1/notifications/...` (CRUD, preferences, read-all, unread-count)
|
||||
- Service: `notifications.service.ts`
|
||||
- Components: `src/components/notifications/`
|
||||
- [x] **Reminders** - Full CRUD with background processors (dispatcher, reminder workers)
|
||||
- [x] **Reminders** - Reminder pages
|
||||
- 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
|
||||
- API: `/api/v1/search/...`, `/api/v1/saved-views/...`
|
||||
- Service: `search.service.ts`, `saved-views.service.ts`
|
||||
@@ -190,12 +178,11 @@
|
||||
|
||||
### Priority 1: Deployment & Go-Live
|
||||
|
||||
- [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
|
||||
- [ ] Push to Gitea and verify CI/CD pipeline builds
|
||||
- [ ] Set up server: install Docker, nginx, configure DNS for `pn.letsbe.solutions`
|
||||
- [ ] Run `certbot --nginx -d pn.letsbe.solutions` for SSL
|
||||
- [ ] Configure production `.env` on server
|
||||
- [ ] Run database migrations (`drizzle-kit migrate` against prod DB — `0000` + `0001` need to apply)
|
||||
- [ ] Run database migrations (`pnpm db:push`)
|
||||
- [ ] Run seed data (`pnpm db:seed`)
|
||||
- [ ] Verify all services start and health check passes
|
||||
|
||||
|
||||
Submodule client-portal updated: 84f89f9409...e2d31815cf
File diff suppressed because it is too large
Load Diff
@@ -1,663 +0,0 @@
|
||||
# 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
|
||||
564
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md
Normal file
564
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# 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';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -44,7 +44,7 @@ const requirements: Requirement[] = [
|
||||
{ label: 'Special character', test: (v) => /[^A-Za-z0-9]/.test(v) },
|
||||
];
|
||||
|
||||
function SetPasswordInner() {
|
||||
export default function SetPasswordPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
@@ -154,7 +154,8 @@ function SetPasswordInner() {
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
errors.confirmPassword && 'border-destructive focus-visible:ring-destructive',
|
||||
errors.confirmPassword &&
|
||||
'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
@@ -173,18 +174,3 @@ function SetPasswordInner() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CompanyList } from '@/components/companies/company-list';
|
||||
|
||||
export default function CompaniesPage() {
|
||||
return <CompanyList />;
|
||||
}
|
||||
@@ -55,13 +55,7 @@ export default function NewInvoicePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = methods;
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = methods;
|
||||
|
||||
const watchedValues = watch();
|
||||
const lineItems = watchedValues.lineItems ?? [];
|
||||
@@ -93,7 +87,7 @@ export default function NewInvoicePage() {
|
||||
async function goNext() {
|
||||
if (step === 1) {
|
||||
const valid = await methods.trigger([
|
||||
'billingEntity',
|
||||
'clientName',
|
||||
'billingEmail',
|
||||
'billingAddress',
|
||||
'dueDate',
|
||||
@@ -118,7 +112,11 @@ export default function NewInvoicePage() {
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/${portSlug}/invoices`)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">New Invoice</h1>
|
||||
@@ -139,10 +137,16 @@ export default function NewInvoicePage() {
|
||||
>
|
||||
{step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id}
|
||||
</div>
|
||||
<span className={`text-sm ${step === s.id ? 'font-medium' : 'text-muted-foreground'}`}>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
step === s.id ? 'font-medium' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
{idx < STEPS.length - 1 && <div className="w-8 h-px bg-border mx-1" />}
|
||||
{idx < STEPS.length - 1 && (
|
||||
<div className="w-8 h-px bg-border mx-1" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -157,36 +161,17 @@ export default function NewInvoicePage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="billingEntityType">
|
||||
Billing Entity <span className="text-destructive">*</span>
|
||||
<Label htmlFor="clientName">
|
||||
Client Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Select
|
||||
defaultValue="client"
|
||||
onValueChange={(v) =>
|
||||
setValue('billingEntity.type', v as 'client' | 'company')
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<Input
|
||||
id="clientName"
|
||||
{...register('clientName')}
|
||||
placeholder="Client or company name"
|
||||
/>
|
||||
{errors.clientName && (
|
||||
<p className="text-xs text-destructive">{errors.clientName.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 className="space-y-1">
|
||||
@@ -217,7 +202,11 @@ export default function NewInvoicePage() {
|
||||
<Label htmlFor="dueDate">
|
||||
Due Date <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="dueDate" type="date" {...register('dueDate')} />
|
||||
<Input
|
||||
id="dueDate"
|
||||
type="date"
|
||||
{...register('dueDate')}
|
||||
/>
|
||||
{errors.dueDate && (
|
||||
<p className="text-xs text-destructive">{errors.dueDate.message}</p>
|
||||
)}
|
||||
@@ -227,9 +216,7 @@ export default function NewInvoicePage() {
|
||||
<Label>Payment Terms</Label>
|
||||
<Select
|
||||
defaultValue="net30"
|
||||
onValueChange={(v) =>
|
||||
setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])
|
||||
}
|
||||
onValueChange={(v) => setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select terms" />
|
||||
@@ -297,10 +284,8 @@ export default function NewInvoicePage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Billing Entity</span>
|
||||
<p className="font-medium mt-0.5">
|
||||
{watchedValues.billingEntity?.type}: {watchedValues.billingEntity?.id}
|
||||
</p>
|
||||
<span className="text-muted-foreground">Client</span>
|
||||
<p className="font-medium mt-0.5">{watchedValues.clientName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Due Date</span>
|
||||
@@ -308,7 +293,9 @@ export default function NewInvoicePage() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Payment Terms</span>
|
||||
<p className="font-medium mt-0.5 capitalize">{watchedValues.paymentTerms}</p>
|
||||
<p className="font-medium mt-0.5 capitalize">
|
||||
{watchedValues.paymentTerms}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Currency</span>
|
||||
@@ -367,7 +354,12 @@ export default function NewInvoicePage() {
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button type="button" variant="outline" onClick={goBack} disabled={step === 1}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={goBack}
|
||||
disabled={step === 1}
|
||||
>
|
||||
<ChevronLeft className="mr-1.5 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { YachtList } from '@/components/yachts/yacht-list';
|
||||
|
||||
export default function YachtsPage() {
|
||||
return <YachtList />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { Anchor, FileText, Receipt, Sailboat, Building2, CalendarCheck } from 'lucide-react';
|
||||
import { Anchor, FileText, Receipt } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
@@ -21,12 +21,15 @@ export default async function PortalDashboardPage() {
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Welcome back, {dashboard.client.fullName.split(' ')[0]}
|
||||
</h1>
|
||||
{dashboard.client.nationality && (
|
||||
<p className="text-sm text-gray-400 mt-0.5">{dashboard.client.nationality}</p>
|
||||
{dashboard.client.companyName && (
|
||||
<p className="text-gray-500 mt-0.5">{dashboard.client.companyName}</p>
|
||||
)}
|
||||
{dashboard.client.yachtName && (
|
||||
<p className="text-sm text-gray-400 mt-0.5">Vessel: {dashboard.client.yachtName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<PortalCard
|
||||
title="Berth Interests"
|
||||
value={dashboard.counts.interests}
|
||||
@@ -48,33 +51,13 @@ export default async function PortalDashboardPage() {
|
||||
icon={Receipt}
|
||||
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 className="bg-white rounded-lg border p-6">
|
||||
<h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Contact the {dashboard.port.name} team directly. This portal provides a read-only view of
|
||||
your account. All changes must be made through your port contact.
|
||||
Contact the {dashboard.port.name} team directly. This portal provides a read-only view
|
||||
of your account. All changes must be made through your port contact.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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';
|
||||
|
||||
import { Suspense, useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
function PortalVerifyInner() {
|
||||
export default function PortalVerifyPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const calledRef = useRef(false);
|
||||
@@ -33,17 +33,3 @@ function PortalVerifyInner() {
|
||||
</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,15 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type { z } from 'zod';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { withTransaction } from '@/lib/db/utils';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
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 { errorResponse, RateLimitError } from '@/lib/errors';
|
||||
import { publicInterestSchema } from '@/lib/validators/interests';
|
||||
@@ -39,14 +35,7 @@ function checkRateLimit(ip: string): void {
|
||||
entry.count += 1;
|
||||
}
|
||||
|
||||
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.
|
||||
// POST /api/public/interests — unauthenticated public interest registration
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||
@@ -61,6 +50,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Resolve the full name
|
||||
const fullName =
|
||||
data.firstName && data.lastName
|
||||
? `${data.firstName} ${data.lastName}`
|
||||
@@ -68,10 +58,10 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
||||
|
||||
// Resolve berth by mooring number (if provided). Read-only lookup — safe
|
||||
// to do outside the transaction.
|
||||
// Resolve berth by mooring number (if provided)
|
||||
let berthId: string | null = null;
|
||||
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
||||
|
||||
if (data.mooringNumber) {
|
||||
const berth = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.mooringNumber, data.mooringNumber), eq(berths.portId, portId)),
|
||||
@@ -82,117 +72,36 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Transactional trio creation ────────────────────────────────────────
|
||||
const result = await withTransaction(async (tx) => {
|
||||
// 1. Find or create client by email (case-sensitive contact match, same
|
||||
// behavior as before the refactor).
|
||||
// Find or create client by email
|
||||
let clientId: string;
|
||||
const existingContact = await tx.query.clientContacts.findFirst({
|
||||
|
||||
const existingContact = await db.query.clientContacts.findFirst({
|
||||
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
|
||||
});
|
||||
|
||||
if (existingContact) {
|
||||
const existingClient = await tx.query.clients.findFirst({
|
||||
const existingClient = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, existingContact.clientId),
|
||||
});
|
||||
if (existingClient && existingClient.portId === portId) {
|
||||
clientId = existingClient.id;
|
||||
// Update preferred contact method if provided
|
||||
if (data.preferredContactMethod) {
|
||||
await tx
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ preferredContactMethod: data.preferredContactMethod })
|
||||
.where(eq(clients.id, clientId));
|
||||
}
|
||||
} else {
|
||||
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||
clientId = await createNewClient(portId, fullName, data);
|
||||
}
|
||||
} else {
|
||||
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||
clientId = await createNewClient(portId, fullName, data);
|
||||
}
|
||||
|
||||
// 2. Optional: upsert company + add membership
|
||||
let companyId: string | null = null;
|
||||
if (data.company) {
|
||||
const existingCompany = await tx.query.companies.findFirst({
|
||||
where: and(
|
||||
eq(companies.portId, portId),
|
||||
sql`lower(${companies.name}) = lower(${data.company.name})`,
|
||||
),
|
||||
});
|
||||
if (existingCompany) {
|
||||
companyId = existingCompany.id;
|
||||
} 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',
|
||||
});
|
||||
|
||||
// 4. Store address if provided AND no primary address exists yet.
|
||||
// Store address if provided
|
||||
if (data.address && Object.values(data.address).some(Boolean)) {
|
||||
const existingAddr = await tx.query.clientAddresses.findFirst({
|
||||
where: and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)),
|
||||
});
|
||||
if (!existingAddr) {
|
||||
await tx.insert(clientAddresses).values({
|
||||
await db.insert(clientAddresses).values({
|
||||
clientId,
|
||||
portId,
|
||||
label: 'Primary',
|
||||
@@ -204,50 +113,33 @@ export async function POST(req: NextRequest) {
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Create interest with yachtId wired up.
|
||||
const [newInterest] = await tx
|
||||
// Create the interest
|
||||
const [interest] = await db
|
||||
.insert(interests)
|
||||
.values({
|
||||
portId,
|
||||
clientId,
|
||||
berthId,
|
||||
yachtId,
|
||||
source: 'website',
|
||||
pipelineStage: 'open',
|
||||
notes: data.notes,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
interestId: newInterest!.id,
|
||||
clientId,
|
||||
yachtId,
|
||||
companyId,
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Post-commit side-effects (fire-and-forget) ─────────────────────────
|
||||
void createAuditLog({
|
||||
userId: null as unknown as string,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'interest',
|
||||
entityId: result.interestId,
|
||||
newValue: {
|
||||
clientId: result.clientId,
|
||||
yachtId: result.yachtId,
|
||||
companyId: result.companyId,
|
||||
source: 'website',
|
||||
pipelineStage: 'open',
|
||||
berthId,
|
||||
},
|
||||
entityId: interest!.id,
|
||||
newValue: { clientId, source: 'website', pipelineStage: 'open', berthId },
|
||||
metadata: { type: 'public_registration', ip },
|
||||
ipAddress: ip,
|
||||
userAgent: req.headers.get('user-agent') ?? 'unknown',
|
||||
});
|
||||
|
||||
// Fire notifications asynchronously (non-blocking)
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, portId),
|
||||
columns: { slug: true },
|
||||
@@ -256,7 +148,7 @@ export async function POST(req: NextRequest) {
|
||||
void sendInquiryNotifications({
|
||||
portId,
|
||||
portSlug: port?.slug ?? portId,
|
||||
interestId: result.interestId,
|
||||
interestId: interest!.id,
|
||||
clientFullName: fullName,
|
||||
clientEmail: data.email,
|
||||
clientPhone: data.phone,
|
||||
@@ -265,7 +157,7 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ data: { id: result.interestId, message: 'Interest registered successfully' } },
|
||||
{ data: { id: interest!.id, message: 'Interest registered successfully' } },
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -273,33 +165,46 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createClientInTx(
|
||||
tx: Tx,
|
||||
async function createNewClient(
|
||||
portId: string,
|
||||
fullName: string,
|
||||
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod'>,
|
||||
data: {
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName?: string;
|
||||
yachtName?: string;
|
||||
yachtLengthFt?: number;
|
||||
yachtWidthFt?: number;
|
||||
yachtDraftFt?: number;
|
||||
preferredBerthSize?: string;
|
||||
preferredContactMethod?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const [newClient] = await tx
|
||||
const [newClient] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
portId,
|
||||
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,
|
||||
source: 'website',
|
||||
})
|
||||
.returning();
|
||||
const clientId = newClient!.id;
|
||||
|
||||
await tx.insert(clientContacts).values({
|
||||
await db.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'email',
|
||||
value: data.email,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
await tx.insert(clientContacts).values({
|
||||
await db.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'phone',
|
||||
value: data.phone,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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));
|
||||
@@ -1,72 +0,0 @@
|
||||
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));
|
||||
@@ -1,50 +0,0 @@
|
||||
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));
|
||||
@@ -1,21 +0,0 @@
|
||||
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));
|
||||
@@ -1,43 +0,0 @@
|
||||
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));
|
||||
@@ -1,49 +0,0 @@
|
||||
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));
|
||||
@@ -1,20 +0,0 @@
|
||||
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));
|
||||
@@ -1,47 +0,0 @@
|
||||
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));
|
||||
@@ -1,16 +0,0 @@
|
||||
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));
|
||||
@@ -1,49 +0,0 @@
|
||||
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));
|
||||
@@ -1,24 +0,0 @@
|
||||
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));
|
||||
@@ -1,20 +0,0 @@
|
||||
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));
|
||||
@@ -1,47 +0,0 @@
|
||||
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));
|
||||
@@ -1,90 +0,0 @@
|
||||
'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,7 +3,6 @@
|
||||
import { type DetailTab } from '@/components/shared/detail-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { BerthReservationsTab } from './berth-reservations-tab';
|
||||
|
||||
type BerthData = {
|
||||
id: string;
|
||||
@@ -88,10 +87,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
}
|
||||
/>
|
||||
<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
|
||||
label="Water Depth"
|
||||
value={
|
||||
@@ -183,11 +179,6 @@ export function buildBerthTabs(berth: BerthData): DetailTab[] {
|
||||
label: 'Interests',
|
||||
content: <StubTab label="Interests" />,
|
||||
},
|
||||
{
|
||||
id: 'reservations',
|
||||
label: 'Reservations',
|
||||
content: <BerthReservationsTab berthId={berth.id} />,
|
||||
},
|
||||
{
|
||||
id: 'waiting-list',
|
||||
label: 'Waiting List',
|
||||
|
||||
@@ -18,7 +18,7 @@ import { TagBadge } from '@/components/shared/tag-badge';
|
||||
export interface ClientRow {
|
||||
id: string;
|
||||
fullName: string;
|
||||
nationality: string | null;
|
||||
companyName: string | null;
|
||||
source: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
@@ -39,10 +39,6 @@ interface GetColumnsOptions {
|
||||
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({
|
||||
portSlug,
|
||||
onEdit,
|
||||
@@ -63,6 +59,14 @@ export function getClientColumns({
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'companyName',
|
||||
accessorKey: 'companyName',
|
||||
header: 'Company',
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'primaryContact',
|
||||
header: 'Primary Contact',
|
||||
@@ -78,14 +82,6 @@ export function getClientColumns({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'nationality',
|
||||
accessorKey: 'nationality',
|
||||
header: 'Nationality',
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
accessorKey: 'source',
|
||||
@@ -153,7 +149,10 @@ export function getClientColumns({
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onArchive(row.original)}
|
||||
>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
'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,7 +12,19 @@ interface ClientData {
|
||||
id: string;
|
||||
portId: string;
|
||||
fullName: string;
|
||||
companyName: 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;
|
||||
preferredLanguage: string | null;
|
||||
timezone: string | null;
|
||||
@@ -34,35 +46,6 @@ interface ClientData {
|
||||
name: 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 {
|
||||
@@ -81,15 +64,11 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
||||
'client:updated': [['clients', clientId]],
|
||||
'client:archived': [['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 ? getClientTabs({ clientId, currentUserId, client: data }) : [];
|
||||
const tabs = data
|
||||
? getClientTabs({ clientId, currentUserId, client: data })
|
||||
: [];
|
||||
|
||||
return (
|
||||
<DetailLayout
|
||||
|
||||
@@ -24,6 +24,11 @@ export const clientFilterDefinitions: FilterDefinition[] = [
|
||||
type: 'text',
|
||||
placeholder: 'Filter by nationality...',
|
||||
},
|
||||
{
|
||||
key: 'isProxy',
|
||||
label: 'Proxy Client',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
key: 'includeArchived',
|
||||
label: 'Include Archived',
|
||||
|
||||
@@ -16,7 +16,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
@@ -30,7 +36,13 @@ interface ClientFormProps {
|
||||
client?: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
companyName?: string | null;
|
||||
nationality?: string | null;
|
||||
isProxy?: boolean;
|
||||
proxyType?: string | null;
|
||||
actualOwnerName?: string | null;
|
||||
yachtName?: string | null;
|
||||
berthSizeDesired?: string | null;
|
||||
preferredContactMethod?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
timezone?: string | null;
|
||||
@@ -41,7 +53,6 @@ interface ClientFormProps {
|
||||
value: string;
|
||||
label?: string | null;
|
||||
isPrimary?: boolean;
|
||||
notes?: string | null;
|
||||
}>;
|
||||
tags?: Array<{ id: string }>;
|
||||
};
|
||||
@@ -64,11 +75,13 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
defaultValues: {
|
||||
fullName: '',
|
||||
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
||||
isProxy: false,
|
||||
tagIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
||||
const isProxy = watch('isProxy');
|
||||
const tagIds = watch('tagIds') ?? [];
|
||||
|
||||
// Populate form when editing
|
||||
@@ -76,10 +89,14 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
if (client && open) {
|
||||
reset({
|
||||
fullName: client.fullName,
|
||||
companyName: client.companyName ?? undefined,
|
||||
nationality: client.nationality ?? undefined,
|
||||
preferredContactMethod:
|
||||
(client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ??
|
||||
undefined,
|
||||
isProxy: client.isProxy ?? false,
|
||||
proxyType: client.proxyType ?? undefined,
|
||||
actualOwnerName: client.actualOwnerName ?? undefined,
|
||||
yachtName: client.yachtName ?? undefined,
|
||||
berthSizeDesired: client.berthSizeDesired ?? undefined,
|
||||
preferredContactMethod: (client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ?? undefined,
|
||||
preferredLanguage: client.preferredLanguage ?? undefined,
|
||||
timezone: client.timezone ?? undefined,
|
||||
source: (client.source as CreateClientInput['source']) ?? undefined,
|
||||
@@ -91,7 +108,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
value: c.value,
|
||||
label: c.label ?? undefined,
|
||||
isPrimary: c.isPrimary ?? false,
|
||||
notes: c.notes ?? undefined,
|
||||
}))
|
||||
: [{ channel: 'email', value: '', isPrimary: true }],
|
||||
tagIds: client.tags?.map((t) => t.id) ?? [],
|
||||
@@ -100,6 +116,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
reset({
|
||||
fullName: '',
|
||||
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
||||
isProxy: false,
|
||||
tagIds: [],
|
||||
});
|
||||
}
|
||||
@@ -134,7 +151,10 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
<SheetTitle>{isEdit ? 'Edit Client' : 'New Client'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
||||
<form
|
||||
onSubmit={handleSubmit((data) => mutation.mutate(data))}
|
||||
className="space-y-6 py-6"
|
||||
>
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
@@ -150,6 +170,11 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Company Name</Label>
|
||||
<Input {...register('companyName')} placeholder="Acme Corp" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nationality</Label>
|
||||
<Input {...register('nationality')} placeholder="British" />
|
||||
@@ -169,7 +194,9 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ channel: 'email', value: '', isPrimary: false })}
|
||||
onClick={() =>
|
||||
append({ channel: 'email', value: '', isPrimary: false })
|
||||
}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add Contact
|
||||
@@ -191,10 +218,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
<Select
|
||||
value={watch(`contacts.${index}.channel`)}
|
||||
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">
|
||||
@@ -230,7 +254,9 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
<div className="col-span-1 flex items-center gap-1 pb-1">
|
||||
<Checkbox
|
||||
checked={watch(`contacts.${index}.isPrimary`)}
|
||||
onCheckedChange={(v) => setValue(`contacts.${index}.isPrimary`, !!v)}
|
||||
onCheckedChange={(v) =>
|
||||
setValue(`contacts.${index}.isPrimary`, !!v)
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Primary</Label>
|
||||
</div>
|
||||
@@ -255,6 +281,72 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
|
||||
<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 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
@@ -265,9 +357,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
<Label>Source</Label>
|
||||
<Select
|
||||
value={watch('source') ?? ''}
|
||||
onValueChange={(v) =>
|
||||
setValue('source', v as 'website' | 'manual' | 'referral' | 'broker')
|
||||
}
|
||||
onValueChange={(v) => setValue('source', v as 'website' | 'manual' | 'referral' | 'broker')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select source" />
|
||||
@@ -284,9 +374,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
<Label>Preferred Contact Method</Label>
|
||||
<Select
|
||||
value={watch('preferredContactMethod') ?? ''}
|
||||
onValueChange={(v) =>
|
||||
setValue('preferredContactMethod', v as 'email' | 'phone' | 'whatsapp')
|
||||
}
|
||||
onValueChange={(v) => setValue('preferredContactMethod', v as 'email' | 'phone' | 'whatsapp')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select method" />
|
||||
@@ -308,7 +396,10 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label>Source Details</Label>
|
||||
<Input {...register('sourceDetails')} placeholder="Referred by John Doe" />
|
||||
<Input
|
||||
{...register('sourceDetails')}
|
||||
placeholder="Referred by John Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,11 +409,18 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
|
||||
<TagPicker
|
||||
selectedIds={tagIds}
|
||||
onChange={(ids) => setValue('tagIds', ids)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
'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,16 +2,22 @@
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
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 {
|
||||
clientId: string;
|
||||
currentUserId?: string;
|
||||
client: {
|
||||
fullName: string;
|
||||
companyName?: 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;
|
||||
preferredLanguage?: string | null;
|
||||
timezone?: string | null;
|
||||
@@ -24,36 +30,6 @@ interface ClientTabsOptions {
|
||||
label?: string | null;
|
||||
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 }>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,10 +51,14 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
|
||||
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
||||
<dl>
|
||||
<InfoRow label="Full Name" value={client.fullName} />
|
||||
<InfoRow label="Company" value={client.companyName} />
|
||||
<InfoRow label="Nationality" value={client.nationality} />
|
||||
<InfoRow label="Preferred Language" value={client.preferredLanguage} />
|
||||
<InfoRow label="Timezone" value={client.timezone} />
|
||||
<InfoRow label="Preferred Contact" value={client.preferredContactMethod} />
|
||||
<InfoRow
|
||||
label="Preferred Contact"
|
||||
value={client.preferredContactMethod}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -92,12 +72,18 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
|
||||
key={c.id}
|
||||
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">{c.channel}</span>
|
||||
<span className="capitalize text-muted-foreground w-20 shrink-0">
|
||||
{c.channel}
|
||||
</span>
|
||||
<span className="flex-1">{c.value}</span>
|
||||
{c.label && (
|
||||
<span className="text-xs text-muted-foreground capitalize">{c.label}</span>
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{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>
|
||||
@@ -106,6 +92,41 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
|
||||
)}
|
||||
</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 */}
|
||||
{(client.source || client.sourceDetails) && (
|
||||
<div className="space-y-1">
|
||||
@@ -117,54 +138,34 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
{/* Proxy Info */}
|
||||
{client.isProxy && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{client.tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-block rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: `${tag.color}20`, color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium mb-2">Proxy Information</h3>
|
||||
<dl>
|
||||
<InfoRow
|
||||
label="Proxy Type"
|
||||
value={client.proxyType?.replace('_', ' ')}
|
||||
/>
|
||||
<InfoRow label="Actual Owner" value={client.actualOwnerName} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOptions): DetailTab[] {
|
||||
export function getClientTabs({
|
||||
clientId,
|
||||
currentUserId,
|
||||
client,
|
||||
}: ClientTabsOptions): DetailTab[] {
|
||||
return [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
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',
|
||||
label: 'Interests',
|
||||
@@ -177,7 +178,13 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
|
||||
content: (
|
||||
<NotesList
|
||||
entityType="clients"
|
||||
entityId={clientId}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
'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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
'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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
'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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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',
|
||||
},
|
||||
];
|
||||
@@ -1,262 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
'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,8 +18,18 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -31,7 +41,6 @@ import {
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useEntityOptions } from '@/hooks/use-entity-options';
|
||||
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
|
||||
@@ -62,7 +71,6 @@ interface InterestFormProps {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName?: string | null;
|
||||
yachtId?: string | null;
|
||||
berthId?: string | null;
|
||||
berthMooringNumber?: string | null;
|
||||
pipelineStage: string;
|
||||
@@ -93,7 +101,6 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
||||
resolver: zodResolver(createInterestSchema),
|
||||
defaultValues: {
|
||||
clientId: '',
|
||||
yachtId: undefined,
|
||||
pipelineStage: 'open',
|
||||
reminderEnabled: false,
|
||||
tagIds: [],
|
||||
@@ -104,22 +111,15 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
||||
const reminderEnabled = watch('reminderEnabled');
|
||||
const selectedClientId = watch('clientId');
|
||||
const selectedBerthId = watch('berthId');
|
||||
const selectedYachtId = watch('yachtId');
|
||||
|
||||
const {
|
||||
options: clientOptions,
|
||||
isLoading: clientsLoading,
|
||||
setSearch: setClientSearch,
|
||||
} = useEntityOptions({
|
||||
const { options: clientOptions, isLoading: clientsLoading, setSearch: setClientSearch } =
|
||||
useEntityOptions({
|
||||
endpoint: '/api/v1/clients/options',
|
||||
labelKey: 'fullName',
|
||||
});
|
||||
|
||||
const {
|
||||
options: berthOptions,
|
||||
isLoading: berthsLoading,
|
||||
setSearch: setBerthSearch,
|
||||
} = useEntityOptions({
|
||||
const { options: berthOptions, isLoading: berthsLoading, setSearch: setBerthSearch } =
|
||||
useEntityOptions({
|
||||
endpoint: '/api/v1/berths/options',
|
||||
labelKey: 'mooringNumber',
|
||||
});
|
||||
@@ -128,10 +128,9 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
||||
if (interest && open) {
|
||||
reset({
|
||||
clientId: interest.clientId,
|
||||
yachtId: interest.yachtId ?? undefined,
|
||||
berthId: interest.berthId ?? undefined,
|
||||
pipelineStage: interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
|
||||
leadCategory: interest.leadCategory as (typeof LEAD_CATEGORIES)[number] | undefined,
|
||||
pipelineStage: interest.pipelineStage as typeof PIPELINE_STAGES[number],
|
||||
leadCategory: interest.leadCategory as typeof LEAD_CATEGORIES[number] | undefined,
|
||||
source: interest.source ?? undefined,
|
||||
notes: interest.notes ?? undefined,
|
||||
reminderEnabled: interest.reminderEnabled ?? false,
|
||||
@@ -141,7 +140,6 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
||||
} else if (!interest && open) {
|
||||
reset({
|
||||
clientId: '',
|
||||
yachtId: undefined,
|
||||
pipelineStage: 'open',
|
||||
reminderEnabled: false,
|
||||
tagIds: [],
|
||||
@@ -180,7 +178,10 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
||||
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
||||
<form
|
||||
onSubmit={handleSubmit((data) => mutation.mutate(data))}
|
||||
className="space-y-6 py-6"
|
||||
>
|
||||
{/* Client */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
@@ -201,13 +202,16 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
||||
)}
|
||||
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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search clients..." onValueChange={setClientSearch} />
|
||||
<CommandInput
|
||||
placeholder="Search clients..."
|
||||
onValueChange={setClientSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{clientsLoading ? 'Loading...' : 'No clients found.'}
|
||||
@@ -254,13 +258,16 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
||||
!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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search berths..." onValueChange={setBerthSearch} />
|
||||
<CommandInput
|
||||
placeholder="Search berths..."
|
||||
onValueChange={setBerthSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{berthsLoading ? 'Loading...' : 'No berths found.'}
|
||||
@@ -305,24 +312,6 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</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>
|
||||
|
||||
<Separator />
|
||||
@@ -337,9 +326,7 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
||||
<Label>Stage</Label>
|
||||
<Select
|
||||
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>
|
||||
<SelectValue placeholder="Select stage" />
|
||||
@@ -359,10 +346,7 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
||||
<Select
|
||||
value={watch('leadCategory') ?? ''}
|
||||
onValueChange={(v) =>
|
||||
setValue(
|
||||
'leadCategory',
|
||||
v ? (v as (typeof LEAD_CATEGORIES)[number]) : undefined,
|
||||
)
|
||||
setValue('leadCategory', v ? v as typeof LEAD_CATEGORIES[number] : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -443,11 +427,18 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
|
||||
<TagPicker
|
||||
selectedIds={tagIds}
|
||||
onChange={(ids) => setValue('tagIds', ids)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
Users,
|
||||
Bookmark,
|
||||
Anchor,
|
||||
Ship,
|
||||
Building2,
|
||||
Receipt,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
@@ -32,7 +30,12 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { UserPortRole } from '@/lib/db/schema/users';
|
||||
import type { Role } from '@/lib/db/schema/users';
|
||||
|
||||
@@ -62,8 +65,6 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
items: [
|
||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ 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}/berths`, label: 'Berths', icon: Anchor },
|
||||
],
|
||||
@@ -279,8 +280,7 @@ export function Sidebar({ portRoles }: SidebarProps) {
|
||||
|
||||
// Check for admin access based on role permissions
|
||||
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 (
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { LayoutDashboard, Anchor, FileText, Receipt, Sailboat, CalendarCheck } from 'lucide-react';
|
||||
import { LayoutDashboard, Anchor, FileText, Receipt } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Dashboard', href: '/portal/dashboard', icon: LayoutDashboard },
|
||||
{ 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: 'Invoices', href: '/portal/invoices', icon: Receipt },
|
||||
];
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
'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 { useRouter } from 'next/navigation';
|
||||
import { Search, Clock, User, TrendingUp, Anchor, Ship, Building2 } from 'lucide-react';
|
||||
import { Search, Clock, User, TrendingUp, Anchor } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSearch } from '@/hooks/use-search';
|
||||
@@ -22,11 +22,7 @@ export function CommandSearch() {
|
||||
const hasQuery = query.length >= 2;
|
||||
const hasResults =
|
||||
results &&
|
||||
(results.clients.length > 0 ||
|
||||
results.interests.length > 0 ||
|
||||
results.berths.length > 0 ||
|
||||
results.yachts.length > 0 ||
|
||||
results.companies.length > 0);
|
||||
(results.clients.length > 0 || results.interests.length > 0 || results.berths.length > 0);
|
||||
|
||||
// Cmd/Ctrl+K focuses the input
|
||||
useEffect(() => {
|
||||
@@ -71,13 +67,7 @@ export function CommandSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
client: User,
|
||||
interest: TrendingUp,
|
||||
berth: Anchor,
|
||||
yacht: Ship,
|
||||
company: Building2,
|
||||
} as const;
|
||||
const iconMap = { client: User, interest: TrendingUp, berth: Anchor } as const;
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
@@ -158,32 +148,6 @@ export function CommandSearch() {
|
||||
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 && (
|
||||
<ResultGroup
|
||||
heading="Interests"
|
||||
@@ -226,12 +190,7 @@ function ResultGroup({
|
||||
onSelect,
|
||||
}: {
|
||||
heading: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
icon: 'client' | 'interest' | 'berth' | 'yacht' | 'company';
|
||||
label: string;
|
||||
sub?: string | null;
|
||||
}>;
|
||||
items: Array<{ id: string; icon: 'client' | 'interest' | 'berth'; label: string; sub?: string | null }>;
|
||||
iconMap: Record<string, React.ElementType | undefined>;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { User, Anchor, TrendingUp, Ship, Building2 } from 'lucide-react';
|
||||
import { User, Anchor, TrendingUp } from 'lucide-react';
|
||||
|
||||
import { CommandItem } from '@/components/ui/command';
|
||||
|
||||
@@ -26,26 +26,10 @@ interface BerthItem {
|
||||
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: 'client'; item: ClientItem; onSelect: () => void }
|
||||
| { type: 'interest'; item: InterestItem; onSelect: () => void }
|
||||
| { type: 'berth'; item: BerthItem; onSelect: () => void }
|
||||
| { type: 'yacht'; item: YachtItem; onSelect: () => void }
|
||||
| { type: 'company'; item: CompanyItem; onSelect: () => void };
|
||||
| { type: 'berth'; item: BerthItem; onSelect: () => void };
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -79,38 +63,6 @@ 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
|
||||
return (
|
||||
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
'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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
'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 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
'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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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',
|
||||
},
|
||||
];
|
||||
@@ -1,356 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
'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." />
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
'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,18 +16,6 @@ interface SearchResults {
|
||||
pipelineStage: 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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,7 +3,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { portRoleOverrides, ports, userPortRoles, userProfiles } from '@/lib/db/schema';
|
||||
import {
|
||||
portRoleOverrides,
|
||||
ports,
|
||||
userPortRoles,
|
||||
userProfiles,
|
||||
} from '@/lib/db/schema';
|
||||
import { type RolePermissions } from '@/lib/db/schema/users';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
@@ -35,7 +40,7 @@ export interface AuthContext {
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
export type RouteHandler<T = unknown> = (
|
||||
type RouteHandler<T = unknown> = (
|
||||
req: NextRequest,
|
||||
ctx: AuthContext,
|
||||
params: Record<string, string>,
|
||||
@@ -128,7 +133,10 @@ export function withAuth(
|
||||
|
||||
if (!profile.isSuperAdmin && portId) {
|
||||
const portRole = await db.query.userPortRoles.findFirst({
|
||||
where: and(eq(userPortRoles.userId, profile.userId), eq(userPortRoles.portId, portId)),
|
||||
where: and(
|
||||
eq(userPortRoles.userId, profile.userId),
|
||||
eq(userPortRoles.portId, portId),
|
||||
),
|
||||
with: {
|
||||
role: true,
|
||||
port: true,
|
||||
@@ -174,7 +182,8 @@ export function withAuth(
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
},
|
||||
ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown',
|
||||
ipAddress:
|
||||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown',
|
||||
userAgent: req.headers.get('user-agent') ?? 'unknown',
|
||||
};
|
||||
|
||||
@@ -204,7 +213,9 @@ export function withPermission(
|
||||
): RouteHandler {
|
||||
return async (req, ctx, params) => {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
const resourcePerms = ctx.permissions?.[resource] as Record<string, boolean> | undefined;
|
||||
const resourcePerms = ctx.permissions?.[resource] as
|
||||
| Record<string, boolean>
|
||||
| undefined;
|
||||
|
||||
if (!resourcePerms || !resourcePerms[action]) {
|
||||
logger.warn({ userId: ctx.userId, resource, action }, 'Permission denied');
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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");
|
||||
@@ -1,80 +0,0 @@
|
||||
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");
|
||||
@@ -1,29 +0,0 @@
|
||||
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';
|
||||
@@ -1,3 +0,0 @@
|
||||
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");
|
||||
@@ -1,3 +0,0 @@
|
||||
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");
|
||||
@@ -1,8 +0,0 @@
|
||||
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");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,48 +15,6 @@
|
||||
"when": 1776185487775,
|
||||
"tag": "0001_soft_ender_wiggin",
|
||||
"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,9 +17,7 @@ import { clients } from './clients';
|
||||
export const berths = pgTable(
|
||||
'berths',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
@@ -72,9 +70,7 @@ export const berths = pgTable(
|
||||
export const berthMapData = pgTable(
|
||||
'berth_map_data',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
berthId: text('berth_id')
|
||||
.notNull()
|
||||
.unique()
|
||||
@@ -93,9 +89,7 @@ export const berthMapData = pgTable(
|
||||
export const berthRecommendations = pgTable(
|
||||
'berth_recommendations',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
interestId: text('interest_id').notNull(), // references interests.id
|
||||
berthId: text('berth_id')
|
||||
.notNull()
|
||||
@@ -115,16 +109,13 @@ export const berthRecommendations = pgTable(
|
||||
export const berthWaitingList = pgTable(
|
||||
'berth_waiting_list',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
berthId: text('berth_id')
|
||||
.notNull()
|
||||
.references(() => berths.id, { onDelete: 'cascade' }),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||
yachtId: text('yacht_id'), // FK added via relation; nullable (waiting for this yacht)
|
||||
position: integer('position').notNull(),
|
||||
priority: text('priority').notNull().default('normal'), // normal, high
|
||||
notifyPref: text('notify_pref').default('email'), // email, in_app, both
|
||||
@@ -140,9 +131,7 @@ export const berthWaitingList = pgTable(
|
||||
export const berthMaintenanceLog = pgTable(
|
||||
'berth_maintenance_log',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
berthId: text('berth_id')
|
||||
.notNull()
|
||||
.references(() => berths.id, { onDelete: 'cascade' }),
|
||||
@@ -160,7 +149,10 @@ export const berthMaintenanceLog = pgTable(
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index('idx_bml_berth').on(table.berthId), index('idx_bml_port').on(table.portId)],
|
||||
(table) => [
|
||||
index('idx_bml_berth').on(table.berthId),
|
||||
index('idx_bml_port').on(table.portId),
|
||||
],
|
||||
);
|
||||
|
||||
export const berthTags = pgTable(
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
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,15 +15,11 @@ import { clients } from './clients';
|
||||
export const files = pgTable(
|
||||
'files',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.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(),
|
||||
originalName: text('original_name').notNull(),
|
||||
mimeType: text('mime_type'),
|
||||
@@ -37,24 +33,18 @@ export const files = pgTable(
|
||||
(table) => [
|
||||
index('idx_files_port').on(table.portId),
|
||||
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(
|
||||
'documents',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
interestId: text('interest_id'), // references interests.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
|
||||
title: text('title').notNull(),
|
||||
status: text('status').notNull().default('draft'), // draft, sent, partially_signed, completed, expired, cancelled
|
||||
@@ -71,8 +61,6 @@ export const documents = pgTable(
|
||||
index('idx_docs_port').on(table.portId),
|
||||
index('idx_docs_interest').on(table.interestId),
|
||||
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),
|
||||
],
|
||||
);
|
||||
@@ -80,9 +68,7 @@ export const documents = pgTable(
|
||||
export const documentSigners = pgTable(
|
||||
'document_signers',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
documentId: text('document_id')
|
||||
.notNull()
|
||||
.references(() => documents.id, { onDelete: 'cascade' }),
|
||||
@@ -102,9 +88,7 @@ export const documentSigners = pgTable(
|
||||
export const documentEvents = pgTable(
|
||||
'document_events',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
documentId: text('document_id')
|
||||
.notNull()
|
||||
.references(() => documents.id, { onDelete: 'cascade' }),
|
||||
@@ -116,18 +100,16 @@ export const documentEvents = pgTable(
|
||||
},
|
||||
(table) => [
|
||||
index('idx_de_doc').on(table.documentId),
|
||||
uniqueIndex('idx_de_dedup')
|
||||
.on(table.documentId, table.signatureHash)
|
||||
.where(sql`${table.signatureHash} IS NOT NULL`),
|
||||
uniqueIndex('idx_de_dedup').on(table.documentId, table.signatureHash).where(
|
||||
sql`${table.signatureHash} IS NOT NULL`
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const documentTemplates = pgTable(
|
||||
'document_templates',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
@@ -150,9 +132,7 @@ export const documentTemplates = pgTable(
|
||||
export const formTemplates = pgTable(
|
||||
'form_templates',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
@@ -171,9 +151,7 @@ export const formTemplates = pgTable(
|
||||
export const formSubmissions = pgTable(
|
||||
'form_submissions',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
formTemplateId: text('form_template_id')
|
||||
.notNull()
|
||||
.references(() => formTemplates.id),
|
||||
|
||||
@@ -15,9 +15,7 @@ import { files } from './documents';
|
||||
export const expenses = pgTable(
|
||||
'expenses',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
@@ -51,16 +49,12 @@ export const expenses = pgTable(
|
||||
export const invoices = pgTable(
|
||||
'invoices',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
invoiceNumber: text('invoice_number').notNull(), // INV-YYYYMM-### auto-generated
|
||||
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'),
|
||||
billingAddress: text('billing_address'),
|
||||
dueDate: date('due_date').notNull(),
|
||||
@@ -88,20 +82,13 @@ export const invoices = pgTable(
|
||||
uniqueIndex('idx_invoices_number').on(table.portId, table.invoiceNumber),
|
||||
index('idx_invoices_port').on(table.portId),
|
||||
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(
|
||||
'invoice_line_items',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
invoiceId: text('invoice_id')
|
||||
.notNull()
|
||||
.references(() => invoices.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -7,21 +7,12 @@ export * from './users';
|
||||
// Clients
|
||||
export * from './clients';
|
||||
|
||||
// Companies
|
||||
export * from './companies';
|
||||
|
||||
// Yachts
|
||||
export * from './yachts';
|
||||
|
||||
// Interests
|
||||
export * from './interests';
|
||||
|
||||
// Berths
|
||||
export * from './berths';
|
||||
|
||||
// Reservations
|
||||
export * from './reservations';
|
||||
|
||||
// Documents & Files
|
||||
export * from './documents';
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { pgTable, text, boolean, integer, timestamp, primaryKey, index } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
timestamp,
|
||||
primaryKey,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { ports } from './ports';
|
||||
import { clients } from './clients';
|
||||
|
||||
@@ -7,9 +15,7 @@ import { clients } from './clients';
|
||||
export const interests = pgTable(
|
||||
'interests',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
@@ -17,7 +23,6 @@ export const interests = pgTable(
|
||||
.notNull()
|
||||
.references(() => clients.id),
|
||||
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'),
|
||||
leadCategory: text('lead_category'), // general_interest, specific_qualified, hot_lead
|
||||
source: text('source'), // website, manual, referral, broker
|
||||
@@ -45,7 +50,6 @@ export const interests = pgTable(
|
||||
index('idx_interests_port').on(table.portId),
|
||||
index('idx_interests_client').on(table.clientId),
|
||||
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_archived').on(table.portId, table.archivedAt),
|
||||
],
|
||||
@@ -54,9 +58,7 @@ export const interests = pgTable(
|
||||
export const interestNotes = pgTable(
|
||||
'interest_notes',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
interestId: text('interest_id')
|
||||
.notNull()
|
||||
.references(() => interests.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -20,18 +20,6 @@ import {
|
||||
// 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
|
||||
import {
|
||||
berths,
|
||||
@@ -42,9 +30,6 @@ import {
|
||||
berthTags,
|
||||
} from './berths';
|
||||
|
||||
// Reservations
|
||||
import { berthReservations } from './reservations';
|
||||
|
||||
// Documents
|
||||
import {
|
||||
files,
|
||||
@@ -94,10 +79,7 @@ export const portsRelations = relations(ports, ({ many }) => ({
|
||||
portRoleOverrides: many(portRoleOverrides),
|
||||
clients: many(clients),
|
||||
interests: many(interests),
|
||||
yachts: many(yachts),
|
||||
companies: many(companies),
|
||||
berths: many(berths),
|
||||
berthReservations: many(berthReservations),
|
||||
documents: many(documents),
|
||||
documentTemplates: many(documentTemplates),
|
||||
formTemplates: many(formTemplates),
|
||||
@@ -177,8 +159,6 @@ export const clientsRelations = relations(clients, ({ one, many }) => ({
|
||||
scratchpadNotes: many(scratchpadNotes),
|
||||
formSubmissions: many(formSubmissions),
|
||||
addresses: many(clientAddresses),
|
||||
companyMemberships: many(companyMemberships),
|
||||
berthReservations: many(berthReservations),
|
||||
}));
|
||||
|
||||
export const clientContactsRelations = relations(clientContacts, ({ one }) => ({
|
||||
@@ -260,10 +240,6 @@ export const interestsRelations = relations(interests, ({ one, many }) => ({
|
||||
fields: [interests.berthId],
|
||||
references: [berths.id],
|
||||
}),
|
||||
yacht: one(yachts, {
|
||||
fields: [interests.yachtId],
|
||||
references: [yachts.id],
|
||||
}),
|
||||
notes: many(interestNotes),
|
||||
tags: many(interestTags),
|
||||
documents: many(documents),
|
||||
@@ -290,101 +266,6 @@ 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const berthsRelations = relations(berths, ({ one, many }) => ({
|
||||
@@ -452,35 +333,6 @@ 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const filesRelations = relations(files, ({ one, many }) => ({
|
||||
@@ -492,14 +344,6 @@ export const filesRelations = relations(files, ({ one, many }) => ({
|
||||
fields: [files.clientId],
|
||||
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' }),
|
||||
documentAsSignedFile: many(documents, { relationName: 'signed_file' }),
|
||||
}));
|
||||
@@ -527,14 +371,6 @@ export const documentsRelations = relations(documents, ({ one, many }) => ({
|
||||
references: [files.id],
|
||||
relationName: 'signed_file',
|
||||
}),
|
||||
yacht: one(yachts, {
|
||||
fields: [documents.yachtId],
|
||||
references: [yachts.id],
|
||||
}),
|
||||
company: one(companies, {
|
||||
fields: [documents.companyId],
|
||||
references: [companies.id],
|
||||
}),
|
||||
signers: many(documentSigners),
|
||||
events: many(documentEvents),
|
||||
}));
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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,4 +1,12 @@
|
||||
import { pgTable, text, boolean, timestamp, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
boolean,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { ports } from './ports';
|
||||
|
||||
// ─── Permission Types ─────────────────────────────────────────────────────────
|
||||
@@ -84,29 +92,6 @@ export type RolePermissions = {
|
||||
generate: 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: {
|
||||
manage_users: boolean;
|
||||
view_audit_log: boolean;
|
||||
@@ -147,9 +132,7 @@ export const account = pgTable('account', {
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_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'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
@@ -180,9 +163,7 @@ export const verification = pgTable('verification', {
|
||||
export const userProfiles = pgTable(
|
||||
'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
|
||||
displayName: text('display_name').notNull(),
|
||||
avatarUrl: text('avatar_url'),
|
||||
@@ -198,15 +179,10 @@ export const userProfiles = pgTable(
|
||||
);
|
||||
|
||||
export const roles = pgTable('roles', {
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
name: text('name').notNull(),
|
||||
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),
|
||||
isSystem: boolean('is_system').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -216,9 +192,7 @@ export const roles = pgTable('roles', {
|
||||
export const portRoleOverrides = pgTable(
|
||||
'port_role_overrides',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
@@ -241,9 +215,7 @@ export const portRoleOverrides = pgTable(
|
||||
export const userPortRoles = pgTable(
|
||||
'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
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,433 +1,130 @@
|
||||
/**
|
||||
* Seed script for Port Nimara CRM.
|
||||
*
|
||||
* Top-level orchestrator:
|
||||
* 1. Create 3 ports (idempotent):
|
||||
* - Port Nimara
|
||||
* - 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.
|
||||
* Seeds:
|
||||
* - 1 Port: Port Nimara
|
||||
* - 5 System roles with full permission maps
|
||||
* - 1 Super admin user profile (matt@portnimara.com)
|
||||
*
|
||||
* Run with: pnpm db:seed
|
||||
* Run with: npm run db:seed
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { ports } from './schema/ports';
|
||||
import { roles, userProfiles } from './schema/users';
|
||||
import type { RolePermissions } from './schema/users';
|
||||
import { seedPortData, type SeedSummary } from './seed-data';
|
||||
|
||||
// ─── Permission Maps ─────────────────────────────────────────────────────────
|
||||
|
||||
const ALL_PERMISSIONS: RolePermissions = {
|
||||
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 },
|
||||
documents: {
|
||||
view: 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,
|
||||
},
|
||||
documents: { view: 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 },
|
||||
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 },
|
||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
||||
document_templates: { view: true, generate: true, manage: 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,
|
||||
},
|
||||
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 = {
|
||||
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 },
|
||||
documents: {
|
||||
view: 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,
|
||||
},
|
||||
documents: { view: 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 },
|
||||
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 },
|
||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
||||
document_templates: { view: true, generate: true, manage: 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: false,
|
||||
},
|
||||
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 = {
|
||||
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 },
|
||||
documents: {
|
||||
view: 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,
|
||||
},
|
||||
documents: { view: 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 },
|
||||
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 },
|
||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
||||
document_templates: { view: true, generate: true, manage: 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,
|
||||
},
|
||||
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 = {
|
||||
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 },
|
||||
documents: {
|
||||
view: 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,
|
||||
},
|
||||
documents: { view: 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 },
|
||||
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 },
|
||||
reports: { view_dashboard: true, view_analytics: true, export: true },
|
||||
document_templates: { view: true, generate: true, manage: 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,
|
||||
},
|
||||
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 = {
|
||||
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 },
|
||||
documents: {
|
||||
view: true,
|
||||
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,
|
||||
},
|
||||
documents: { view: true, 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 },
|
||||
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 },
|
||||
reports: { view_dashboard: true, view_analytics: false, export: false },
|
||||
document_templates: { view: true, generate: false, manage: 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,
|
||||
},
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
async function seed() {
|
||||
console.log('Seeding Port Nimara CRM...');
|
||||
|
||||
// ── 1. Ports ────────────────────────────────────────────────────────────────
|
||||
console.log('Creating ports...');
|
||||
const portIds: Array<{ id: string; name: string; slug: string }> = [];
|
||||
|
||||
for (const def of PORT_DEFINITIONS) {
|
||||
const [inserted] = await db
|
||||
// ── 1. Port ─────────────────────────────────────────────────────────────────
|
||||
console.log('Creating Port Nimara...');
|
||||
const [port] = await db
|
||||
.insert(ports)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: def.name,
|
||||
slug: def.slug,
|
||||
name: 'Port Nimara',
|
||||
slug: 'port-nimara',
|
||||
logoUrl: null,
|
||||
primaryColor: def.primaryColor,
|
||||
defaultCurrency: def.defaultCurrency,
|
||||
timezone: def.timezone,
|
||||
primaryColor: '#0F4C81',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Anguilla',
|
||||
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 });
|
||||
const portId = port?.id;
|
||||
if (!portId) {
|
||||
console.log('Port already exists, skipping...');
|
||||
} 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}`);
|
||||
}
|
||||
}
|
||||
console.log(`Port created: ${portId}`);
|
||||
}
|
||||
|
||||
// ── 2. System Roles ─────────────────────────────────────────────────────────
|
||||
@@ -461,8 +158,7 @@ async function seed() {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
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,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
@@ -479,7 +175,7 @@ async function seed() {
|
||||
|
||||
for (const role of systemRoles) {
|
||||
await db.insert(roles).values(role).onConflictDoNothing();
|
||||
console.log(` Role: ${role.name}`);
|
||||
console.log(`Role created: ${role.name}`);
|
||||
}
|
||||
|
||||
// ── 3. Super Admin User Profile ─────────────────────────────────────────────
|
||||
@@ -506,32 +202,7 @@ async function seed() {
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
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(`Super admin profile created for user_id: ${superAdminUserId}`);
|
||||
console.log('');
|
||||
console.log('Seed complete!');
|
||||
console.log('');
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
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