82 Commits

Author SHA1 Message Date
Matt Ciaccio
ea8181d108 test(visual): regression baselines for stable list/landing pages
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m7s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
New `visual` project covers six low-volatility screens — portal login,
dashboard, and the four core lists (clients/yachts/berths/invoices) —
with full-page screenshots that diff to a 2% pixel-ratio tolerance.
Animations and the cursor caret are disabled inline so transient
rendering doesn't trigger flaky diffs.

Detail screens (yacht detail, EOI dialog, invoice form steps) are
intentionally deferred until we have stable per-id fixtures so
snapshots don't drift with seed data.

Regenerate with: pnpm exec playwright test --project=visual --update-snapshots

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:42:40 +02:00
Matt Ciaccio
65b241805e test(portal): IMAP full-lifecycle activation E2E + dev probe helper
New realapi spec walks the entire portal activation loop over real
network: invite via the admin endpoint → wait for the activation email
to land in the IMAP mailbox → extract the token from the body link →
activate the portal user via the public API → sign in with the new
password.

The match logic deliberately doesn't filter on the TO header — the
combination of EMAIL_REDIRECT_TO rewriting and +addressing made TO
matching brittle. Instead we discriminate by sender (noreply@…),
subject keyword, and body link pattern, which is unique enough to find
exactly the email this test triggered.

Companion script scripts/dev-imap-probe.ts dumps the most recent ~10
messages with from/to/subject/date — useful for debugging when an IMAP
match goes wrong.

Skips when IMAP_HOST / IMAP_USER / IMAP_PASS are absent so the suite
stays portable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:40:28 +02:00
Matt Ciaccio
4a859245b7 test(documenso): real-API E2E spec + 2.x response normalization
The documenso-template pathway was returning 201 with documensoId=null
because Documenso 2.x renamed `id` → `documentId` and recipient `id` →
`recipientId` in its API responses. Our DocumensoDocument interface
still expected the legacy v1.13 shape, so destructuring silently yielded
undefined and the documents row got NULL'd.

- Add normalizeDocument() in documenso-client that reads either field
  name and surfaces the legacy `id` form downstream consumers expect
- Apply normalization at every callsite that returns DocumensoDocument
  (createDocument, generateDocumentFromTemplate, sendDocument, getDocument)
- New realapi Playwright project (opt-in: --project=realapi) targeting
  tests/e2e/realapi/, with 2-min timeout for real-network calls
- New spec: documenso-real-api.spec.ts seeds client/yacht/berth/interest
  via the v1 API, fires generate-and-sign through the documenso-template
  pathway, asserts the response carries a documensoId, then GETs the
  document directly from Documenso to confirm it exists with PENDING
  status and recipients populated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:25:06 +02:00
Matt Ciaccio
4441f1177f feat(portal): branded auth pages + legacy email styling + dev redirect override
- New PortalAuthShell component: blurred Port Nimara overhead background +
  circular logo + white rounded card, used by /portal/login,
  /portal/activate, /portal/reset-password
- New email/templates/portal-auth.ts: table-based, responsive (max-width
  600px / width 100%), matching the existing legacy inquiry templates;
  replaces the inline templates that lived in portal-auth.service
- EMAIL_REDIRECT_TO env override: when set, sendEmail routes every
  outbound message to that address regardless of recipient and tags the
  subject with "[redirected from <original>]". Dev/test safety net only;
  unset in production
- Portal password minimum length 12 → 9 (service + both API routes +
  client-side form)
- Dev helper script scripts/dev-trigger-portal-invite.ts: seeds a portal
  user against the first port-nimara client and uses EMAIL_REDIRECT_TO
  as the stored email so the tester can sign in with the address that
  received the activation mail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:04:21 +02:00
Matt Ciaccio
c4085265ff fix(documenso): align webhook receiver with Documenso v1.13 + 2.x protocol
Documenso authenticates outbound webhooks via the X-Documenso-Secret
header carrying the plaintext secret (no HMAC). The previous receiver
verified an HMAC against a non-existent x-documenso-signature header
and switched on parsed.type, neither of which Documenso emits — so
every real delivery was being silently rejected.

- Read X-Documenso-Secret, compare timing-safe to env secret
- Switch on parsed.event with uppercase normalization for both v1.13
  (DOCUMENT_SIGNED) and 2.x (lowercase-dotted UI labels) wire formats
- Alias DOCUMENT_RECIPIENT_COMPLETED to DOCUMENT_SIGNED (same
  semantics across versions)
- Handle DOCUMENT_OPENED / DOCUMENT_REJECTED / DOCUMENT_CANCELLED in
  addition to the existing DOCUMENT_SIGNED + DOCUMENT_COMPLETED paths
- Bypass session middleware for /api/webhooks/* (signature is the auth)

Verified end-to-end against signatures.letsbe.solutions: real
DOCUMENT_RECIPIENT_COMPLETED + DOCUMENT_COMPLETED deliveries now pass
secret verification, dispatch correctly, and the handler updates
state (or warns gracefully when the documensoId is unknown).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:46:48 +02:00
Matt Ciaccio
475b051e29 feat(portal): replace magic-link with email/password + admin-initiated activation
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m0s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
The client portal no longer uses passwordless / magic-link sign-in. Each
client now has a `portal_users` row with a scrypt-hashed password,
created by an admin from the client detail page; the admin's invite
mails an activation link that the client uses to set their own password.
Forgot-password is wired through the same token mechanism.

Schema (migration `0009_outgoing_rumiko_fujikawa.sql`):

- `portal_users` — one per client account, separate from the CRM
  `users` table (better-auth) so the auth realms stay isolated. Email
  is globally unique, password is null until activation.
- `portal_auth_tokens` — single-use activation / reset tokens. Stores
  only the SHA-256 hash so a DB compromise never leaks live tokens.

Services:

- `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps;
  uses node:crypto), token mint+hash helpers.
- `src/lib/services/portal-auth.service.ts` — createPortalUser,
  resendActivation, activateAccount, signIn (timing-safe),
  requestPasswordReset, resetPassword. Auth failures throw the new
  UnauthorizedError (401); enumeration-safe behaviour everywhere.

Routes:

- POST /api/portal/auth/sign-in — sets the existing portal JWT cookie.
- POST /api/portal/auth/forgot-password — always 200.
- POST /api/portal/auth/reset-password — token + new password.
- POST /api/portal/auth/activate — token + initial password.
- POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`).
- Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link).

UI:

- /portal/login — replaced email-only magic-link form with email +
  password + "forgot password" link.
- /portal/forgot-password, /portal/reset-password, /portal/activate — new.
- New shared `PasswordSetForm` component used by activate + reset.
- New `PortalInviteButton` rendered on the client detail header.

Email send:

- `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are
  set (gmail app-password or marina-server creds, configured via env).
- `SMTP_FROM` env var lets the sender address be overridden without
  pinning it to `noreply@${SMTP_HOST}`.

Tests:

- Smoke spec 17 (client-portal) updated to the new flow: 7/7 green.
- Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to
  match the post-refactor client + invoice forms (drop companyName,
  use OwnerPicker + billingEmail).
- Vitest 652/652 still green; type-check clean.

Drops the dead `requestMagicLink` from portal.service.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00
Matt Ciaccio
4da8ed3ae4 docs: reflect data-model refactor in CLAUDE.md + DB schema overview
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
PR 15 (docs): the numbered spec files mostly described the new model
already at the conceptual level, but two needed concrete updates:

- 07-DATABASE-SCHEMA.md: schema overview now lists the new Yacht /
  Company / Reservation domains alongside the existing ones, names the
  partial unique indexes (idx_yoh_active, idx_br_active) that enforce
  exclusivity, and notes that yacht/company details are no longer
  stored on `clients`.
- CLAUDE.md: the Conventions section now points future contributors at
  the new schema files, the polymorphic ownership pattern, the
  EoiContext/dual-path EOI flow, and the merge-token allow-list. Adds
  a pointer to the husky `.env*` block so it doesn't trip people up.
  References the new field-mapping doc and `assets/README.md`.

Task 15.3 (Tier 4 golden-image PDF regression) is deferred — those
tests need committed reference PDFs that come out of a real, manually
verified EOI render. Best landed once the actual `assets/eoi-template.pdf`
is in place; tracking as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:14:34 +02:00
Matt Ciaccio
4c67b9dbd4 test(e2e): exhaustive click-through suite + destructive narrow tests
PR 14: adds a tier-3.5 Playwright pass that opens every refactored page,
clicks every visible button/link/role=button, and asserts no console
errors, no app-side network 4xx/5xx, and no click-time exceptions.

Helper:
- tests/helpers/click-everything.ts — shared `clickEverythingOnPage`
  with default skips for destructive selectors (archive, delete,
  transfer, sign-out), auto-closing of dialogs, and return-to-start
  after navigation.

Exhaustive specs (tests/e2e/exhaustive/):
- 01-yachts: list + detail + transfer dialog
- 02-companies: list + detail + add-membership dialog
- 03-reservations: berth list + detail reservations tab + reserve
  dialog
- 04-client-detail: list + detail walking every tab
- 05-eoi-generate: generate dialog opens with Documenso option
- 06-invoice-form: new-invoice dialog billing-entity toggle
- 07-berths: list + detail walking every tab
- 08-portal: client portal yachts / memberships / reservations
- 09-navigation: every primary nav target loads cleanly

Destructive specs (tests/e2e/destructive/):
- 01-yacht-archive: create-via-API → archive via UI → assert removed.
  Skips with a clear message when the global setup does not seed an
  owner client (avoids brittle failures while the full destructive
  fixture lands).

Playwright config: testDir hoisted to ./tests/e2e; new `exhaustive` and
`destructive` projects share the existing setup project. New scripts
test:e2e / test:e2e:smoke / test:e2e:exhaustive / test:e2e:destructive
in package.json drive each project independently.

CI integration deferred — no .github/workflows/* exists in this repo
yet, so the PR 14 task to wire a separate CI job is N/A. The new
projects will pick up automatically when a workflow lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:06:10 +02:00
Matt Ciaccio
0ed401d083 refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.

Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.

Caller cleanup (zero behavioral change to remaining flows):

- Drops the legacy `generateEoi` flow entirely (route, service function,
  pdfme template, validator schema). The dual-path generate-and-sign
  service from PR 11 has fully replaced it; the route was no longer
  wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
  removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
  `yachts` via `interest.yachtId` instead of the dropped
  `client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
  lookup (direct + active company memberships); interest-summary fetches
  yacht via `interest.yachtId`. Both PDF templates updated to read
  yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
  `search-result-item`, `use-search` hook, `types/domain.ts`,
  `search.service` — drop the companyName badge / sub-label / typed
  field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
  prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
  yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.

Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
Matt Ciaccio
456d399ee2 refactor(templates): merge-field allow-list rejects unknown tokens
Extracts the MERGE_FIELDS catalog out of the document-templates service
into src/lib/templates/merge-fields.ts so the Zod validator can import
it without circular deps. createTemplateSchema now refines mergeFields
against VALID_MERGE_TOKENS — unknown tokens (including the deprecated
`{{client.yachtName}}` / `{{client.companyName}}` family) are rejected
at template creation time with a message naming the offenders.

Adds the missing `eoi` value to templateType enum so seeded EOI rows
round-trip through the validator. Drops the historical "Removed (PR 11):"
comment from the catalog (per project convention against `// removed`
markers).

6 new validator unit tests; 652/652 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:48:06 +02:00
Matt Ciaccio
f4ec51002c feat(eoi): template-aware generate-EOI dialog
The EOI dialog now lists "Documenso Standard EOI" (default) plus any
seeded in-app EOI templates and routes the submit to the dual-path
generate-and-sign endpoint with the correct pathway:

  - "documenso-template" sentinel id → pathway: documenso-template
  - any other template id → pathway: inapp

Signers are derived server-side from EoiContext for both pathways when
the template type is EOI (interest's client + hardcoded developer +
approver), so the dialog doesn't collect them. Non-EOI templates still
require explicit signers.

Drops the legacy `client.yachtLengthFt` prerequisite check (yacht is now
a first-class entity) and replaces it with hasYacht based on
interest.yachtId. Tests updated; 646/646 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:42:08 +02:00
Matt Ciaccio
2ff24a7132 feat(eoi): in-app pathway fills the same source PDF as Documenso
When the in-app pathway is used for EOI templates, we now load the same
source PDF that the Documenso template uploads and fill its AcroForm
fields with values from EoiContext via pdf-lib. Field names mirror the
Documenso template's formValues keys exactly (Name, Email, Address,
Yacht Name, Length, Width, Draft, Berth Number + Lease_10 / Purchase
checkboxes), so both pathways produce equivalent legal documents — only
the renderer differs.

The form is left interactive (not flattened) so a recipient can still
adjust values before signing. Non-EOI templates (welcome letters,
acknowledgments, etc.) keep using the existing HTML→pdfme path.

Adds:
- pdf-lib direct dep
- src/lib/pdf/fill-eoi-form.ts — load + fill helpers, EOI_TEMPLATE_PDF_PATH
  env override
- assets/ + README documenting the expected source PDF
- next.config outputFileTracingIncludes so the asset is bundled in the
  standalone build

Tests: 8 new (4 fill-form unit + 2 source-PDF route + 2 fallback);
645/645 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:38:02 +02:00
Matt Ciaccio
f8255cedb8 feat(eoi): dual-path generateAndSign (inapp + documenso-template)
generateAndSign now accepts a `pathway` parameter:

- `inapp` (existing): resolve in-app template -> pdfme -> MinIO -> Documenso
  createDocument + sendDocument.
- `documenso-template` (new): build EOI context from interestId, assemble
  the Documenso template payload, and call Documenso's
  /api/v1/templates/{id}/generate-document. Documenso owns the PDF; we
  still record a documents row for tracking.

Adds generateDocumentFromTemplate helper to the Documenso client and new
env vars (DOCUMENSO_TEMPLATE_ID_EOI + client/developer/approval recipient
IDs) with defaults matching the legacy flow. Covered by 6 new integration
tests (637/637 green).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:43:41 +02:00
Matt Ciaccio
13d07e3906 feat(templates): merge-field resolver supports yacht/company/owner scopes
Task 11.4. Extends resolveTemplate to use buildEoiContext when interestId
is provided, populating the new yacht.*, company.*, owner.* token scopes
from the shared EOI context. Legacy non-EOI templates still resolve via
direct client/berth/port lookups. Deprecated client.yachtName /
client.companyName / client.yacht*Ft tokens are removed from the catalog;
PR 12 will drop the backing columns. berth.mooringNumber is relaxed to
required:false so welcome-letter-style templates without a berth context
no longer trip the required-merge-field check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:20:53 +02:00
Matt Ciaccio
7ef7b9bb5f feat(eoi): seed Standard EOI in-app template per port
Adds a new per-port document_templates row of type 'eoi' containing an
HTML EOI / Letter of Intent body with {{section.field}} merge tokens
that mirror the EoiContext shape. Enables the in-app pdfme PDF path as
an alternative to the Documenso template flow.

- New getStandardEoiTemplateHtml() returns the Letter-sized HTML body
  with Applicant / Yacht / Owner / Berth / Interest / Signatures blocks
- STANDARD_EOI_MERGE_FIELDS exported for resolveTemplate wiring (11.4)
- seed-data.ts inserts one document_templates row per port inside the
  existing withTransaction block, between ownership transfers and
  interests, using SEED_USER_ID for audit consistency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:13:51 +02:00
Matt Ciaccio
7200c31486 feat(eoi): add Documenso template payload builder 2026-04-24 16:09:27 +02:00
Matt Ciaccio
db74c9394b docs(eoi): document Documenso template field name mapping
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:06:39 +02:00
Matt Ciaccio
d133d6d656 feat(ui): wire OwnerPicker into invoice billing-entity field 2026-04-24 16:04:07 +02:00
Matt Ciaccio
9d7decfc5b feat(invoices): polymorphic billing entity with snapshot clientName
Wires the billingEntityType/billingEntityId columns (added in PR 1) through
the invoice validator and service. Clients can now be billed as either a
client or a company; clientName becomes a snapshot derived from the entity
at create time.

- createInvoiceSchema: replace clientName with billingEntity {type,id}
- listInvoicesSchema: add billingEntityType/billingEntityId filters
- createInvoice: resolveBillingEntity helper (tenant-scoped; tx-aware)
  falls back to entity primary email/address when not supplied
- listInvoices: honor new billing-entity filters
- updateInvoice: unchanged — billing entity is fixed after create
- invoice wizard step 1: temporary billing-entity id input (Task 10.2
  replaces this with a proper picker)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:02:00 +02:00
Matt Ciaccio
c685c9fada feat(recommendations): read yacht dimensions from yachts table
Switch recommendations engine to read yacht dimensions (lengthFt, widthFt,
draftFt) from the yachts table via interest.yachtId instead of from the
deprecated client fields. Cross-tenant safety is maintained by scoping the
yacht lookup to the same portId. Falls back gracefully to null dimensions
when interest.yachtId is null or yacht is not found.

- Modified: src/lib/services/recommendations.ts — replaced client.yacht*Ft
  fields with yacht table lookups via interest.yachtId
- Created: tests/integration/recommendations-yacht-dims.test.ts — 4 tests
  covering happy path, null-yacht fallback, cross-tenant safety, and
  dimension-based scoring

All 594 tests passing, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:51:17 +02:00
Matt Ciaccio
71d7daf1ae feat(search): index yachts and companies alongside clients
Extend the global search service to include yacht and company results
using ILIKE matching on name, hull number, registration, legal name,
and tax ID. Results are tenant-scoped and exclude archived rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:47:54 +02:00
Matt Ciaccio
1fd05a886d feat(public-interest): atomic client+yacht+company+interest trio
Restructures the public interest endpoint to create the yacht as a
first-class row (owned by the new client, or by a newly upserted
company when a company block is provided) and writes the yacht_id
onto the new interest. All writes now run inside a single
transaction instead of the previous unwrapped sequence.

The public validator gains structured `yacht` (required) and
`company` (optional) sub-objects; legacy flat fields remain in the
schema for backward compatibility but are silently ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:42:45 +02:00
Matt Ciaccio
bcf4c1f797 feat(interests): add yacht-picker to interest form 2026-04-24 15:36:27 +02:00
Matt Ciaccio
f9cb8003b5 feat(interests): wire yachtId, enforce ownership + stage-gate
- Add yachtId (optional) to createInterestSchema + listInterestsSchema
  (updateInterestSchema inherits it via partial() automatically).
- Add assertYachtBelongsToClient helper that accepts direct client
  ownership OR company-represented clients with an active membership
  in the owning company.
- createInterest + updateInterest validate yacht ownership whenever
  yachtId is supplied/changed.
- changeInterestStage rejects moving out of stage=open with yachtId
  null (ValidationError).
- listInterests filter supports yachtId.
- Integration tests cover all 7 paths; validator test for yachtId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:34:44 +02:00
Matt Ciaccio
3b0421aa81 fix(tests): use dynamic imports in portal.test.ts to avoid env validation 2026-04-24 14:48:40 +02:00
Matt Ciaccio
a14dc8143c feat(portal): surface yachts, memberships, reservations for portal users
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:43:12 +02:00
Matt Ciaccio
b75834ab7e refactor(clients): rebuild detail tabs + columns for new data model
- ClientData in client-detail.tsx now reflects the stripped shape from
  Task 8.2 (drop companyName/isProxy/proxy*/yacht*/berthSizeDesired) and
  gains yachts / companies / activeReservations arrays.
- client-tabs.tsx: Overview trimmed (personal, contacts, source, tags);
  three new count-badged tabs (Yachts, Companies, Reservations).
- New client-yachts-tab.tsx renders owned yachts + Add yacht CTA (TODO:
  YachtForm preset-owner wiring for v2).
- New client-companies-tab.tsx renders memberships with Primary badge and
  since-date; management still lives on the company detail page.
- New client-reservations-tab.tsx maps activeReservations into ReservationRow
  shape and delegates to <ReservationList showBerth />.
- client-columns.tsx drops companyName column (TODO: add Yachts count +
  Primary company once list endpoint joins those).
- client-filters.tsx drops isProxy filter.
- Wire realtime invalidations for yacht:ownership_transferred,
  company_membership:added/ended, and berth_reservation:*.
2026-04-24 14:36:34 +02:00
Matt Ciaccio
4c171848fc refactor(clients): strip deprecated fields + extend getClientById with yachts/companies/reservations
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:31:14 +02:00
Matt Ciaccio
a6d6647bb2 refactor(clients): strip yacht/company/proxy sections from client form 2026-04-24 14:27:47 +02:00
Matt Ciaccio
367fc9800e refactor(clients): strip yacht/company/proxy fields from validator
Remove deprecated companyName, isProxy, proxyType, actualOwnerName, yacht
dimensions, and berthSizeDesired fields from createClientSchema and the
isProxy filter from listClientsSchema. First step of PR 8; cascading TS
errors in clients.service.ts and client-form.tsx are addressed in 8.2/8.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:25:10 +02:00
Matt Ciaccio
ddcffe9f6f feat(ui): add reservations tab to berth detail 2026-04-24 14:22:06 +02:00
Matt Ciaccio
3c5267f5e9 feat(ui): berth-reserve dialog with create-and-activate flow 2026-04-24 14:20:08 +02:00
Matt Ciaccio
2111bb8b60 feat(ui): add reservation-list table component 2026-04-24 14:18:11 +02:00
Matt Ciaccio
64d7b5c765 feat(ui): company list page with columns, filters, and sidebar entry 2026-04-24 14:05:24 +02:00
Matt Ciaccio
4e448dd06e feat(ui): add-membership dialog for company members 2026-04-24 14:02:47 +02:00
Matt Ciaccio
29a7fc8857 feat(ui): add shared client-picker autocomplete 2026-04-24 14:02:00 +02:00
Matt Ciaccio
5d76a8a1cf feat(ui): company detail page with header, tabs, members, owned yachts 2026-04-24 13:59:21 +02:00
Matt Ciaccio
d6743ed52c feat(ui): add company-form for create/edit with 409 handling
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:53:35 +02:00
Matt Ciaccio
ba86b7a897 feat(ui): add company-picker autocomplete component
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:52:52 +02:00
Matt Ciaccio
4f56c2bdfd feat(ui): add Yachts entry to sidebar navigation 2026-04-24 13:48:37 +02:00
Matt Ciaccio
508518b6c8 feat(ui): yacht transfer dialog with atomic ownership change
Replaces the Task 5.3 stub with a real YachtTransferDialog backed by
OwnerPicker, a date input, reason select, and notes textarea. Submits to
POST /api/v1/yachts/{id}/transfer, invalidates yacht + ownership-history
queries on success, and surfaces API errors (same-owner 400, cross-tenant
404, no-permission 403) as form-level messages. Transfer button is now
gated by PermissionGate resource="yachts" action="transfer".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:47:26 +02:00
Matt Ciaccio
f64a52b995 feat(ui): yacht list page with columns and filters 2026-04-24 13:44:15 +02:00
Matt Ciaccio
76d2348873 feat(ui): yacht detail page with header, tabs, ownership history
Implements Task 5.3: server page passes yachtId to a client YachtDetail,
which fetches via TanStack Query and renders the shared DetailLayout with
Overview / Ownership History / Interests / Reservations / Notes / Tags
tabs. Header shows name, dimensions, polymorphic owner link, status badge,
and Edit / Transfer / Archive actions. Transfer is a stub dialog pending
Task 5.5; Notes tab is a placeholder because NotesList does not yet support
entityType='yachts'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:40:41 +02:00
Matt Ciaccio
a604223c17 feat(ui): add yacht-form for create/edit
Sheet-based react-hook-form + zod component for yacht CRUD.
CREATE mode uses OwnerPicker to set the yacht's owner (required
by createYachtSchema). EDIT mode hides the picker and shows a
notice directing users to the Transfer button, matching the
service-layer guard that blocks owner mutation via PATCH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:34:55 +02:00
Matt Ciaccio
d4f58abb9c feat(ui): add owner-picker and yacht-picker components
Task 5.1 of the data-model refactor. Adds:
- OwnerPicker: polymorphic combobox that toggles between client and
  company autocomplete via a type switch inside the popover. Uses
  /api/v1/clients/options (search=) and /api/v1/companies/autocomplete
  (q=).
- YachtPicker: yacht autocomplete against /api/v1/yachts/autocomplete
  with optional ownerFilter prop to scope to a given client/company.

Both components use TanStack Query with debounced (300ms) input via the
existing use-debounce hook, and apiFetch which attaches X-Port-Id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:32:28 +02:00
Matt Ciaccio
727e323288 feat(seed): rewrite seed for multi-cardinality refactor
Split seed into orchestrator (seed.ts) + per-port fixture builder
(seed-data.ts). Creates three ports (Port Nimara, Marina Azzurra,
Harbor Royale) and seeds each with a realistic multi-cardinality
dataset: 12 berths (5 available / 5 reserved / 2 sold), 8 clients
with contacts and primary addresses, 3 companies (2 active / 1
dissolved) with billing addresses, memberships exercising dual-
company ownership and ended state, 12 yachts (7 client-owned /
5 company-owned) plus matching open ownership-history rows, 3
completed ownership transfers per port (client <-> company), 15
interests spanning all pipeline stages, and 8 reservations (5
active on distinct berths / 2 ended / 1 cancelled). Seed wraps
per-port work in withTransaction and is idempotent: re-running
detects existing company rows and skips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:26:37 +02:00
Matt Ciaccio
7abbdd4913 feat(factories): add makeMembership, makeReservation, makeOwnershipTransfer 2026-04-24 13:19:54 +02:00
Matt Ciaccio
94f8b76a03 feat(events): register yacht, company, membership, reservation webhook events 2026-04-24 12:56:47 +02:00
Matt Ciaccio
a78f653f5a feat(api): berth reservations (create pending + lifecycle PATCH)
Add Task 3.6 routes:

- POST /api/v1/berths/:id/reservations — creates a pending reservation;
  the URL berthId is authoritative and any body-supplied berthId is
  ignored.
- GET /api/v1/berths/:id/reservations — list filtered by URL berthId.
- GET /api/v1/berth-reservations/:id — fetch scoped to tenant.
- PATCH /api/v1/berth-reservations/:id — action-based dispatch
  (activate | end | cancel) via a discriminated union. Because the
  required permission depends on the action, PATCH is wrapped with
  withAuth only and calls requirePermission inside the handler.
- DELETE /api/v1/berth-reservations/:id — alias for cancel (204).

Cross-tenant berths return 404 on both POST and GET via an explicit
pre-check.

Tests cover happy paths, invalid transitions, 404/400/403 cases, the
URL-vs-body berthId precedence, and per-action permission gating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:55:12 +02:00
Matt Ciaccio
aca45fb1b2 feat(api): company memberships (add/update/end/set-primary)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:49:10 +02:00
Matt Ciaccio
183ff1ff9e feat(api): company list/create/detail/patch/archive/autocomplete 2026-04-24 12:45:10 +02:00
Matt Ciaccio
90463269ce feat(api): yacht detail, patch, archive, transfer, history, autocomplete 2026-04-24 12:40:51 +02:00
Matt Ciaccio
a5036c6358 feat(api): GET/POST /api/v1/yachts
Add yacht list + create routes, export RouteHandler type and inner
handlers so tests can invoke them directly with a mock AuthContext.
New tests/helpers/route-tester.ts provides makeMockCtx/makeMockRequest
reusable by subsequent Task 3.x routes.
2026-04-24 12:35:25 +02:00
Matt Ciaccio
f743169354 feat(permissions): add yacht, company, membership, reservation keys 2026-04-24 12:30:06 +02:00
Matt Ciaccio
b053a6388e feat(eoi): shared context builder + tests 2026-04-24 12:20:40 +02:00
Matt Ciaccio
b1133c4e87 feat(reservations): service + validators + exclusivity tests
Adds the berth_reservations service covering the full lifecycle
(pending -> active -> ended/cancelled) with tenant scoping, DB-enforced
exclusivity on the idx_br_active partial unique index, and
client-or-company-member cross-checks for yacht ownership.

- validators: createPending / activate / end / cancel / list schemas
- service: createPending, activate, endReservation, cancel, getById,
  listReservations — with narrow 23505/idx_br_active catch that
  re-queries the conflicting active reservation
- socket events: berth_reservation:{created,activated,ended,cancelled}
- tests: unit (lifecycle, tenant, membership cross-check),
  integration (concurrent-activate ConflictError + re-activate after end)
2026-04-24 12:15:22 +02:00
Matt Ciaccio
15a79e7990 feat(company-memberships): service + validators + tests
Adds company-membership service with six operations (add, update, end,
setPrimary, listByCompany, listByClient), the corresponding Zod
validators, three socket events, and a unit-test suite covering the
portId-scoping rules, the unique_cm_exact conflict path, and the atomic
setPrimary transaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:07:58 +02:00
Matt Ciaccio
037f2544e8 feat(companies): service + validators + unit tests 2026-04-24 12:02:08 +02:00
Matt Ciaccio
7c408cf975 feat(yachts): list + owner-scoped list + autocomplete
Adds `listYachts`, `listYachtsForOwner`, and `autocomplete` to the
yacht service so UIs can page/filter yachts per port, look up all
yachts tied to a given client/company, and power search-as-you-type.

`listYachts` delegates to the shared port-scoped `buildListQuery`,
supporting search over name/hullNumber/registration plus ownerType,
ownerId and status filters; `autocomplete` caps at 10 results and is
tenant-scoped; `listYachtsForOwner` returns all yachts whose current
owner matches, newest first. Extends `makeYacht` factory to accept
flat `name`, `status`, `hullNumber`, `registration` overrides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:03:36 +02:00
Matt Ciaccio
8a5cd1ef0e feat(yachts): atomic transferOwnership with partial-unique guard 2026-04-23 23:58:20 +02:00
Matt Ciaccio
d0ab4b8102 feat(yachts): updateYacht + archiveYacht 2026-04-23 23:52:24 +02:00
Matt Ciaccio
aaf4847fc2 refactor(yachts): use withTransaction helper per project convention 2026-04-23 23:47:12 +02:00
Matt Ciaccio
feacb8c7ac fix(yachts): run owner existence check inside transaction 2026-04-23 23:46:03 +02:00
Matt Ciaccio
2f2ad4452f feat(yachts): createYacht + getYachtById services with tests 2026-04-23 23:40:56 +02:00
Matt Ciaccio
27d438929b refactor(yachts): rename schema + consolidate tests per project conventions 2026-04-23 23:35:30 +02:00
Matt Ciaccio
899e588a0c feat(yachts): add zod validators + tests 2026-04-23 23:31:29 +02:00
Matt Ciaccio
7a6e95c87a test(schema): verify partial unique indexes and case-insensitive company uniqueness
Adds integration test covering:
- idx_yoh_active: only one active ownership row per yacht
- idx_br_active: only one active reservation per berth (non-active rows
  are ignored by the partial index)
- Case-insensitive company name uniqueness within a port, with same-name
  companies allowed across different ports

Extends tests/helpers/factories.ts with async DB-inserting factories for
ports, clients, berths, yachts (+ ownership history row) and companies.
The new factories use the app's `db` handle so FK and partial unique
indexes are enforced by Postgres. The in-memory data helpers used by
unit tests (makeAuditMeta, makeCreateClientInput, permission helpers)
are preserved.
2026-04-23 18:06:37 +02:00
Matt Ciaccio
077ba5bf6b feat(schema): wire yacht, company, reservation relations in Drizzle 2026-04-23 18:02:22 +02:00
Matt Ciaccio
14dac2f3e1 feat(documents): add yachtId/companyId to files and documents 2026-04-23 18:00:12 +02:00
Matt Ciaccio
117cfae52e feat(invoices): add billingEntityType/Id for polymorphic billing 2026-04-23 17:58:52 +02:00
Matt Ciaccio
d43298a74e feat(schema): add yachtId to interests and berth_waiting_list 2026-04-23 17:57:29 +02:00
Matt Ciaccio
88a87afa77 feat(reservations): add berth_reservations schema with partial unique exclusivity 2026-04-23 17:55:53 +02:00
Matt Ciaccio
299e893e2b feat(companies): add companies, memberships, addresses, notes, tags schema 2026-04-23 17:54:02 +02:00
Matt Ciaccio
51523e6768 feat(yachts): add yachts, ownership history, notes, tags schema 2026-04-23 17:51:19 +02:00
Matt Ciaccio
11969c0d8a docs(plan): add data-model refactor implementation plan (Spec 1)
15-PR sequenced plan covering schema migration, services, API,
seeder, UI, EOI dual-path, exhaustive click-through tests,
documentation updates, and final merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:17:02 +02:00
Matt Ciaccio
1c0a16fd59 docs(spec): add data-model refactor design (Spec 1 of 3)
Introduces yachts and companies as first-class entities with memberships,
ownership history, berth reservations, and dual-path EOI templates.
Explicit non-goals (importer, merge endpoint) carved out as Specs 2 and 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:04:41 +02:00
Matt Ciaccio
b6996f9a31 test(e2e): repair 26 Playwright smoke-test failures
Failures were mostly stale selectors, not product regressions:

- .or() traps matching the topbar "+ New" button → use specific names
  (Add Webhook, New Field, New Template)
- broad /create|add|new/ patterns → same fix
- [role="dialog"] overlay matched before content → getByRole('dialog').last()
- locator('input') picked hidden Radix Select inputs → getByPlaceholder /
  getByRole('combobox', { name })
- 11-global-search rewritten for the inline topbar search (the cmdk
  CommandDialog the old tests targeted was replaced)
- missing .first() causing strict-mode failures on notifications heading,
  version history text, nav links
- dashboard landing test: no h1 exists, target KPI text instead
- activity-feed: items aren't anchors; match action badge text
- monitoring data-leak check scoped to <main> (sidebar has Email/Documents)
- admin API without port context returns 400 (not 403) for non-admins —
  accept 400 as a valid "blocked" status in the sales-agent test

Also dropped dead imports and unused locals surfaced by lint-staged.

Full suite: 124 passed (11.2m).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:24:52 +02:00
Matt Ciaccio
46bd8aaef1 fix: allow /portal and /api/portal paths without CRM session
The portal has its own JWT-based auth (withPortalAuth). The CRM
middleware was redirecting /portal/login and /api/portal/auth/request
to /login, breaking the magic-link flow for unauthenticated clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:22:53 +02:00
Matt Ciaccio
b5d8e1ecb8 docs: update PROGRESS.md with 2026-03-26 → 2026-04-22 changelog
Adds a 'Since 2026-03-26' section summarizing the admin/reminders
expansion, multi-address clients, full inquiry notifications feature,
and Next.js 15 build fixes. Updates the Layer 3 reminders entry to
reflect full CRUD + background processors. Marks Priority 1 push-to-
Gitea as done and splits out CI verification as its own checkbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:37:43 +02:00
Matt Ciaccio
ed40662b99 chore: gitignore docker-compose.override.yml and .remember/
The override file is a local-only port remap for when the default
dev postgres port is already bound by another project. .remember/ is
skill-maintained session-state storage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:36:11 +02:00
Matt Ciaccio
9d815c4dcc fix: wrap useSearchParams pages in Suspense for prerender
Next.js 15 static prerender bails out when useSearchParams is used
outside a Suspense boundary. Extract the hook-using component into
an inner child and wrap it in Suspense at the page root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:06:39 +02:00
Matt Ciaccio
b9b3f942a6 chore: add .gitattributes to normalize line endings to LF
Prevents cross-platform CRLF/LF churn between Windows and macOS checkouts.
Windows-only scripts (bat/cmd/ps1) pinned to CRLF; shell scripts pinned to LF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:02:46 +02:00
244 changed files with 93889 additions and 2124 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}

30
.gitattributes vendored Normal file
View File

@@ -0,0 +1,30 @@
# Normalize line endings on commit; check out LF on every OS.
* text=auto eol=lf
# Binary files — never touch line endings.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.pdf binary
*.zip binary
*.gz binary
*.tar binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.eot binary
*.mp4 binary
*.mov binary
*.wasm binary
# Shell scripts must stay LF regardless.
*.sh text eol=lf
# Windows batch / PowerShell must stay CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf

4
.gitignore vendored
View File

@@ -17,3 +17,7 @@ playwright-report/
nginx/certs/
tsconfig.tsbuildinfo
.playwright-mcp/
docker-compose.override.yml
.remember/
.DS_Store
eoi/

View File

@@ -20,16 +20,42 @@
### Client Domain
- `clients` — Anchor records for people/entities
- `clients` — Anchor records for people/entities. Yacht and company details
are no longer stored here — see the Yacht and Company domains.
- `client_contacts` — Multi-channel contact entries per client
- `client_addresses` — Physical addresses per client (primary + others)
- `client_relationships` — Relationships between clients (referrals, broker, family)
- `client_notes` — Timestamped notes on clients
- `client_tags` — Tags assigned to clients
- `client_merge_log` — Audit trail of client merges
### Yacht Domain
- `yachts` — First-class yacht records. Polymorphic ownership via
`current_owner_type` (`'client' | 'company'`) + `current_owner_id`.
- `yacht_ownership_history` — Append-only log of every transfer; partial
unique index `idx_yoh_active` enforces a single active owner per yacht.
- `yacht_notes`, `yacht_tags` — Notes / tags on yachts.
### Company Domain
- `companies` — Legal entities that may own yachts or be billed.
- `company_addresses` — Addresses per company.
- `company_memberships` — Active client ↔ company links with role
(director / shareholder / beneficial_owner / authorised_signatory),
start/end dates.
### Reservation Domain
- `berth_reservations` — Concrete client + yacht + berth holds with
start/end dates and status. Partial unique index `idx_br_active`
enforces one active reservation per berth.
### Interest Domain
- `interests` — Per-berth pipeline records, each belonging to a client (milestone dates are inline columns)
- `interests` — Per-berth pipeline records. Each row references a
`client_id`, `yacht_id` (the yacht in scope for the inquiry), and
optional `berth_id`. Milestone dates are inline columns.
- `interest_notes` — Timestamped notes on interests
- `interest_tags` — Tags assigned to interests

View File

@@ -70,10 +70,13 @@ src/
- **Formatting:** Prettier - single quotes, semicolons, trailing commas, 2-space indent, 100 char line width.
- **Lint:** ESLint flat config extending `next/core-web-vitals`, `next/typescript`, `prettier`. Unused vars prefixed with `_` are allowed.
- **Imports:** Use `@/*` path alias (maps to `src/*`).
- **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`.
- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`.
- **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`. Yacht / company / reservation domains live in `components/yachts`, `components/companies`, `components/reservations` respectively.
- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`. Domain files include `clients.ts`, `yachts.ts`, `companies.ts`, `reservations.ts`, `interests.ts`, `berths.ts`, `documents.ts`, `invoices.ts`, etc.
- **Polymorphic ownership:** Yachts and invoice billing-entities use `<entity>_type` + `<entity>_id` column pairs (`'client' | 'company'`). Resolve owner identity through `src/lib/services/yachts.service.ts` / `eoi-context.ts` rather than reading the columns ad hoc — those services apply the type discriminator.
- **EOI generation:** Two pathways share the same `EoiContext` (`src/lib/services/eoi-context.ts`). Documenso pathway calls the template-generate endpoint via `documenso-payload.ts`; in-app pathway fills the same source PDF (`assets/eoi-template.pdf`) via `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm). Routed through `generateAndSign(...)` in `src/lib/services/document-templates.ts` with a `pathway` parameter.
- **Merge fields:** Token catalog lives in `src/lib/templates/merge-fields.ts`; the `createTemplateSchema` validator uses `VALID_MERGE_TOKENS` as an allow-list, so unknown tokens are rejected at template creation time.
- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled.
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files.
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files. The hook also blocks `.env*` files (including `.env.example`) from being committed; pass them via a separate workflow if needed.
## Environment
@@ -89,3 +92,11 @@ Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full s
## Architecture docs
Numbered spec files in repo root (`01-CONSOLIDATED-SYSTEM-SPEC.md` through `15-DESIGN-TOKENS.md`) contain detailed architecture decisions, feature specs, DB schema docs, API catalog, and implementation sequence.
Domain-specific references:
- `docs/eoi-documenso-field-mapping.md` — canonical mapping from `EoiContext`
paths to the Documenso template's `formValues` keys, with the matching
AcroForm field names used by the in-app pathway.
- `assets/README.md` — what the in-app EOI source PDF must contain and how
to override its path in dev/test.

View File

@@ -1,12 +1,22 @@
# Port Nimara CRM - Project Progress
**Last updated:** 2026-03-26
**Last updated:** 2026-04-22
**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)
@@ -80,8 +90,10 @@
- API: `/api/v1/notifications/...` (CRUD, preferences, read-all, unread-count)
- Service: `notifications.service.ts`
- Components: `src/components/notifications/`
- [x] **Reminders** - Reminder pages
- [x] **Reminders** - Full CRUD with background processors (dispatcher, reminder workers)
- 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`
@@ -178,11 +190,12 @@
### Priority 1: Deployment & Go-Live
- [ ] Push to Gitea and verify CI/CD pipeline builds
- [x] Push to Gitea (origin/main at `9d815c4` as of 2026-04-22)
- [ ] Verify CI/CD pipeline builds the latest image and pushes to the Gitea container registry
- [ ] Set up server: install Docker, nginx, configure DNS for `pn.letsbe.solutions`
- [ ] Run `certbot --nginx -d pn.letsbe.solutions` for SSL
- [ ] Configure production `.env` on server
- [ ] Run database migrations (`pnpm db:push`)
- [ ] Run database migrations (`drizzle-kit migrate` against prod DB — `0000` + `0001` need to apply)
- [ ] Run seed data (`pnpm db:seed`)
- [ ] Verify all services start and health check passes

48
assets/README.md Normal file
View File

@@ -0,0 +1,48 @@
# `assets/`
Server-side runtime assets bundled by Next.js (via `outputFileTracingIncludes`
in `next.config.ts`). These files are read with `fs.readFile` from
`process.cwd()` at runtime, so they are NOT served as public URLs — use
`public/` for that.
## `eoi-template.pdf`
The source PDF used by the in-app EOI generation pathway
(`src/lib/pdf/fill-eoi-form.ts`). It must be the **same** PDF that the
Documenso EOI template uploads, so both pathways produce equivalent
documents.
The PDF must contain AcroForm fields with these exact names (mirroring the
Documenso template's `formValues` keys — see
`docs/eoi-documenso-field-mapping.md`):
| Field name | Type | Filled with |
| -------------- | -------- | ----------------------------------------------------- |
| `Name` | Text | `EoiContext.client.fullName` |
| `Email` | Text | `EoiContext.client.primaryEmail` |
| `Address` | Text | `street, city, country` |
| `Yacht Name` | Text | `EoiContext.yacht.name` |
| `Length` | Text | `EoiContext.yacht.lengthFt` |
| `Width` | Text | `EoiContext.yacht.widthFt` |
| `Draft` | Text | `EoiContext.yacht.draftFt` |
| `Berth Number` | Text | `EoiContext.berth.mooringNumber` |
| `Lease_10` | Checkbox | always `false` (legacy default — Purchase, not Lease) |
| `Purchase` | Checkbox | always `true` |
Form fields stay interactive after generation (not flattened), so the
recipient can still tweak values before signing if the in-app pathway is
followed by a Documenso send.
### Override path
In dev/test, set `EOI_TEMPLATE_PDF_PATH=/abs/path/to/your/template.pdf` to
point at a different file (e.g. a fixture).
### How to extract this PDF
The legacy flow uploads this PDF to Documenso template ID 8. To get the
exact bytes:
1. In Documenso, open the EOI template.
2. Download the source PDF.
3. Drop it here as `eoi-template.pdf`.

BIN
assets/eoi-template.pdf Normal file

Binary file not shown.

Submodule client-portal updated: e2d31815cf...84f89f9409

View File

@@ -0,0 +1,76 @@
# Documenso EOI Template — Field Mapping
**Purpose:** This doc is the canonical reference for mapping the Documenso EOI template's `formValues` keys to the new data model's `EoiContext` shape. It drives `buildDocumensoPayload()` (Task 11.2), the in-app Standard EOI HTML tokens (Task 11.3), and the Spec 2 importer's yacht/company hydration.
## Source
The legacy field list comes from `client-portal/server/api/eoi/generate-quick-eoi.ts`, specifically the POST body sent to `POST /api/v1/templates/{templateId}/generate-document` (Documenso template 8). The relevant lines in that file are around the `createDocumentPayload.formValues` object.
## Documenso template `formValues` keys
Documenso template IDs and recipient IDs are configured via env vars:
- `NUXT_DOCUMENSO_TEMPLATE_ID` (default: `8`)
- `NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID` (default: `192`) — signing order 1
- `NUXT_DOCUMENSO_DEVELOPER_RECIPIENT_ID` (default: `193`) — signing order 2
- `NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID` (default: `194`) — APPROVER, signing order 3
The template exposes eight text fields (`formValues` keys) and two boolean checkboxes.
## Field mapping
| Documenso key | Type | Legacy source | New `EoiContext` path | Notes |
| -------------- | ------- | --------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- |
| `Name` | text | `interest['Full Name']` | `context.client.fullName` | The interest's point-of-contact client (billing signer). |
| `Email` | text | `interest['Email Address']` | `context.client.primaryEmail` | Primary email contact from `client_contacts`. |
| `Address` | text | `interest['Address']` | concat `context.client.address.{street,city,country}` | Concatenate street, city, country with `', '`. Empty if address is null. |
| `Yacht Name` | text | `interest['Yacht Name']` | `context.yacht.name` | Yacht is now a first-class row; pulled via `interest.yachtId`. |
| `Length` | text | `interest['Length']` | `context.yacht.lengthFt` | Send as string. Documenso doesn't enforce numeric format. |
| `Width` | text | `interest['Width']` | `context.yacht.widthFt` | Same. |
| `Draft` | text | `interest['Depth']` | `context.yacht.draftFt` | Legacy field was named "Depth" in NocoDB; Documenso key is "Draft". |
| `Berth Number` | text | `berthNumbers` (joined) | `context.berth.mooringNumber` | One berth per reservation. Multi-berth case was multi-interest in legacy. |
| `Lease_10` | boolean | hardcoded `false` | `false` | Hardcoded — legacy flow defaults to Purchase (not Lease). |
| `Purchase` | boolean | hardcoded `true` | `true` | Hardcoded — legacy flow defaults to Purchase. |
## Document `meta` fields (non-`formValues`)
| Documenso key | Type | Legacy source | New source |
| ------------------------- | ---- | ---------------------------------------- | ----------------------------------------------------------------- |
| `meta.message` | text | `Dear ${interest['Full Name']}...` | `Dear ${context.client.fullName}, ...port name interpolated` |
| `meta.subject` | text | `"Your LOI is ready to be signed"` | Same — constant. |
| `meta.redirectUrl` | text | `"https://portnimara.com"` | `context.port.redirectUrl` if per-port; otherwise global app URL. |
| `meta.distributionMethod` | text | `"NONE"` | Same — constant. We use manual send flow (Documenso webhook). |
| `title` | text | `` `${interest['Full Name']}-EOI-NDA` `` | `` `${context.client.fullName}-EOI-NDA` `` |
| `externalId` | text | `` `loi-${interestId}` `` | Same. |
## Recipients (non-`formValues`)
| Recipient | Role | Name | Email | Signing order |
| ------------------- | -------- | ------------------------- | ----------------------------- | ------------- |
| Client (signer) | SIGNER | `context.client.fullName` | `context.client.primaryEmail` | 1 |
| Developer (signer) | SIGNER | `"David Mizrahi"` | `"dm@portnimara.com"` | 2 |
| Approval (approver) | APPROVER | `"Abbie May"` | `"sales@portnimara.com"` | 3 |
The Developer and Approval recipients are currently hardcoded in the legacy flow. In the new system these should eventually come from port-level settings (e.g., `ports.settings.eoi.developerName` + email). For Task 11.2, keep them hardcoded as the legacy system does — tracking as TODO: "Replace hardcoded Developer/Approval recipients with port-level configuration."
## Company-owned yacht handling
The legacy flow has no concept of company ownership — the signer is always the interest's client. In the new system:
- If `context.yacht.ownerType === 'client'`: behavior unchanged.
- If `context.yacht.ownerType === 'company'`: the interest's point-of-contact client still signs (they're the representative of the yacht's owning company), but an extra block should appear in the message body: `"On behalf of ${context.company.legalName ?? context.company.name} (representing the yacht's owner)."`. This isn't a separate Documenso field — it's woven into `meta.message`.
Tracking this in the mapping doc rather than as a hard TODO because company-owned EOIs were rare in the legacy system and need product input before committing to the final wording.
## Deprecated fields (no longer sourced from `clients`)
The legacy system read these fields from the client row. They are now sourced elsewhere:
| Legacy source | New source |
| ------------------------- | --------------------------------------------------- |
| `client.yachtName` | `yachts.name` via `interest.yachtId` |
| `client.yachtLengthFt` | `yachts.lengthFt` via `interest.yachtId` |
| `client.yachtWidthFt` | `yachts.widthFt` via `interest.yachtId` |
| `client.yachtDraftFt` | `yachts.draftFt` via `interest.yachtId` |
| `client.companyName` | `companies.name` via polymorphic owner resolution |
| `client.berthSizeDesired` | Removed. Berth is picked via reservation, not text. |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,663 @@
# Data-Model Refactor: Yachts and Companies as First-Class Entities
**Status:** Draft — awaiting final review
**Date:** 2026-04-23
**Spec position:** 1 of 3 (Spec 2 = NocoDB+MinIO importer; Spec 3 = client merge endpoint)
## Overview
This spec delivers a refactor of the core client / yacht / company data model to support real-world ownership relationships that the current schema cannot express.
The current `clients` table holds yacht dimensions and company name as columns directly on the person row. This enforces a one-person = one-yacht = one-company assumption that breaks the moment:
- A client owns multiple yachts (a common marina scenario)
- A person is a broker or director of multiple companies
- A yacht is legally owned by a shell company (common for tax / liability reasons) rather than by the human on the dock
- A yacht changes hands between owners and the marina needs chain-of-title
The refactor pulls yacht and company data into their own first-class tables, adds join tables for person↔company memberships, and introduces a proper `berth_reservations` table for exclusive-reservation lifecycle tracking.
This spec also fixes two existing schema gaps that surface during the refactor:
- `berths.status` tracks the state of a berth but there is no table recording which client/yacht exclusively reserves a berth
- `invoices.clientName` is a text field with no FK — there's no first-class link between invoices and billing entities
## Scope boundaries
### In scope (this spec)
- New `yachts`, `yacht_ownership_history`, `yacht_notes`, `yacht_tags` tables
- New `companies`, `company_memberships`, `company_addresses`, `company_notes`, `company_tags` tables
- New `berth_reservations` table with partial-unique-index exclusivity enforcement
- Updates to `interests`, `berth_waiting_list`, `invoices`, `files`, `documents` to add FKs to the new entities
- Removal of yacht, company, and proxy columns from `clients`
- New services, API routes, permissions, and socket/webhook events
- New UI pages for yachts, companies, and berth reservations; modifications to client, interest, berth, invoice forms
- Dual-path EOI generation (Documenso + in-app PDF template) with a shared payload builder
- Comprehensive test coverage: unit, integration, E2E, exhaustive click-through, template regression
- Seeder with realistic multi-cardinality dummy data
### Explicitly out of scope
- **Importing NocoDB records and MinIO documents** → Spec 2
- **Client merge endpoint** → Spec 3
- Yacht survey / class-cert document categorization
- Company hierarchy (holding → subsidiary)
- Line-item-level yacht references on invoices
- Auto-renewal flow for berth reservations
- Per-yacht row-level permissions
- Portal branding per company
## Decisions and rationale
| Topic | Decision | Why |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Yacht scope | Full entity: own page, documents, ownership history, yacht-keyed interests / reservations / invoices | Marina domain cares about yachts as first-class objects (dimensions for berth fit, registration for port entry, ownership for liability) |
| Company scope | Full entity: memberships join, company-owned yachts, company billing | Yachts are frequently owned by shell companies for tax/liability reasons — the human on the dock is a director or broker. Lightweight/medium models can't route invoices to the correct legal entity |
| Ownership history | Dedicated `yacht_ownership_history` table + denormalized current-owner columns on `yachts` | Ownership change is exactly the kind of event that needs queryable history (chain of title, insurance, broker commission attribution). Denormalized current-owner keeps common reads fast |
| Proxy fields on clients (`isProxy`, `proxyType`, `actualOwnerName`, `relationshipNotes`) | Drop all four | Every real proxy scenario is expressible through `company_memberships` roles or `client_relationships`. Keeping the old fields creates two sources of truth and drift risk |
| Berth exclusive reservation | New `berth_reservations` table with partial unique index `WHERE status = 'active'` | Current schema tracks berth state via `berths.status` but does not record which client/yacht holds the reservation. Partial unique index enforces exclusivity at the DB level |
| Invoice billing entity | `billingEntityType` (`'client' \| 'company'`) + `billingEntityId`; `clientName` retained as an immutable snapshot | Companies become first-class payers. `clientName` as text is preserved on the invoice as a snapshot so invoices never retroactively rename themselves |
| Data state | Green-field with dummy seeder; real data arrives via Spec 2 | No production data lives in this Postgres DB yet. NocoDB holds the real records until Spec 2 imports them |
| Delivery | One cohesive spec covering both yacht + company refactor | Splitting doubles the migration/UI/test churn for no architectural gain; both sets of changes overlap heavily |
| EOI template strategy | Support both Documenso-template path and in-app PDF template path, both fully functional from day one | Handoff risk: client must not come back claiming "EOIs don't work." If Documenso breaks or is replaced, in-app path is the fallback. Both consume the same payload builder for data consistency |
| EOI UI picker | Dropdown at generation time (user picks Documenso or in-app explicitly) | Explicit beats automatic fallback for handoff — misconfiguration is visible, not silently masked |
| Testing | Unit, integration, full E2E scenarios, exhaustive Playwright click-through, template regression (including visual diff) | Explicit "test thoroughly" direction plus the handoff concern justify going heavier than normal on integration + E2E tiers |
## Schema design
### New tables
```
yachts
id text PK
portId text NOT NULL FK → ports.id
name text NOT NULL
hullNumber text
registration text
flag text
yearBuilt integer
builder text
model text
hullMaterial text
lengthFt numeric
widthFt numeric
draftFt numeric
lengthM numeric
widthM numeric
draftM numeric
currentOwnerType text NOT NULL -- 'client' | 'company'
currentOwnerId text NOT NULL
status text NOT NULL DEFAULT 'active' -- 'active' | 'retired' | 'sold_away'
notes text
archivedAt timestamptz
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_yachts_port on (portId)
idx_yachts_current_owner on (portId, currentOwnerType, currentOwnerId)
idx_yachts_name on (portId, name)
yacht_ownership_history
id text PK
yachtId text NOT NULL FK → yachts.id ON DELETE CASCADE
ownerType text NOT NULL -- 'client' | 'company'
ownerId text NOT NULL
startDate date NOT NULL
endDate date -- NULL = currently active
transferReason text -- 'sale' | 'inheritance' | 'gift' | 'company_restructure' | 'other'
transferNotes text
createdBy text NOT NULL
createdAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_yoh_yacht on (yachtId)
idx_yoh_active (partial) on (yachtId) WHERE endDate IS NULL
yacht_notes -- mirrors client_notes shape
id, yachtId (FK CASCADE), authorId, content, mentions text[], isLocked, createdAt, updatedAt
yacht_tags
yachtId, tagId composite PK; tagId references system.tags.id
companies
id text PK
portId text NOT NULL FK → ports.id
name text NOT NULL
legalName text
taxId text
registrationNumber text
incorporationCountry text
incorporationDate date
status text NOT NULL DEFAULT 'active' -- 'active' | 'dissolved'
billingEmail text
notes text
archivedAt timestamptz
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_companies_port on (portId)
idx_companies_name_unique UNIQUE on (portId, lower(name)) -- case-insensitive
idx_companies_taxid on (portId, taxId) WHERE taxId IS NOT NULL
company_memberships
id text PK
companyId text NOT NULL FK → companies.id ON DELETE CASCADE
clientId text NOT NULL FK → clients.id ON DELETE CASCADE
role text NOT NULL -- 'director' | 'officer' | 'broker' | 'representative' | 'legal_counsel' | 'employee' | 'shareholder' | 'other'
roleDetail text -- free-text qualifier: "Managing Director", "Exclusive Broker"
startDate date NOT NULL
endDate date -- NULL = active
isPrimary boolean NOT NULL DEFAULT false
notes text
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_cm_company on (companyId)
idx_cm_client on (clientId)
idx_cm_active (partial) on (companyId, clientId) WHERE endDate IS NULL
unique_cm_exact UNIQUE on (companyId, clientId, role, startDate)
company_addresses -- mirrors client_addresses shape with companyId FK
company_notes -- mirrors client_notes shape with companyId FK
company_tags
companyId, tagId composite PK
berth_reservations
id text PK
berthId text NOT NULL FK → berths.id
portId text NOT NULL FK → ports.id
clientId text NOT NULL FK → clients.id -- contract holder
yachtId text NOT NULL FK → yachts.id -- which yacht occupies the slip
interestId text FK → interests.id -- nullable link back to originating interest
status text NOT NULL -- 'pending' | 'active' | 'ended' | 'cancelled'
startDate date NOT NULL
endDate date -- NULL = open-ended
tenureType text NOT NULL DEFAULT 'permanent' -- 'permanent' | 'fixed_term' | 'seasonal'
contractFileId text FK → files.id
createdBy text NOT NULL
createdAt timestamptz NOT NULL DEFAULT now()
updatedAt timestamptz NOT NULL DEFAULT now()
Indexes:
idx_br_berth on (berthId)
idx_br_client on (clientId)
idx_br_yacht on (yachtId)
idx_br_active (partial) UNIQUE on (berthId) WHERE status = 'active'
```
### Modified tables
```
clients
DROP COLUMN yachtName, yachtLengthFt, yachtWidthFt, yachtDraftFt,
yachtLengthM, yachtWidthM, yachtDraftM, berthSizeDesired
DROP COLUMN companyName
DROP COLUMN isProxy, proxyType, actualOwnerName, relationshipNotes
(retains: fullName, nationality, preferredContactMethod, preferredLanguage,
timezone, source, sourceDetails, archivedAt, createdAt, updatedAt)
interests
ADD COLUMN yachtId text FK → yachts.id -- nullable initially; enforced non-null before pipeline_stage leaves 'open'
ADD INDEX idx_interests_yacht on (yachtId)
berth_waiting_list
ADD COLUMN yachtId text FK → yachts.id
invoices
ADD COLUMN billingEntityType text NOT NULL -- 'client' | 'company'
ADD COLUMN billingEntityId text NOT NULL
(clientName column kept as immutable snapshot — must never auto-update)
ADD INDEX idx_invoices_billing_entity on (portId, billingEntityType, billingEntityId)
files
ADD COLUMN yachtId text FK → yachts.id -- nullable
ADD COLUMN companyId text FK → companies.id -- nullable
(existing clientId stays nullable; a file links to one of: client, yacht, or company)
documents
ADD COLUMN yachtId text FK → yachts.id -- nullable
ADD COLUMN companyId text FK → companies.id -- nullable
```
### DB-level invariants
| # | Invariant | Enforced by |
| --- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | One active ownership row per yacht | Partial unique index on `yacht_ownership_history(yachtId) WHERE endDate IS NULL` |
| 2 | One active reservation per berth | Partial unique index on `berth_reservations(berthId) WHERE status = 'active'` |
| 3 | Yacht always has a current owner | Both `currentOwnerType` and `currentOwnerId` NOT NULL; ownership row inserted atomically with yacht creation inside service transaction |
| 4 | Company names unique per port (case-insensitive) | Unique index on `(portId, lower(name))` |
| 5 | Exact-duplicate memberships blocked | Unique index on `(companyId, clientId, role, startDate)` |
### Service-layer invariants (not DB-enforceable due to polymorphic columns)
| # | Invariant | Enforced by |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| 6 | `yacht.currentOwnerType='client'``currentOwnerId` references an existing row in `clients`; same for `'company'``companies` | Zod validator + service-layer lookup before insert/update |
| 7 | `yacht_ownership_history.ownerType/ownerId` consistent with the corresponding entity table | Same as #6 |
| 8 | `invoices.billingEntityType` + `billingEntityId` consistent with entity table | Same as #6 |
| 9 | `files.clientId`, `files.yachtId`, `files.companyId` — exactly one of the three must be non-null if the file is entity-scoped | Service-layer validation on insert/update |
### Drizzle relations (`relations.ts`)
All new tables wire into the relations map. Notable additions:
- `clientsRelations`: `companyMemberships` (many), `ownedYachts` (many, via polymorphic query), `berthReservations` (many)
- `yachtsRelations`: `port` (one), `ownershipHistory` (many), `notes` (many), `tags` (many), `interests` (many), `reservations` (many), `documents` (many)
- `companiesRelations`: `port` (one), `memberships` (many), `addresses` (many), `notes` (many), `tags` (many), `documents` (many)
- `berthReservationsRelations`: `berth`, `port`, `client`, `yacht`, `interest`, `contractFile`
## Service layer and API
### New services (`src/lib/services/`)
| File | Key functions |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `yachts.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `transferOwnership(yachtId, newOwnerType, newOwnerId, effectiveDate, reason, notes)` — atomic: closes current history row, opens new row, updates denormalized `currentOwner*` columns |
| `companies.service.ts` | `list`, `getById`, `create`, `update`, `archive`, `upsertByName(portId, name)` (case-insensitive, for autocomplete) |
| `company-memberships.service.ts` | `addMembership`, `endMembership(id, endDate)`, `updateMembership`, `listByCompany`, `listByClient`, `setPrimary` |
| `berth-reservations.service.ts` | `createPending`, `activate(id)` (gates on partial unique index), `end(id, endDate)`, `cancel(id)`, `listByBerth`, `listByClient`, `listByYacht` |
### Modified services
| File | Change |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `clients.service.ts` | Strip yacht/company/proxy field handling from create/update paths |
| `interests.service.ts` | Accept `yachtId`; validate yacht is owned by the interest's client OR by a company the client actively represents. Promote-to-stage helpers require `yachtId` non-null before leaving `'open'` |
| `berths.service.ts` | Read reservation state via `berth_reservations` instead of deriving from `berths.status`. Reservation state changes also update `berths.status` via trigger-in-service-layer |
| `invoices.service.ts` | Accept `billingEntityType` + `billingEntityId`; snapshot the entity's current display name into `clientName` at creation (immutable afterward) |
| `search.service.ts` | Extend to yachts and companies; include yacht name, hull number, registration in search index; include company name, legal name, taxId |
| `recommendations.ts` (berth matcher) | Pull yacht dimensions from `yachts` table via `interest.yachtId` instead of from `clients.yacht*` |
| `document-templates.ts` | Update `MERGE_FIELDS` catalog: deprecate `{{client.yachtName}}`, `{{client.companyName}}` and old yacht dimension tokens; add `{{yacht.*}}`, `{{company.*}}`, `{{owner.*}}` scopes. Update `resolveTemplate()` to resolve new scopes |
| `portal.service.ts` | Portal user dashboards surface their yachts (owned + represented via memberships), their active memberships, and their active berth reservations |
### New REST endpoints
```
# Yachts
GET /api/v1/yachts
POST /api/v1/yachts
GET /api/v1/yachts/:id
PATCH /api/v1/yachts/:id
DELETE /api/v1/yachts/:id — archive (soft delete)
POST /api/v1/yachts/:id/transfer — ownership transfer
GET /api/v1/yachts/:id/ownership-history
GET /api/v1/yachts/autocomplete?q=…
# Companies
GET /api/v1/companies
POST /api/v1/companies
GET /api/v1/companies/:id
PATCH /api/v1/companies/:id
DELETE /api/v1/companies/:id — archive
GET /api/v1/companies/autocomplete?q=…
# Company memberships
GET /api/v1/companies/:id/members
POST /api/v1/companies/:id/members
PATCH /api/v1/companies/:id/members/:mid
DELETE /api/v1/companies/:id/members/:mid — sets endDate
# Berth reservations
GET /api/v1/berths/:id/reservations
POST /api/v1/berths/:id/reservations — create pending
PATCH /api/v1/berth-reservations/:id — state transitions
```
### Modified endpoints
- `GET /api/v1/clients/:id` — response now includes nested `yachts` (owned + represented), `companies` (via active memberships), `activeReservations`
- `POST /api/v1/clients` — no longer accepts yacht/company/proxy fields
- `POST /api/v1/interests` — requires `yachtId`
- `POST /api/v1/invoices` — requires `billingEntityType` + `billingEntityId`
- `POST /api/public/interests` — creates new `client` + `yacht` + optional `company` + `membership` + `interest` in one transaction, all marked `source: 'public_submission'`. No dedup against existing records (anonymous trust boundary).
### Permissions (new keys)
```
yachts:view
yachts:write
yachts:transfer — higher-stakes operation, separate from :write
yachts:delete — archive permission
companies:view
companies:write
companies:delete
memberships:write — covers both directions of company_memberships
reservations:view
reservations:write
```
Existing role updates:
- `admin` — all new keys
- `team_lead``yachts:view`, `yachts:write`, `companies:view`, `companies:write`, `memberships:write`, `reservations:view`; NOT `yachts:transfer` or `reservations:write`
- `front_desk` — all `:view` keys
### Socket / webhook events (new)
```
yacht.created
yacht.updated
yacht.ownership_transferred
yacht.archived
company.created
company.updated
company.archived
company_membership.added
company_membership.ended
berth_reservation.created
berth_reservation.activated
berth_reservation.ended
berth_reservation.cancelled
```
Webhook event map in `src/lib/services/webhooks.ts` gains the same list.
## EOI template strategy (dual-path)
Both paths fully supported from day one. Required to mitigate handoff risk — if Documenso breaks or is replaced, the in-app path is the fallback.
### Shared payload builder
```ts
// src/lib/services/eoi-context.ts
export async function buildEoiContext(interestId: string): Promise<EoiContext>
type EoiContext = {
client: { fullName; nationality; primaryEmail; primaryPhone; address; }
yacht: { name; lengthFt; widthFt; draftFt; hullNumber; flag; yearBuilt; } // via interest.yachtId
company: { name; legalName; taxId; billingAddress } | null // if yacht owner is a company
owner: { type: 'client' | 'company'; name; } // polymorphic current owner
berth: { mooringNumber; area; lengthFt; price; priceCurrency; tenureType; }
interest: { stage; leadCategory; dateFirstContact; notes; }
port: { name; defaultCurrency; legalEntity; }
date: { today; year }
}
```
Both paths consume this. Guarantees the two rendering engines see the same data and stay in sync as schema evolves.
### Path A — Documenso template
- Documenso hosts the template, referenced by ID via env var `DOCUMENSO_TEMPLATE_ID` (matches the old system's `NUXT_DOCUMENSO_TEMPLATE_ID` pattern — a single global template ID; per-port templates are a future extension if needed)
- Payload builder flattens `EoiContext` into Documenso's field-name format, POSTs to `/api/v1/templates/{id}/generate-document`
- Signing flow unchanged: Documenso emails signers, webhook updates status in our DB
- Mitigation for "Documenso's template expects specific field names": one-time audit mapping every field name expected by `templateId=8` (from the old system) to a source in the new schema
### Path B — In-app PDF template
- Seed a "Standard EOI" HTML template into `document_templates` table on first boot. Template references tokens: `{{client.fullName}}`, `{{yacht.name}}`, `{{yacht.lengthFt}}`, `{{company.name}}`, `{{berth.mooringNumber}}`, `{{interest.dateFirstContact}}`, etc.
- `resolveTemplate()` substitutes tokens from `EoiContext`
- `pdfme` renders the resolved HTML to PDF
- **Signing**: generated PDF is uploaded to Documenso via existing `documensoCreate` + `documensoSend` — Documenso supports signing ad-hoc PDFs (not just its own templates). Signing experience identical to Path A from the signer's perspective.
- **Fallback**: if Documenso is unavailable, the PDF can be emailed to the signer via `nodemailer` as a manual fallback (flag in UI, not auto-fallback)
### UI picker
Generate-EOI dialog adds a Template dropdown:
```
Template: [ Documenso — Standard EOI v ]
[ Documenso — Standard EOI ]
[ In-app — Standard EOI ]
[ In-app — (any custom template user authored) ]
```
Explicit picker chosen over automatic fallback: misconfiguration is visible, not silently masked — important for handoff.
## UI impact
### New pages
| Route | Purpose |
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
| `/[portSlug]/yachts` | List view: name, dimensions, current owner, status. Filters by owner type, size, status |
| `/[portSlug]/yachts/[yachtId]` | Detail — Tabs: Overview, Ownership History, Interests, Reservations, Documents, Notes, Tags |
| `/[portSlug]/companies` | List view: name, legal name, # members, # owned yachts |
| `/[portSlug]/companies/[companyId]` | Detail — Tabs: Overview, Members, Owned Yachts, Addresses, Documents, Notes, Tags |
### Modified pages
| Page | Change |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `client-form` | Remove yacht / companyName / proxy fields. Becomes a clean "person" form. Yacht and company associations managed from detail page, not here |
| `client-detail` | Add tabs: Yachts (owned + represented), Companies (active memberships), Reservations |
| `client-columns` | Replace yacht/company text columns with "# yachts" and "Primary company" (from active memberships marked `isPrimary`) |
| `interest-form` | New required field: yacht picker, constrained to client's yachts (with inline "Add new yacht" option) |
| `interest-detail` | Display yacht prominently; berth recommendations match against yacht dimensions |
| `berth-detail` | New tab: Reservations. Shows active reservation + history. "Reserve this berth" button opens reservation dialog |
| `invoice-form` | New billing-entity picker (client or company toggle + autocomplete); `clientName` snapshot populates automatically |
| `eoi-generate-dialog` | New template-picker dropdown (per dual-path strategy) |
| Global search | Extended to yachts and companies |
| Sidebar | Adds "Yachts" and "Companies" entries. Reservations lives inside the Berths page |
| `/api/public/interest` form (new interest submission) | Captures yacht + company sub-forms; creates new trio on submission |
### Portal pages
- Dashboard: shows owned + represented yachts, active memberships, active reservations
- New "My Yachts" tab — read-only yacht detail scoped to ones user owns or represents
- New "My Reservations" tab
- Authenticated interest submissions create yacht row linked to the portal user (not anonymous)
### New components (`src/components/`)
```
yachts/
yacht-form.tsx
yacht-detail.tsx
yacht-detail-header.tsx
yacht-tabs.tsx
yacht-columns.tsx
yacht-picker.tsx
yacht-ownership-history.tsx
yacht-transfer-dialog.tsx
companies/
company-form.tsx
company-detail.tsx
company-detail-header.tsx
company-tabs.tsx
company-columns.tsx
company-picker.tsx
company-members-tab.tsx
company-owned-yachts-tab.tsx
add-membership-dialog.tsx
reservations/
reservation-form.tsx
reservation-list.tsx
berth-reserve-dialog.tsx
shared/
owner-picker.tsx — polymorphic client|company autocomplete
billing-entity-picker.tsx
```
All follow existing `shadcn/ui` + CVA + react-hook-form + zod pattern.
### Seeder (`src/lib/db/seed.ts`) — rewrite
Produces realistic multi-cardinality fixtures:
- 3 companies (two with multiple members, one dissolved with an `endDate` on all memberships)
- 8 clients (some personal-only, some with company memberships, at least one representing multiple companies)
- 12 yachts (mix of client-owned and company-owned; 2-3 with ownership-transfer history)
- Interests linking clients ↔ yachts ↔ berths with realistic pipeline-stage distribution
- A handful of active berth reservations + a few ended/cancelled ones
- Rich contact / address / membership / ownership-history data covering every test scenario
Seeder shares factory helpers with tests (`tests/helpers/factories.ts`).
## Testing strategy
### Coverage targets (CI-enforced)
| Tier | Target |
| ------------- | ------------------- |
| Service layer | ≥ 90% line coverage |
| Validators | 100% line coverage |
| API routes | ≥ 85% line coverage |
| Overall | ≥ 85% line coverage |
Hard rules: no skipped tests on `main`; no PR merge without green CI on all tiers.
### Tier 1 — Unit tests (Vitest)
- Every new service function: happy path, each validation failure, each precondition failure, tenant-scoping
- Merge-field resolver: every new token resolves correctly across each context shape
- Validators: every zod schema tested for pass + fail on each field
### Tier 2 — Integration tests (Vitest + Postgres via docker-compose test DB)
- Migration up/down correctness
- Partial unique indexes (`berth_reservations(berthId) WHERE status='active'`, `yacht_ownership_history(yachtId) WHERE endDate IS NULL`) reject duplicate inserts
- FK cascades: deleting a client cascades contacts/addresses; yacht-with-this-owner is BLOCKED from being lost
- Atomic `transferOwnership`: concurrent retries result in consistent state
- Polymorphic integrity checks: `yacht.currentOwnerType='client'` with a companyId is rejected by service-layer validation
- Company name case-insensitive uniqueness
- Every new API route: auth → permission → service → DB → response shape
### Tier 3 — E2E scenario tests (Playwright)
Full-lifecycle flows:
1. Create client → add yacht → create interest → generate EOI (Documenso path) → PDF in MinIO
2. Same, in-app template path → verify PDF content contains expected yacht name
3. Create company → add two clients as members → create yacht owned by company → generate invoice billed to company
4. Yacht transfer: client-owned → company-owned; verify history + denormalized column + UI
5. Reserve berth: create → verify visible → attempt duplicate reservation → blocked
6. Public interest form → admin sees new client+yacht+company+interest trio
7. (Spec 3 stub): merge flow tested end-to-end in Spec 3
Multi-cardinality flows (the core justification for this refactor):
8. One client with 3 yachts, 3 interests, 3 different berths — all representable
9. One person as broker for 2 companies, each owning 1 yacht — memberships + owned yachts visible from client detail
Portal flows:
10. Portal user views "my yachts" — sees only owned/represented
11. Portal user submits interest — new yacht linked to their identity
### Tier 3.5 — Exhaustive Playwright click-through suite
Location: `tests/e2e/exhaustive/`. Separate CI job (15-20 min, runs in parallel with other tiers, blocks merge if failing).
Spec files: `yachts`, `companies`, `reservations`, `client-detail-refactored`, `eoi-generate`, `invoice-form`, `berths-with-reservations`, `portal`, `navigation`.
Per-page logic:
1. Navigate to page
2. Enumerate every interactive element (`button`, `a`, `[role="button"]`, `[data-testid]`, form inputs)
3. Click/fill each; post-click: assert no console errors, no 4xx/5xx network responses, UI returns to stable state
4. Coverage assertion: elements clicked ≥ total elements on page (minus declared destructive-action allowlist)
Helper: `tests/helpers/click-everything.ts` exports `clickEverythingOnPage(page, opts)`.
Destructive actions allowlist (tested separately with create-then-destroy isolation):
```
yachts.delete, yachts.archive, yachts.transferOwnership
companies.delete, companies.archive
companyMemberships.end
berthReservations.cancel, berthReservations.end
invoices.delete
```
Acceptance criteria for Spec 1 completion:
- Every new or changed page has 100% coverage in the exhaustive suite (minus allowlist)
- Every allowlist entry has its own narrow destructive test
- Zero console errors across the full suite
- Zero unexpected 4xx/5xx responses
### Tier 4 — EOI template regression
- **Documenso payload snapshot test**: mock Documenso API; assert POST body contains every expected field name with correct value sourced from new schema
- **In-app template rendering test**: render seeded template against each scenario's context; assert resolved HTML contains expected substrings; assert `pdfme` produces a non-empty PDF
- **Visual diff**: render in-app EOI to PDF, compare against committed golden-image PDFs per scenario; regressions surface as image diffs in PR
- **Error paths**: missing yacht, missing company with company-owned yacht reference, missing config (Documenso API key missing) — all produce explicit errors, not silent blanks
### Tier 5 — Security tests
- Cross-tenant isolation: yacht/company/reservation in port A invisible/unmodifiable from port B
- Permission enforcement: user without `yachts:write` cannot `POST /yachts`; `yachts:transfer` required for transfer endpoint
- Portal authorization: portal user cannot see yachts they don't own/represent
- Public interest endpoint: anonymous submitter cannot read existing records
### Test infrastructure
Fixture factories in `tests/helpers/factories.ts`:
```
makeYacht({ owner: client|company, ...overrides })
makeCompany({ overrides })
makeMembership({ client, company, role, ...overrides })
makeOwnershipHistoryRow({ yacht, owner, startDate, endDate })
makeReservation({ berth, client, yacht, status })
```
Scenario builders produce Tier 3 multi-cardinality setups in a single call.
Integration tests run against a fresh migrated DB; each test file wraps in a transaction that rolls back OR uses per-file schema isolation.
## Rollout plan
Green-field Postgres DB — no dual-write, no phased migration needed. Concern is only sequencing so the working tree never enters a broken half-migrated state.
### PR sequence (≈ 15 PRs, feature branch `refactor/data-model`)
| # | PR | Depends on |
| --- | --------------------------------------------------------------------------------------------------- | ------------ |
| 1 | Schema migration: add all new tables, leave old client columns in place | — |
| 2 | Service layer: new services (yachts, companies, memberships, reservations) | 1 |
| 3 | API routes for new services + new permissions | 2 |
| 4 | Seeder rewrite with multi-cardinality fixtures | 2 |
| 5 | UI: yacht list + detail + form + picker + ownership-history + transfer-dialog | 3 |
| 6 | UI: company list + detail + form + picker + memberships tab + add-membership dialog | 3 |
| 7 | UI: berth reservations tab + reserve dialog + ownership-transfer wiring | 3 |
| 8 | Client form refactor: strip yacht/company/proxy fields, add nav links to yachts/companies | 5, 6 |
| 9 | Interest form: require `yachtId` + public interest form creates trio | 5 |
| 10 | Invoice billing-entity support (client or company) | 6 |
| 11 | EOI shared payload builder + seed in-app Standard EOI template + dual-path dialog | 5, 6 |
| 12 | Merge-field catalog update + resolver extension for `{{yacht.*}}` / `{{company.*}}` / `{{owner.*}}` | 11 |
| 13 | Drop old columns from `clients` (`yacht*`, `companyName`, proxy fields) | 8, 9, 10, 11 |
| 14 | Exhaustive Playwright click-through suite (Tier 3.5) | 13 |
| 15 | Documentation updates (CLAUDE.md, numbered spec files 01-15, API catalog) | 13 |
After PR 15, merge the feature branch into `main` as one final PR.
## Risks and mitigations
| Risk | Severity | Mitigation |
| -------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Spec 2 (importer) depends on final schema; mid-development schema churn → rework | High | Schema freeze after PR 1 lands; amendments require deliberate spec update |
| Polymorphic owner columns have no DB-level FK — service-layer bug could insert inconsistent owner | Medium | Service-layer validation + integration test for every create/update path; runtime assertion in `buildEoiContext` |
| EOI dual-template drift (two engines produce subtly different output) | Medium | Golden-image visual-diff tests in Tier 4, CI-gated |
| Documenso template at `templateId=8` expects specific field names — new payload builder must match | Medium | One-time audit: document every field the existing template expects; map each to a source in new schema; Spec 2's importer uses same mapping |
| Old `client-portal/` sub-repo coordination during Spec 2 cutover | Low | Confirm old client-portal is decommissioned at Spec 2 cutover (not running concurrently against shared data) |
| Seeder becomes dev-onboarding bottleneck | Low | Seeder uses same factory helpers as tests — code path shared + tested |
| Documentation rot in numbered spec files | Low | PR 15 updates them before the feature branch merges to `main` |
| Exhaustive-click-suite runtime (15-20 min per PR) | Low | Separate CI job, runs in parallel with other tiers |
| Handoff quality — "EOIs don't work" / "I can't see my yachts" | Addressed | Dual template paths + exhaustive click coverage + golden-image diff + template regression tests collectively mitigate |
## Open questions / deferred items
Explicitly out of scope for this spec:
- Yacht survey / class-cert document categorization (requires taxonomy work)
- Multi-level company hierarchy (holding → subsidiary) — additive later
- Invoice line items referencing specific yacht
- Berth reservation auto-renewal flow
- Per-yacht row-level permissions (e.g., "broker can only see yachts they represent")
- Portal branding per company
## Success criteria
Spec 1 is complete when:
1. All PRs in the sequence are merged to `main`
2. CI is green: all coverage gates met, zero skipped tests, exhaustive click-through suite passes
3. Manual verification: developer walks through every multi-cardinality scenario in Tier 3 E2E list against a dev build
4. Both EOI paths produce documents that match the current system's outputs (visual verification + golden images committed)
5. Documentation (CLAUDE.md + numbered spec files) updated
6. Spec 2 (NocoDB+MinIO importer) can begin against a frozen schema

View File

@@ -18,6 +18,12 @@ const nextConfig: NextConfig = {
experimental: {
typedRoutes: true,
},
outputFileTracingIncludes: {
// Bundle the EOI source PDF so the in-app EOI pathway can read it at
// runtime in the standalone build. Reading via fs.readFile from
// process.cwd() requires the file to be traced explicitly.
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
},
};
export default nextConfig;

View File

@@ -14,6 +14,10 @@
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/lib/db/seed.ts",
"test:e2e": "playwright test",
"test:e2e:smoke": "playwright test --project=smoke",
"test:e2e:exhaustive": "playwright test --project=exhaustive",
"test:e2e:destructive": "playwright test --project=destructive",
"prepare": "husky"
},
"dependencies": {
@@ -65,6 +69,7 @@
"next-themes": "^0.4.0",
"nodemailer": "^6.9.0",
"openai": "^6.27.0",
"pdf-lib": "^1.17.1",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"postgres": "^3.4.0",
@@ -91,9 +96,9 @@
"@types/react-dom": "^19.0.0",
"@vitest/coverage-v8": "^4.1.0",
"autoprefixer": "^10.4.27",
"esbuild": "^0.25.0",
"dotenv": "^17.3.1",
"drizzle-kit": "^0.30.0",
"esbuild": "^0.25.0",
"eslint": "^9.0.0",
"eslint-config-next": "15.1.0",
"eslint-config-prettier": "^9.1.0",

View File

@@ -1,7 +1,7 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e/smoke',
testDir: './tests/e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
@@ -22,11 +22,53 @@ export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /global-setup\.ts/,
testMatch: /smoke\/global-setup\.ts/,
},
{
name: 'smoke',
testMatch: /\d{2}-.*\.spec\.ts/,
testMatch: /smoke\/\d{2}-.*\.spec\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
name: 'exhaustive',
testMatch: /exhaustive\/.*\.spec\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
name: 'destructive',
testMatch: /destructive\/.*\.spec\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
// Real-API tests hit live external services (Documenso, IMAP, etc.).
// Opt-in only: pnpm exec playwright test --project=realapi
name: 'realapi',
testMatch: /realapi\/.*\.spec\.ts/,
dependencies: ['setup'],
timeout: 120_000,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
// Visual regression baselines. Regenerate with --update-snapshots after
// intentional UI changes; otherwise pnpm exec playwright test --project=visual
// diffs against the committed PNGs.
name: 'visual',
testMatch: /visual\/.*\.spec\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],

18
pnpm-lock.yaml generated
View File

@@ -152,6 +152,9 @@ importers:
openai:
specifier: ^6.27.0
version: 6.27.0(ws@8.18.3)(zod@3.25.76)
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
pino:
specifier: ^9.5.0
version: 9.14.0
@@ -4417,6 +4420,9 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
@@ -5375,6 +5381,9 @@ packages:
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -9668,6 +9677,13 @@ snapshots:
pathe@2.0.3: {}
pdf-lib@1.17.1:
dependencies:
'@pdf-lib/standard-fonts': 1.0.0
'@pdf-lib/upng': 1.0.1
pako: 1.0.11
tslib: 1.14.1
peberminta@0.9.0: {}
performance-now@2.1.0: {}
@@ -10843,6 +10859,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@1.14.1: {}
tslib@2.8.1: {}
tsx@4.21.0:

66
scripts/dev-imap-probe.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* Dev diagnostic: connect to IMAP and print the most recent ~10 messages,
* showing TO/FROM/subject/date so we can see what the dev mailbox is
* actually receiving.
*
* Run: pnpm tsx scripts/dev-imap-probe.ts
*/
import 'dotenv/config';
import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
async function main(): Promise<void> {
const host = process.env.IMAP_HOST!;
const port = Number(process.env.IMAP_PORT ?? 993);
const user = process.env.IMAP_USER!;
const pass = process.env.IMAP_PASS!;
if (!host || !user || !pass) {
throw new Error('IMAP_HOST / IMAP_USER / IMAP_PASS not set');
}
console.log(`Connecting to ${user}@${host}:${port}`);
const client = new ImapFlow({
host,
port,
secure: port === 993,
auth: { user, pass },
logger: false,
});
await client.connect();
console.log('Connected. Inbox status:');
const lock = await client.getMailboxLock('INBOX');
try {
const status = await client.status('INBOX', { messages: true, recent: true });
console.log(' total:', status.messages, '| recent:', status.recent);
// Pull the last 10 by UID
const since = new Date(Date.now() - 30 * 60 * 1000); // last 30 min
const result = await client.search({ since });
const uids = Array.isArray(result) ? result.slice(-10).reverse() : [];
console.log(`Found ${uids.length} messages in last 30min:`);
for (const uid of uids) {
const msg = await client.fetchOne(String(uid), { source: true, envelope: true });
if (!msg || !msg.source) continue;
const parsed = await simpleParser(msg.source);
const tos = (Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : [])
.flatMap((a) => a.value.map((v) => v.address ?? ''))
.join(', ');
console.log(
` uid=${uid} date=${parsed.date?.toISOString()} from=${parsed.from?.text} to=${tos} subject=${parsed.subject}`,
);
}
} finally {
lock.release();
}
await client.logout();
console.log('Done.');
process.exit(0);
}
main().catch((err) => {
console.error('Probe failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,59 @@
/**
* Dev-only helper: pick an existing client and trigger a portal-invite email.
* The activation email gets routed to EMAIL_REDIRECT_TO (set in .env) regardless
* of the per-portal-user `email` field — so we can use any throwaway address
* here without conflicting with seed data.
*
* Run: pnpm tsx scripts/dev-trigger-portal-invite.ts
*/
import 'dotenv/config';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { portalUsers } from '@/lib/db/schema/portal';
import { createPortalUser } from '@/lib/services/portal-auth.service';
import { env } from '@/lib/env';
import { eq } from 'drizzle-orm';
async function main(): Promise<void> {
if (!env.EMAIL_REDIRECT_TO) {
throw new Error(
'EMAIL_REDIRECT_TO is not set — refusing to send a real activation email to a real client.',
);
}
console.log(`EMAIL_REDIRECT_TO is set: ${env.EMAIL_REDIRECT_TO}`);
const client = await db.query.clients.findFirst({
where: eq(clients.portId, '294c8240-49a7-403e-92e8-fc3a524c00b4'),
});
if (!client) throw new Error('No client found in port-nimara');
// Use the redirect target as the portal user's actual email, so the
// tester can sign in with the same address that received the activation mail.
const portalEmail = env.EMAIL_REDIRECT_TO;
console.log(
`Creating portal user for client ${client.fullName} (${client.id}) with email ${portalEmail}`,
);
// Clear any prior dev-script seed so uniqueness checks don't trip.
await db.delete(portalUsers).where(eq(portalUsers.clientId, client.id));
await db.delete(portalUsers).where(eq(portalUsers.email, portalEmail));
const result = await createPortalUser({
clientId: client.id,
portId: client.portId,
email: portalEmail,
name: client.fullName,
createdBy: 'dev-script',
});
console.log('Portal user created:', result);
console.log(`Activation email enqueued — should arrive at ${portalEmail}.`);
process.exit(0);
}
main().catch((err) => {
console.error('Script failed:', err);
process.exit(1);
});

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { Suspense, 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) },
];
export default function SetPasswordPage() {
function SetPasswordInner() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
@@ -154,8 +154,7 @@ export default function SetPasswordPage() {
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')}
/>
@@ -174,3 +173,18 @@ export default function SetPasswordPage() {
</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>
);
}

View File

@@ -0,0 +1,16 @@
import { CompanyDetail } from '@/components/companies/company-detail';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
interface CompanyDetailPageProps {
params: Promise<{ companyId: string }>;
}
export default async function CompanyDetailPage({ params }: CompanyDetailPageProps) {
const { companyId } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id;
return <CompanyDetail companyId={companyId} currentUserId={currentUserId} />;
}

View File

@@ -0,0 +1,5 @@
import { CompanyList } from '@/components/companies/company-list';
export default function CompaniesPage() {
return <CompanyList />;
}

View File

@@ -19,6 +19,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { OwnerPicker } from '@/components/shared/owner-picker';
import { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
import { apiFetch } from '@/lib/api/client';
import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices';
@@ -55,7 +56,13 @@ export default function NewInvoicePage() {
},
});
const { register, handleSubmit, watch, setValue, formState: { errors } } = methods;
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = methods;
const watchedValues = watch();
const lineItems = watchedValues.lineItems ?? [];
@@ -87,7 +94,7 @@ export default function NewInvoicePage() {
async function goNext() {
if (step === 1) {
const valid = await methods.trigger([
'clientName',
'billingEntity',
'billingEmail',
'billingAddress',
'dueDate',
@@ -112,11 +119,7 @@ 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>
@@ -131,22 +134,16 @@ export default function NewInvoicePage() {
step > s.id
? 'bg-primary text-primary-foreground'
: step === s.id
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{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>
@@ -160,18 +157,29 @@ export default function NewInvoicePage() {
<CardTitle className="text-base">Client Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="clientName">
Client Name <span className="text-destructive">*</span>
<div className="space-y-2">
<Label>
Billing entity <span className="text-destructive">*</span>
</Label>
<Input
id="clientName"
{...register('clientName')}
placeholder="Client or company name"
<OwnerPicker
value={watchedValues.billingEntity ?? null}
onChange={(ref) => {
if (ref) {
setValue('billingEntity', ref, { shouldValidate: true });
}
}}
/>
{errors.clientName && (
<p className="text-xs text-destructive">{errors.clientName.message}</p>
{errors.billingEntity && (
<p className="text-xs text-destructive">
{errors.billingEntity.message ??
errors.billingEntity.id?.message ??
errors.billingEntity.type?.message}
</p>
)}
<p className="text-xs text-muted-foreground">
Select the client or company to invoice. Their name will be snapshotted into the
invoice.
</p>
</div>
<div className="space-y-1">
@@ -202,11 +210,7 @@ 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>
)}
@@ -216,7 +220,9 @@ 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" />
@@ -284,8 +290,19 @@ 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">Client</span>
<p className="font-medium mt-0.5">{watchedValues.clientName}</p>
<span className="text-muted-foreground">Billing Entity</span>
<p className="font-medium mt-0.5">
{watchedValues.billingEntity ? (
<>
<span className="capitalize">{watchedValues.billingEntity.type}</span>{' '}
<span className="text-xs opacity-60">
{watchedValues.billingEntity.id.slice(0, 12)}
</span>
</>
) : (
<span className="text-muted-foreground italic">Not selected</span>
)}
</p>
</div>
<div>
<span className="text-muted-foreground">Due Date</span>
@@ -293,9 +310,7 @@ 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>
@@ -354,12 +369,7 @@ 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>

View File

@@ -0,0 +1,16 @@
import { YachtDetail } from '@/components/yachts/yacht-detail';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
interface YachtDetailPageProps {
params: Promise<{ yachtId: string }>;
}
export default async function YachtDetailPage({ params }: YachtDetailPageProps) {
const { yachtId } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id;
return <YachtDetail yachtId={yachtId} currentUserId={currentUserId} />;
}

View File

@@ -0,0 +1,5 @@
import { YachtList } from '@/components/yachts/yacht-list';
export default function YachtsPage() {
return <YachtList />;
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from 'react';
import { PasswordSetForm } from '@/components/portal/password-set-form';
export default function PortalActivatePage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50 text-sm text-gray-500">
Loading
</div>
}
>
<PasswordSetForm
endpoint="/api/portal/auth/activate"
title="Activate your account"
description="Welcome — choose a password to finish setting up your client portal account."
successTitle="Account activated"
successDescription="You can now sign in with your new password."
submitLabel="Activate account"
/>
</Suspense>
);
}

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
import { Anchor, FileText, Receipt } from 'lucide-react';
import { Anchor, FileText, Receipt, Sailboat, Building2, CalendarCheck } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
@@ -21,15 +21,12 @@ export default async function PortalDashboardPage() {
<h1 className="text-2xl font-semibold text-gray-900">
Welcome back, {dashboard.client.fullName.split(' ')[0]}
</h1>
{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>
{dashboard.client.nationality && (
<p className="text-sm text-gray-400 mt-0.5">{dashboard.client.nationality}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<PortalCard
title="Berth Interests"
value={dashboard.counts.interests}
@@ -51,13 +48,33 @@ 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>

View File

@@ -0,0 +1,107 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { Loader2, Mail } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function PortalForgotPasswordPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
// Always returns 200 — caller never sees whether email exists.
await fetch('/api/portal/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
} finally {
setSubmitted(true);
setLoading(false);
}
}
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
<Mail className="h-7 w-7 text-green-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Check your email</h1>
<p className="text-gray-500 text-sm leading-relaxed">
If <strong>{email}</strong> matches a portal account, we&apos;ve sent a reset link. The
link expires in 30 minutes.
</p>
<Link
href="/portal/login"
className="mt-6 inline-block text-sm text-[#1e2844] hover:underline"
>
Back to sign in
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-lg border p-8 shadow-sm">
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
<p className="text-sm text-gray-500 mt-1">
Enter your email and we&apos;ll send you a reset link.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
disabled={loading}
/>
</div>
<Button
type="submit"
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
disabled={loading || !email}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending
</>
) : (
'Send reset link'
)}
</Button>
</form>
<Link
href="/portal/login"
className="block mt-4 text-center text-xs text-gray-500 hover:underline"
>
Back to sign in
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,15 +1,23 @@
'use client';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { Mail, Loader2 } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
export default function PortalLoginPage() {
const router = useRouter();
const search = useSearchParams();
const next = search.get('next') ?? '/portal/dashboard';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
@@ -18,101 +26,90 @@ export default function PortalLoginPage() {
setLoading(true);
try {
const res = await fetch('/api/portal/auth/request', {
const res = await fetch('/api/portal/auth/sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? 'Something went wrong. Please try again.');
setError((data as { error?: string }).error ?? 'Invalid email or password');
return;
}
setSubmitted(true);
// typedRoutes: `next` is a runtime string we can't statically check.
router.replace(next as never);
router.refresh();
} catch {
setError('Unable to connect. Please check your connection and try again.');
setError('Unable to connect. Please try again.');
} finally {
setLoading(false);
}
}
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
<Mail className="h-7 w-7 text-green-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Check your email</h1>
<p className="text-gray-500 text-sm leading-relaxed">
If <strong>{email}</strong> is associated with a client account, you will receive a
sign-in link shortly. The link expires in 24 hours.
</p>
<button
type="button"
onClick={() => { setSubmitted(false); setEmail(''); }}
className="mt-6 text-sm text-[#1e2844] hover:underline"
>
Try a different email
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-lg border p-8 shadow-sm">
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
<p className="text-sm text-gray-500 mt-1">
Enter your email to receive a sign-in link
</p>
</div>
<PortalAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
<p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
disabled={loading}
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<Button
type="submit"
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
disabled={loading || !email}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending link...
</>
) : (
'Send sign-in link'
)}
</Button>
</form>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
autoComplete="email"
disabled={loading}
/>
</div>
<p className="text-center text-xs text-gray-400 mt-4">
This portal is for existing clients only.
</p>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link href="/portal/forgot-password" className="text-xs text-[#007bff] hover:underline">
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={loading || !email || !password}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Signing in
</>
) : (
'Sign in'
)}
</Button>
</form>
<p className="text-center text-xs text-gray-400 mt-6">
This portal is for existing clients only.
</p>
</PortalAuthShell>
);
}

View File

@@ -0,0 +1,83 @@
import { redirect } from 'next/navigation';
import { CalendarCheck } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getPortalUserReservations } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
export const metadata: Metadata = { title: 'My Reservations' };
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
pending: 'secondary',
active: 'default',
ended: 'outline',
cancelled: 'destructive',
};
const TENURE_LABELS: Record<string, string> = {
permanent: 'Permanent',
fixed_term: 'Fixed term',
seasonal: 'Seasonal',
};
function formatDate(d: Date | string): string {
return new Date(d).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export default async function PortalMyReservationsPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const reservations = await getPortalUserReservations(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">My Reservations</h1>
<p className="text-sm text-gray-500 mt-1">Your current and pending berth reservations</p>
</div>
{reservations.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<CalendarCheck className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No active reservations</p>
<p className="text-sm text-gray-400 mt-1">
Contact your port representative to discuss reservations.
</p>
</div>
) : (
<div className="space-y-3">
{reservations.map((r) => (
<div key={r.id} className="bg-white rounded-lg border p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
{r.berthMooringNumber && (
<span className="text-sm text-gray-400"> Berth {r.berthMooringNumber}</span>
)}
</div>
<p className="text-sm text-gray-500">
{TENURE_LABELS[r.tenureType] ?? r.tenureType}
</p>
<div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-400">
<span>
From {formatDate(r.startDate)}
{r.endDate ? ` to ${formatDate(r.endDate)}` : ' · ongoing'}
</span>
</div>
</div>
<Badge variant={STATUS_COLORS[r.status] ?? 'default'}>{r.status}</Badge>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { redirect } from 'next/navigation';
import { Sailboat } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getPortalUserYachts } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
export const metadata: Metadata = { title: 'My Yachts' };
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
active: 'default',
retired: 'secondary',
sold_away: 'outline',
};
export default async function PortalMyYachtsPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const yachts = await getPortalUserYachts(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">My Yachts</h1>
<p className="text-sm text-gray-500 mt-1">Vessels you own directly or through a company</p>
</div>
{yachts.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Sailboat className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No yachts on file</p>
<p className="text-sm text-gray-400 mt-1">
Yachts owned by you or a company you are a member of will appear here.
</p>
</div>
) : (
<div className="space-y-3">
{yachts.map((y) => (
<div key={y.id} className="bg-white rounded-lg border p-5">
<div className="flex items-start gap-4">
<Sailboat className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">{y.name}</p>
<p className="text-sm text-gray-500 mt-0.5">
{y.hullNumber ? `Hull ${y.hullNumber}` : 'No hull number'}
{y.flag ? ` · ${y.flag}` : ''}
{y.yearBuilt ? ` · ${y.yearBuilt}` : ''}
</p>
{y.ownerContext === 'company' && y.ownerCompanyName && (
<p className="text-xs text-[#1e2844] mt-2">Owned by {y.ownerCompanyName}</p>
)}
</div>
<Badge variant={STATUS_COLORS[y.status] ?? 'default'}>
{y.status.replace(/_/g, ' ')}
</Badge>
</div>
{(y.lengthFt || y.widthFt || y.registration) && (
<div className="flex flex-wrap gap-3 mt-3 text-xs text-gray-400">
{y.registration && <span>Reg: {y.registration}</span>}
{y.lengthFt && <span>Length: {y.lengthFt}ft</span>}
{y.widthFt && <span>Beam: {y.widthFt}ft</span>}
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from 'react';
import { PasswordSetForm } from '@/components/portal/password-set-form';
export default function PortalResetPasswordPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50 text-sm text-gray-500">
Loading
</div>
}
>
<PasswordSetForm
endpoint="/api/portal/auth/reset-password"
title="Choose a new password"
description="Enter a new password to regain access to your client portal."
successTitle="Password updated"
successDescription="You can now sign in with your new password."
submitLabel="Update password"
/>
</Suspense>
);
}

View File

@@ -1,35 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
export default function PortalVerifyPage() {
const router = useRouter();
const searchParams = useSearchParams();
const calledRef = useRef(false);
useEffect(() => {
if (calledRef.current) return;
calledRef.current = true;
const token = searchParams.get('token');
if (!token) {
router.replace('/portal/login?error=missing_token');
return;
}
// Redirect to the verify API route which will set the cookie and redirect
window.location.href = `/api/portal/auth/verify?token=${encodeURIComponent(token)}`;
}, [searchParams, router]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-[#1e2844] mx-auto mb-3" />
<p className="text-sm text-gray-500">Verifying your access...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { activateAccount } from '@/lib/services/portal-auth.service';
const bodySchema = z.object({
token: z.string().min(1),
password: z.string().min(9),
});
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
{ status: 400 },
);
}
try {
await activateAccount(parsed.data.token, parsed.data.password);
return NextResponse.json({ success: true });
} catch (err) {
return errorResponse(err);
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { logger } from '@/lib/logger';
import { requestPasswordReset } from '@/lib/services/portal-auth.service';
const bodySchema = z.object({ email: z.string().email() });
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
}
// Always return 200 to prevent account-enumeration. Errors are logged
// server-side, never surfaced to the client.
try {
await requestPasswordReset(parsed.data.email);
} catch (err) {
logger.error({ err }, 'Portal forgot-password failed (swallowed)');
}
return NextResponse.json({ success: true });
}

View File

@@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { requestMagicLink } from '@/lib/services/portal.service';
import { logger } from '@/lib/logger';
const bodySchema = z.object({
email: z.string().email(),
});
export async function POST(req: NextRequest): Promise<NextResponse> {
try {
const body = await req.json();
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
}
await requestMagicLink(parsed.data.email);
// Always return success to prevent email enumeration
return NextResponse.json({ success: true });
} catch (error) {
logger.error({ error }, 'Portal magic link request failed');
return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { resetPassword } from '@/lib/services/portal-auth.service';
const bodySchema = z.object({
token: z.string().min(1),
password: z.string().min(9),
});
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
{ status: 400 },
);
}
try {
await resetPassword(parsed.data.token, parsed.data.password);
return NextResponse.json({ success: true });
} catch (err) {
return errorResponse(err);
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { PORTAL_COOKIE } from '@/lib/portal/auth';
import { signIn } from '@/lib/services/portal-auth.service';
const bodySchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24; // 24h, matches createPortalToken
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid email or password' }, { status: 400 });
}
try {
const result = await signIn(parsed.data);
const res = NextResponse.json({ success: true });
res.cookies.set(PORTAL_COOKIE, result.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: SESSION_MAX_AGE_SECONDS,
});
return res;
} catch (err) {
return errorResponse(err);
}
}

View File

@@ -1,38 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyPortalToken, PORTAL_COOKIE } from '@/lib/portal/auth';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
export async function GET(req: NextRequest): Promise<NextResponse> {
try {
const token = req.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.redirect(new URL('/portal/login?error=missing_token', env.APP_URL));
}
const session = await verifyPortalToken(token);
if (!session) {
return NextResponse.redirect(new URL('/portal/login?error=invalid_token', env.APP_URL));
}
const response = NextResponse.redirect(new URL('/portal/dashboard', env.APP_URL));
response.cookies.set(PORTAL_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24, // 24 hours
});
logger.info({ clientId: session.clientId }, 'Portal session created');
return response;
} catch (error) {
logger.error({ error }, 'Portal token verification failed');
return NextResponse.redirect(new URL('/portal/login?error=server_error', env.APP_URL));
}
}

View File

@@ -1,11 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { and, eq, isNull, sql } from 'drizzle-orm';
import type { z } from 'zod';
import { db } from '@/lib/db';
import { 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';
@@ -35,7 +39,14 @@ function checkRateLimit(ip: string): void {
entry.count += 1;
}
// POST /api/public/interests — unauthenticated public interest registration
type PublicInterestData = z.infer<typeof publicInterestSchema>;
// `withTransaction` exposes its tx argument as `typeof db` (see lib/db/utils.ts).
// Keep the helper aligned with that.
type Tx = typeof db;
// POST /api/public/interests — unauthenticated public interest registration.
// Creates the trio (client + yacht + interest) plus an optional company +
// membership, all inside a single transaction.
export async function POST(req: NextRequest) {
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
@@ -50,7 +61,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
}
// Resolve the full name
const fullName =
data.firstName && data.lastName
? `${data.firstName} ${data.lastName}`
@@ -58,10 +68,10 @@ export async function POST(req: NextRequest) {
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
// Resolve berth by mooring number (if provided)
// Resolve berth by mooring number (if provided). Read-only lookup — safe
// to do outside the transaction.
let berthId: string | null = null;
let 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)),
@@ -72,74 +82,172 @@ export async function POST(req: NextRequest) {
}
}
// Find or create client by email
let clientId: string;
const existingContact = await db.query.clientContacts.findFirst({
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
});
if (existingContact) {
const existingClient = await db.query.clients.findFirst({
where: eq(clients.id, existingContact.clientId),
// ─── 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).
let clientId: string;
const existingContact = await tx.query.clientContacts.findFirst({
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
});
if (existingClient && existingClient.portId === portId) {
clientId = existingClient.id;
// Update preferred contact method if provided
if (data.preferredContactMethod) {
await db
.update(clients)
.set({ preferredContactMethod: data.preferredContactMethod })
.where(eq(clients.id, clientId));
if (existingContact) {
const existingClient = await tx.query.clients.findFirst({
where: eq(clients.id, existingContact.clientId),
});
if (existingClient && existingClient.portId === portId) {
clientId = existingClient.id;
if (data.preferredContactMethod) {
await tx
.update(clients)
.set({ preferredContactMethod: data.preferredContactMethod })
.where(eq(clients.id, clientId));
}
} else {
clientId = await createClientInTx(tx, portId, fullName, data);
}
} else {
clientId = await createNewClient(portId, fullName, data);
clientId = await createClientInTx(tx, portId, fullName, data);
}
} else {
clientId = await createNewClient(portId, fullName, data);
}
// Store address if provided
if (data.address && Object.values(data.address).some(Boolean)) {
await db.insert(clientAddresses).values({
clientId,
portId,
label: 'Primary',
streetAddress: data.address.street ?? null,
city: data.address.city ?? null,
stateProvince: data.address.stateProvince ?? null,
postalCode: data.address.postalCode ?? null,
country: data.address.country ?? null,
isPrimary: true,
// 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',
});
}
// Create the interest
const [interest] = await db
.insert(interests)
.values({
portId,
// 4. Store address if provided AND no primary address exists yet.
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({
clientId,
portId,
label: 'Primary',
streetAddress: data.address.street ?? null,
city: data.address.city ?? null,
stateProvince: data.address.stateProvince ?? null,
postalCode: data.address.postalCode ?? null,
country: data.address.country ?? null,
isPrimary: true,
});
}
}
// 5. Create interest with yachtId wired up.
const [newInterest] = await tx
.insert(interests)
.values({
portId,
clientId,
berthId,
yachtId,
source: 'website',
pipelineStage: 'open',
notes: data.notes,
})
.returning();
return {
interestId: newInterest!.id,
clientId,
berthId,
source: 'website',
pipelineStage: 'open',
notes: data.notes,
})
.returning();
yachtId,
companyId,
};
});
// ─── Post-commit side-effects (fire-and-forget) ─────────────────────────
void createAuditLog({
userId: null as unknown as string,
portId,
action: 'create',
entityType: 'interest',
entityId: interest!.id,
newValue: { clientId, source: 'website', pipelineStage: 'open', berthId },
entityId: result.interestId,
newValue: {
clientId: result.clientId,
yachtId: result.yachtId,
companyId: result.companyId,
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 },
@@ -148,7 +256,7 @@ export async function POST(req: NextRequest) {
void sendInquiryNotifications({
portId,
portSlug: port?.slug ?? portId,
interestId: interest!.id,
interestId: result.interestId,
clientFullName: fullName,
clientEmail: data.email,
clientPhone: data.phone,
@@ -157,7 +265,7 @@ export async function POST(req: NextRequest) {
});
return NextResponse.json(
{ data: { id: interest!.id, message: 'Interest registered successfully' } },
{ data: { id: result.interestId, message: 'Interest registered successfully' } },
{ status: 201 },
);
} catch (error) {
@@ -165,46 +273,33 @@ export async function POST(req: NextRequest) {
}
}
async function createNewClient(
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function createClientInTx(
tx: Tx,
portId: string,
fullName: string,
data: {
email: string;
phone: string;
companyName?: string;
yachtName?: string;
yachtLengthFt?: number;
yachtWidthFt?: number;
yachtDraftFt?: number;
preferredBerthSize?: string;
preferredContactMethod?: string;
},
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod'>,
): Promise<string> {
const [newClient] = await db
const [newClient] = await tx
.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 db.insert(clientContacts).values({
await tx.insert(clientContacts).values({
clientId,
channel: 'email',
value: data.email,
isPrimary: true,
});
await db.insert(clientContacts).values({
await tx.insert(clientContacts).values({
clientId,
channel: 'phone',
value: data.phone,

View File

@@ -0,0 +1,114 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { requirePermission } from '@/lib/auth/permissions';
import { errorResponse } from '@/lib/errors';
import {
activate,
cancel,
endReservation,
getById,
} from '@/lib/services/berth-reservations.service';
// ─── PATCH body schema (action-based discriminated union) ────────────────────
const patchBodySchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('activate'),
contractFileId: z.string().optional(),
effectiveDate: z.coerce.date().optional(),
}),
z.object({
action: z.literal('end'),
endDate: z.coerce.date(),
notes: z.string().optional(),
}),
z.object({
action: z.literal('cancel'),
reason: z.string().optional(),
}),
]);
// ─── Handlers ────────────────────────────────────────────────────────────────
export const getHandler: RouteHandler = async (_req, ctx, params) => {
try {
const reservation = await getById(params.id!, ctx.portId);
return NextResponse.json({ data: reservation });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, patchBodySchema);
const meta = {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
};
if (body.action === 'activate') {
requirePermission(ctx, 'reservations', 'activate');
const result = await activate(
params.id!,
ctx.portId,
{
contractFileId: body.contractFileId,
effectiveDate: body.effectiveDate,
},
meta,
);
return NextResponse.json({ data: result });
}
if (body.action === 'end') {
// `end` is lifecycle progression; same privilege as activate.
requirePermission(ctx, 'reservations', 'activate');
const result = await endReservation(
params.id!,
ctx.portId,
{ endDate: body.endDate, notes: body.notes },
meta,
);
return NextResponse.json({ data: result });
}
// action === 'cancel'
requirePermission(ctx, 'reservations', 'cancel');
const result = await cancel(params.id!, ctx.portId, { reason: body.reason }, meta);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
};
export const deleteHandler: RouteHandler = async (_req, ctx, params) => {
try {
await cancel(
params.id!,
ctx.portId,
{},
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
// PATCH cannot use `withPermission` wrapper — the required permission depends
// on the `action` field in the body. `requirePermission` is called inside the
// handler after the body is parsed.
export const PATCH = withAuth(patchHandler);
export const DELETE = withAuth(withPermission('reservations', 'cancel', deleteHandler));

View File

@@ -0,0 +1,72 @@
import { and, eq } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { NotFoundError, errorResponse } from '@/lib/errors';
import { createPending, listReservations } from '@/lib/services/berth-reservations.service';
import { createPendingSchema, listReservationsSchema } from '@/lib/validators/reservations';
// URL berthId is authoritative; make body berthId optional (ignored anyway).
const createPendingBodySchema = createPendingSchema
.omit({ berthId: true })
.extend({ berthId: createPendingSchema.shape.berthId.optional() });
async function assertBerthInPort(berthId: string, portId: string): Promise<void> {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
}
export const listHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertBerthInPort(params.id!, ctx.portId);
const query = parseQuery(req, listReservationsSchema);
// URL berthId is authoritative; override any client-supplied value.
const result = await listReservations(ctx.portId, { ...query, berthId: params.id! });
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertBerthInPort(params.id!, ctx.portId);
const body = await parseBody(req, createPendingBodySchema);
// URL berthId is authoritative; any body-supplied berthId is ignored.
const reservation = await createPending(
ctx.portId,
{ ...body, berthId: params.id! },
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: reservation }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
export const POST = withAuth(withPermission('reservations', 'create', createHandler));

View File

@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createPortalUser, resendActivation } from '@/lib/services/portal-auth.service';
import { db } from '@/lib/db';
import { eq } from 'drizzle-orm';
import { portalUsers } from '@/lib/db/schema/portal';
const inviteSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(200).optional(),
});
/**
* POST /api/v1/clients/:id/portal-user
*
* Admin creates a portal account for a client and triggers the activation
* email. Idempotent in spirit: if a portal user already exists for the
* email, returns 409 — the admin can resend the activation via
* ?action=resend.
*/
export const POST = withAuth(
withPermission('clients', 'edit', async (req, ctx, params) => {
try {
const url = new URL(req.url);
const action = url.searchParams.get('action');
if (action === 'resend') {
// Body is optional in resend mode; the portal user id is the path id
// in this case (not the client id). Looking up by client+email so
// admins don't have to track portal-user ids.
const body = await parseBody(req, inviteSchema);
const existing = await db.query.portalUsers.findFirst({
where: eq(portalUsers.email, body.email.toLowerCase().trim()),
});
if (!existing) {
return NextResponse.json({ error: 'Portal user not found' }, { status: 404 });
}
await resendActivation(existing.id, ctx.portId);
return NextResponse.json({ success: true });
}
const body = await parseBody(req, inviteSchema);
const result = await createPortalUser({
clientId: params.id!,
portId: ctx.portId,
email: body.email,
name: body.name,
createdBy: ctx.userId,
});
return NextResponse.json({ data: result }, { status: 201 });
} catch (err) {
return errorResponse(err);
}
}),
);

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { endMembership, updateMembership } from '@/lib/services/company-memberships.service';
import { endMembershipSchema, updateMembershipSchema } from '@/lib/validators/company-memberships';
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateMembershipSchema);
const updated = await updateMembership(params.mid!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
let endDate = new Date();
const text = await req.text();
if (text.length > 0) {
const parsed = endMembershipSchema.parse(JSON.parse(text));
endDate = parsed.endDate;
}
await endMembership(
params.mid!,
ctx.portId,
{ endDate },
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};
export const PATCH = withAuth(withPermission('memberships', 'manage', patchHandler));
export const DELETE = withAuth(withPermission('memberships', 'manage', deleteHandler));

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { setPrimary } from '@/lib/services/company-memberships.service';
export const setPrimaryHandler: RouteHandler = async (_req, ctx, params) => {
try {
const membership = await setPrimary(params.mid!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: membership });
} catch (error) {
return errorResponse(error);
}
};
export const POST = withAuth(withPermission('memberships', 'manage', setPrimaryHandler));

View File

@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { addMembership, listByCompany } from '@/lib/services/company-memberships.service';
import { addMembershipSchema } from '@/lib/validators/company-memberships';
const listQuerySchema = z.object({
activeOnly: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
.default('true'),
});
export const listHandler: RouteHandler = async (req, ctx, params) => {
try {
const { activeOnly } = parseQuery(req, listQuerySchema);
const memberships = await listByCompany(params.id!, ctx.portId, { activeOnly });
return NextResponse.json({ data: memberships });
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, addMembershipSchema);
const membership = await addMembership(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: membership }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};
export const GET = withAuth(withPermission('memberships', 'view', listHandler));
export const POST = withAuth(withPermission('memberships', 'manage', createHandler));

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getCompanyById, updateCompany, archiveCompany } from '@/lib/services/companies.service';
import { updateCompanySchema } from '@/lib/validators/companies';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const company = await getCompanyById(params.id!, ctx.portId);
return NextResponse.json({ data: company });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateCompanySchema);
const updated = await updateCompany(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveCompany(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};
export const GET = withAuth(withPermission('companies', 'view', getHandler));
export const PATCH = withAuth(withPermission('companies', 'edit', patchHandler));
export const DELETE = withAuth(withPermission('companies', 'delete', deleteHandler));

View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { autocomplete } from '@/lib/services/companies.service';
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
try {
const q = req.nextUrl.searchParams.get('q');
if (!q) {
return NextResponse.json({ data: [] });
}
const companies = await autocomplete(ctx.portId, q);
return NextResponse.json({ data: companies });
} catch (error) {
return errorResponse(error);
}
};
export const GET = withAuth(withPermission('companies', 'view', autocompleteHandler));

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listCompanies, createCompany } from '@/lib/services/companies.service';
import { listCompaniesSchema, createCompanySchema } from '@/lib/validators/companies';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const query = parseQuery(req, listCompaniesSchema);
const result = await listCompanies(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createCompanySchema);
const company = await createCompany(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: company }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};
export const GET = withAuth(withPermission('companies', 'view', listHandler));
export const POST = withAuth(withPermission('companies', 'create', createHandler));

View File

@@ -11,7 +11,7 @@ export const POST = withAuth(
try {
const body = await parseBody(req, generateAndSignSchema);
const result = await generateAndSign(
params.id!,
params.id === 'documenso-template' ? null : params.id!,
ctx.portId,
{
clientId: body.clientId,
@@ -19,6 +19,7 @@ export const POST = withAuth(
berthId: body.berthId,
},
body.signers,
body.pathway,
{
userId: ctx.userId,
portId: ctx.portId,

View File

@@ -1,24 +0,0 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { generateEoi } from '@/lib/services/documents.service';
import { generateEoiSchema } from '@/lib/validators/documents';
export const POST = withAuth(
withPermission('documents', 'create', async (req, ctx) => {
try {
const body = await parseBody(req, generateEoiSchema);
const doc = await generateEoi(body.interestId, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: doc }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { listOwnershipHistory } from '@/lib/services/yachts.service';
export const historyHandler: RouteHandler = async (req, ctx, params) => {
try {
const history = await listOwnershipHistory(params.id!, ctx.portId);
return NextResponse.json({ data: history });
} catch (error) {
return errorResponse(error);
}
};
export const GET = withAuth(withPermission('yachts', 'view', historyHandler));

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getYachtById, updateYacht, archiveYacht } from '@/lib/services/yachts.service';
import { updateYachtSchema } from '@/lib/validators/yachts';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const yacht = await getYachtById(params.id!, ctx.portId);
return NextResponse.json({ data: yacht });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateYachtSchema);
const updated = await updateYacht(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveYacht(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
export const PATCH = withAuth(withPermission('yachts', 'edit', patchHandler));
export const DELETE = withAuth(withPermission('yachts', 'delete', deleteHandler));

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { transferOwnership } from '@/lib/services/yachts.service';
import { transferOwnershipSchema } from '@/lib/validators/yachts';
export const transferHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, transferOwnershipSchema);
const yacht = await transferOwnership(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: yacht });
} catch (error) {
return errorResponse(error);
}
};
export const POST = withAuth(withPermission('yachts', 'transfer', transferHandler));

View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { autocomplete } from '@/lib/services/yachts.service';
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
try {
const q = req.nextUrl.searchParams.get('q');
if (!q) {
return NextResponse.json({ data: [] });
}
const yachts = await autocomplete(ctx.portId, q);
return NextResponse.json({ data: yachts });
} catch (error) {
return errorResponse(error);
}
};
export const GET = withAuth(withPermission('yachts', 'view', autocompleteHandler));

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listYachts, createYacht } from '@/lib/services/yachts.service';
import { listYachtsSchema, createYachtSchema } from '@/lib/validators/yachts';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const query = parseQuery(req, listYachtsSchema);
const result = await listYachts(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createYachtSchema);
const yacht = await createYacht(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: yacht }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};
export const GET = withAuth(withPermission('yachts', 'view', listHandler));
export const POST = withAuth(withPermission('yachts', 'create', createHandler));

View File

@@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from 'next/server';
import { createHash } from 'crypto';
import { db } from '@/lib/db';
import { verifyDocumensoSignature } from '@/lib/services/documenso-webhook';
import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
import {
handleRecipientSigned,
handleDocumentCompleted,
handleDocumentExpired,
handleDocumentOpened,
handleDocumentRejected,
handleDocumentCancelled,
} from '@/lib/services/documents.service';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
@@ -14,39 +16,58 @@ import { logger } from '@/lib/logger';
// BR-024: Dedup via signatureHash unique index on documentEvents
// Always return 200 from webhook (webhook best practice)
// Documenso emits Prisma enum names on the wire (e.g. "DOCUMENT_SIGNED").
// The UI displays them as lowercase-dotted ("document.signed") but the JSON
// body uses the enum value as-is. Normalize both forms in case 2.x ever flips.
function canonicalizeEvent(event: string): string {
return event.toUpperCase().replace(/\./g, '_');
}
type DocumensoRecipient = {
email: string;
signingStatus?: string;
readStatus?: string;
signedAt?: string | null;
};
type DocumensoWebhookBody = {
event: string;
payload: {
id: number | string;
recipients?: DocumensoRecipient[];
};
};
export async function POST(req: NextRequest): Promise<NextResponse> {
let payload: string;
let rawBody: string;
try {
payload = await req.text();
rawBody = await req.text();
} catch {
return NextResponse.json({ ok: false }, { status: 200 });
}
// Verify HMAC signature
const signature = req.headers.get('x-documenso-signature') ?? '';
// Documenso v1.13 + 2.x send the secret in plaintext via X-Documenso-Secret.
const providedSecret = req.headers.get('x-documenso-secret') ?? '';
if (!verifyDocumensoSignature(payload, signature, env.DOCUMENSO_WEBHOOK_SECRET)) {
logger.warn({ signature }, 'Invalid Documenso webhook signature');
return NextResponse.json({ ok: false, error: 'Invalid signature' }, { status: 200 });
if (!verifyDocumensoSecret(providedSecret, env.DOCUMENSO_WEBHOOK_SECRET)) {
logger.warn({ providedLen: providedSecret.length }, 'Invalid Documenso webhook secret');
return NextResponse.json({ ok: false, error: 'Invalid secret' }, { status: 200 });
}
// Compute deduplication hash
const signatureHash = createHash('sha256').update(payload).digest('hex');
const signatureHash = createHash('sha256').update(rawBody).digest('hex');
let parsed: { type: string; payload: Record<string, unknown> };
let parsed: DocumensoWebhookBody;
try {
parsed = JSON.parse(payload) as typeof parsed;
parsed = JSON.parse(rawBody) as DocumensoWebhookBody;
} catch {
logger.warn('Failed to parse Documenso webhook payload');
return NextResponse.json({ ok: false }, { status: 200 });
}
// Dedup: try to insert a sentinel documentEvent with signatureHash
// We need a documentId — if dedup fails at this stage we can't easily check.
// Instead, store the hash lookup on the first real documentEvent insert in handlers.
// Here we just check if this hash was already seen in any event.
// Replay guard: if any event with this hash already exists, skip.
try {
const existing = await db.query.documentEvents.findFirst({
where: (de, { eq }) => eq(de.signatureHash, signatureHash),
@@ -60,33 +81,69 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
logger.error({ err }, 'Failed to check duplicate webhook');
}
const event = canonicalizeEvent(parsed.event);
const documensoId = String(parsed.payload?.id ?? '');
const recipients = parsed.payload?.recipients ?? [];
if (!documensoId) {
logger.warn({ event }, 'Documenso webhook missing payload.id');
return NextResponse.json({ ok: true }, { status: 200 });
}
try {
switch (parsed.type) {
case 'RECIPIENT_SIGNED':
await handleRecipientSigned({
documentId: parsed.payload.documentId as string,
recipientEmail: parsed.payload.recipientEmail as string,
switch (event) {
case 'DOCUMENT_SIGNED':
case 'DOCUMENT_RECIPIENT_COMPLETED': {
// v1.13 fires DOCUMENT_SIGNED per recipient sign;
// 2.x fires DOCUMENT_RECIPIENT_COMPLETED for the same semantics.
const signedRecipients = recipients.filter(
(r) => r.signingStatus === 'SIGNED' || Boolean(r.signedAt),
);
for (const r of signedRecipients) {
await handleRecipientSigned({
documentId: documensoId,
recipientEmail: r.email,
signatureHash: `${signatureHash}:signed:${r.email}`,
});
}
break;
}
case 'DOCUMENT_OPENED': {
const openedRecipients = recipients.filter((r) => r.readStatus === 'OPENED');
for (const r of openedRecipients) {
await handleDocumentOpened({
documentId: documensoId,
recipientEmail: r.email,
signatureHash: `${signatureHash}:opened:${r.email}`,
});
}
break;
}
case 'DOCUMENT_COMPLETED':
await handleDocumentCompleted({ documentId: documensoId });
break;
case 'DOCUMENT_REJECTED': {
const rejecting = recipients.find((r) => r.signingStatus === 'REJECTED');
await handleDocumentRejected({
documentId: documensoId,
recipientEmail: rejecting?.email,
signatureHash,
});
break;
}
case 'DOCUMENT_COMPLETED':
await handleDocumentCompleted({
documentId: parsed.payload.documentId as string,
});
break;
case 'DOCUMENT_EXPIRED':
await handleDocumentExpired({
documentId: parsed.payload.documentId as string,
});
case 'DOCUMENT_CANCELLED':
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
break;
default:
logger.info({ type: parsed.type }, 'Unhandled Documenso webhook event type');
logger.info({ event }, 'Unhandled Documenso webhook event type');
}
} catch (err) {
logger.error({ err, type: parsed.type }, 'Error processing Documenso webhook');
logger.error({ err, event }, 'Error processing Documenso webhook');
}
return NextResponse.json({ ok: true }, { status: 200 });

View File

@@ -0,0 +1,90 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PermissionGate } from '@/components/shared/permission-gate';
import { EmptyState } from '@/components/shared/empty-state';
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
import { BerthReserveDialog } from '@/components/reservations/berth-reserve-dialog';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
interface BerthReservationsTabProps {
berthId: string;
}
export function BerthReservationsTab({ berthId }: BerthReservationsTabProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const [reserveOpen, setReserveOpen] = useState(false);
const { data, isLoading } = useQuery<{ data: ReservationRow[]; pagination?: unknown }>({
queryKey: ['berths', berthId, 'reservations'],
queryFn: () =>
apiFetch(
`/api/v1/berths/${berthId}/reservations?page=1&limit=50&order=desc&includeArchived=false`,
),
});
useRealtimeInvalidation({
'berth_reservation:created': [['berths', berthId, 'reservations']],
'berth_reservation:activated': [['berths', berthId, 'reservations']],
'berth_reservation:ended': [['berths', berthId, 'reservations']],
'berth_reservation:cancelled': [['berths', berthId, 'reservations']],
});
const reservations = data?.data ?? [];
const active = reservations.find((r) => r.status === 'active');
const history = reservations.filter((r) => r.status !== 'active');
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Reservations</h3>
<PermissionGate resource="reservations" action="create">
<Button size="sm" onClick={() => setReserveOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
Reserve this berth
</Button>
</PermissionGate>
</div>
{/* Active reservation card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Active reservation</CardTitle>
</CardHeader>
<CardContent>
{active ? (
<ReservationList reservations={[active]} portSlug={portSlug} />
) : (
<p className="text-sm text-muted-foreground">No active reservation.</p>
)}
</CardContent>
</Card>
{/* History */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">History</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : history.length === 0 ? (
<EmptyState title="No past reservations" description="Nothing here yet." />
) : (
<ReservationList reservations={history} portSlug={portSlug} />
)}
</CardContent>
</Card>
<BerthReserveDialog open={reserveOpen} onOpenChange={setReserveOpen} berthId={berthId} />
</div>
);
}

View File

@@ -3,6 +3,7 @@
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;
@@ -87,7 +88,10 @@ 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={
@@ -179,6 +183,11 @@ 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',

View File

@@ -18,7 +18,7 @@ import { TagBadge } from '@/components/shared/tag-badge';
export interface ClientRow {
id: string;
fullName: string;
companyName: string | null;
nationality: string | null;
source: string | null;
archivedAt: string | null;
createdAt: string;
@@ -39,6 +39,10 @@ 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,
@@ -59,14 +63,6 @@ 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',
@@ -82,6 +78,14 @@ export function getClientColumns({
);
},
},
{
id: 'nationality',
accessorKey: 'nationality',
header: 'Nationality',
cell: ({ getValue }) => (
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span>
),
},
{
id: 'source',
accessorKey: 'source',
@@ -149,10 +153,7 @@ 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>

View File

@@ -0,0 +1,103 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { format } from 'date-fns';
import {
Table,
TableHeader,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/shared/empty-state';
interface ClientCompaniesTabProps {
clientId: string;
companies: Array<{
membershipId: string;
role: string;
isPrimary: boolean;
startDate: string | Date;
company: {
id: string;
name: string;
legalName: string | null;
status: string;
};
}>;
}
function formatSince(startDate: string | Date): string {
const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
if (Number.isNaN(d.getTime())) return '—';
return format(d, 'MMM d, yyyy');
}
export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCompaniesTabProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
if (companies.length === 0) {
return (
<EmptyState
title="No company memberships"
description="This client is not affiliated with any companies yet. Add a membership from a company's detail page."
/>
);
}
return (
<div className="space-y-4">
<h3 className="text-sm font-medium">Company affiliations</h3>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Company</TableHead>
<TableHead>Role</TableHead>
<TableHead>Primary</TableHead>
<TableHead>Since</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.map((m) => (
<TableRow key={m.membershipId}>
<TableCell>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/companies/${m.company.id}` as any}
className="text-primary hover:underline"
>
{m.company.name}
</Link>
{m.company.legalName && (
<span className="ml-2 text-xs text-muted-foreground">
({m.company.legalName})
</span>
)}
</TableCell>
<TableCell className="capitalize">{m.role.replace('_', ' ')}</TableCell>
<TableCell>
{m.isPrimary ? (
<Badge variant="secondary" className="text-xs">
Primary
</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatSince(m.startDate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -9,19 +9,14 @@ import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { ClientForm } from '@/components/clients/client-form';
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
import { apiFetch } from '@/lib/api/client';
interface ClientDetailHeaderProps {
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;
@@ -36,13 +31,7 @@ interface ClientDetailHeaderProps {
type ClientFormClient = {
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;
@@ -67,8 +56,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const isArchived = !!client.archivedAt;
const archiveMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }),
mutationFn: () => apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
queryClient.invalidateQueries({ queryKey: ['clients'] });
@@ -77,8 +65,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
});
const restoreMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }),
mutationFn: () => apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
queryClient.invalidateQueries({ queryKey: ['clients'] });
@@ -86,10 +73,12 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
},
});
const primaryEmail = client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)
?? client.contacts?.find((c) => c.channel === 'email');
const primaryPhone = client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary)
?? client.contacts?.find((c) => c.channel === 'phone');
const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'email');
const primaryPhone =
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'phone');
return (
<>
@@ -97,23 +86,14 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
<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">
{client.fullName}
</h1>
<h1 className="text-2xl font-bold text-foreground truncate">{client.fullName}</h1>
{isArchived && (
<Badge variant="secondary" className="text-xs">Archived</Badge>
)}
{client.isProxy && (
<Badge variant="outline" className="text-xs capitalize">
Proxy {client.proxyType ? `(${client.proxyType.replace('_', ' ')})` : ''}
<Badge variant="secondary" className="text-xs">
Archived
</Badge>
)}
</div>
{client.companyName && (
<p className="text-muted-foreground mt-0.5">{client.companyName}</p>
)}
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
{client.source && (
<span>
@@ -148,11 +128,14 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
>
{!isArchived && (
<PortalInviteButton
clientId={client.id}
clientName={client.fullName}
defaultEmail={primaryEmail?.value}
/>
)}
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>

View File

@@ -12,19 +12,7 @@ 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;
@@ -46,6 +34,35 @@ 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 {
@@ -64,11 +81,15 @@ 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

View File

@@ -24,11 +24,6 @@ export const clientFilterDefinitions: FilterDefinition[] = [
type: 'text',
placeholder: 'Filter by nationality...',
},
{
key: 'isProxy',
label: 'Proxy Client',
type: 'boolean',
},
{
key: 'includeArchived',
label: 'Include Archived',

View File

@@ -16,13 +16,7 @@ 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';
@@ -36,13 +30,7 @@ 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;
@@ -53,6 +41,7 @@ interface ClientFormProps {
value: string;
label?: string | null;
isPrimary?: boolean;
notes?: string | null;
}>;
tags?: Array<{ id: string }>;
};
@@ -75,13 +64,11 @@ 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
@@ -89,14 +76,10 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
if (client && open) {
reset({
fullName: client.fullName,
companyName: client.companyName ?? undefined,
nationality: client.nationality ?? 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,
preferredContactMethod:
(client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ??
undefined,
preferredLanguage: client.preferredLanguage ?? undefined,
timezone: client.timezone ?? undefined,
source: (client.source as CreateClientInput['source']) ?? undefined,
@@ -108,6 +91,7 @@ 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) ?? [],
@@ -116,7 +100,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
reset({
fullName: '',
contacts: [{ channel: 'email', value: '', isPrimary: true }],
isProxy: false,
tagIds: [],
});
}
@@ -151,10 +134,7 @@ 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">
@@ -170,11 +150,6 @@ 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" />
@@ -194,9 +169,7 @@ 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
@@ -218,7 +191,10 @@ 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">
@@ -254,9 +230,7 @@ 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>
@@ -281,72 +255,6 @@ 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">
@@ -357,7 +265,9 @@ 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" />
@@ -374,7 +284,9 @@ 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" />
@@ -396,10 +308,7 @@ 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>
@@ -409,18 +318,11 @@ 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}>

View File

@@ -0,0 +1,51 @@
'use client';
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
interface ClientReservationsTabProps {
clientId: string;
activeReservations: Array<{
id: string;
berthId: string;
yachtId: string;
startDate: string | Date;
tenureType: string;
status: string;
}>;
}
export function ClientReservationsTab({
clientId,
activeReservations,
}: ClientReservationsTabProps) {
const rows: ReservationRow[] = activeReservations.map((r) => ({
id: r.id,
berthId: r.berthId,
portId: '', // not rendered by ReservationList
clientId,
yachtId: r.yachtId,
status: r.status as ReservationRow['status'],
startDate: typeof r.startDate === 'string' ? r.startDate : r.startDate.toISOString(),
endDate: null,
tenureType: r.tenureType,
contractFileId: null,
notes: null,
createdAt: '',
}));
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium">Active reservations</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Showing currently active reservations. History is coming soon.
</p>
</div>
<ReservationList
reservations={rows}
showBerth
emptyMessage="This client has no active reservations."
/>
</div>
);
}

View File

@@ -2,22 +2,16 @@
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;
@@ -30,6 +24,36 @@ 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 }>;
};
}
@@ -51,14 +75,10 @@ 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>
@@ -72,18 +92,12 @@ 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>
)}
{c.isPrimary && (
<span className="text-xs font-medium text-primary">Primary</span>
<span className="text-xs text-muted-foreground capitalize">{c.label}</span>
)}
{c.isPrimary && <span className="text-xs font-medium text-primary">Primary</span>}
</div>
))}
</div>
@@ -92,41 +106,6 @@ 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">
@@ -138,34 +117,54 @@ function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
</div>
)}
{/* Proxy Info */}
{client.isProxy && (
{/* Tags */}
{client.tags && client.tags.length > 0 && (
<div className="space-y-1">
<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>
<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>
</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',
@@ -178,13 +177,7 @@ export function getClientTabs({
{
id: 'notes',
label: 'Notes',
content: (
<NotesList
entityType="clients"
entityId={clientId}
currentUserId={currentUserId}
/>
),
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
},
{
id: 'files',

View File

@@ -0,0 +1,97 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Table,
TableHeader,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@/components/ui/table';
import { EmptyState } from '@/components/shared/empty-state';
import { PermissionGate } from '@/components/shared/permission-gate';
import { YachtForm } from '@/components/yachts/yacht-form';
interface ClientYachtsTabProps {
clientId: string;
yachts: Array<{
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
lengthFt: string | null;
widthFt: string | null;
status: string;
}>;
}
export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTabProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const [createOpen, setCreateOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Client-owned yachts</h3>
<PermissionGate resource="yachts" action="create">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
Add yacht
</Button>
</PermissionGate>
</div>
{yachts.length === 0 ? (
<EmptyState title="No yachts" description="No yachts owned by this client yet." />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Dimensions</TableHead>
<TableHead>Hull Number</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{yachts.map((y) => (
<TableRow key={y.id}>
<TableCell>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/yachts/${y.id}` as any}
className="text-primary hover:underline"
>
{y.name}
</Link>
</TableCell>
<TableCell>
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '—'}
</TableCell>
<TableCell>{y.hullNumber ?? '—'}</TableCell>
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/*
TODO: YachtForm (Task 5.2) does not yet accept a preset owner prop.
When opened here, the user must manually pick this client in the owner
picker. Wire an `initialOwner` prop into YachtForm in a follow-up so
we can pre-select `{ type: 'client', id: clientId }`.
*/}
{createOpen && <YachtForm open={createOpen} onOpenChange={setCreateOpen} />}
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { useState } from 'react';
import { UserPlus, Loader2, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client';
interface PortalInviteButtonProps {
clientId: string;
clientName: string;
defaultEmail?: string;
}
/**
* Admin button on the client detail header that creates a portal user for
* the client and sends them an activation email. Uses the client's primary
* email as the default but lets the admin override.
*/
export function PortalInviteButton({
clientId,
clientName,
defaultEmail,
}: PortalInviteButtonProps) {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState(defaultEmail ?? '');
const [name, setName] = useState(clientName);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
function reset() {
setEmail(defaultEmail ?? '');
setName(clientName);
setError('');
setSuccess(false);
setLoading(false);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
try {
await apiFetch(`/api/v1/clients/${clientId}/portal-user`, {
method: 'POST',
body: { email, name },
});
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send invitation');
} finally {
setLoading(false);
}
}
return (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
reset();
setOpen(true);
}}
>
<UserPlus className="mr-1.5 h-3.5 w-3.5" />
Invite to portal
</Button>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) reset();
setOpen(o);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Invite to client portal</DialogTitle>
<DialogDescription>
We&apos;ll email an activation link. The client picks their own password from there.
</DialogDescription>
</DialogHeader>
{success ? (
<div className="py-4 flex items-center gap-3 text-sm text-green-700">
<Check className="h-5 w-5" />
Activation email sent to <strong>{email}</strong>.
</div>
) : (
<form onSubmit={submit} className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="invite-email">Email address</Label>
<Input
id="invite-email"
type="email"
required
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
placeholder="client@example.com"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="invite-name">Display name (optional)</Label>
<Input
id="invite-name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</form>
)}
<DialogFooter>
{success ? (
<Button onClick={() => setOpen(false)}>Done</Button>
) : (
<>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={submit} disabled={loading || !email}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending
</>
) : (
'Send invitation'
)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,220 @@
'use client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { ClientPicker } from '@/components/shared/client-picker';
import { apiFetch } from '@/lib/api/client';
import { ROLES } from '@/lib/validators/company-memberships';
type RoleEnum = (typeof ROLES)[number];
type FormValues = {
clientId: string | null;
role: RoleEnum;
roleDetail?: string;
startDate: string; // YYYY-MM-DD
isPrimary: boolean;
notes?: string;
};
interface AddMembershipDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companyId: string;
}
const todayIso = (): string => new Date().toISOString().slice(0, 10);
const ROLE_LABEL: Record<RoleEnum, string> = {
director: 'Director',
officer: 'Officer',
broker: 'Broker',
representative: 'Representative',
legal_counsel: 'Legal counsel',
employee: 'Employee',
shareholder: 'Shareholder',
other: 'Other',
};
export function AddMembershipDialog({ open, onOpenChange, companyId }: AddMembershipDialogProps) {
const queryClient = useQueryClient();
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
defaultValues: {
clientId: null,
role: 'director',
roleDetail: '',
startDate: todayIso(),
isPrimary: false,
notes: '',
},
});
useEffect(() => {
if (open) {
setFormError(null);
reset({
clientId: null,
role: 'director',
roleDetail: '',
startDate: todayIso(),
isPrimary: false,
notes: '',
});
}
}, [open, reset]);
const clientId = watch('clientId');
const role = watch('role');
const isPrimary = watch('isPrimary');
const mutation = useMutation({
mutationFn: async (data: FormValues) => {
if (!data.clientId) {
throw new Error('Please select a client');
}
await apiFetch(`/api/v1/companies/${companyId}/members`, {
method: 'POST',
body: {
clientId: data.clientId,
role: data.role,
roleDetail: data.roleDetail?.trim() || undefined,
startDate: data.startDate,
isPrimary: data.isPrimary,
notes: data.notes?.trim() || undefined,
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies', companyId, 'members'] });
onOpenChange(false);
},
onError: (err: unknown) => {
let msg = err instanceof Error ? err.message : 'Failed to add membership';
// Detect 409 — service returns a "membership already exists" message
if (/already exists/i.test(msg)) {
msg = 'This membership already exists (same client + role + start date).';
}
setFormError(msg);
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add member</DialogTitle>
<DialogDescription>
Associate a client with this company in a specific role.
</DialogDescription>
</DialogHeader>
<form
onSubmit={handleSubmit((data) => {
setFormError(null);
mutation.mutate(data);
})}
className="space-y-4"
>
<div className="space-y-2">
<Label>Client</Label>
<ClientPicker value={clientId} onChange={(id) => setValue('clientId', id)} />
{!clientId && errors.clientId && <p className="text-xs text-destructive">Required</p>}
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={role} onValueChange={(v) => setValue('role', v as RoleEnum)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLES.map((r) => (
<SelectItem key={r} value={r}>
{ROLE_LABEL[r]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="roleDetail">Role detail (optional)</Label>
<Input
id="roleDetail"
{...register('roleDetail')}
placeholder="e.g. Chief Investment Officer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="startDate">Start date</Label>
<Input id="startDate" type="date" {...register('startDate', { required: true })} />
{errors.startDate && <p className="text-xs text-destructive">Required</p>}
</div>
<div className="flex items-center gap-2">
<Checkbox
id="isPrimary"
checked={isPrimary}
onCheckedChange={(v) => setValue('isPrimary', v === true)}
/>
<Label htmlFor="isPrimary" className="cursor-pointer">
Set as primary contact
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes (optional)</Label>
<Textarea id="notes" rows={2} {...register('notes')} />
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
{(isSubmitting || mutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Add member
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import Link from 'next/link';
import { MoreHorizontal, Pencil, Archive, Eye } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
// TODO: add member/yacht counts once the list endpoint returns them via a join.
export interface CompanyRow {
id: string;
name: string;
legalName: string | null;
taxId: string | null;
registrationNumber: string | null;
incorporationCountry: string | null;
incorporationDate: string | null;
status: string;
billingEmail: string | null;
notes: string | null;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800 border-green-300',
dissolved: 'bg-red-100 text-red-800 border-red-300',
};
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
dissolved: 'Dissolved',
};
interface GetCompanyColumnsOptions {
portSlug: string;
onEdit: (company: CompanyRow) => void;
onArchive: (company: CompanyRow) => void;
}
export function getCompanyColumns({
portSlug,
onEdit,
onArchive,
}: GetCompanyColumnsOptions): ColumnDef<CompanyRow, unknown>[] {
return [
{
id: 'name',
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/companies/${row.original.id}` as any}
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.name}
</Link>
),
},
{
id: 'legalName',
accessorKey: 'legalName',
header: 'Legal Name',
enableSorting: false,
cell: ({ getValue }) => {
const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>;
return <span className="text-sm">{value}</span>;
},
},
{
id: 'taxId',
accessorKey: 'taxId',
header: 'Tax ID',
enableSorting: false,
cell: ({ getValue }) => {
const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>;
return <span className="text-sm">{value}</span>;
},
},
{
id: 'status',
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.original.status;
const label = STATUS_LABELS[status] ?? status;
const color = STATUS_COLORS[status] ?? 'bg-muted text-muted-foreground border-muted';
return (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${color}`}
>
{label}
</span>
);
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/companies/${row.original.id}` as any}
>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
}

View File

@@ -0,0 +1,153 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { CompanyForm } from '@/components/companies/company-form';
import { apiFetch } from '@/lib/api/client';
interface CompanyDetailHeaderCompany {
id: string;
name: string;
legalName: string | null;
taxId: string | null;
registrationNumber: string | null;
incorporationCountry: string | null;
incorporationDate: string | null;
status: string;
billingEmail: string | null;
notes: string | null;
archivedAt: string | null;
}
interface CompanyDetailHeaderProps {
company: CompanyDetailHeaderCompany;
}
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800 border-green-300',
dissolved: 'bg-red-100 text-red-800 border-red-300',
};
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
dissolved: 'Dissolved',
};
export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
const queryClient = useQueryClient();
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const isArchived = !!company.archivedAt;
const showLegalName = company.legalName && company.legalName !== company.name;
const archiveMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/companies/${company.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies', company.id] });
queryClient.invalidateQueries({ queryKey: ['companies'] });
toast.success('Company archived');
setArchiveOpen(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/${portSlug}/companies` as any);
},
onError: (err: Error) => {
toast.error(err.message || 'Failed to archive company');
},
});
const statusLabel = STATUS_LABELS[company.status] ?? company.status;
const statusColor =
STATUS_COLORS[company.status] ?? 'bg-muted text-muted-foreground border-muted';
return (
<>
<div className="space-y-3">
<div className="flex items-start gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold text-foreground truncate">{company.name}</h1>
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
>
{statusLabel}
</span>
{isArchived && (
<Badge variant="secondary" className="text-xs">
Archived
</Badge>
)}
</div>
<div className="mt-1 space-y-0.5 text-sm text-muted-foreground">
{showLegalName && <p>{company.legalName}</p>}
{company.taxId && <p>Tax ID: {company.taxId}</p>}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<PermissionGate resource="companies" action="edit">
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
</PermissionGate>
<PermissionGate resource="companies" action="delete">
<Button
variant="outline"
size="sm"
onClick={() => setArchiveOpen(true)}
disabled={isArchived}
>
<Archive className="mr-1.5 h-3.5 w-3.5" />
Archive
</Button>
</PermissionGate>
</div>
</div>
</div>
<CompanyForm
open={editOpen}
onOpenChange={setEditOpen}
company={{
id: company.id,
name: company.name,
legalName: company.legalName,
taxId: company.taxId,
registrationNumber: company.registrationNumber,
incorporationCountry: company.incorporationCountry,
incorporationDate: company.incorporationDate,
status: company.status,
billingEmail: company.billingEmail,
notes: company.notes,
}}
/>
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
entityName={company.name}
entityType="Company"
isArchived={isArchived}
onConfirm={() => {
archiveMutation.mutate();
}}
isLoading={archiveMutation.isPending}
/>
</>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { DetailLayout } from '@/components/shared/detail-layout';
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
import { getCompanyTabs } from '@/components/companies/company-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
export interface CompanyData {
id: string;
portId: string;
name: string;
legalName: string | null;
taxId: string | null;
registrationNumber: string | null;
incorporationCountry: string | null;
incorporationDate: string | null;
status: string;
billingEmail: string | null;
notes: string | null;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface CompanyDetailProps {
companyId: string;
currentUserId?: string;
}
export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<CompanyData>({
queryKey: ['companies', companyId],
queryFn: () =>
apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data),
});
useRealtimeInvalidation({
'company:updated': [['companies', companyId]],
'company:archived': [['companies', companyId]],
'company_membership:added': [['companies', companyId, 'members']],
'company_membership:updated': [['companies', companyId, 'members']],
'company_membership:ended': [['companies', companyId, 'members']],
});
const tabs = data ? getCompanyTabs({ companyId, portSlug, currentUserId, company: data }) : [];
return (
<DetailLayout
header={data ? <CompanyDetailHeader company={data} /> : null}
tabs={tabs}
defaultTab="overview"
isLoading={isLoading}
/>
);
}

View File

@@ -0,0 +1,24 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
export const companyFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search by name, legal name, tax ID...',
},
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ label: 'Active', value: 'active' },
{ label: 'Dissolved', value: 'dissolved' },
],
},
{
key: 'includeArchived',
label: 'Include Archived',
type: 'boolean',
},
];

View File

@@ -0,0 +1,262 @@
'use client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client';
import { createCompanySchema, type CreateCompanyInput } from '@/lib/validators/companies';
type CompanyStatus = 'active' | 'dissolved';
type CompanyFormValues = z.input<typeof createCompanySchema>;
interface CompanyFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** If provided, form is in edit mode */
company?: {
id: string;
name: string;
legalName: string | null;
taxId: string | null;
registrationNumber: string | null;
incorporationCountry: string | null;
incorporationDate: string | null;
status: string;
billingEmail: string | null;
notes: string | null;
};
}
export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
const queryClient = useQueryClient();
const isEdit = !!company;
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<CompanyFormValues>({
resolver: zodResolver(createCompanySchema),
defaultValues: {
name: '',
status: 'active',
tagIds: [],
},
});
const tagIds = watch('tagIds') ?? [];
const status = watch('status') ?? 'active';
// Populate form when editing, or reset to defaults in create mode.
useEffect(() => {
if (company && open) {
reset({
name: company.name,
legalName: company.legalName ?? undefined,
taxId: company.taxId ?? undefined,
registrationNumber: company.registrationNumber ?? undefined,
incorporationCountry: company.incorporationCountry ?? undefined,
incorporationDate: company.incorporationDate
? new Date(company.incorporationDate)
: undefined,
status: (company.status as CompanyStatus) ?? 'active',
billingEmail: company.billingEmail ?? undefined,
notes: company.notes ?? undefined,
tagIds: [],
});
} else if (!company && open) {
reset({ name: '', status: 'active', tagIds: [] });
}
setFormError(null);
}, [company, open, reset]);
const mutation = useMutation({
mutationFn: async (data: CreateCompanyInput) => {
if (isEdit) {
// updateCompanySchema omits tagIds — strip them from PATCH body.
const { tagIds: _tIds, ...rest } = data;
void _tIds;
await apiFetch(`/api/v1/companies/${company!.id}`, {
method: 'PATCH',
body: rest,
});
} else {
await apiFetch('/api/v1/companies', { method: 'POST', body: data });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
onOpenChange(false);
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to save company';
setFormError(msg);
},
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Company' : 'New Company'}</SheetTitle>
</SheetHeader>
<form
onSubmit={handleSubmit((data) => {
setFormError(null);
mutation.mutate(data as CreateCompanyInput);
})}
className="space-y-6 py-6"
>
{/* Basics */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Basics
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-1">
<Label>Name *</Label>
<Input {...register('name')} placeholder="Acme Holdings Ltd" />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="col-span-2 space-y-1">
<Label>Legal Name</Label>
<Input {...register('legalName')} placeholder="Acme Holdings Limited" />
</div>
</div>
</div>
<Separator />
{/* Registration */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Registration
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Tax ID</Label>
<Input {...register('taxId')} placeholder="VAT / EIN" />
</div>
<div className="space-y-1">
<Label>Registration Number</Label>
<Input {...register('registrationNumber')} placeholder="Company #" />
</div>
<div className="space-y-1">
<Label>Incorporation Country</Label>
<Input {...register('incorporationCountry')} placeholder="e.g. MT" />
</div>
<div className="space-y-1">
<Label>Incorporation Date</Label>
<Input type="date" {...register('incorporationDate')} />
{errors.incorporationDate && (
<p className="text-xs text-destructive">{errors.incorporationDate.message}</p>
)}
</div>
</div>
</div>
<Separator />
{/* Contact & Status */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Contact & Status
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Billing Email</Label>
<Input
type="email"
{...register('billingEmail')}
placeholder="billing@example.com"
/>
{errors.billingEmail && (
<p className="text-xs text-destructive">{errors.billingEmail.message}</p>
)}
</div>
<div className="space-y-1">
<Label>Status</Label>
<Select
value={status}
onValueChange={(v) => setValue('status', v as CompanyStatus)}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="dissolved">Dissolved</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
{/* Notes */}
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
{...register('notes')}
placeholder="Internal notes about this company"
rows={4}
/>
</div>
<Separator />
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
</div>
{formError && (
<p className="text-sm text-destructive" role="alert">
{formError}
</p>
)}
<SheetFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
{(isSubmitting || mutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit ? 'Save Changes' : 'Create Company'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { CompanyForm } from '@/components/companies/company-form';
import { companyFilterDefinitions } from '@/components/companies/company-filters';
import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
export function CompanyList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editCompany, setEditCompany] = useState<CompanyRow | null>(null);
const [archiveCompany, setArchiveCompany] = useState<CompanyRow | null>(null);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<CompanyRow>({
queryKey: ['companies'],
endpoint: '/api/v1/companies',
filterDefinitions: companyFilterDefinitions,
});
useRealtimeInvalidation({
'company:created': [['companies']],
'company:updated': [['companies']],
'company:archived': [['companies']],
});
const archiveMutation = useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/companies/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
setArchiveCompany(null);
},
});
const columns = getCompanyColumns({
portSlug,
onEdit: (company) => setEditCompany(company),
onArchive: (company) => setArchiveCompany(company),
});
return (
<div className="space-y-4">
<PageHeader
title="Companies"
description="Manage company records"
actions={
<PermissionGate resource="companies" action="create">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New Company
</Button>
</PermissionGate>
}
/>
<div className="flex items-center gap-2">
<FilterBar
filters={companyFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<SavedViewsDropdown
entityType="companies"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}}
/>
</div>
{isLoading ? (
<TableSkeleton />
) : !data.length ? (
<EmptyState
title="No companies yet"
description="Create your first company to get started."
action={{ label: 'New Company', onClick: () => setCreateOpen(true) }}
/>
) : (
<DataTable
columns={columns}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
emptyState={
<EmptyState
title="No companies yet"
description="Create your first company to get started."
action={{ label: 'New Company', onClick: () => setCreateOpen(true) }}
/>
}
/>
)}
<CompanyForm open={createOpen} onOpenChange={setCreateOpen} />
{editCompany && (
<CompanyForm
open={!!editCompany}
onOpenChange={(open) => !open && setEditCompany(null)}
company={{
id: editCompany.id,
name: editCompany.name,
legalName: editCompany.legalName,
taxId: editCompany.taxId,
registrationNumber: editCompany.registrationNumber,
incorporationCountry: editCompany.incorporationCountry,
incorporationDate: editCompany.incorporationDate,
status: editCompany.status,
billingEmail: editCompany.billingEmail,
notes: editCompany.notes,
}}
/>
)}
<ArchiveConfirmDialog
open={!!archiveCompany}
onOpenChange={(open) => !open && setArchiveCompany(null)}
entityName={archiveCompany?.name ?? ''}
entityType="Company"
isArchived={false}
onConfirm={() => archiveCompany && archiveMutation.mutate(archiveCompany.id)}
isLoading={archiveMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,266 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, MoreHorizontal, Plus, Star, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { EmptyState } from '@/components/shared/empty-state';
import { PermissionGate } from '@/components/shared/permission-gate';
import { apiFetch } from '@/lib/api/client';
import { AddMembershipDialog } from './add-membership-dialog';
interface MembershipRow {
id: string;
companyId: string;
clientId: string;
role: string;
roleDetail: string | null;
startDate: string;
endDate: string | null;
isPrimary: boolean;
notes: string | null;
}
interface CompanyMembersTabProps {
companyId: string;
portSlug: string;
}
const ROLE_LABELS: Record<string, string> = {
director: 'Director',
officer: 'Officer',
broker: 'Broker',
representative: 'Representative',
legal_counsel: 'Legal counsel',
employee: 'Employee',
shareholder: 'Shareholder',
other: 'Other',
};
function formatDate(value: string | null): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString();
}
/**
* Renders a client's name as a link by fetching the client record.
* Memoization is handled via the TanStack Query cache, so repeat renders
* for the same clientId are free.
*/
function ClientName({ clientId, portSlug }: { clientId: string; portSlug: string }) {
const { data } = useQuery<{ fullName: string | null }>({
queryKey: ['clients', clientId, 'name-only'],
queryFn: () =>
apiFetch<{ data: { fullName: string | null } }>(`/api/v1/clients/${clientId}`).then(
(r) => r.data,
),
});
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${clientId}` as any}
className="text-primary hover:underline"
>
{data?.fullName ?? `Client ${clientId.slice(0, 8)}`}
</Link>
);
}
export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProps) {
const queryClient = useQueryClient();
const [activeOnly, setActiveOnly] = useState(true);
const [addOpen, setAddOpen] = useState(false);
const membersKey = ['companies', companyId, 'members', { activeOnly }];
const { data, isLoading } = useQuery<MembershipRow[]>({
queryKey: membersKey,
queryFn: () =>
apiFetch<{ data: MembershipRow[] }>(
`/api/v1/companies/${companyId}/members?activeOnly=${activeOnly ? 'true' : 'false'}`,
).then((r) => r.data),
});
const endMutation = useMutation({
mutationFn: (membershipId: string) =>
apiFetch(`/api/v1/companies/${companyId}/members/${membershipId}`, {
method: 'DELETE',
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies', companyId, 'members'] });
toast.success('Membership ended');
},
onError: (err: Error) => {
toast.error(err.message || 'Failed to end membership');
},
});
const setPrimaryMutation = useMutation({
mutationFn: (membershipId: string) =>
apiFetch(`/api/v1/companies/${companyId}/members/${membershipId}/set-primary`, {
method: 'POST',
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies', companyId, 'members'] });
toast.success('Primary contact updated');
},
onError: (err: Error) => {
toast.error(err.message || 'Failed to set primary');
},
});
const members = data ?? [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="inline-flex rounded-md border p-0.5 text-xs">
<button
type="button"
onClick={() => setActiveOnly(true)}
className={`px-3 py-1 rounded-sm transition-colors ${
activeOnly ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'
}`}
>
Active
</button>
<button
type="button"
onClick={() => setActiveOnly(false)}
className={`px-3 py-1 rounded-sm transition-colors ${
!activeOnly ? 'bg-primary text-primary-foreground' : 'text-muted-foreground'
}`}
>
All
</button>
</div>
<PermissionGate resource="memberships" action="manage">
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
Add Member
</Button>
</PermissionGate>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : members.length === 0 ? (
<EmptyState
title={activeOnly ? 'No active members' : 'No members yet'}
description={
activeOnly
? 'This company has no active memberships. Switch to "All" to see past members.'
: 'Add the first member to this company to get started.'
}
/>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Client</TableHead>
<TableHead>Role</TableHead>
<TableHead>Role Detail</TableHead>
<TableHead>Start Date</TableHead>
<TableHead>End Date</TableHead>
<TableHead>Primary</TableHead>
<TableHead className="w-[48px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((m) => {
const isActive = !m.endDate;
return (
<TableRow key={m.id}>
<TableCell>
<ClientName clientId={m.clientId} portSlug={portSlug} />
</TableCell>
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
{m.roleDetail ?? '—'}
</TableCell>
<TableCell>{formatDate(m.startDate)}</TableCell>
<TableCell>
{m.endDate ? (
formatDate(m.endDate)
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{m.isPrimary ? (
<Badge variant="secondary" className="text-xs">
Primary
</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<PermissionGate resource="memberships" action="manage">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isActive && !m.isPrimary && (
<DropdownMenuItem
onClick={() => setPrimaryMutation.mutate(m.id)}
disabled={setPrimaryMutation.isPending}
>
<Star className="mr-2 h-3.5 w-3.5" />
Set Primary
</DropdownMenuItem>
)}
{isActive && (
<DropdownMenuItem
className="text-destructive"
onClick={() => endMutation.mutate(m.id)}
disabled={endMutation.isPending}
>
<XCircle className="mr-2 h-3.5 w-3.5" />
End Membership
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</PermissionGate>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
<AddMembershipDialog open={addOpen} onOpenChange={setAddOpen} companyId={companyId} />
</div>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { EmptyState } from '@/components/shared/empty-state';
import { apiFetch } from '@/lib/api/client';
interface OwnedYachtRow {
id: string;
name: string;
hullNumber: string | null;
lengthFt: string | null;
widthFt: string | null;
lengthM: string | null;
widthM: string | null;
status: string;
}
interface YachtListResponse {
data: OwnedYachtRow[];
}
interface CompanyOwnedYachtsTabProps {
companyId: string;
portSlug: string;
}
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800 border-green-300',
retired: 'bg-gray-100 text-gray-800 border-gray-300',
sold_away: 'bg-amber-100 text-amber-800 border-amber-300',
};
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
retired: 'Retired',
sold_away: 'Sold Away',
};
function formatDimensions(y: OwnedYachtRow): string | null {
if (y.lengthFt || y.widthFt) {
const length = y.lengthFt ?? '—';
const width = y.widthFt ?? '—';
return `${length} × ${width} ft`;
}
if (y.lengthM || y.widthM) {
const length = y.lengthM ?? '—';
const width = y.widthM ?? '—';
return `${length} × ${width} m`;
}
return null;
}
export function CompanyOwnedYachtsTab({ companyId, portSlug }: CompanyOwnedYachtsTabProps) {
const { data, isLoading } = useQuery<OwnedYachtRow[]>({
queryKey: ['companies', companyId, 'owned-yachts'],
queryFn: async () => {
const params = new URLSearchParams({
ownerType: 'company',
ownerId: companyId,
page: '1',
limit: '50',
includeArchived: 'false',
order: 'desc',
});
const res = await apiFetch<YachtListResponse>(`/api/v1/yachts?${params.toString()}`);
return res.data;
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
const yachts = data ?? [];
if (yachts.length === 0) {
return (
<EmptyState
title="No yachts owned"
description="Yachts owned by this company will appear here."
/>
);
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Dimensions</TableHead>
<TableHead>Hull Number</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{yachts.map((y) => {
const dims = formatDimensions(y);
const statusLabel = STATUS_LABELS[y.status] ?? y.status;
const statusColor =
STATUS_COLORS[y.status] ?? 'bg-muted text-muted-foreground border-muted';
return (
<TableRow key={y.id}>
<TableCell>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/yachts/${y.id}` as any}
className="font-medium text-primary hover:underline"
>
{y.name}
</Link>
</TableCell>
<TableCell>
{dims ? (
<span className="text-sm">{dims}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{y.hullNumber ? (
<span className="text-sm">{y.hullNumber}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${statusColor}`}
>
{statusLabel}
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useDebounce } from '@/hooks/use-debounce';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface CompanyOption {
id: string;
name: string;
legalName?: string | null;
}
interface CompanyPickerProps {
value: string | null;
onChange: (companyId: string | null) => void;
placeholder?: string;
disabled?: boolean;
}
export function CompanyPicker({
value,
onChange,
placeholder = 'Select company...',
disabled,
}: CompanyPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const debounced = useDebounce(search, 300);
const { data } = useQuery<{ data: CompanyOption[] }>({
queryKey: ['company-picker', debounced],
queryFn: () => apiFetch(`/api/v1/companies/autocomplete?q=${encodeURIComponent(debounced)}`),
enabled: open,
});
const options = data?.data ?? [];
const selectedLabel = (() => {
if (!value) return placeholder;
const match = options.find((o) => o.id === value);
return match?.name ?? `Company ${value.slice(0, 8)}`;
})();
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={disabled}
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
>
<span className="truncate">{selectedLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput placeholder="Search companies…" value={search} onValueChange={setSearch} />
<CommandList>
<CommandEmpty>No companies found.</CommandEmpty>
<CommandGroup>
{options.map((c) => (
<CommandItem
key={c.id}
value={c.id}
onSelect={() => {
onChange(c.id);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === c.id ? 'opacity-100' : 'opacity-0')}
/>
<span>
{c.name}
{c.legalName ? (
<span className="ml-2 text-xs opacity-60">{c.legalName}</span>
) : null}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import type { DetailTab } from '@/components/shared/detail-layout';
import { EmptyState } from '@/components/shared/empty-state';
import { CompanyMembersTab } from '@/components/companies/company-members-tab';
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab';
interface CompanyTabsCompany {
id: string;
name: string;
legalName: string | null;
taxId: string | null;
registrationNumber: string | null;
incorporationCountry: string | null;
incorporationDate: string | null;
status: string;
billingEmail: string | null;
notes: string | null;
}
interface CompanyTabsOptions {
companyId: string;
portSlug: string;
currentUserId?: string;
company: CompanyTabsCompany;
}
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
dissolved: 'Dissolved',
};
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
if (value === null || value === undefined || value === '') return null;
return (
<div className="flex gap-2 py-1.5 border-b last:border-0">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="text-sm">{value}</dd>
</div>
);
}
function formatDate(value: string | null): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString();
}
function OverviewTab({ company }: { company: CompanyTabsCompany }) {
const incorporationDate = formatDate(company.incorporationDate);
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Identity */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Identity</h3>
<dl>
<InfoRow label="Name" value={company.name} />
<InfoRow label="Legal Name" value={company.legalName} />
<InfoRow label="Status" value={STATUS_LABELS[company.status] ?? company.status} />
</dl>
</div>
{/* Registration */}
{(company.taxId ||
company.registrationNumber ||
company.incorporationCountry ||
incorporationDate) && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Registration</h3>
<dl>
<InfoRow label="Tax ID" value={company.taxId} />
<InfoRow label="Registration Number" value={company.registrationNumber} />
<InfoRow label="Incorporation Country" value={company.incorporationCountry} />
<InfoRow label="Incorporation Date" value={incorporationDate} />
</dl>
</div>
)}
{/* Contact */}
{company.billingEmail && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<dl>
<InfoRow label="Billing Email" value={company.billingEmail} />
</dl>
</div>
)}
{/* Notes */}
{company.notes && (
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<p className="text-sm whitespace-pre-wrap rounded-md border bg-muted/30 p-3">
{company.notes}
</p>
</div>
)}
</div>
);
}
export function getCompanyTabs({
companyId,
portSlug,
// currentUserId reserved for when NotesList supports entityType='companies'.
currentUserId: _currentUserId,
company,
}: CompanyTabsOptions): DetailTab[] {
void _currentUserId;
return [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab company={company} />,
},
{
id: 'members',
label: 'Members',
content: <CompanyMembersTab companyId={companyId} portSlug={portSlug} />,
},
{
id: 'owned-yachts',
label: 'Owned Yachts',
content: <CompanyOwnedYachtsTab companyId={companyId} portSlug={portSlug} />,
},
{
id: 'addresses',
label: 'Addresses',
// TODO: wire to future company-addresses endpoint (see company-addresses schema).
content: (
<EmptyState
title="Addresses"
description="Company addresses coming soon — the addresses endpoint is pending wiring."
/>
),
},
{
id: 'documents',
label: 'Documents',
content: <EmptyState title="Documents" description="Coming soon" />,
},
{
id: 'notes',
label: 'Notes',
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
// Extend NotesList (or swap to a company-notes endpoint) in a follow-up.
content: (
<EmptyState
title="Notes"
description="Company notes coming soon — the notes endpoint is pending wiring."
/>
),
},
{
id: 'tags',
label: 'Tags',
// TODO: replace with an inline tag editor once one exists; company tags
// can be edited via the Edit form in the meantime.
content: (
<EmptyState title="Tags" description="Manage tags from the Edit company form for now." />
),
},
];
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
Dialog,
@@ -12,12 +12,19 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client';
interface EoiPrerequisites {
hasName: boolean;
hasEmail: boolean;
hasYachtDims: boolean;
hasYacht: boolean;
hasBerth: boolean;
}
@@ -30,11 +37,23 @@ interface EoiGenerateDialogProps {
const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
{ key: 'hasName', label: 'Client has full name' },
{ key: 'hasEmail', label: 'Client has email address' },
{ key: 'hasYachtDims', label: 'Yacht dimensions set' },
{ key: 'hasYacht', label: 'Yacht linked to interest' },
{ key: 'hasBerth', label: 'Berth linked to interest' },
];
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
interface InAppTemplate {
id: string;
name: string;
description?: string | null;
templateType: string;
}
interface ListResponse {
data: InAppTemplate[];
}
export function EoiGenerateDialog({
interestId,
open,
@@ -44,9 +63,21 @@ export function EoiGenerateDialog({
const queryClient = useQueryClient();
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
const allMet = Object.values(prerequisites).every(Boolean);
// Load in-app EOI templates so the operator can pick one as an alternative
// to the Documenso external-signing flow.
const { data: templatesRes } = useQuery<ListResponse>({
queryKey: ['document-templates', { templateType: 'eoi', isActive: true }],
queryFn: () =>
apiFetch<ListResponse>('/api/v1/document-templates?templateType=eoi&isActive=true'),
enabled: open,
});
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
const handleGenerate = async () => {
if (!allMet) return;
@@ -54,9 +85,17 @@ export function EoiGenerateDialog({
setError(null);
try {
await apiFetch('/api/v1/documents/generate-eoi', {
const isDocumensoPath = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE;
const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`;
await apiFetch(url, {
method: 'POST',
body: { interestId },
body: {
interestId,
pathway: isDocumensoPath ? 'documenso-template' : 'inapp',
// Signers are derived server-side from EOI context for both pathways
// when the template type is EOI, so the dialog doesn't collect them.
signers: [],
},
});
queryClient.invalidateQueries({ queryKey: ['documents', { interestId }] });
@@ -74,39 +113,58 @@ export function EoiGenerateDialog({
<DialogHeader>
<DialogTitle>Generate Expression of Interest</DialogTitle>
<DialogDescription>
The following prerequisites must be met before generating the EOI document.
Pick how to render the EOI. Documenso is the primary path; in-app templates use the same
source PDF but render and store the PDF locally before sending for signing.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2">
{PREREQUISITE_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3">
<span
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
prerequisites[key]
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{prerequisites[key] ? '✓' : '✗'}
</span>
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}>
{label}
</span>
</div>
))}
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="eoi-template">Template</Label>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger id="eoi-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
Documenso Standard EOI (recommended)
</SelectItem>
{inAppTemplates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Prerequisites</p>
{PREREQUISITE_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3">
<span
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}
>
{prerequisites[key] ? '✓' : '✗'}
</span>
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}>
{label}
</span>
</div>
))}
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleGenerate} disabled={!allMet || isGenerating}>
{isGenerating ? 'Generating...' : 'Generate EOI'}
{isGenerating ? 'Generating' : 'Generate EOI'}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -14,13 +14,9 @@ interface InterestDocumentsTabProps {
interface InterestData {
id: string;
yachtId?: string | null;
berthId?: string | null;
client?: {
fullName?: string | null;
yachtLengthFt?: string | null;
yachtLengthM?: string | null;
contacts?: Array<{ channel: string; value: string }>;
};
clientName?: string | null;
}
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
@@ -28,20 +24,14 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
const { data: interestRes } = useQuery({
queryKey: ['interests', interestId],
queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`),
queryFn: () => apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`),
});
const interest = interestRes?.data;
const prerequisites = {
hasName: Boolean(interest?.client?.fullName),
hasEmail: Boolean(
interest?.client?.contacts?.some((c) => c.channel === 'email' && c.value),
),
hasYachtDims: Boolean(
interest?.client?.yachtLengthFt || interest?.client?.yachtLengthM,
),
hasName: Boolean(interest?.clientName),
hasYacht: Boolean(interest?.yachtId),
hasBerth: Boolean(interest?.berthId),
};

View File

@@ -18,18 +18,8 @@ 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,
@@ -41,6 +31,7 @@ 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';
@@ -71,6 +62,7 @@ interface InterestFormProps {
id: string;
clientId: string;
clientName?: string | null;
yachtId?: string | null;
berthId?: string | null;
berthMooringNumber?: string | null;
pipelineStage: string;
@@ -101,6 +93,7 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
resolver: zodResolver(createInterestSchema),
defaultValues: {
clientId: '',
yachtId: undefined,
pipelineStage: 'open',
reminderEnabled: false,
tagIds: [],
@@ -111,26 +104,34 @@ 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({
endpoint: '/api/v1/clients/options',
labelKey: 'fullName',
});
const {
options: clientOptions,
isLoading: clientsLoading,
setSearch: setClientSearch,
} = useEntityOptions({
endpoint: '/api/v1/clients/options',
labelKey: 'fullName',
});
const { options: berthOptions, isLoading: berthsLoading, setSearch: setBerthSearch } =
useEntityOptions({
endpoint: '/api/v1/berths/options',
labelKey: 'mooringNumber',
});
const {
options: berthOptions,
isLoading: berthsLoading,
setSearch: setBerthSearch,
} = useEntityOptions({
endpoint: '/api/v1/berths/options',
labelKey: 'mooringNumber',
});
useEffect(() => {
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,
@@ -140,6 +141,7 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps
} else if (!interest && open) {
reset({
clientId: '',
yachtId: undefined,
pipelineStage: 'open',
reminderEnabled: false,
tagIds: [],
@@ -178,10 +180,7 @@ 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">
@@ -202,16 +201,13 @@ 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.'}
@@ -258,16 +254,13 @@ 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.'}
@@ -312,6 +305,24 @@ 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 &quot;Open&quot; 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 />
@@ -326,7 +337,9 @@ 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" />
@@ -346,7 +359,10 @@ 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>
@@ -427,18 +443,11 @@ 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}>

View File

@@ -8,6 +8,8 @@ import {
Users,
Bookmark,
Anchor,
Ship,
Building2,
Receipt,
FileText,
FolderOpen,
@@ -30,12 +32,7 @@ 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';
@@ -65,6 +62,8 @@ 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 },
],
@@ -280,7 +279,8 @@ 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 (

View File

@@ -0,0 +1,178 @@
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { CheckCircle2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
interface PasswordSetFormProps {
/** API endpoint that accepts `{ token, password }` and sets / resets the password. */
endpoint: string;
title: string;
description: string;
successTitle: string;
successDescription: string;
submitLabel: string;
}
const MIN_LENGTH = 9;
/**
* Shared form used by both the activation and password-reset flows. The
* activation token is read from the `?token=` query string. Empty / missing
* tokens land the user in an explicit error state instead of submitting a
* doomed request.
*/
export function PasswordSetForm({
endpoint,
title,
description,
successTitle,
successDescription,
submitLabel,
}: PasswordSetFormProps) {
const search = useSearchParams();
const token = search.get('token') ?? '';
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const tooShort = password.length > 0 && password.length < MIN_LENGTH;
const mismatch = confirm.length > 0 && password !== confirm;
const canSubmit = !!token && password.length >= MIN_LENGTH && password === confirm && !loading;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setLoading(true);
setError('');
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? 'Something went wrong. Please try again.');
return;
}
setDone(true);
} catch {
setError('Unable to connect. Please try again.');
} finally {
setLoading(false);
}
}
if (!token) {
return (
<PortalAuthShell>
<div className="text-center space-y-3">
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
<p className="text-sm text-gray-500">
Please use the link from the email we sent you. If the link is broken, request a new
one.
</p>
<Link
href="/portal/forgot-password"
className="inline-block text-sm text-[#007bff] hover:underline"
>
Request a new link
</Link>
</div>
</PortalAuthShell>
);
}
if (done) {
return (
<PortalAuthShell>
<div className="text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
<CheckCircle2 className="h-7 w-7 text-green-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">{successTitle}</h1>
<p className="text-gray-500 text-sm">{successDescription}</p>
<Link
href="/portal/login"
className="mt-6 inline-block text-sm text-[#007bff] hover:underline"
>
Sign in
</Link>
</div>
</PortalAuthShell>
);
}
return (
<PortalAuthShell>
<div className="mb-6">
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
<p className="text-sm text-gray-500 mt-1">{description}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
autoComplete="new-password"
minLength={MIN_LENGTH}
disabled={loading}
/>
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
{tooShort && (
<p className="text-xs text-red-600">
Password must be at least {MIN_LENGTH} characters.
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">Confirm password</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
autoComplete="new-password"
disabled={loading}
/>
{mismatch && <p className="text-xs text-red-600">Passwords don&apos;t match.</p>}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={!canSubmit}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving
</>
) : (
submitLabel
)}
</Button>
</form>
</PortalAuthShell>
);
}

View File

@@ -0,0 +1,27 @@
const BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
export function PortalAuthShell({ children }: { children: React.ReactNode }) {
return (
<div
className="min-h-screen flex items-center justify-center px-4 py-8"
style={{
backgroundImage: `url('${BG_URL}')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: '#f2f2f2',
}}
>
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-lg p-8">
<div className="flex justify-center mb-6">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={LOGO_URL} alt="Port Nimara" className="w-24 h-auto" />
</div>
{children}
</div>
</div>
</div>
);
}

View File

@@ -2,12 +2,14 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LayoutDashboard, Anchor, FileText, Receipt } from 'lucide-react';
import { LayoutDashboard, Anchor, FileText, Receipt, Sailboat, CalendarCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
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 },
];

View File

@@ -0,0 +1,251 @@
'use client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ClientPicker } from '@/components/shared/client-picker';
import { YachtPicker } from '@/components/yachts/yacht-picker';
import { apiFetch } from '@/lib/api/client';
type TenureType = 'permanent' | 'fixed_term' | 'seasonal';
type FormValues = {
clientId: string | null;
yachtId: string | null;
startDate: string; // YYYY-MM-DD
tenureType: TenureType;
notes?: string;
};
interface BerthReserveDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
berthId: string;
}
export function BerthReserveDialog({ open, onOpenChange, berthId }: BerthReserveDialogProps) {
const queryClient = useQueryClient();
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
defaultValues: {
clientId: null,
yachtId: null,
startDate: new Date().toISOString().slice(0, 10),
tenureType: 'permanent',
notes: '',
},
});
useEffect(() => {
if (open) {
setFormError(null);
reset({
clientId: null,
yachtId: null,
startDate: new Date().toISOString().slice(0, 10),
tenureType: 'permanent',
notes: '',
});
}
}, [open, reset]);
const clientId = watch('clientId');
const yachtId = watch('yachtId');
const tenureType = watch('tenureType');
// When client changes, clear yacht (since yacht-picker is filtered to owner=client)
useEffect(() => {
setValue('yachtId', null);
}, [clientId, setValue]);
function validate(data: FormValues): string | null {
if (!data.clientId) return 'Please select a client';
if (!data.yachtId) return 'Please select a yacht';
return null;
}
async function createPending(data: FormValues): Promise<{ id: string }> {
const res = await apiFetch<{ data: { id: string } }>(`/api/v1/berths/${berthId}/reservations`, {
method: 'POST',
body: {
clientId: data.clientId!,
yachtId: data.yachtId!,
startDate: data.startDate,
tenureType: data.tenureType,
notes: data.notes?.trim() || undefined,
},
});
return res.data;
}
const createMutation = useMutation({
mutationFn: async (data: FormValues) => {
const err = validate(data);
if (err) throw new Error(err);
await createPending(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['berths', berthId, 'reservations'] });
queryClient.invalidateQueries({ queryKey: ['berth-reservations'] });
toast.success('Reservation created');
onOpenChange(false);
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to create reservation';
setFormError(msg);
},
});
const createAndActivateMutation = useMutation({
mutationFn: async (data: FormValues) => {
const err = validate(data);
if (err) throw new Error(err);
const pending = await createPending(data);
// Immediately activate
await apiFetch(`/api/v1/berth-reservations/${pending.id}`, {
method: 'PATCH',
body: { action: 'activate' },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['berths', berthId, 'reservations'] });
queryClient.invalidateQueries({ queryKey: ['berth-reservations'] });
toast.success('Reservation created and activated');
onOpenChange(false);
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to activate';
if (/active reservation|conflict|409/i.test(msg)) {
setFormError(
'This berth already has an active reservation. The pending record was created — activate it manually once the other reservation ends.',
);
} else {
setFormError(msg);
}
},
});
const isPending = isSubmitting || createMutation.isPending || createAndActivateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Reserve this berth</DialogTitle>
<DialogDescription>
Create a pending reservation or activate it immediately.
</DialogDescription>
</DialogHeader>
<form className="space-y-4">
<div className="space-y-2">
<Label>Client</Label>
<ClientPicker value={clientId} onChange={(id) => setValue('clientId', id)} />
</div>
<div className="space-y-2">
<Label>Yacht</Label>
<YachtPicker
value={yachtId}
onChange={(id) => setValue('yachtId', id)}
ownerFilter={clientId ? { type: 'client', id: clientId } : undefined}
disabled={!clientId}
placeholder={clientId ? 'Select yacht...' : 'Select a client first'}
/>
</div>
<div className="space-y-2">
<Label htmlFor="startDate">Start date</Label>
<Input id="startDate" type="date" {...register('startDate', { required: true })} />
{errors.startDate && <p className="text-xs text-destructive">Required</p>}
</div>
<div className="space-y-2">
<Label>Tenure</Label>
<Select
value={tenureType}
onValueChange={(v) => setValue('tenureType', v as TenureType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="permanent">Permanent</SelectItem>
<SelectItem value="fixed_term">Fixed term</SelectItem>
<SelectItem value="seasonal">Seasonal</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes (optional)</Label>
<Textarea id="notes" rows={2} {...register('notes')} />
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
<DialogFooter className="flex-col-reverse sm:flex-row gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={handleSubmit((data) => {
setFormError(null);
createMutation.mutate(data);
})}
>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create reservation
</Button>
<Button
type="button"
disabled={isPending}
onClick={handleSubmit((data) => {
setFormError(null);
createAndActivateMutation.mutate(data);
})}
>
{createAndActivateMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create and activate
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,215 @@
'use client';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { EmptyState } from '@/components/shared/empty-state';
import { apiFetch } from '@/lib/api/client';
export interface ReservationRow {
id: string;
berthId: string;
portId: string;
clientId: string;
yachtId: string;
status: 'pending' | 'active' | 'ended' | 'cancelled';
startDate: string;
endDate: string | null;
tenureType: string;
contractFileId: string | null;
notes: string | null;
createdAt: string;
}
export interface ReservationListProps {
reservations: ReservationRow[];
showBerth?: boolean;
portSlug?: string;
emptyMessage?: string;
}
/**
* Renders a client's name as a link by fetching the client record.
* Uses TanStack Query cache for memoization of repeated clientId queries.
*/
function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
const { data } = useQuery<{ fullName: string }>({
queryKey: ['clients', clientId, 'name-only'],
queryFn: () =>
apiFetch<{ data: { fullName: string } }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
});
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${clientId}` as any}
className="text-primary hover:underline"
>
{data?.fullName ?? `Client ${clientId.slice(0, 8)}`}
</Link>
);
}
/**
* Renders a yacht's name as a link by fetching the yacht record.
*/
function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
const { data } = useQuery<{ name: string }>({
queryKey: ['yachts', yachtId, 'name-only'],
queryFn: () =>
apiFetch<{ data: { name: string } }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data),
});
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/yachts/${yachtId}` as any}
className="text-primary hover:underline"
>
{data?.name ?? `Yacht ${yachtId.slice(0, 8)}`}
</Link>
);
}
/**
* Renders a berth's mooring number as a link by fetching the berth record.
*/
function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
const { data } = useQuery<{ mooringNumber: string }>({
queryKey: ['berths', berthId, 'name-only'],
queryFn: () =>
apiFetch<{ data: { mooringNumber: string } }>(`/api/v1/berths/${berthId}`).then(
(r) => r.data,
),
});
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/berths/${berthId}` as any}
className="text-primary hover:underline"
>
{data?.mooringNumber ?? `Berth ${berthId.slice(0, 8)}`}
</Link>
);
}
/**
* Renders a status badge with appropriate color coding.
*/
function StatusBadge({ status }: { status: ReservationRow['status'] }) {
const colorMap: Record<ReservationRow['status'], string> = {
pending: 'bg-gray-100 text-gray-800',
active: 'bg-green-100 text-green-800',
ended: 'bg-blue-100 text-blue-800',
cancelled: 'bg-red-100 text-red-800',
};
const color = colorMap[status];
const label = status.charAt(0).toUpperCase() + status.slice(1);
return (
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
{label}
</span>
);
}
/**
* Pretty-prints tenure type for display.
*/
function prettyTenure(tenureType: string): string {
const tenureMap: Record<string, string> = {
permanent: 'Permanent',
fixed_term: 'Fixed term',
seasonal: 'Seasonal',
};
return tenureMap[tenureType] ?? tenureType;
}
/**
* Formats a date range as "{startDate} → {endDate or 'ongoing'}".
*/
function formatDateRange(startDate: string, endDate: string | null): string {
const start = new Date(startDate).toLocaleDateString();
const end = endDate ? new Date(endDate).toLocaleDateString() : 'ongoing';
return `${start}${end}`;
}
export function ReservationList({
reservations,
showBerth = false,
portSlug: portSlugProp,
emptyMessage,
}: ReservationListProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = portSlugProp ?? routeParams?.portSlug ?? '';
if (reservations.length === 0) {
return (
<EmptyState title="No reservations" description={emptyMessage ?? 'No reservations yet.'} />
);
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{showBerth && <TableHead>Berth</TableHead>}
<TableHead>Client</TableHead>
<TableHead>Yacht</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Tenure</TableHead>
<TableHead>Status</TableHead>
<TableHead>Contract</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reservations.map((r) => (
<TableRow key={r.id}>
{showBerth && (
<TableCell>
<BerthLink berthId={r.berthId} portSlug={portSlug} />
</TableCell>
)}
<TableCell>
<ClientLink clientId={r.clientId} portSlug={portSlug} />
</TableCell>
<TableCell>
<YachtLink yachtId={r.yachtId} portSlug={portSlug} />
</TableCell>
<TableCell>{formatDateRange(r.startDate, r.endDate)}</TableCell>
<TableCell>{prettyTenure(r.tenureType)}</TableCell>
<TableCell>
<StatusBadge status={r.status} />
</TableCell>
<TableCell>
{r.contractFileId ? (
// TODO: Confirm final file-download endpoint URL when available
<a
href={`/api/v1/files/${r.contractFileId}/download`}
className="text-primary hover:underline"
>
View contract
</a>
) : (
'—'
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Search, Clock, User, TrendingUp, Anchor } from 'lucide-react';
import { Search, Clock, User, TrendingUp, Anchor, Ship, Building2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSearch } from '@/hooks/use-search';
@@ -22,7 +22,11 @@ export function CommandSearch() {
const hasQuery = query.length >= 2;
const hasResults =
results &&
(results.clients.length > 0 || results.interests.length > 0 || results.berths.length > 0);
(results.clients.length > 0 ||
results.interests.length > 0 ||
results.berths.length > 0 ||
results.yachts.length > 0 ||
results.companies.length > 0);
// Cmd/Ctrl+K focuses the input
useEffect(() => {
@@ -67,7 +71,13 @@ export function CommandSearch() {
}
}
const iconMap = { client: User, interest: TrendingUp, berth: Anchor } as const;
const iconMap = {
client: User,
interest: TrendingUp,
berth: Anchor,
yacht: Ship,
company: Building2,
} as const;
return (
<div ref={wrapperRef} className="relative">
@@ -142,12 +152,38 @@ export function CommandSearch() {
id: c.id,
icon: 'client',
label: c.fullName,
sub: c.companyName,
sub: null,
}))}
iconMap={iconMap}
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"
@@ -190,7 +226,12 @@ function ResultGroup({
onSelect,
}: {
heading: string;
items: Array<{ id: string; icon: 'client' | 'interest' | 'berth'; label: string; sub?: string | null }>;
items: Array<{
id: string;
icon: 'client' | 'interest' | 'berth' | 'yacht' | 'company';
label: string;
sub?: string | null;
}>;
iconMap: Record<string, React.ElementType | undefined>;
onSelect: (id: string) => void;
}) {

View File

@@ -1,6 +1,6 @@
'use client';
import { User, Anchor, TrendingUp } from 'lucide-react';
import { User, Anchor, TrendingUp, Ship, Building2 } from 'lucide-react';
import { CommandItem } from '@/components/ui/command';
@@ -9,7 +9,6 @@ import { CommandItem } from '@/components/ui/command';
interface ClientItem {
id: string;
fullName: string;
companyName: string | null;
}
interface InterestItem {
@@ -26,10 +25,26 @@ 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: 'berth'; item: BerthItem; onSelect: () => void }
| { type: 'yacht'; item: YachtItem; onSelect: () => void }
| { type: 'company'; item: CompanyItem; onSelect: () => void };
// ─── Component ────────────────────────────────────────────────────────────────
@@ -38,12 +53,7 @@ export function SearchResultItem({ type, item, onSelect }: SearchResultItemProps
return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
<User className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">{item.fullName}</span>
{item.companyName && (
<span className="text-xs text-muted-foreground">{item.companyName}</span>
)}
</div>
<span className="text-sm font-medium">{item.fullName}</span>
</CommandItem>
);
}
@@ -63,6 +73,38 @@ export function SearchResultItem({ type, item, onSelect }: SearchResultItemProps
);
}
if (type === 'yacht') {
return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
<Ship className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">{item.name}</span>
{(item.hullNumber || item.registration) && (
<span className="text-xs text-muted-foreground">
{[item.hullNumber, item.registration].filter(Boolean).join(' · ')}
</span>
)}
</div>
</CommandItem>
);
}
if (type === 'company') {
return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
<Building2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">{item.name}</span>
{(item.legalName || item.taxId) && (
<span className="text-xs text-muted-foreground">
{[item.legalName, item.taxId].filter(Boolean).join(' · ')}
</span>
)}
</div>
</CommandItem>
);
}
// berth
return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">

View File

@@ -0,0 +1,100 @@
'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;
}
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}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,164 @@
'use client';
import { useEffect, useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useDebounce } from '@/hooks/use-debounce';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
export type OwnerRef = { type: 'client' | 'company'; id: string };
interface OwnerOption {
id: string;
name?: string | null;
fullName?: string | null;
}
interface OwnerPickerProps {
value: OwnerRef | null;
onChange: (value: OwnerRef | null) => void;
/** Optional placeholder when empty */
placeholder?: string;
/** Disable the component */
disabled?: boolean;
}
export function OwnerPicker({
value,
onChange,
placeholder = 'Select owner...',
disabled,
}: OwnerPickerProps) {
const [open, setOpen] = useState(false);
const [type, setType] = useState<'client' | 'company'>(value?.type ?? 'client');
const [search, setSearch] = useState('');
const debounced = useDebounce(search, 300);
// Keep local `type` in sync if value.type changes externally.
useEffect(() => {
if (value?.type) setType(value.type);
}, [value?.type]);
const endpoint =
type === 'client'
? `/api/v1/clients/options?search=${encodeURIComponent(debounced)}`
: `/api/v1/companies/autocomplete?q=${encodeURIComponent(debounced)}`;
const { data } = useQuery<{ data: OwnerOption[] }>({
queryKey: ['owner-picker', type, debounced],
queryFn: () => apiFetch(endpoint),
enabled: open,
});
const options = data?.data ?? [];
// Selected display label — show entity's name from current options if
// available, otherwise a truncated id fallback.
const selectedLabel = (() => {
if (!value) return placeholder;
const match = options.find((o) => o.id === value.id);
if (match) {
return type === 'client'
? (match.fullName ?? '(unnamed client)')
: (match.name ?? '(unnamed company)');
}
return value.type === 'client'
? `Client ${value.id.slice(0, 8)}`
: `Company ${value.id.slice(0, 8)}`;
})();
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={disabled}
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
>
<span className="truncate">
{value && (
<span className="mr-2 text-xs opacity-60">
{value.type === 'client' ? 'Client:' : 'Company:'}
</span>
)}
{selectedLabel}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
{/* Type toggle */}
<div className="flex border-b">
<button
type="button"
onClick={() => {
setType('client');
setSearch('');
}}
className={cn(
'flex-1 px-3 py-2 text-xs',
type === 'client' ? 'bg-accent font-medium' : 'hover:bg-accent/50',
)}
>
Client
</button>
<button
type="button"
onClick={() => {
setType('company');
setSearch('');
}}
className={cn(
'flex-1 px-3 py-2 text-xs',
type === 'company' ? 'bg-accent font-medium' : 'hover:bg-accent/50',
)}
>
Company
</button>
</div>
<Command shouldFilter={false}>
<CommandInput placeholder={`Search ${type}s…`} value={search} onValueChange={setSearch} />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup>
{options.map((opt) => {
const label =
type === 'client' ? (opt.fullName ?? '(unnamed)') : (opt.name ?? '(unnamed)');
const isSelected = value?.id === opt.id && value?.type === type;
return (
<CommandItem
key={opt.id}
value={opt.id}
onSelect={() => {
onChange({ type, id: opt.id });
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', isSelected ? 'opacity-100' : 'opacity-0')}
/>
{label}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,176 @@
'use client';
import Link from 'next/link';
import { MoreHorizontal, Pencil, Archive, Eye } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { OwnerLink } from '@/components/yachts/yacht-detail-header';
export interface YachtRow {
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
currentOwnerType: 'client' | 'company';
currentOwnerId: string;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
status: string;
archivedAt: string | null;
updatedAt: string;
}
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800 border-green-300',
retired: 'bg-gray-100 text-gray-800 border-gray-300',
sold_away: 'bg-amber-100 text-amber-800 border-amber-300',
};
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
retired: 'Retired',
sold_away: 'Sold Away',
};
function formatDimensions(yacht: YachtRow): string | null {
if (yacht.lengthFt || yacht.widthFt) {
const length = yacht.lengthFt ?? '—';
const width = yacht.widthFt ?? '—';
return `${length} × ${width} ft`;
}
if (yacht.lengthM || yacht.widthM) {
const length = yacht.lengthM ?? '—';
const width = yacht.widthM ?? '—';
return `${length} × ${width} m`;
}
return null;
}
interface GetYachtColumnsOptions {
portSlug: string;
onEdit: (yacht: YachtRow) => void;
onArchive: (yacht: YachtRow) => void;
}
export function getYachtColumns({
portSlug,
onEdit,
onArchive,
}: GetYachtColumnsOptions): ColumnDef<YachtRow, unknown>[] {
return [
{
id: 'name',
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/yachts/${row.original.id}` as any}
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.name}
</Link>
),
},
{
id: 'currentOwner',
header: 'Current Owner',
enableSorting: false,
cell: ({ row }) => (
<OwnerLink
portSlug={portSlug}
type={row.original.currentOwnerType}
id={row.original.currentOwnerId}
/>
),
},
{
id: 'dimensions',
header: 'Dimensions',
enableSorting: false,
cell: ({ row }) => {
const dims = formatDimensions(row.original);
if (!dims) return <span className="text-muted-foreground"></span>;
return <span className="text-sm">{dims}</span>;
},
},
{
id: 'hullNumber',
accessorKey: 'hullNumber',
header: 'Hull Number',
enableSorting: false,
cell: ({ getValue }) => {
const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>;
return <span className="text-sm">{value}</span>;
},
},
{
id: 'status',
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.original.status;
const label = STATUS_LABELS[status] ?? status;
const color = STATUS_COLORS[status] ?? 'bg-muted text-muted-foreground border-muted';
return (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${color}`}
>
{label}
</span>
);
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/yachts/${row.original.id}` as any}
>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
}

View File

@@ -0,0 +1,241 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive, ArrowRightLeft } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
import { apiFetch } from '@/lib/api/client';
interface YachtDetailHeaderYacht {
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
flag: string | null;
yearBuilt: number | null;
builder: string | null;
model: string | null;
hullMaterial: string | null;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
draftM: string | null;
currentOwnerType: 'client' | 'company';
currentOwnerId: string;
status: string;
notes: string | null;
archivedAt: string | null;
}
interface YachtDetailHeaderProps {
yacht: YachtDetailHeaderYacht;
}
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800 border-green-300',
retired: 'bg-gray-100 text-gray-800 border-gray-300',
sold_away: 'bg-amber-100 text-amber-800 border-amber-300',
};
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
retired: 'Retired',
sold_away: 'Sold Away',
};
export function OwnerLink({
portSlug,
type,
id,
}: {
portSlug: string;
type: 'client' | 'company';
id: string;
}) {
const { data } = useQuery<{ fullName?: string; name?: string }>({
queryKey: [type === 'client' ? 'clients' : 'companies', id, 'name-only'],
queryFn: () =>
apiFetch<{ data: { fullName?: string; name?: string } }>(
type === 'client' ? `/api/v1/clients/${id}` : `/api/v1/companies/${id}`,
).then((r) => r.data),
});
const label = type === 'client' ? data?.fullName : data?.name;
const href = type === 'client' ? `/${portSlug}/clients/${id}` : `/${portSlug}/companies/${id}`;
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
className="text-primary hover:underline"
>
{label ?? `${type === 'client' ? 'Client' : 'Company'} ${id.slice(0, 8)}`}
</Link>
);
}
function formatDimensions(yacht: YachtDetailHeaderYacht): string | null {
const parts: string[] = [];
if (yacht.lengthFt) parts.push(`${yacht.lengthFt} ft`);
if (yacht.widthFt) parts.push(`${yacht.widthFt} ft`);
let summary: string | null = null;
if (parts.length > 0) {
summary = parts.join(' × ');
}
if (yacht.draftFt) {
summary = summary ? `${summary} (draft ${yacht.draftFt} ft)` : `draft ${yacht.draftFt} ft`;
}
return summary;
}
export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
const queryClient = useQueryClient();
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const isArchived = !!yacht.archivedAt;
const archiveMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/yachts/${yacht.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['yachts', yacht.id] });
queryClient.invalidateQueries({ queryKey: ['yachts'] });
toast.success('Yacht archived');
setArchiveOpen(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/${portSlug}/yachts` as any);
},
onError: (err: Error) => {
toast.error(err.message || 'Failed to archive yacht');
},
});
const dimensions = formatDimensions(yacht);
const statusLabel = STATUS_LABELS[yacht.status] ?? yacht.status;
const statusColor = STATUS_COLORS[yacht.status] ?? 'bg-muted text-muted-foreground border-muted';
return (
<>
<div className="space-y-3">
<div className="flex items-start gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold text-foreground truncate">{yacht.name}</h1>
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
>
{statusLabel}
</span>
{isArchived && (
<Badge variant="secondary" className="text-xs">
Archived
</Badge>
)}
</div>
{dimensions && <p className="text-muted-foreground mt-0.5 text-sm">{dimensions}</p>}
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<span>Owner:</span>
<OwnerLink
portSlug={portSlug}
type={yacht.currentOwnerType}
id={yacht.currentOwnerId}
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<PermissionGate resource="yachts" action="transfer">
<Button
variant="outline"
size="sm"
onClick={() => setTransferOpen(true)}
disabled={isArchived}
>
<ArrowRightLeft className="mr-1.5 h-3.5 w-3.5" />
Transfer
</Button>
</PermissionGate>
<Button
variant="outline"
size="sm"
onClick={() => setArchiveOpen(true)}
disabled={isArchived}
>
<Archive className="mr-1.5 h-3.5 w-3.5" />
Archive
</Button>
</div>
</div>
</div>
<YachtForm
open={editOpen}
onOpenChange={setEditOpen}
yacht={{
id: yacht.id,
name: yacht.name,
hullNumber: yacht.hullNumber,
registration: yacht.registration,
flag: yacht.flag,
yearBuilt: yacht.yearBuilt,
builder: yacht.builder,
model: yacht.model,
hullMaterial: yacht.hullMaterial,
lengthFt: yacht.lengthFt,
widthFt: yacht.widthFt,
draftFt: yacht.draftFt,
lengthM: yacht.lengthM,
widthM: yacht.widthM,
draftM: yacht.draftM,
currentOwnerType: yacht.currentOwnerType,
currentOwnerId: yacht.currentOwnerId,
status: yacht.status,
notes: yacht.notes,
}}
/>
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
entityName={yacht.name}
entityType="Yacht"
isArchived={isArchived}
onConfirm={() => {
archiveMutation.mutate();
}}
isLoading={archiveMutation.isPending}
/>
<YachtTransferDialog
open={transferOpen}
onOpenChange={setTransferOpen}
yachtId={yacht.id}
currentOwner={{ type: yacht.currentOwnerType, id: yacht.currentOwnerId }}
/>
</>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DetailLayout } from '@/components/shared/detail-layout';
import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
import { getYachtTabs } from '@/components/yachts/yacht-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
export interface YachtData {
id: string;
portId: string;
name: string;
hullNumber: string | null;
registration: string | null;
flag: string | null;
yearBuilt: number | null;
builder: string | null;
model: string | null;
hullMaterial: string | null;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
draftM: string | null;
currentOwnerType: 'client' | 'company';
currentOwnerId: string;
status: string;
notes: string | null;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface YachtDetailProps {
yachtId: string;
currentUserId?: string;
}
export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
const { data, isLoading } = useQuery<YachtData>({
queryKey: ['yachts', yachtId],
queryFn: () => apiFetch<{ data: YachtData }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data),
});
useRealtimeInvalidation({
'yacht:updated': [['yachts', yachtId]],
'yacht:archived': [['yachts', yachtId]],
'yacht:ownership_transferred': [
['yachts', yachtId],
['yachts', yachtId, 'ownership-history'],
],
});
const tabs = data ? getYachtTabs({ yachtId, currentUserId, yacht: data }) : [];
return (
<DetailLayout
header={data ? <YachtDetailHeader yacht={data} /> : null}
tabs={tabs}
defaultTab="overview"
isLoading={isLoading}
/>
);
}

View File

@@ -0,0 +1,34 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
export const yachtFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search by name, hull, registration...',
},
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ label: 'Active', value: 'active' },
{ label: 'Retired', value: 'retired' },
{ label: 'Sold Away', value: 'sold_away' },
],
},
{
key: 'ownerType',
label: 'Owner Type',
type: 'select',
options: [
{ label: 'Client', value: 'client' },
{ label: 'Company', value: 'company' },
],
},
{
key: 'includeArchived',
label: 'Include Archived',
type: 'boolean',
},
];

View File

@@ -0,0 +1,356 @@
'use client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { OwnerPicker, type OwnerRef } from '@/components/shared/owner-picker';
import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client';
import { createYachtSchema, type CreateYachtInput } from '@/lib/validators/yachts';
interface YachtFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** If provided, form is in edit mode */
yacht?: {
id: string;
name: string;
hullNumber?: string | null;
registration?: string | null;
flag?: string | null;
yearBuilt?: number | null;
builder?: string | null;
model?: string | null;
hullMaterial?: string | null;
lengthFt?: string | null;
widthFt?: string | null;
draftFt?: string | null;
lengthM?: string | null;
widthM?: string | null;
draftM?: string | null;
currentOwnerType: 'client' | 'company';
currentOwnerId: string;
status?: string | null;
notes?: string | null;
};
}
type YachtStatus = 'active' | 'retired' | 'sold_away';
export function YachtForm({ open, onOpenChange, yacht }: YachtFormProps) {
const queryClient = useQueryClient();
const isEdit = !!yacht;
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateYachtInput>({
resolver: zodResolver(createYachtSchema),
defaultValues: {
name: '',
status: 'active',
tagIds: [],
},
});
const tagIds = watch('tagIds') ?? [];
const owner = watch('owner') as OwnerRef | undefined;
const status = watch('status') ?? 'active';
// Populate form when editing, or reset to defaults in create mode.
useEffect(() => {
if (yacht && open) {
reset({
name: yacht.name,
hullNumber: yacht.hullNumber ?? undefined,
registration: yacht.registration ?? undefined,
flag: yacht.flag ?? undefined,
yearBuilt: yacht.yearBuilt ?? undefined,
builder: yacht.builder ?? undefined,
model: yacht.model ?? undefined,
hullMaterial: yacht.hullMaterial ?? undefined,
lengthFt: yacht.lengthFt ?? undefined,
widthFt: yacht.widthFt ?? undefined,
draftFt: yacht.draftFt ?? undefined,
lengthM: yacht.lengthM ?? undefined,
widthM: yacht.widthM ?? undefined,
draftM: yacht.draftM ?? undefined,
// Owner is required by the schema in create mode. In edit mode we
// strip it before PATCH, but we still satisfy the resolver by
// supplying the current owner.
owner: { type: yacht.currentOwnerType, id: yacht.currentOwnerId },
status: (yacht.status as YachtStatus | null) ?? 'active',
notes: yacht.notes ?? undefined,
tagIds: [],
});
} else if (!yacht && open) {
reset({
name: '',
status: 'active',
tagIds: [],
});
}
setFormError(null);
}, [yacht, open, reset]);
const mutation = useMutation({
mutationFn: async (data: CreateYachtInput) => {
if (isEdit) {
// updateYachtSchema omits owner + tagIds — strip them from PATCH body.
const { owner: _owner, tagIds: _tIds, ...rest } = data;
void _owner;
void _tIds;
await apiFetch(`/api/v1/yachts/${yacht!.id}`, {
method: 'PATCH',
body: rest,
});
} else {
await apiFetch('/api/v1/yachts', { method: 'POST', body: data });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['yachts'] });
onOpenChange(false);
},
onError: (err: Error) => {
setFormError(err.message || 'Failed to save yacht');
},
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Yacht' : 'New Yacht'}</SheetTitle>
</SheetHeader>
<form
onSubmit={handleSubmit((data) => {
setFormError(null);
mutation.mutate(data);
})}
className="space-y-6 py-6"
>
{/* Basic */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Basic
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-1">
<Label>Name *</Label>
<Input {...register('name')} placeholder="Sea Breeze" />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-1">
<Label>Hull Number</Label>
<Input {...register('hullNumber')} placeholder="HIN" />
</div>
<div className="space-y-1">
<Label>Registration</Label>
<Input {...register('registration')} placeholder="Registration #" />
</div>
<div className="space-y-1">
<Label>Flag</Label>
<Input {...register('flag')} placeholder="e.g. MT" />
</div>
<div className="space-y-1">
<Label>Year Built</Label>
<Input
type="number"
{...register('yearBuilt', { valueAsNumber: true })}
placeholder="2015"
/>
{errors.yearBuilt && (
<p className="text-xs text-destructive">{errors.yearBuilt.message}</p>
)}
</div>
</div>
</div>
<Separator />
{/* Build */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Build
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Builder</Label>
<Input {...register('builder')} placeholder="Benetti" />
</div>
<div className="space-y-1">
<Label>Model</Label>
<Input {...register('model')} placeholder="Classic 120" />
</div>
<div className="col-span-2 space-y-1">
<Label>Hull Material</Label>
<Input {...register('hullMaterial')} placeholder="Aluminium" />
</div>
</div>
</div>
<Separator />
{/* Dimensions (ft) */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Dimensions (ft)
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label>Length (ft)</Label>
<Input {...register('lengthFt')} placeholder="120" />
</div>
<div className="space-y-1">
<Label>Width (ft)</Label>
<Input {...register('widthFt')} placeholder="25" />
</div>
<div className="space-y-1">
<Label>Draft (ft)</Label>
<Input {...register('draftFt')} placeholder="8" />
</div>
</div>
</div>
<Separator />
{/* Dimensions (m) */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Dimensions (m)
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label>Length (m)</Label>
<Input {...register('lengthM')} placeholder="36.5" />
</div>
<div className="space-y-1">
<Label>Width (m)</Label>
<Input {...register('widthM')} placeholder="7.6" />
</div>
<div className="space-y-1">
<Label>Draft (m)</Label>
<Input {...register('draftM')} placeholder="2.4" />
</div>
</div>
</div>
<Separator />
{/* Ownership */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Ownership
</h3>
{isEdit ? (
<p className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
Ownership changes use the Transfer button.
</p>
) : (
<div className="space-y-1">
<Label>Owner *</Label>
<OwnerPicker
value={owner ?? null}
onChange={(v) => {
if (v) {
setValue('owner', v, { shouldValidate: true });
}
}}
/>
{errors.owner && (
<p className="text-xs text-destructive">
{errors.owner.message ?? 'Owner is required'}
</p>
)}
</div>
)}
</div>
<Separator />
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setValue('status', v as YachtStatus)}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="retired">Retired</SelectItem>
<SelectItem value="sold_away">Sold away</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* Notes */}
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
{...register('notes')}
placeholder="Internal notes about this yacht"
rows={4}
/>
</div>
<Separator />
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
</div>
{formError && (
<p className="text-sm text-destructive" role="alert">
{formError}
</p>
)}
<SheetFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
{(isSubmitting || mutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit ? 'Save Changes' : 'Create Yacht'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { YachtForm } from '@/components/yachts/yacht-form';
import { yachtFilterDefinitions } from '@/components/yachts/yacht-filters';
import { getYachtColumns, type YachtRow } from '@/components/yachts/yacht-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
export function YachtList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editYacht, setEditYacht] = useState<YachtRow | null>(null);
const [archiveYacht, setArchiveYacht] = useState<YachtRow | null>(null);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<YachtRow>({
queryKey: ['yachts'],
endpoint: '/api/v1/yachts',
filterDefinitions: yachtFilterDefinitions,
});
useRealtimeInvalidation({
'yacht:created': [['yachts']],
'yacht:updated': [['yachts']],
'yacht:archived': [['yachts']],
'yacht:ownership_transferred': [['yachts']],
});
const archiveMutation = useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/yachts/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['yachts'] });
setArchiveYacht(null);
},
});
const columns = getYachtColumns({
portSlug,
onEdit: (yacht) => setEditYacht(yacht),
onArchive: (yacht) => setArchiveYacht(yacht),
});
return (
<div className="space-y-4">
<PageHeader
title="Yachts"
description="Manage yacht records"
actions={
<PermissionGate resource="yachts" action="create">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New Yacht
</Button>
</PermissionGate>
}
/>
<div className="flex items-center gap-2">
<FilterBar
filters={yachtFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<SavedViewsDropdown
entityType="yachts"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}}
/>
</div>
{isLoading ? (
<TableSkeleton />
) : !data.length ? (
<EmptyState
title="No yachts found"
description="Get started by adding your first yacht."
action={{ label: 'New Yacht', onClick: () => setCreateOpen(true) }}
/>
) : (
<DataTable
columns={columns}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
emptyState={
<EmptyState
title="No yachts found"
description="Get started by adding your first yacht."
action={{ label: 'New Yacht', onClick: () => setCreateOpen(true) }}
/>
}
/>
)}
<YachtForm open={createOpen} onOpenChange={setCreateOpen} />
{editYacht && (
<YachtForm
open={!!editYacht}
onOpenChange={(open) => !open && setEditYacht(null)}
yacht={{
id: editYacht.id,
name: editYacht.name,
hullNumber: editYacht.hullNumber,
registration: editYacht.registration,
lengthFt: editYacht.lengthFt,
widthFt: editYacht.widthFt,
draftFt: editYacht.draftFt,
lengthM: editYacht.lengthM,
widthM: editYacht.widthM,
currentOwnerType: editYacht.currentOwnerType,
currentOwnerId: editYacht.currentOwnerId,
status: editYacht.status,
}}
/>
)}
<ArchiveConfirmDialog
open={!!archiveYacht}
onOpenChange={(open) => !open && setArchiveYacht(null)}
entityName={archiveYacht?.name ?? ''}
entityType="Yacht"
isArchived={false}
onConfirm={() => archiveYacht && archiveMutation.mutate(archiveYacht.id)}
isLoading={archiveMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/shared/empty-state';
import { OwnerLink } from '@/components/yachts/yacht-detail-header';
import { apiFetch } from '@/lib/api/client';
interface OwnershipHistoryRow {
id: string;
yachtId: string;
ownerType: 'client' | 'company';
ownerId: string;
startDate: string;
endDate: string | null;
transferReason: string | null;
transferNotes: string | null;
createdBy: string;
createdAt: string;
}
interface YachtOwnershipHistoryProps {
yachtId: string;
}
const REASON_LABELS: Record<string, string> = {
sale: 'Sale',
inheritance: 'Inheritance',
gift: 'Gift',
company_restructure: 'Company restructure',
other: 'Other',
};
function formatDate(value: string | null): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString();
}
export function YachtOwnershipHistory({ yachtId }: YachtOwnershipHistoryProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<OwnershipHistoryRow[]>({
queryKey: ['yachts', yachtId, 'ownership-history'],
queryFn: () =>
apiFetch<{ data: OwnershipHistoryRow[] }>(`/api/v1/yachts/${yachtId}/ownership-history`).then(
(r) => r.data,
),
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!data || data.length === 0) {
return (
<EmptyState
title="No ownership history"
description="This yacht's ownership transfers will appear here."
/>
);
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Start Date</TableHead>
<TableHead>End Date</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => (
<TableRow key={row.id}>
<TableCell>{formatDate(row.startDate)}</TableCell>
<TableCell>
{row.endDate ? (
formatDate(row.endDate)
) : (
<Badge variant="secondary" className="text-xs">
Current
</Badge>
)}
</TableCell>
<TableCell>
<OwnerLink portSlug={portSlug} type={row.ownerType} id={row.ownerId} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.transferReason
? (REASON_LABELS[row.transferReason] ?? row.transferReason)
: '—'}
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[320px] truncate">
{row.transferNotes ?? '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useDebounce } from '@/hooks/use-debounce';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface YachtOption {
id: string;
name: string;
hullNumber?: string | null;
registration?: string | null;
currentOwnerType?: 'client' | 'company';
currentOwnerId?: string;
}
interface YachtPickerProps {
value: string | null;
onChange: (yachtId: string | null) => void;
/** Optional filter to only show yachts owned by the given client or company. */
ownerFilter?: { type: 'client' | 'company'; id: string };
placeholder?: string;
disabled?: boolean;
}
export function YachtPicker({
value,
onChange,
ownerFilter,
placeholder = 'Select yacht...',
disabled,
}: YachtPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const debounced = useDebounce(search, 300);
const { data } = useQuery<{ data: YachtOption[] }>({
queryKey: ['yacht-picker', debounced],
queryFn: () => apiFetch(`/api/v1/yachts/autocomplete?q=${encodeURIComponent(debounced)}`),
enabled: open,
});
const rawOptions = data?.data ?? [];
const options = ownerFilter
? rawOptions.filter(
(y) => y.currentOwnerType === ownerFilter.type && y.currentOwnerId === ownerFilter.id,
)
: rawOptions;
const selectedLabel = (() => {
if (!value) return placeholder;
const match = rawOptions.find((o) => o.id === value);
return match?.name ?? `Yacht ${value.slice(0, 8)}`;
})();
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={disabled}
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
>
<span className="truncate">{selectedLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput placeholder="Search yachts…" value={search} onValueChange={setSearch} />
<CommandList>
<CommandEmpty>No yachts found.</CommandEmpty>
<CommandGroup>
{options.map((y) => (
<CommandItem
key={y.id}
value={y.id}
onSelect={() => {
onChange(y.id);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === y.id ? 'opacity-100' : 'opacity-0')}
/>
<span>
{y.name}
{y.hullNumber ? (
<span className="ml-2 text-xs opacity-60">{y.hullNumber}</span>
) : null}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

Some files were not shown because too many files have changed in this diff Show More