49 Commits

Author SHA1 Message Date
866930c943 feat(intake): residence-type capture + CRM-owned inquiry emails for website cutover
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m17s
Website register-interest form now offers the 3 residence types as a
multi-select; the choice + preferred-contact flow into the CRM inquiry
payload, the inbox detail, and the residential emails.

- inquiry inbox detail surfaces residence type(s), preferred contact,
  type-of-interest, comments (full data capture)
- residential-inquiry emails: client confirmation names the chosen
  villa(s); sales alert converted to the canonical detail-line format
  (uniform with berth/contact) + residence type(s)/preferred contact +
  plain-text part
- website-intake-fields parses residence_types[] + method_of_contact
- contact_form alerts split to their own recipient key
  (contact_notification_recipients)
- Residential Interests section: new residence_type field (schema +
  migration 0099, validators, inline select on the detail)
- contact-form-alert email refactor shipped (interest-alert style)

Tests: website-intake-fields, residential-inquiry templates,
contact-form-alert, residential-interest validators.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-25 20:58:53 +02:00
64a488dc15 fix(signing): branded embed URL for developer + Copy link
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m6s
Two bugs in the marketing-site embed-URL rewrite, both surfaced by
testing through the actual invitation emails:

1. Developer invite linked to `…/sign/undefined/<token>`. document_signers
   persists Documenso's normalized role, so the order-2 EOI developer
   arrives as 'signer', which ROLE_TO_URL_SEGMENT didn't have — the lookup
   returned undefined. Add a 'signer' → 'developer' alias and a 'cc'
   fallback so an unknown role can never emit `/sign/undefined/`.

2. "Copy link" copied the bare Documenso URL, not the branded embed URL.
   listDocumentSigners (feeds the EOI tab signers + Copy link) now runs
   each signing_url through transformSigningUrl, so the copied link
   matches what the invitation email sends. Send-invitation/automation
   read the raw rows directly, so they're unaffected.

Regression tests pin 'signer' → /sign/developer and the unknown-role
fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:14:28 +02:00
2bc2cfac6f fix(eoi): render signed-PDF preview inline (preview endpoint + font-src)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m5s
After adding frame-src the preview iframe stopped showing the broken-file
icon but went blank: it pointed at /api/v1/files/[id]/download, which
presigns with the filename so S3 returns Content-Disposition: attachment
— the browser downloaded the PDF instead of rendering it. Point the
SignedPdfPreview iframe at the existing /preview endpoint, which presigns
WITHOUT a filename (inline disposition) so the native PDF viewer renders.

Also widen font-src to include https: so react-pdf/pdf.js can load its
standard-font pack (LiberationSans*) — previously blocked by
font-src 'self' data:, breaking the pdf.js-based viewers' glyphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:45:34 +02:00
3f6f845c02 fix(csp): add frame-src so signed-PDF previews + embedded signing load
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m55s
Build & Push Docker Images / build-and-push (push) Successful in 8m22s
The CSP set default-src 'self' with no frame-src, so it fell back to
'self' for frames. Inline signed-EOI previews iframe a presigned
s3.portnimara.com URL and the admin embedded-signing card iframes the
Documenso host — both were blocked, rendering a broken-file placeholder
("Framing 'https://s3.portnimara.com/' violates ... default-src 'self'").

Add `frame-src 'self' blob: https:` to both CSP definitions (proxy.ts
middleware + next.config.ts), matching the existing https: posture of
img-src/connect-src. frame-ancestors 'none' is unchanged, so we still
can't be embedded by third parties.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:26:38 +02:00
fc994cd88b fix(eoi): silence Documenso's own lifecycle emails on createDocument
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m53s
The local-fill EOI pathway creates fresh Documenso envelopes via
createDocument, which (unlike the template pathway that inherits
template 8's all-false emailSettings) used Documenso's defaults — every
email event defaults to true on both the v1 and v2.13 APIs. So Documenso
fired its OWN unbranded "Waiting for others to complete signing." and
"Signing Complete!" emails (signed PDF attached, reply-to sales@),
bypassing EMAIL_REDIRECT_TO and duplicating the CRM's branded sends.

Force emailSettings to all-false (DOCUMENSO_SILENT_EMAIL_SETTINGS) on
every createDocument call (v1 JSON + v2 multipart). The CRM stays the
sole sender of signing comms. Verified against the live v2.13 OpenAPI +
template 8's stored meta.

Also stop the EMAIL_REDIRECT_TO gate from appending "(was: <email>)" to
the recipient NAME: a "Name" field auto-fills from it into the signed
PDF, so the annotation overlapped the signature. Redirect the email
only; the original is still logged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:20:55 +02:00
e17476f3e3 fix(eoi): v1 field placement uses percent coords, not absolute points
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m50s
Build & Push Docker Images / build-and-push (push) Successful in 8m38s
Documenso 2.x's v1-compat POST /documents/{id}/fields expects percent
coordinates (0-100), same as v2 — but placeFields multiplied by a hardcoded
A4 page size, so 39.6% became ~237 and was read as 237%, placing the EOI
signature fields far off-page. Send percent straight through (matches
template-native fields, which render correctly). Drop the now-dead
getPageDimensions/DEFAULT_PAGE_DIMENSIONS A4 fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 04:05:42 +02:00
f4cfc5600f fix(eoi): uppercase Documenso recipient roles at the API boundary
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m48s
Build & Push Docker Images / build-and-push (push) Successful in 8m9s
The local-fill EOI pathway passed lowercase roles ('signer'/'approver') to
createDocument; Documenso's API requires UPPERCASE (CC|SIGNER|VIEWER|APPROVER|
ASSISTANT) and rejected them with a 400 "Invalid enum value", surfacing as a
502 DOCUMENSO_UPSTREAM_ERROR on generate. Normalize role to uppercase where
safeRecipients is built so both v1 + v2 paths and all callers are covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 03:37:14 +02:00
0ca9b2c3b5 feat(eoi): toggleable local-fill pathway — clean detail render + address wrapping
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m48s
Build & Push Docker Images / build-and-push (push) Successful in 8m34s
EOI detail fields (address, name, yacht, berth) rendered oversized and
top-clipped because Documenso auto-sizes AcroForm text when *it* fills the
template (ignores the PDF's 12pt font; a taller box → bigger font → more clip,
and a 2-line address box renders huge). Proven: filling the same source PDF
locally at 12pt renders cleanly and wraps long addresses to a 2nd line.

Add a per-port `eoi_fill_method` setting (default `local`), toggleable in
admin → Documenso → Templates & signing pathway:
- local:     CRM fills + flattens the source PDF (pdf-lib, fixed 12pt +
             multiline address wrap), uploads the flattened PDF to Documenso,
             and places ONLY the 6 page-3 signature fields. Documenso never
             re-renders the body text → no clipping.
- documenso: legacy template AcroForm fill (auto-sizes/clips) — fallback only.

Both still flow through Documenso for signing, so branded invites, embedded
signing, webhooks, signer rows, and the EOI milestone are unchanged.

- computeEoiSignatureLayout(): 6 page-3 fields at template-8 coords (unit-tested)
- createDocument (v1): PUT bytes to Documenso's presigned uploadUrl (2.x v1-compat
  ignores the base64 field) so the uploaded document actually has content
- placeFields (v1): pass fieldMeta through so the Place-of-Signing TEXT field
  keeps its label/required

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:48:11 +02:00
af05bb18dc fix(documenso): follow v1 /download JSON {downloadUrl} to fetch the real signed PDF
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m47s
Build & Push Docker Images / build-and-push (push) Successful in 8m39s
Documenso 2.13's v1-compat `GET /api/v1/documents/{id}/download` returns JSON
`{ downloadUrl }` (a presigned S3 URL), not raw PDF bytes. `downloadSignedPdf`
was doing `res.arrayBuffer()` directly, so it stored the ~500-byte JSON as the
"signed PDF" — every signer got a corrupt attachment ("Adobe could not open …
damaged") and the corrupt file was filed against the deal in the CRM.

Fix: magic-byte detection — if the v1 /download body isn't a `%PDF-`, parse the
JSON and follow `downloadUrl` to fetch the actual file; validate the result is a
real PDF before returning. Backward-compatible with older v1 servers that return
the PDF directly. This also protects the CRM deposit + email fan-out, since both
consume downloadSignedPdf's buffer — a non-PDF now throws instead of depositing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
2026-06-24 23:55:16 +02:00
1c91d76c52 feat(signing): internal "who signed" + completion email alerts to configurable recipients
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m56s
Adds a per-port `signing_notification_recipients` setting (users / roles /
explicit emails via the existing RecipientPicker) and fires a branded
internal email on (a) each party signing and (b) full completion —
replicating the legacy "Document Signed" / "EOI Complete Update Status"
Activepieces flows that staff relied on.

- New branded template `signing-status-notification.tsx` (per-signer
  progress + completion variants, deep-links into the CRM).
- New `sendSigningStatusNotification` resolver in document-signing-emails:
  resolves recipients, falls back to the port reply-to (sales@) when the
  list is empty so alerts are never silently dropped, per-recipient send.
- Wired into `handleRecipientSigned` (first signed transition) and
  `handleDocumentCompleted` (idempotent, fires once) — reached by both the
  Documenso webhook and the 5-min poll. Fully guarded so a notification
  failure never undoes a signing side effect. Respects EMAIL_REDIRECT_TO.
- Admin UI: `Document signing alerts` recipients card in settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
2026-06-24 21:38:22 +02:00
352b2420b7 fix(ui): mobile cutoff polish — onboarding banner + yacht owner truncate (R1/R2)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m2s
Build & Push Docker Images / build-and-push (push) Successful in 8m28s
Responsive-overflow sweep findings (tests/e2e/matrix/responsive-overflow.spec.ts):

- R1: the onboarding banner's verbose "N of M steps done. Next: <link>" was
  clipped on mobile (extended ~160px past a 390px viewport) and duplicated the
  always-visible "View checklist" button. Now hidden below sm:; mobile shows
  just "Setup X% complete" + the checklist button.
- R2: yacht card owner subtitle used inline-flex + truncate, so a long owner
  name overflowed ~11px on the narrowest widths. Switched to flex min-w-0 so it
  truncates within the card.
- Detector: skip SVG internals (icons / the react-grab dev overlay) and elements
  inside overflow-x scroll containers (data tables scroll on purpose) to drop
  false positives. Sweep now confirms mobile/tablet clean + no real desktop
  overflow (berths wide table is the DataTable's intended horizontal scroll).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:23:56 +02:00
459c68a2c3 feat(rbac): residential-partner route lockdown + role-aware mobile nav
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m0s
Build & Push Docker Images / build-and-push (push) Successful in 8m32s
UAT (residential partners must have zero access to anything non-residential;
no marina dashboard). Server-side their permission map already 403s every
marina domain — this locks the client surface to match:

- AppShell: a residential-only user (residential_clients.view && !clients.view,
  non-super-admin) is redirected off ANY non-residential route to
  /residential/clients. Blocks the marina dashboard + every marina page in one
  place; personal surfaces (settings, inbox) stay reachable. (Fixes F4 — they
  no longer land on a marina dashboard of 403-ing empty widgets.)
- Mobile bottom tabs were hardcoded Dashboard/Clients/Berths regardless of role;
  now role-aware — residential-only users get Residential Clients/Interests
  instead of marina tabs they 403 on. (Fixes F5.)
- e2e: stale `#email` login selector → `#identifier` (smoke helper) — a real
  reason the smoke auth specs fail independent of the dev-server OOM.
- New crash-safe `matrix` Playwright project (role×viewport access matrix +
  responsive overflow sweep) — lean alternative to the full suite which
  OOM-crashes next dev locally.

Verified: matrix run shows residential_partner redirected to residential +
residential-scoped mobile tabs; 403s unchanged; tsc + eslint + 42 permission
tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:53:22 +02:00
adc9802361 fix(rbac): sales/operational roles see deal alerts; quiet admin-only onboarding probe
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m3s
Build & Push Docker Images / build-and-push (push) Successful in 8m23s
UAT findings from the Sales-role functional walkthrough:

F1 — The deal-alert feed (stale interest, hot-lead-silent, EOI unsigned,
signer overdue, reservation-needs-agreement, berth stalled, expense dupes)
was gated on admin.view_audit_log, so salespeople got a 403 on the Alerts
inbox. None of the 9 alert rules are audit/security signals — they're all
operational — so re-gate the list route to interests.view (sales, director,
viewer get it; external residential partners don't) and hide the Alerts
section in the inbox for users without it instead of letting the query 403.

F2 — Non-admins triggered /api/v1/admin/onboarding/status (admin-only) and
ate a 403 in the console. Make useOnboardingStatus strictly opt-in
(enabled: opts.enabled === true) so a transient/stale isSuperAdmin during
permission hydration can't fire the privileged request.

1664 vitest pass; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:49:12 +02:00
d8f739a7c2 feat(rbac): GDPR export becomes a toggleable clients.gdpr_export permission
Previously the GDPR export trigger + download routes were gated by
admin.manage_settings, so sales roles couldn't run a client data export.
Per request, make it a dedicated, toggleable permission that's on by
default for sales-capable roles and hides the button when withheld.

- New RolePermissions leaf clients.gdpr_export (+ PERMISSION_CATALOG entry);
  strict type forces every role map + fixture to declare it.
- Granted true for super_admin / director / sales_manager / sales_agent;
  false for viewer / residential_partner.
- GDPR export POST (trigger) and [exportId] GET (download) re-gated from
  admin.manage_settings -> clients.gdpr_export.
- GdprExportButton visibility now keys off clients.gdpr_export, so toggling
  it off per-user hides the function entirely.
- Migration 0098 backfills the key onto existing role rows (idempotent).

Verified end-to-end as a Sales user: trigger (202) -> worker build (ready)
-> list (200) -> download (200). 1664 vitest pass; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:20:31 +02:00
93989b1e1d feat(admin): single Sales role, welcome-email password setup, Director=sales
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m24s
- Collapse the two sales roles in the create-user dropdown to one "Sales"
  (sales_manager relabelled). Hide super_admin + sales_agent from selection
  via NON_ASSIGNABLE_ROLE_NAMES; the form keeps a user's *current* role even
  if hidden so existing assignments stay editable.
- Director becomes a senior-title twin of Sales: DIRECTOR_PERMISSIONS now
  equals SALES_MANAGER_PERMISSIONS (no admin/settings — Super-Admin only).
  Migration 0097 updates the existing global director row (idempotent,
  data-only; 0 users assigned on prod, so no blast radius).
- Admin create-user defaults to emailing a set-password link instead of an
  inline password (manual entry still available via a toggle). createUserSchema:
  password optional + sendSetupEmail; createUser provisions with a throwaway
  password then triggers the set-password email.
- New users get a dedicated, unique WELCOME email (crmWelcomeEmail), not the
  self-service "reset your password" email. A pending-welcome flag routes the
  shared better-auth sendResetPassword callback via account-setup-email.ts.
- Phone confirmed already optional for staff accounts (no change needed).

Tests: +welcome-routing, +create-user-setup; permission-matrix director block
realigned to no-admin. 1662 vitest pass; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:40:55 +02:00
5b9560531e fix(ui): remove PN brand mark from mobile topbar; balance title with spacer
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m59s
Build & Push Docker Images / build-and-push (push) Successful in 8m32s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:55:18 +02:00
f55be14813 test(berths): CM-2 — drop unused var in price-reconcile test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:53:24 +02:00
6bc81270b9 feat(interests): CM-2 Part B — deal-price override route + UI on linked berths
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:48:38 +02:00
38e392e38b feat(interests): CM-2 Part B — EOI/doc generation honours berth price override
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:41:42 +02:00
039ef25fe5 feat(interests): CM-2 Part B — interest_berths price override (data + resolver)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:40:17 +02:00
b3753b96a1 feat(berths): CM-2 — bulk price-reconcile admin page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:38:08 +02:00
9147f2857e feat(berths): CM-2 — price-reconcile API (list + bulk apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:35:09 +02:00
47778796ad feat(berths): CM-2 — bulk price-reconcile service (parse + apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:33:40 +02:00
f7425d1231 fix(berths): CM-2 — robust purchase-price extraction (clean-token + magnitude floor)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:30:12 +02:00
df8c26d1b3 feat(proxies): CM-9 UI — ProxyCard on client, interest, and yacht detail pages
- shared ProxyCard (view/add/edit/remove point-of-contact) reading each entity's
  /[id]/proxy sub-resource; permission-gated on the entity's edit right
- wired into the client overview, interest overview, and yacht overview tabs

Completes CM-9. tsc clean, lint 0 errors, prod build green, 1638 vitest pass.
Comms send-side wiring (route EOIs/emails through resolveEffectiveProxy) is a
deliberate follow-up — the resolver + data are ready for it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:01:08 +02:00
91703bdb00 feat(proxies): CM-9 backend — polymorphic point-of-contact + resolver
- proxies table (migration 0095, port_id cascade), one per client/interest/yacht
- service: get/set(upsert)/clear + resolveEffectiveProxy (yacht → interest →
  client precedence), port-scoped with entity-in-port guard
- per-entity sub-resource routes (/clients|interests|yachts/[id]/proxy) reusing
  each entity's existing view/edit permission (no new permission resource)
- 3 integration tests (CRUD/upsert, tenant guard, resolution precedence)

Backend only — ProxyCard UI on the 3 detail pages to follow. tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:54:47 +02:00
3165ec651f feat(client-groups): CM-1 API routes + UI (list, member viewer, copy-emails)
- /api/v1/client-groups (list/create), /[id] (get/patch/delete),
  /[id]/members (get/set) — route.ts + handlers.ts split, client_groups perms
- Client Groups list page (grid + create dialog) and detail page
  (member viewer, per-row copy email, "Copy all emails" → To:-bar format,
  manage-members picker over /api/v1/clients)
- Sidebar nav entry (UsersRound icon)

tsc clean, lint 0 errors, prod build green. Completes CM-1 (Mailchimp push
still deferred until client creds/account).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:49:29 +02:00
661187cc79 feat(client-groups): CM-1 data layer — groups entity, membership, service, Mailchimp scaffold
- client_groups + client_group_members tables (migration 0094, port_id cascade)
- client_groups permission resource (view/manage) in catalog + role backfill
- service: CRUD + wipe-and-rewrite membership + member email resolution
- mailchimp.service scaffold: config reader + inert one-way sync (mapping
  deferred until the client's MC account is wired, per CM-1 decision)
- 4 integration tests (CRUD, membership, email resolution, port-scope guard)

Backend only — API routes + UI to follow. tsc clean, 1635 vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:28:20 +02:00
4dc0bdd8c4 feat(crm): client-meeting batch — contact-pill cleanup, assignment toggle, receipt manual mode
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 9m16s
CM-4: remove Email/Call/WhatsApp deep-link pills from the client + interest
  detail headers; relocate GDPR export into the client-header action cluster
  as a compact icon. Keeps the interest "Log contact" quick action.
CM-5: gate the interest assignment feature behind a per-port `assignment_enabled`
  setting (default OFF for single-rep ports). Hides the AssignedToChip +
  residential assigned-to row and skips tier-2/3 auto-assign on create; the
  column + data are preserved and reversible. Tests cover the auto-assign guard.
CM-6: add a per-port `manualEntry` receipt mode (skip all parsing → empty form).
  Threaded through ocr-config.service, the admin OCR form, the scan-receipt
  route, and the scanner shell (skips Tesseract + the server call). Tests cover
  the save/resolve round-trip.

Verified: tsc clean, lint 0 errors, 1631 vitest pass, prod build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:42:36 +02:00
7f04c765f4 fix(crm): inquiry detail polish, EOI preview mime, EOI next-step, documenso v1 banner
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 7m43s
- inquiries: format triage badges with labels (Open/Assigned/Converted/Dismissed),
  surface the lead's free-text message for every kind, and gate the raw-payload
  tab to super admins (was exposing raw JSON to all users)
- file preview: fall back to the server-resolved mime (getPreviewUrl already
  returns it) so files whose stored name lacks a .pdf extension — e.g.
  migration-backfilled signed EOIs — render instead of "preview not supported"
- interest overview: a signed EOI left at stage=eoi no longer shows as
  "NEXT STEP"; completion ordering rolls the next step to Reservation (display
  only, no pipeline_stage change)
- documenso admin: warning banner discouraging the deprecated v1 API + what
  breaks on it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:36:35 +02:00
4d018be800 feat(inquiries): one-off NocoDB historical contact-form import (idempotent, dry-run default)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m1s
Build & Push Docker Images / build-and-push (push) Successful in 8m22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:28:01 +02:00
95d7776bb6 test(inquiries): drop unused import 2026-06-17 18:25:13 +02:00
0cc05f302f feat(inquiries): top-level Inquiries page (list + detail + convert), nav entries; retire admin inbox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:23:13 +02:00
54554a0928 feat(inquiries): list/get/triage/convert service + API routes (find-or-create client)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:09:59 +02:00
9879b82e5f feat(inquiries): website_submissions tracking + display columns; capture populates contact name/email
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:03:47 +02:00
08adb4aeea feat(permissions): add inquiries resource (view/manage) + idempotent role backfill
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:59:32 +02:00
6c4490f653 feat(alerts): always-visible dismiss/ack actions + Dismiss all (service, endpoint, UI)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:53:12 +02:00
13efe177a5 feat(alerts): split interest.stale into worked-then-quiet + new-untouched (interest.no_activity)
- interest.stale now fires only for interests with real in-system follow-up
  (contact log / note / update audit) that went quiet 14+ days.
- new interest.no_activity rule covers never-touched, non-imported interests.
- guard interest.high_value_silent against imported-untouched hot leads.
- keys off migration_source_links ledger to identify the bulk import, so the
  imported backlog matches neither rule and the engine auto-resolves the flood.
- test teardown: delete interest_contact_log + test migration ledger rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:49:22 +02:00
7591231c47 test(e2e): add Initiative 4 end-to-end + integration specs
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m12s
Build & Push Docker Images / build-and-push (push) Successful in 8m24s
Sales-process coverage (launch-readiness Initiative 4):
- exhaustive: full 7-stage sales journey + illegal-skip rejection + deposit
  total + tenancy/berth-sold; multi-berth EOI berth-range; EOI pathway parity
  (in-app vs Documenso, shared EoiContext); mobile-viewport journey.
- realapi (Documenso-gated, opt-in): generate-and-sign + post-EOI stages.
- integration: Documenso DOCUMENT_COMPLETED webhook idempotency (3x replay ->
  single file/audit write); storage backend swap (s3 <-> filesystem) with a
  real on-disk filesystem round-trip.
- visual: Reports UI snapshot cases (baselines captured separately).

1615 unit/integration pass; tsc + lint clean. Test-only change (specs are not
bundled into the app image) - no app behavior modified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:10:35 +02:00
2e8c4b43bf fix(backup): install pg_dump (postgresql16-client) in app + worker images
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 8m54s
The DR backup engine spawns `pg_dump` (backup.service.ts), but neither
runner image installed a postgres client — so producing a bundle fails
in prod with ENOENT (only worked in dev, where the host has pg_dump).
Surfaced by testing the feature on the live prod container.

Add `postgresql16-client` (pg_dump 16.x, matched to the postgres:16
server) to the runner stage of Dockerfile (crm-app: on-demand export +
"back up now") and Dockerfile.worker (scheduled backup-push cron).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:58:14 +02:00
fe863a588e feat(backup): full DR bundle export + admin-configurable offsite destinations
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 11m59s
Backend-agnostic disaster-recovery backup engine that runs on the current
storage backend (no storage cutover required):

- Full-bundle export: db.dump (pg_dump custom) + every storage blob +
  manifest.json with per-object SHA-256, streamed as a tar. Entry points:
  admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts.
- Admin-configurable push destinations (backup_destinations table, migration
  0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted
  path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM
  at rest; API returns only *IsSet markers.
- Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) +
  scripts/decrypt-backup.ts for restore.
- Wired the previously-dead database-backup cron to runScheduledBackupPush
  (push to enabled destinations, prune to retention, alert super-admins on
  failure).

Tests: 1608 unit/integration pass; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:23:42 +02:00
05950ae0b6 feat(uat): file preview/download fix, clients-by-country page, residential column picker
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m42s
Build & Push Docker Images / build-and-push (push) Successful in 7m20s
Batch #4 UAT items.

1. Documents — clicking any file dumped raw presigned-URL JSON. Was
   systemic: 6 surfaces linked a browser directly at the JSON-returning
   /files/[id]/{download,preview} routes. Those routes now 302-redirect
   when called with ?redirect=1 (default stays JSON for the dialog +
   interest-eoi-tab programmatic consumers); the six <Link> sites use it.
   The documents-hub file row now opens the inline FilePreviewDialog +
   has a per-row Download button, and the preview dialog header gained a
   persistent Download button for all file types.

2. Clients-by-country — the widget's "+N more" dead text is now a
   "Show all" link to a new /clients/by-country page rendering the full
   ranked country breakdown (each row drills into the filtered list).

3. Residential clients list — moved off its bespoke table onto the
   shared DataTable + ColumnPicker (same UX as clients/interests). Adds
   a "Date added" column, default-hides the empty "Residence" column,
   preserves the mobile card view, persists per-user column choices.

tsc clean, eslint clean, 1584/1584 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:34:47 +02:00
eff57af571 fix(storage): make S3 server-side-encryption optional (default off)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m46s
Build & Push Docker Images / build-and-push (push) Successful in 7m53s
Prod MinIO has no KMS/KES, so the unconditional
`x-amz-server-side-encryption: AES256` header on every PutObject was
rejected with `NotImplemented` ("KMS not configured") — breaking ALL
server-side uploads on prod: avatars, the signed-PDF deposit on
Documenso completion, GDPR exports, the nightly DB backup, generated
EOI/contract PDFs, report renders. Reads/presigned downloads were
unaffected, so the cutover walkthrough missed it.

The SSE header is now sent only when explicitly configured via the
per-port `storage_s3_sse` setting (or the STORAGE_S3_SSE env fallback);
the default is off so a vanilla S3-compatible backend accepts uploads.
This also resolves the put()-encrypts-but-presignUpload-doesn't
asymmetry — presigned PUTs never sent SSE, so both paths now match by
default.

Extracted buildPutObjectMetadata() as a pure, unit-tested helper.

Interim fix; the planned filesystem-storage migration removes SSE from
the prod path entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:08:41 +02:00
1750e265e7 feat(berths): inline spec-PDF preview, manual-pin badge, maintenance module toggle, under-offer popover
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m45s
Build & Push Docker Images / build-and-push (push) Successful in 8m11s
Post-cutover UAT batch #3:
- #62 Spec tab renders the current berth spec PDF inline (lazy PdfViewer,
  toggleable, default-open) + explicit download. Interest Documents tab
  already previews/downloads linked deal docs inline (verified).
- #57 Surface berths.status_override_mode through the interest-berths API;
  linked-berth rows show an amber "Pin overrides pitch" badge + corrected
  consequence copy when a berth is specifically-pitched but manually pinned
  (the soft-pin wins on the public map).
- #63 New maintenance-module gate (maintenance_module_enabled, default on):
  registry + admin Settings toggle, maintenance-module.service, port-provider
  useMaintenanceModuleEnabled, layout wiring, buildBerthTabs hides the
  Maintenance tab when off, and both maintenance log routes assert the gate.
- #66 BerthOccupancyChip: >1 competing interest opens a popover listing every
  deal (name + stage + in-EOI/primary + link); single stays a direct link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:15:04 +02:00
2a7f922a01 fix(uat): dashboard snapshots current-state, pulse-chip gate, phone display, chip width
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m54s
Build & Push Docker Images / build-and-push (push) Successful in 8m10s
- pipeline funnel: count active interests by current stage (drop created_at
  window) — backfill had collapsed it to early stages (UAT 2026-06-03)
- pipeline value tile: render current-state (don't thread the date range)
- deal pulse chip: gate on the pulse_enabled master toggle (default ON) —
  was rendering even when admin turned it off; useFeatureFlag gains a
  default arg + the feature-flag endpoint a ?default= param (default-ON safe)
- contact phone display: show international format + country flag (E164),
  not the bare national format that hid the country
- berths: remove the dead row-density toggle; widen "Under offer to" chip on
  desktop so client names aren't truncated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:27:56 +02:00
39c19b2340 feat(berths): click-to-change status from the list (chip → reason modal)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m11s
Build & Push Docker Images / build-and-push (push) Successful in 13m12s
Adds BerthStatusQuickEdit — wraps the status chip on the berths list (card +
table) in a click target that opens a compact change-status dialog: status
dropdown + required reason (quick-pick chips) + optional interest link when
moving to under_offer/sold. Reuses the existing PATCH /api/v1/berths/[id]/status
endpoint + validator + audit (same capability the detail page already had).
Gated by berths.edit (non-editors see a plain chip); stops click propagation
so it doesn't also navigate into the berth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:01:40 +02:00
d1f6d6a427 feat(eoi): signed-EOI hero + send-signed-copy; fix search dropdown z-order
- EOI tab: when an EOI is already signed and none is in flight, lead with a
  SignedEoiCard (preview + download + send-to-client) instead of the big
  "Generate EOI" empty state; quiet "Generate new EOI" remains for re-issue
- history rows + hero gain a "Send to client" action — POST
  /api/v1/documents/[id]/send-signed-copy emails the deal's client the
  finalized signed PDF (sendSignedCopyToClient reuses sendSigningCompleted),
  guarded by a confirm
- topbar: header gets z-30 so the global search dropdown paints above page
  content (charts/tables were bleeding through — header + main are sibling
  normal-flow boxes, so the dropdown's own z-50 couldn't win cross-context).
  Stays below the z-50 modal tier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:55:28 +02:00
3b227fe9b2 feat(files): in-app .docx preview + allow office/text mimes
- .docx now renders client-side via docx-preview (fetches bytes from our
  own storage; works with private MinIO/disk). Drops Microsoft's hosted
  Office viewer which can't reach a private object store.
- add office (.docx/.doc/.xlsx/.xls) + text/csv to PREVIEWABLE_MIMES so
  /api/v1/files/[id]/preview returns a URL instead of rejecting them
  (was surfacing as a misleading "Failed to load preview")
- legacy .doc + spreadsheets fall through to a download CTA (can't render
  client-side); text/csv use the existing TextPreview

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:45:11 +02:00
95724c8e3a fix(uat): prod UAT batch — reports, sidebar, search, berths, breakpoint
- financial report: drop Expenses KPI, Net Contribution, cash-flow chart,
  expense donut + ledger (expenses are business-trip costs, not net contribution)
- dashboard report PDF: pagination-safe tables (TableSection + per-row wrap)
  so long doc lists no longer overlap/crush
- clients PDF report: rename "Nationality" -> "Country"
- sidebar: hide a section header when all its items gate off (FINANCIAL orphan)
- topbar: move global search into the 1fr grid track so it can't overlap "New"
- clients card: show all linked berths (not just latest interest's primary)
- berths list: hide table-only toggles (ft/m, density, columns) in card mode
- lists: lower table/card breakpoint lg -> md so narrow desktops get tables
- alert-rules: stale floor created_at -> updated_at (survives created_at backfill)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:41:31 +02:00
258 changed files with 14466 additions and 1457 deletions

3
.gitignore vendored
View File

@@ -65,3 +65,6 @@ tmp/
# Internal docs + Claude instructions: kept local-only, not in the shared repo # Internal docs + Claude instructions: kept local-only, not in the shared repo
docs/ docs/
/CLAUDE.md /CLAUDE.md
# Client-facing feature screenshots (real PII — do not commit)
docs/feature-screenshots/

View File

@@ -59,6 +59,9 @@ RUN apk add --no-cache --virtual .merge-deps rsync \
&& rsync -a --ignore-existing /opt/prod-node-modules/ ./node_modules/ \ && rsync -a --ignore-existing /opt/prod-node-modules/ ./node_modules/ \
&& rm -rf /opt/prod-node-modules \ && rm -rf /opt/prod-node-modules \
&& apk del .merge-deps && apk del .merge-deps
# pg_dump for the backup/DR bundle engine (src/lib/services/backup.service.ts
# spawns `pg_dump`). Version pinned to match the postgres:16 server.
RUN apk add --no-cache postgresql16-client
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \

View File

@@ -26,6 +26,9 @@ FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
WORKDIR /app WORKDIR /app
# pg_dump for the scheduled backup-push cron (maintenance worker runs
# runScheduledBackupPush → pg_dump). Pinned to match the postgres:16 server.
RUN apk add --no-cache postgresql16-client
RUN chown -R worker:nodejs /app RUN chown -R worker:nodejs /app
USER worker USER worker
COPY --chown=worker:nodejs package.json pnpm-lock.yaml ./ COPY --chown=worker:nodejs package.json pnpm-lock.yaml ./

View File

@@ -51,8 +51,13 @@ const csp = [
`script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`, `script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`,
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:", "img-src 'self' data: blob: https:",
"font-src 'self' data:", // https: so react-pdf/pdf.js can load its standard-font pack + branding fonts.
"font-src 'self' data: https:",
`connect-src 'self' ws: wss: https:${devConnectHosts}`, `connect-src 'self' ws: wss: https:${devConnectHosts}`,
// PDF previews iframe a presigned storage URL; embedded-signing iframes the
// Documenso host. Both are per-port/per-env, so allow https: (matching
// img-src). frame-ancestors 'none' still blocks others from embedding us.
"frame-src 'self' blob: https:",
"frame-ancestors 'none'", "frame-ancestors 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",

View File

@@ -80,6 +80,7 @@
"country-flag-icons": "^1.6.17", "country-flag-icons": "^1.6.17",
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"docx-preview": "^0.3.7",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
@@ -125,6 +126,7 @@
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"ssh2-sftp-client": "^12.1.1",
"svgo": "^4.0.1", "svgo": "^4.0.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
@@ -153,6 +155,7 @@
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/topojson-client": "^3.1.5", "@types/topojson-client": "^3.1.5",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",

View File

@@ -24,6 +24,28 @@ export default defineConfig({
name: 'setup', name: 'setup',
testMatch: /smoke\/global-setup\.ts/, testMatch: /smoke\/global-setup\.ts/,
}, },
{
// Permission-matrix UX sweep. Users + roles are seeded separately via
// `pnpm tsx tests/e2e/permissions/seed-permission-matrix.ts` (no global
// setup dependency — relies on the already-seeded dev DB).
name: 'permissions',
testMatch: /permissions\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
// Lean role × viewport access matrix. Users pre-seeded (admin/director/
// sales/viewer/residential_partner) — no global-setup dependency. Few
// route compilations, so it stays under the dev-server OOM threshold.
name: 'matrix',
testMatch: /matrix\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{ {
name: 'smoke', name: 'smoke',
testMatch: /smoke\/\d{2}-.*\.spec\.ts/, testMatch: /smoke\/\d{2}-.*\.spec\.ts/,

121
pnpm-lock.yaml generated
View File

@@ -157,6 +157,9 @@ importers:
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
docx-preview:
specifier: ^0.3.7
version: 0.3.7
drizzle-orm: drizzle-orm:
specifier: ^0.45.2 specifier: ^0.45.2
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9) version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)
@@ -292,6 +295,9 @@ importers:
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
ssh2-sftp-client:
specifier: ^12.1.1
version: 12.1.1
svgo: svgo:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -371,6 +377,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@types/ssh2-sftp-client':
specifier: ^9.0.6
version: 9.0.6
'@types/topojson-client': '@types/topojson-client':
specifier: ^3.1.5 specifier: ^3.1.5
version: 3.1.5 version: 3.1.5
@@ -3092,6 +3101,9 @@ packages:
'@types/node@14.18.63': '@types/node@14.18.63':
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@20.19.41': '@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
@@ -3124,6 +3136,12 @@ packages:
'@types/readdir-glob@1.1.5': '@types/readdir-glob@1.1.5':
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
'@types/ssh2-sftp-client@9.0.6':
resolution: {integrity: sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==}
'@types/ssh2@1.15.5':
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
'@types/tedious@4.0.14': '@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
@@ -3579,6 +3597,9 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
assertion-error@2.0.1: assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -3691,6 +3712,9 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
better-auth@1.6.11: better-auth@1.6.11:
resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==} resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==}
peerDependencies: peerDependencies:
@@ -3853,6 +3877,10 @@ packages:
resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
engines: {node: '>=0.2.0'} engines: {node: '>=0.2.0'}
buildcheck@0.0.7:
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
engines: {node: '>=10.0.0'}
bullmq@5.76.8: bullmq@5.76.8:
resolution: {integrity: sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ==} resolution: {integrity: sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ==}
engines: {node: '>=12.22.0'} engines: {node: '>=12.22.0'}
@@ -3992,6 +4020,10 @@ packages:
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
conf@15.1.0: conf@15.1.0:
resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==} resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -4020,6 +4052,10 @@ packages:
country-flag-icons@1.6.17: country-flag-icons@1.6.17:
resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==} resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==}
cpu-features@0.0.10:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
crc-32@1.2.2: crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@@ -4226,6 +4262,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
docx-preview@0.3.7:
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
dom-serializer@2.0.0: dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -5717,6 +5756,9 @@ packages:
msgpackr@2.0.1: msgpackr@2.0.1:
resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==} resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==}
nan@2.27.0:
resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==}
nanoid@3.3.12: nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -6623,6 +6665,14 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
ssh2-sftp-client@12.1.1:
resolution: {integrity: sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==}
engines: {node: '>=18.20.4'}
ssh2@1.17.0:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
stable-hash@0.0.5: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -6960,6 +7010,9 @@ packages:
tw-animate-css@1.4.0: tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -6988,6 +7041,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript-eslint@8.59.3: typescript-eslint@8.59.3:
resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==} resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -7015,6 +7071,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -9984,6 +10043,10 @@ snapshots:
'@types/node@14.18.63': {} '@types/node@14.18.63': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@20.19.41': '@types/node@20.19.41':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -10024,6 +10087,14 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.19.41 '@types/node': 20.19.41
'@types/ssh2-sftp-client@9.0.6':
dependencies:
'@types/ssh2': 1.15.5
'@types/ssh2@1.15.5':
dependencies:
'@types/node': 18.19.130
'@types/tedious@4.0.14': '@types/tedious@4.0.14':
dependencies: dependencies:
'@types/node': 20.19.41 '@types/node': 20.19.41
@@ -10576,6 +10647,10 @@ snapshots:
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
is-array-buffer: 3.0.5 is-array-buffer: 3.0.5
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
assertion-error@2.0.1: {} assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {} ast-types-flow@0.0.8: {}
@@ -10657,6 +10732,10 @@ snapshots:
baseline-browser-mapping@2.10.29: {} baseline-browser-mapping@2.10.29: {}
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5
better-auth@1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(mongodb@7.1.0(socks@2.8.8))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6): better-auth@1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(mongodb@7.1.0(socks@2.8.8))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6):
dependencies: dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
@@ -10797,6 +10876,9 @@ snapshots:
buffers@0.1.1: {} buffers@0.1.1: {}
buildcheck@0.0.7:
optional: true
bullmq@5.76.8: bullmq@5.76.8:
dependencies: dependencies:
cron-parser: 4.9.0 cron-parser: 4.9.0
@@ -10930,6 +11012,13 @@ snapshots:
concat-map@0.0.1: {} concat-map@0.0.1: {}
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
conf@15.1.0: conf@15.1.0:
dependencies: dependencies:
ajv: 8.20.0 ajv: 8.20.0
@@ -10965,6 +11054,12 @@ snapshots:
country-flag-icons@1.6.17: {} country-flag-icons@1.6.17: {}
cpu-features@0.0.10:
dependencies:
buildcheck: 0.0.7
nan: 2.27.0
optional: true
crc-32@1.2.2: {} crc-32@1.2.2: {}
crc32-stream@4.0.3: crc32-stream@4.0.3:
@@ -11146,6 +11241,10 @@ snapshots:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
docx-preview@0.3.7:
dependencies:
jszip: 3.10.1
dom-serializer@2.0.0: dom-serializer@2.0.0:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
@@ -12726,6 +12825,9 @@ snapshots:
optionalDependencies: optionalDependencies:
msgpackr-extract: 3.0.3 msgpackr-extract: 3.0.3
nan@2.27.0:
optional: true
nanoid@3.3.12: {} nanoid@3.3.12: {}
nanostores@1.3.0: {} nanostores@1.3.0: {}
@@ -13755,6 +13857,19 @@ snapshots:
split2@4.2.0: {} split2@4.2.0: {}
ssh2-sftp-client@12.1.1:
dependencies:
concat-stream: 2.0.0
ssh2: 1.17.0
ssh2@1.17.0:
dependencies:
asn1: 0.2.6
bcrypt-pbkdf: 1.0.2
optionalDependencies:
cpu-features: 0.0.10
nan: 2.27.0
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
stackback@0.0.2: {} stackback@0.0.2: {}
@@ -14093,6 +14208,8 @@ snapshots:
tw-animate-css@1.4.0: {} tw-animate-css@1.4.0: {}
tweetnacl@0.14.5: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -14136,6 +14253,8 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 reflect.getprototypeof: 1.0.10
typedarray@0.0.6: {}
typescript-eslint@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): typescript-eslint@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
@@ -14162,6 +14281,8 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
which-boxed-primitive: 1.1.1 which-boxed-primitive: 1.1.1
undici-types@5.26.5: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici@7.25.0: {} undici@7.25.0: {}

View File

@@ -0,0 +1,48 @@
/**
* Produce a full disaster-recovery bundle (db.dump + every blob + manifest.json)
* to a local file. Same code path as the admin "Download full backup" button
* (`createFullBackupTar`), minus the HTTP layer — for headless/ops use and for
* rehearsing the restore runbook (docs/backup-restore-runbook.md).
*
* pnpm tsx scripts/create-full-backup.ts [outfile.tar]
*
* Defaults the output name to ./pn-crm-backup-<timestamp>.tar in the CWD.
*/
import 'dotenv/config';
import { copyFile } from 'node:fs/promises';
import path from 'node:path';
import { createFullBackupTar } from '@/lib/services/backup-export.service';
import { logger } from '@/lib/logger';
async function main(): Promise<void> {
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
try {
const dest = path.resolve(process.argv[2] ?? filename);
await copyFile(tarPath, dest);
logger.info(
{
dest,
storageBackend: manifest.storageBackend,
dbDumpBytes: manifest.database.sizeBytes,
blobs: manifest.counts.blobs,
blobBytes: manifest.counts.blobBytes,
skipped: manifest.counts.skipped,
},
'Full backup written',
);
if (manifest.skipped.length) {
logger.warn({ skipped: manifest.skipped }, 'Some referenced blobs were missing in storage');
}
} finally {
await cleanup();
}
}
main()
.then(() => process.stdout.write('', () => process.exit(0)))
.catch((err) => {
logger.error({ err }, 'Full backup failed');
process.stderr.write('', () => process.exit(1));
});

31
scripts/decrypt-backup.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Decrypt an encrypted backup bundle (`*.tar.enc`) produced when a destination
* has bundle encryption enabled. Restore step — see
* docs/backup-restore-runbook.md.
*
* BACKUP_PASSPHRASE='…' pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>
*
* The passphrase is read from $BACKUP_PASSPHRASE (not argv, to keep it out of
* shell history / the process list).
*/
import { decryptFileToFile } from '@/lib/services/backup-destinations/bundle-encryption';
async function main(): Promise<void> {
const [input, output] = process.argv.slice(2);
const passphrase = process.env.BACKUP_PASSPHRASE;
if (!input || !output) {
throw new Error(
'Usage: BACKUP_PASSPHRASE=… pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>',
);
}
if (!passphrase) throw new Error('Set BACKUP_PASSPHRASE in the environment');
await decryptFileToFile(input, output, passphrase);
process.stdout.write(`Decrypted → ${output}\n`, () => process.exit(0));
}
main().catch((err) => {
process.stderr.write(
`Decrypt failed: ${err instanceof Error ? err.message : String(err)}\n`,
() => process.exit(1),
);
});

View File

@@ -0,0 +1,176 @@
/**
* One-off import of historical "Website Contact Form Submissions" from NocoDB
* into the CRM `website_submissions` table, so they show up in the Inquiries
* workbench alongside post-cutover submissions.
*
* The cutover migration imported interests / residential / berths / expenses but
* NOT the contact-form table — those general contact-page inquiries (the
* "broker"/"investor"/"owner" enquiries) were left behind in NocoDB.
*
* Idempotent: each row maps to a deterministic `submission_id`
* (`nocodb-cf-<id>`) guarded by the unique index, plus a `migration_source_links`
* ledger row (`source_system='nocodb_website_submissions'`). Re-running is a
* no-op for already-imported rows.
*
* Usage:
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts # dry-run
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply # write
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply --port-slug port-nimara
*
* Requires NOCODB_URL + NOCODB_TOKEN in env (same as the migration). Writes to
* whatever DATABASE_URL points at — point it at prod ONLY with explicit approval.
*/
import 'dotenv/config';
import { eq } from 'drizzle-orm';
import { db, closeDb } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
import {
loadNocoDbConfig,
fetchAllRows,
NOCO_TABLES,
type NocoDbRow,
} from '@/lib/dedup/nocodb-source';
const SOURCE_SYSTEM = 'nocodb_website_submissions';
const APPLIED_ID = 'import-website-inquiries';
function arg(name: string): string | undefined {
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
if (hit) return hit.split('=')[1];
const idx = process.argv.indexOf(`--${name}`);
if (idx !== -1 && process.argv[idx + 1] && !process.argv[idx + 1]!.startsWith('--')) {
return process.argv[idx + 1];
}
return undefined;
}
function str(row: NocoDbRow, ...keys: string[]): string {
for (const k of keys) {
const v = row[k];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return '';
}
function parseDate(row: NocoDbRow): Date {
const raw = str(row, 'CreatedAt', 'created_at', 'Created At', 'createdAt');
if (raw) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d;
}
return new Date();
}
async function main() {
const apply = process.argv.includes('--apply');
const portSlug = arg('port-slug') ?? 'port-nimara';
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, portSlug))
.limit(1);
if (!port) throw new Error(`Unknown port slug: ${portSlug}`);
const config = loadNocoDbConfig();
console.log(`[import] Fetching contact-form submissions from NocoDB…`);
const rows = await fetchAllRows(NOCO_TABLES.websiteContactFormSubmissions, config);
console.log(`[import] Fetched ${rows.length} rows from NocoDB.`);
let inserted = 0;
let skipped = 0;
const samples: Array<Record<string, unknown>> = [];
for (const row of rows) {
const legacyId = String(row.Id);
const submissionId = `nocodb-cf-${legacyId}`;
const fullName = str(row, 'Full Name', 'Name', 'full_name');
const email = str(row, 'Email Address', 'Email', 'email');
const interest = str(row, 'Type of Interest', 'interest');
const comments = str(row, 'Comments', 'comments');
const receivedAt = parseDate(row);
const payload = {
name: fullName,
email,
interest,
comments,
imported_from: 'nocodb_contact_form',
legacy_nocodb_id: legacyId,
};
if (samples.length < 3) {
samples.push({
submissionId,
fullName,
email,
interest,
receivedAt: receivedAt.toISOString(),
});
}
if (!apply) {
// Dry-run: count how many are not yet present.
const [existing] = await db
.select({ id: websiteSubmissions.id })
.from(websiteSubmissions)
.where(eq(websiteSubmissions.submissionId, submissionId))
.limit(1);
if (existing) skipped += 1;
else inserted += 1;
continue;
}
const result = await db
.insert(websiteSubmissions)
.values({
portId: port.id,
submissionId,
kind: 'contact_form',
payload,
contactName: fullName || null,
contactEmail: email || null,
legacyNocodbId: legacyId,
receivedAt,
triageState: 'open',
})
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
.returning({ id: websiteSubmissions.id });
if (result[0]) {
inserted += 1;
await db
.insert(migrationSourceLinks)
.values({
sourceSystem: SOURCE_SYSTEM,
sourceId: legacyId,
targetEntityType: 'website_submission',
targetEntityId: result[0].id,
appliedId: APPLIED_ID,
})
.onConflictDoNothing();
} else {
skipped += 1;
}
}
console.log('\n[import] Sample rows:');
for (const s of samples) console.log(' ', JSON.stringify(s));
console.log(
`\n[import] ${apply ? 'APPLIED' : 'DRY-RUN'} — port=${portSlug}: ${inserted} ${
apply ? 'inserted' : 'would insert'
}, ${skipped} skipped (already present).`,
);
if (!apply) console.log('[import] Re-run with --apply to write these rows.');
await closeDb();
}
main().catch((err) => {
console.error('[import] FAILED:', err);
process.exitCode = 1;
});

View File

@@ -1,4 +1,5 @@
import { BackupAdminPanel } from '@/components/admin/backup-admin-panel'; import { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
import { BackupDestinationsCard } from '@/components/admin/backup-destinations-card';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
export default function BackupManagementPage() { export default function BackupManagementPage() {
@@ -7,9 +8,10 @@ export default function BackupManagementPage() {
<PageHeader <PageHeader
title="Backup & Restore" title="Backup & Restore"
eyebrow="ADMIN" eyebrow="ADMIN"
description="Trigger ad-hoc database snapshots, browse the history, and download a .dump file for offline restore." description="Download a full backup, configure where automated backups are pushed, and browse history. Restore steps live in docs/backup-restore-runbook.md."
/> />
<BackupAdminPanel /> <BackupAdminPanel />
<BackupDestinationsCard />
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import type { Route } from 'next'; import type { Route } from 'next';
import { AlertCircle, Anchor, FileSearch } from 'lucide-react'; import { AlertCircle, Anchor, FileSearch, BadgeDollarSign } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -33,6 +33,13 @@ export default async function BerthsAdminIndex({
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.", "Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
icon: FileSearch, icon: FileSearch,
}, },
{
href: `/${portSlug}/admin/berths/price-reconcile` as Route,
label: 'Price reconciliation',
description:
'Parse the purchase price from each berths current spec sheet and review old→new per berth. Approve per row or in bulk; nothing is written until you approve.',
icon: BadgeDollarSign,
},
] as const; ] as const;
return ( return (

View File

@@ -0,0 +1,15 @@
import { PageHeader } from '@/components/shared/page-header';
import { BerthPriceReconcileTable } from '@/components/berths/berth-price-reconcile-table';
export default function BerthPriceReconcilePage() {
return (
<div className="space-y-6">
<PageHeader
title="Berth price reconciliation"
eyebrow="ADMIN"
description="Prices parsed from each berth's current spec sheet, shown against the stored price. Review the changes and approve the ones you trust — nothing is written until you approve it."
/>
<BerthPriceReconcileTable />
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card'; import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { WarningCallout } from '@/components/ui/warning-callout';
// All field arrays removed - every Documenso setting now flows through // All field arrays removed - every Documenso setting now flows through
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global // `RegistryDrivenForm`, which surfaces the env-fallback / port / global
@@ -22,6 +23,35 @@ export default function DocumensoSettingsPage() {
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it." description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
/> />
<WarningCallout title="Use Documenso v2, not v1 (v1 API is deprecated)">
<p>
The CRM&apos;s signing features are built for Documenso 2.x (v2). Set the API version
below to <strong>v1</strong> only if this port still points at a Documenso 1.13.x server.
Be aware these CRM functions <strong>do not work (or run degraded)</strong> on v1:
</p>
<ul className="ms-4 mt-1 list-disc space-y-1">
<li>
<strong>Editing an envelope after it is created</strong> (title, subject, redirect URL):
hard-fails, because v1 has no <code>/envelope/update</code> endpoint.
</li>
<li>
<strong>Upload-and-send contracts / reservations</strong> fall back to v1&apos;s
per-field placement: page size is assumed to be A4, and rich field metadata (required
flags, NUMBER min/max, CHECKBOX / DROPDOWN / RADIO option lists) is dropped.
</li>
<li>
<strong>One-call send with per-recipient signing links</strong>,{' '}
<strong>sequential signing enforcement</strong>, and the{' '}
<strong>v2 webhook events</strong> (recipient viewed / signed, declined, reminder sent)
are unavailable or ignored on v1.
</li>
</ul>
<p className="mt-1">
Recommended: upgrade the Documenso server to 2.x, then set the API version to v2 and run
the test-connection button to confirm.
</p>
</WarningCallout>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">

View File

@@ -1,5 +1,15 @@
import { InquiryInbox } from '@/components/admin/inquiry-inbox'; import { redirect } from 'next/navigation';
export default function InquiriesPage() { /**
return <InquiryInbox />; * The inquiry inbox is now a top-level, permission-gated page at
* `/[portSlug]/inquiries` (resource `inquiries`), no longer admin-only.
* Redirect the legacy admin URL so old bookmarks/links still land.
*/
interface AdminInquiriesRedirectProps {
params: Promise<{ portSlug: string }>;
}
export default async function AdminInquiriesRedirect({ params }: AdminInquiriesRedirectProps) {
const { portSlug } = await params;
redirect(`/${portSlug}/inquiries`);
} }

View File

@@ -0,0 +1,10 @@
import { ClientGroupDetail } from '@/components/client-groups/client-group-detail';
export default async function ClientGroupDetailPage({
params,
}: {
params: Promise<{ portSlug: string; groupId: string }>;
}) {
const { groupId } = await params;
return <ClientGroupDetail groupId={groupId} />;
}

View File

@@ -0,0 +1,5 @@
import { ClientGroupsList } from '@/components/client-groups/client-groups-list';
export default function ClientGroupsPage() {
return <ClientGroupsList />;
}

View File

@@ -0,0 +1,5 @@
import { ClientsByCountryPage } from '@/components/clients/clients-by-country-page';
export default function ClientsByCountryRoute() {
return <ClientsByCountryPage />;
}

View File

@@ -0,0 +1,11 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<div className="space-y-4">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { InquiryDetail } from '@/components/inquiries/inquiry-detail';
interface InquiryDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function InquiryDetailPage({ params }: InquiryDetailPageProps) {
const { id } = await params;
return <InquiryDetail id={id} />;
}

View File

@@ -0,0 +1,5 @@
import { InquiryList } from '@/components/inquiries/inquiry-list';
export default function InquiriesPage() {
return <InquiryList />;
}

View File

@@ -20,6 +20,7 @@ import { getPortBrandingConfig } from '@/lib/services/port-config';
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service'; import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service'; import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service';
import { isMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) { export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers(); const headerList = await headers();
@@ -127,12 +128,29 @@ export default async function DashboardLayout({ children }: { children: React.Re
const residentialModuleByPort: Record<string, boolean> = const residentialModuleByPort: Record<string, boolean> =
Object.fromEntries(residentialModuleEntries); Object.fromEntries(residentialModuleEntries);
// Per-port maintenance-module gate. Defaults to enabled (registry
// default) so existing ports keep the berth Maintenance tab on deploy.
// Resolved server-side so the tab SSRs in/out without flicker.
const maintenanceModuleEntries = await Promise.all(
ports.map(async (p) => {
try {
return [p.id, await isMaintenanceModuleEnabled(p.id)] as const;
} catch {
// Conservative default on lookup failure: keep the feature visible.
return [p.id, true] as const;
}
}),
);
const maintenanceModuleByPort: Record<string, boolean> =
Object.fromEntries(maintenanceModuleEntries);
return ( return (
<QueryProvider> <QueryProvider>
<PortProvider <PortProvider
ports={ports} ports={ports}
defaultPortId={ports[0]?.id ?? null} defaultPortId={ports[0]?.id ?? null}
tenanciesModuleByPort={tenanciesModuleByPort} tenanciesModuleByPort={tenanciesModuleByPort}
maintenanceModuleByPort={maintenanceModuleByPort}
> >
<PermissionsProvider> <PermissionsProvider>
<SocketProvider> <SocketProvider>

View File

@@ -5,6 +5,7 @@ import { ScanShell } from '@/components/scan/scan-shell';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { getPortBrandingConfig } from '@/lib/services/port-config'; import { getPortBrandingConfig } from '@/lib/services/port-config';
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Scan receipt', title: 'Scan receipt',
@@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug:
const { portSlug } = await params; const { portSlug } = await params;
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) }); const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null; const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />; // CM-6: manual-entry mode is resolved server-side so the client can skip
// on-device parsing entirely (no wasted Tesseract pass) and open an empty form.
const ocr = port ? await getResolvedOcrConfig(port.id).catch(() => null) : null;
return (
<ScanShell
logoUrl={branding?.logoUrl ?? null}
portName={port?.name ?? null}
manualEntry={ocr?.manualEntry ?? false}
/>
);
} }

View File

@@ -25,6 +25,7 @@ import {
autoPromoteWebsiteBerthInquiry, autoPromoteWebsiteBerthInquiry,
isWebsiteBerthAutopromoteEnabled, isWebsiteBerthAutopromoteEnabled,
} from '@/lib/services/website-intake-promote.service'; } from '@/lib/services/website-intake-promote.service';
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
/** /**
* POST /api/public/website-inquiries * POST /api/public/website-inquiries
@@ -149,6 +150,10 @@ export async function POST(req: NextRequest) {
// hits, `returning()` yields zero rows and we look up the existing row to // hits, `returning()` yields zero rows and we look up the existing row to
// return its id, mirroring the first-delivery shape so the website never // return its id, mirroring the first-delivery shape so the website never
// sees a difference between fresh and dup. // sees a difference between fresh and dup.
// Extract contact name/email into real columns so the inquiry list can
// search/sort/display without digging into the JSONB payload per row.
const fields = extractInquiryFields(parsed.payload);
const insertResult = await db const insertResult = await db
.insert(websiteSubmissions) .insert(websiteSubmissions)
.values({ .values({
@@ -157,6 +162,8 @@ export async function POST(req: NextRequest) {
kind: parsed.kind, kind: parsed.kind,
payload: parsed.payload, payload: parsed.payload,
legacyNocodbId: parsed.legacy_nocodb_id ?? null, legacyNocodbId: parsed.legacy_nocodb_id ?? null,
contactName: fields.fullName || null,
contactEmail: fields.email || null,
sourceIp: ip, sourceIp: ip,
userAgent: req.headers.get('user-agent') ?? null, userAgent: req.headers.get('user-agent') ?? null,
utmSource: parsed.utm_source ?? null, utmSource: parsed.utm_source ?? null,

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import {
deleteDestination,
updateDestination,
type DestinationInput,
} from '@/lib/services/backup-destinations.service';
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** Update a backup destination. Super-admin only. */
export const PUT = withAuth(async (req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.update');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
const body = await parseBody(req, backupDestinationSchema);
const updated = await updateDestination(id, body as DestinationInput);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
metadata: { name: updated.name, type: updated.type, enabled: updated.enabled },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
});
/** Delete a backup destination. Super-admin only. */
export const DELETE = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.delete');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
await deleteDestination(id);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'delete',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { pushBackupToDestination } from '@/lib/services/backup-destinations.service';
export const runtime = 'nodejs';
// A full backup (pg_dump + every blob) is assembled before the push, so allow
// a long run on large datasets.
export const maxDuration = 3600;
/** Assemble a fresh full backup and push it to this destination now. Super-admin. */
export const POST = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.run');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
const result = await pushBackupToDestination(id, {
trigger: 'manual',
triggeredBy: ctx.userId,
});
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'backup_export',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
metadata: { bytes: result.bytes, remoteRef: result.remoteRef },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { testDestination } from '@/lib/services/backup-destinations.service';
export const runtime = 'nodejs';
/**
* Test connectivity to a destination (connect + verify the target dir/bucket).
* Returns `{ data: { ok: true } }` or a structured error the UI can surface.
* Super-admin only.
*/
export const POST = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.test');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
await testDestination(id);
return NextResponse.json({ data: { ok: true } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import {
createDestination,
listDestinations,
type DestinationInput,
} from '@/lib/services/backup-destinations.service';
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** List configured backup destinations (secrets masked). Super-admin only. */
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.list');
return NextResponse.json({ data: await listDestinations() });
} catch (error) {
return errorResponse(error);
}
});
/** Create a backup destination. Super-admin only. */
export const POST = withAuth(async (req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.create');
const body = await parseBody(req, backupDestinationSchema);
const created = await createDestination(body as DestinationInput);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'create',
entityType: 'backup_destination',
entityId: created.id,
severity: 'warning',
metadata: { name: created.name, type: created.type, enabled: created.enabled },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: created }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,71 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import { createFullBackupTar } from '@/lib/services/backup-export.service';
export const runtime = 'nodejs';
// A full backup pg_dumps the DB and streams every blob; on a large dataset the
// assembly phase can run for a while before the download starts. Lift the
// platform timeout accordingly (no-op on hosts without a hard cap).
export const maxDuration = 3600;
/**
* Stream a full disaster-recovery bundle (db.dump + all blobs + manifest.json)
* as a tar download. Super-admin only — this egresses every tenant's data.
*
* The bundle is assembled to a temp file first, so any failure (pg_dump,
* storage read) surfaces as a clean JSON error *before* the download begins
* rather than as a truncated tar. The temp file is removed once the response
* stream closes (including on client disconnect).
*/
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.export');
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'backup_export',
entityType: 'system_backup',
entityId: filename,
severity: 'warning',
source: 'user',
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
metadata: {
storageBackend: manifest.storageBackend,
blobs: manifest.counts.blobs,
blobBytes: manifest.counts.blobBytes,
skipped: manifest.counts.skipped,
dbDumpBytes: manifest.database.sizeBytes,
},
});
const { size } = await stat(tarPath);
const nodeStream = createReadStream(tarPath);
// Remove the temp tar once it's been fully sent or the client bails.
nodeStream.on('close', () => void cleanup());
nodeStream.on('error', () => void cleanup());
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
return new NextResponse(webStream, {
status: 200,
headers: {
'Content-Type': 'application/x-tar',
'Content-Length': String(size),
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-store',
},
});
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import { getSchedule, setSchedule } from '@/lib/services/backup-destinations.service';
import { backupScheduleSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** Read the global automated-backup schedule. Super-admin only. */
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.schedule.get');
return NextResponse.json({ data: { schedule: await getSchedule() } });
} catch (error) {
return errorResponse(error);
}
});
/** Set the global automated-backup schedule (off | daily | weekly). Super-admin. */
export const PUT = withAuth(async (req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.schedule.set');
const { schedule } = await parseBody(req, backupScheduleSchema);
await setSchedule(schedule, ctx.userId);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'backup_schedule',
entityId: 'global',
severity: 'warning',
metadata: { schedule },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: { schedule } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -15,6 +15,7 @@ const saveSchema = z.object({
clearApiKey: z.boolean().optional(), clearApiKey: z.boolean().optional(),
useGlobal: z.boolean().optional(), useGlobal: z.boolean().optional(),
aiEnabled: z.boolean().optional(), aiEnabled: z.boolean().optional(),
manualEntry: z.boolean().optional(),
}); });
// Only role tiers that hold `admin.manage_settings` (director / super_admin) // Only role tiers that hold `admin.manage_settings` (director / super_admin)
@@ -58,6 +59,7 @@ export const PUT = withAuth(
clearApiKey: body.clearApiKey, clearApiKey: body.clearApiKey,
useGlobal: body.useGlobal, useGlobal: body.useGlobal,
aiEnabled: body.aiEnabled, aiEnabled: body.aiEnabled,
manualEntry: body.manualEntry,
}, },
ctx.userId, ctx.userId,
); );

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { ALERT_RULES } from '@/lib/db/schema/insights';
import { dismissAllForPort } from '@/lib/services/alerts.service';
const bodySchema = z.object({
ruleId: z.enum(ALERT_RULES).optional(),
severity: z.enum(['info', 'warning', 'critical']).optional(),
});
export const POST = withAuth(async (req, ctx) => {
try {
const { ruleId, severity } = await parseBody(req, bodySchema);
const dismissed = await dismissAllForPort(ctx.portId, ctx.userId, { ruleId, severity });
return NextResponse.json({ data: { dismissed } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -5,11 +5,13 @@ import { listAlertsForPort } from '@/lib/services/alerts.service';
type AlertStatus = 'open' | 'dismissed' | 'resolved'; type AlertStatus = 'open' | 'dismissed' | 'resolved';
// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent // The alert feed is entirely operational/deal signals (stale interest, hot lead
// signals. Gated on admin.view_audit_log - same permission the audit log // silent, EOI unsigned, signer overdue, reservation needs agreement, berth
// page uses. // stalled, duplicate/unscanned expense) — there are no audit/security alert
// rules. Gated on interests.view so the operational roles that act on these
// (sales, director, viewer) see them; external residential partners don't.
export const GET = withAuth( export const GET = withAuth(
withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => { withPermission('interests', 'view', async (req: NextRequest, ctx) => {
const url = new URL(req.url); const url = new URL(req.url);
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus; const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;

View File

@@ -4,12 +4,14 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers'; import { parseBody } from '@/lib/api/route-helpers';
import { updateMaintenanceLogSchema } from '@/lib/validators/berths'; import { updateMaintenanceLogSchema } from '@/lib/validators/berths';
import { updateMaintenanceLog, deleteMaintenanceLog } from '@/lib/services/berths.service'; import { updateMaintenanceLog, deleteMaintenanceLog } from '@/lib/services/berths.service';
import { assertMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
// PATCH /api/v1/berths/[id]/maintenance/[logId] // PATCH /api/v1/berths/[id]/maintenance/[logId]
export const PATCH = withAuth( export const PATCH = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => { withPermission('berths', 'edit', async (req, ctx, params) => {
try { try {
await assertMaintenanceModuleEnabled(ctx.portId);
const body = await parseBody(req, updateMaintenanceLogSchema); const body = await parseBody(req, updateMaintenanceLogSchema);
const log = await updateMaintenanceLog(params.id!, params.logId!, ctx.portId, body, { const log = await updateMaintenanceLog(params.id!, params.logId!, ctx.portId, body, {
userId: ctx.userId, userId: ctx.userId,
@@ -28,6 +30,7 @@ export const PATCH = withAuth(
export const DELETE = withAuth( export const DELETE = withAuth(
withPermission('berths', 'edit', async (_req, ctx, params) => { withPermission('berths', 'edit', async (_req, ctx, params) => {
try { try {
await assertMaintenanceModuleEnabled(ctx.portId);
await deleteMaintenanceLog(params.id!, params.logId!, ctx.portId, { await deleteMaintenanceLog(params.id!, params.logId!, ctx.portId, {
userId: ctx.userId, userId: ctx.userId,
portId: ctx.portId, portId: ctx.portId,

View File

@@ -4,12 +4,14 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers'; import { parseBody } from '@/lib/api/route-helpers';
import { addMaintenanceLogSchema } from '@/lib/validators/berths'; import { addMaintenanceLogSchema } from '@/lib/validators/berths';
import { getMaintenanceLogs, addMaintenanceLog } from '@/lib/services/berths.service'; import { getMaintenanceLogs, addMaintenanceLog } from '@/lib/services/berths.service';
import { assertMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/[id]/maintenance // GET /api/v1/berths/[id]/maintenance
export const GET = withAuth( export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx, params) => { withPermission('berths', 'view', async (req, ctx, params) => {
try { try {
await assertMaintenanceModuleEnabled(ctx.portId);
const logs = await getMaintenanceLogs(params.id!, ctx.portId); const logs = await getMaintenanceLogs(params.id!, ctx.portId);
return NextResponse.json({ data: logs }); return NextResponse.json({ data: logs });
} catch (error) { } catch (error) {
@@ -22,6 +24,7 @@ export const GET = withAuth(
export const POST = withAuth( export const POST = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => { withPermission('berths', 'edit', async (req, ctx, params) => {
try { try {
await assertMaintenanceModuleEnabled(ctx.portId);
const body = await parseBody(req, addMaintenanceLogSchema); const body = await parseBody(req, addMaintenanceLogSchema);
const log = await addMaintenanceLog(params.id!, ctx.portId, body, { const log = await addMaintenanceLog(params.id!, ctx.portId, body, {
userId: ctx.userId, userId: ctx.userId,

View File

@@ -0,0 +1,36 @@
/**
* Route handler for `/api/v1/berths/price-reconcile/apply` (CM-2 Part A).
*
* Writes a rep-approved slice of parsed prices to the berths. In handlers.ts so
* integration tests can call it directly.
*/
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { applyBulkBerthPrices } from '@/lib/services/berth-price-reconcile.service';
const bodySchema = z.object({
approvals: z
.array(
z.object({
berthId: z.string().min(1),
price: z.number().nonnegative(),
currency: z.string().min(1).max(8),
}),
)
.min(1),
});
export const postHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, bodySchema);
const result = await applyBulkBerthPrices(ctx.portId, body.approvals, ctx.userId);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { postHandler } from './handlers';
export const POST = withAuth(withPermission('berths', 'edit', postHandler));

View File

@@ -0,0 +1,21 @@
/**
* Route handlers for `/api/v1/berths/price-reconcile` (CM-2 Part A).
*
* In handlers.ts so integration tests can call them directly, bypassing the
* auth/permission middleware (per CLAUDE.md "Route handler exports").
*/
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { listPriceReconciliation } from '@/lib/services/berth-price-reconcile.service';
export const getHandler: RouteHandler = async (_req, ctx) => {
try {
const data = await listPriceReconciliation(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler } from './handlers';
export const GET = withAuth(withPermission('berths', 'edit', getHandler));

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
archiveClientGroup,
getClientGroupById,
updateClientGroup,
} from '@/lib/services/client-groups.service';
import { updateClientGroupSchema } from '@/lib/validators/client-groups';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const group = await getClientGroupById(params.id!, ctx.portId);
return NextResponse.json({ data: group });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateClientGroupSchema);
const updated = await updateClientGroup(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 archiveClientGroup(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);
}
};

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listGroupMembers, setGroupMembers } from '@/lib/services/client-groups.service';
import { setGroupMembersSchema } from '@/lib/validators/client-groups';
export const getMembersHandler: RouteHandler = async (req, ctx, params) => {
try {
const members = await listGroupMembers(params.id!, ctx.portId);
return NextResponse.json({ data: members, total: members.length });
} catch (error) {
return errorResponse(error);
}
};
export const putMembersHandler: RouteHandler = async (req, ctx, params) => {
try {
const { clientIds } = await parseBody(req, setGroupMembersSchema);
await setGroupMembers(params.id!, ctx.portId, clientIds, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,6 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getMembersHandler, putMembersHandler } from './handlers';
export const GET = withAuth(withPermission('client_groups', 'view', getMembersHandler));
export const PUT = withAuth(withPermission('client_groups', 'manage', putMembersHandler));

View File

@@ -0,0 +1,7 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('client_groups', 'view', getHandler));
export const PATCH = withAuth(withPermission('client_groups', 'manage', patchHandler));
export const DELETE = withAuth(withPermission('client_groups', 'manage', deleteHandler));

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createClientGroup, listClientGroups } from '@/lib/services/client-groups.service';
import { createClientGroupSchema } from '@/lib/validators/client-groups';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const groups = await listClientGroups(ctx.portId);
return NextResponse.json({ data: groups, total: groups.length });
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createClientGroupSchema);
const group = await createClientGroup(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: group }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,6 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { listHandler, createHandler } from './handlers';
export const GET = withAuth(withPermission('client_groups', 'view', listHandler));
export const POST = withAuth(withPermission('client_groups', 'manage', createHandler));

View File

@@ -16,8 +16,8 @@ import { createAuditLog } from '@/lib/audit';
*/ */
export const GET = withAuth( export const GET = withAuth(
withPermission( withPermission(
'admin', 'clients',
'manage_settings', 'gdpr_export',
withRateLimit('exports', async (req, ctx, params) => { withRateLimit('exports', async (req, ctx, params) => {
try { try {
const url = await getExportDownloadUrl(params.exportId!, ctx.portId); const url = await getExportDownloadUrl(params.exportId!, ctx.portId);

View File

@@ -26,8 +26,8 @@ export const GET = withAuth(
export const POST = withAuth( export const POST = withAuth(
withPermission( withPermission(
'admin', 'clients',
'manage_settings', 'gdpr_export',
withRateLimit('exports', async (req, ctx, params) => { withRateLimit('exports', async (req, ctx, params) => {
try { try {
const body = await parseBody(req, requestSchema); const body = await parseBody(req, requestSchema);

View File

@@ -0,0 +1,8 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('client');
export const GET = withAuth(withPermission('clients', 'view', getHandler));
export const PUT = withAuth(withPermission('clients', 'edit', putHandler));
export const DELETE = withAuth(withPermission('clients', 'edit', deleteHandler));

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { sendSignedCopyToClient } from '@/lib/services/documents.service';
/**
* Manually (re)send the finalized signed PDF to the deal's client. Backs
* the "Send signed copy to client" affordance on the EOI tab + document
* detail. Same `documents.edit` gate as the reminder endpoint.
*/
export const POST = withAuth(
withPermission('documents', 'edit', async (_req, ctx, params) => {
try {
const result = await sendSignedCopyToClient(params.id!, ctx.portId);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -48,6 +48,14 @@ export const POST = withAuth(
} }
const config = await getResolvedOcrConfig(ctx.portId); const config = await getResolvedOcrConfig(ctx.portId);
// CM-6: manual-entry mode short-circuits ALL parsing - the operator
// types the details by hand. The client should skip this route entirely
// in manual mode, but we guard server-side too.
if (config.manualEntry) {
return NextResponse.json({
data: { parsed: EMPTY, source: 'manual', reason: 'manual-mode' },
});
}
// Tesseract.js (in-browser) is the default. The server only invokes // Tesseract.js (in-browser) is the default. The server only invokes
// an AI provider when (a) the port admin has flipped `aiEnabled` on // an AI provider when (a) the port admin has flipped `aiEnabled` on
// and (b) a key resolves. Otherwise the client falls back to its // and (b) a key resolves. Otherwise the client falls back to its

View File

@@ -8,6 +8,13 @@ export const GET = withAuth(
withPermission('files', 'view', async (req, ctx, params) => { withPermission('files', 'view', async (req, ctx, params) => {
try { try {
const result = await getDownloadUrl(params.id!, ctx.portId); const result = await getDownloadUrl(params.id!, ctx.portId);
// `?redirect=1` → 302 straight to the presigned (attachment) URL so a
// plain <a href>/<Link> downloads the file. Without it we return the
// JSON envelope for programmatic consumers (e.g. fetch + anchor click).
// Linking the browser at the JSON form used to dump raw `{data:{url}}`.
if (new URL(req.url).searchParams.has('redirect')) {
return NextResponse.redirect(result.url, 302);
}
return NextResponse.json({ data: result }); return NextResponse.json({ data: result });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);

View File

@@ -8,6 +8,12 @@ export const GET = withAuth(
withPermission('files', 'view', async (req, ctx, params) => { withPermission('files', 'view', async (req, ctx, params) => {
try { try {
const result = await getPreviewUrl(params.id!, ctx.portId); const result = await getPreviewUrl(params.id!, ctx.portId);
// `?redirect=1` → 302 to the presigned (inline) URL so a plain
// <a href>/<Link> opens the file in the browser. Default returns the
// JSON envelope for programmatic consumers (e.g. FilePreviewDialog).
if (new URL(req.url).searchParams.has('redirect')) {
return NextResponse.redirect(result.url, 302);
}
return NextResponse.json({ data: result }); return NextResponse.json({ data: result });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { convertInquiryToClient, convertInquiryToInterest } from '@/lib/services/inquiries.service';
import { convertInquirySchema } from '@/lib/validators/inquiries';
export const POST = withAuth(
withPermission('inquiries', 'manage', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const { target } = await parseBody(req, convertInquirySchema);
const meta = {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
};
const data =
target === 'interest'
? await convertInquiryToInterest(id, ctx.portId, meta)
: await convertInquiryToClient(id, ctx.portId, meta);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { getInquiryById } from '@/lib/services/inquiries.service';
export const GET = withAuth(
withPermission('inquiries', 'view', async (_req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const data = await getInquiryById(id, ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { triageInquiry } from '@/lib/services/inquiries.service';
import { triageInquirySchema } from '@/lib/validators/inquiries';
export const PATCH = withAuth(
withPermission('inquiries', 'manage', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const { state } = await parseBody(req, triageInquirySchema);
const data = await triageInquiry(id, ctx.portId, state, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listInquiries } from '@/lib/services/inquiries.service';
import { listInquiriesSchema } from '@/lib/validators/inquiries';
export const GET = withAuth(
withPermission('inquiries', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listInquiriesSchema);
const result = await listInquiries(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);
}
}),
);

View File

@@ -0,0 +1,39 @@
/**
* Route handler for `/api/v1/interests/[id]/berths/[berthId]/price` (CM-2 Part B).
*
* Sets or clears the deal-specific price override for one (interest, berth).
* In handlers.ts so integration tests can call it directly.
*/
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { setBerthPriceOverride } from '@/lib/services/interest-berths.service';
const bodySchema = z.object({
price: z.number().nonnegative().nullable(),
currency: z.string().min(1).max(8).optional(),
});
export const putHandler: RouteHandler<{ id: string; berthId: string }> = async (
req,
ctx,
params,
) => {
try {
const body = await parseBody(req, bodySchema);
await setBerthPriceOverride(
params.id!,
params.berthId!,
body.price,
body.currency ?? null,
ctx.portId,
);
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { putHandler } from './handlers';
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));

View File

@@ -0,0 +1,8 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('interest');
export const GET = withAuth(withPermission('interests', 'view', getHandler));
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
export const DELETE = withAuth(withPermission('interests', 'edit', deleteHandler));

View File

@@ -15,7 +15,13 @@ export const GET = withAuth(async (req, ctx) => {
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, ctx.portId)), where: and(eq(systemSettings.key, key), eq(systemSettings.portId, ctx.portId)),
}); });
return NextResponse.json({ enabled: setting?.value === true }); // `default` applies ONLY when the setting was never written for this
// port (row absent). An explicit stored `false` always disables. Lets
// default-ON settings (e.g. pulse_enabled) gate correctly via
// ?default=true while default-OFF flags keep the old behaviour.
const def = req.nextUrl.searchParams.get('default') === 'true';
const enabled = setting ? setting.value === true : def;
return NextResponse.json({ enabled });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
} }

View File

@@ -0,0 +1,8 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('yacht');
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
export const PUT = withAuth(withPermission('yachts', 'edit', putHandler));
export const DELETE = withAuth(withPermission('yachts', 'edit', deleteHandler));

View File

@@ -1,7 +1,14 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Loader2, Download, Database, RefreshCw, AlertTriangle } from 'lucide-react'; import {
Loader2,
Download,
Database,
RefreshCw,
AlertTriangle,
HardDriveDownload,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -94,6 +101,18 @@ export function BackupAdminPanel() {
} }
} }
function downloadFullBundle() {
// Streams a tar (db.dump + every blob + manifest.json) straight to disk via
// the GET endpoint (cookie auth). The server assembles the bundle before
// the first byte arrives, so the browser download sits "pending" for a
// moment on large datasets — flag that so it doesn't look stuck.
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
toast.info(
'Preparing full backup — your download starts once the server finishes assembling it.',
);
triggerUrlDownload('/api/v1/admin/backup/export', `pn-crm-backup-${stamp}.tar`);
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card> <Card>
@@ -126,6 +145,32 @@ export function BackupAdminPanel() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div>
<CardTitle className="text-base flex items-center gap-2">
<HardDriveDownload className="h-4 w-4" aria-hidden />
Full disaster-recovery export
</CardTitle>
<CardDescription>
Bundles the database dump <em>and every stored file</em> (documents, berth PDFs,
brochures, GDPR exports) into one <code>.tar</code> and downloads it to this computer.
Use this as an offsite, storage-backend-independent backup.
</CardDescription>
</div>
<Button variant="outline" onClick={downloadFullBundle}>
<HardDriveDownload className="me-1.5 h-4 w-4" aria-hidden />
Download full backup
</Button>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
The archive contains <code>db.dump</code>, <code>blobs/&lt;key&gt;</code> for every file,
and a <code>manifest.json</code> with a SHA-256 per object for restore-side verification.
Unlike the DB-only backup above, this does not depend on the active storage backend
surviving. Restore steps live in <code>docs/backup-restore-runbook.md</code>.
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">History</CardTitle> <CardTitle className="text-base">History</CardTitle>

View File

@@ -0,0 +1,604 @@
'use client';
import { useEffect, useState } from 'react';
import {
Loader2,
Plus,
Server,
Cloud,
FolderTree,
Trash2,
Pencil,
PlugZap,
UploadCloud,
ShieldCheck,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type DestType = 'sftp' | 's3' | 'filesystem';
type Schedule = 'off' | 'daily' | 'weekly';
interface Destination {
id: string;
name: string;
type: DestType;
enabled: boolean;
config: Record<string, unknown>;
retentionCount: number | null;
encryptBundle: boolean;
encryptionKeyIsSet: boolean;
lastRunAt: string | null;
lastStatus: string | null;
lastError: string | null;
lastBackupBytes: number | null;
}
const TYPE_META: Record<DestType, { label: string; icon: typeof Server; hint: string }> = {
sftp: { label: 'SFTP / SSH server', icon: Server, hint: 'Push to a server over SFTP.' },
s3: { label: 'S3-compatible', icon: Cloud, hint: 'AWS S3, Backblaze B2, Wasabi, R2, MinIO.' },
filesystem: {
label: 'Mounted path / NAS',
icon: FolderTree,
hint: 'A directory this server can write to.',
},
};
const STATUS_TONE: Record<string, string> = {
ok: 'text-emerald-700',
failed: 'text-rose-700',
};
function formatBytes(n: number | null): string {
if (n === null) return '—';
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export function BackupDestinationsCard() {
const [destinations, setDestinations] = useState<Destination[]>([]);
const [schedule, setSchedule] = useState<Schedule>('off');
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [editing, setEditing] = useState<Destination | 'new' | null>(null);
useEffect(() => {
void load();
}, []);
async function load() {
setLoading(true);
try {
const [d, s] = await Promise.all([
apiFetch<{ data: Destination[] }>('/api/v1/admin/backup/destinations'),
apiFetch<{ data: { schedule: Schedule } }>('/api/v1/admin/backup/schedule'),
]);
setDestinations(d.data);
setSchedule(s.data.schedule);
} catch (err) {
toastError(err);
} finally {
setLoading(false);
}
}
async function changeSchedule(value: Schedule) {
setSchedule(value);
try {
await apiFetch('/api/v1/admin/backup/schedule', {
method: 'PUT',
body: { schedule: value },
});
toast.success(
value === 'off' ? 'Automated backups turned off' : `Automated backups: ${value}`,
);
} catch (err) {
toastError(err);
void load();
}
}
async function test(id: string) {
setBusyId(id);
try {
await apiFetch(`/api/v1/admin/backup/destinations/${id}/test`, { method: 'POST' });
toast.success('Connection OK');
} catch (err) {
toastError(err);
} finally {
setBusyId(null);
}
}
async function runNow(id: string) {
setBusyId(id);
try {
toast.info('Backing up — assembling the bundle, then pushing. This can take a minute.');
await apiFetch(`/api/v1/admin/backup/destinations/${id}/run`, { method: 'POST' });
toast.success('Backup pushed');
await load();
} catch (err) {
toastError(err);
await load();
} finally {
setBusyId(null);
}
}
async function remove(id: string) {
if (!confirm('Delete this backup destination? This does not delete already-pushed backups.'))
return;
setBusyId(id);
try {
await apiFetch(`/api/v1/admin/backup/destinations/${id}`, { method: 'DELETE' });
toast.success('Destination deleted');
await load();
} catch (err) {
toastError(err);
} finally {
setBusyId(null);
}
}
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div>
<CardTitle className="text-base flex items-center gap-2">
<UploadCloud className="h-4 w-4" aria-hidden />
Automated backup destinations
</CardTitle>
<CardDescription>
Where scheduled backups are pushed. Each destination receives the same full bundle
(database + every file) you can download above.
</CardDescription>
</div>
<Button size="sm" onClick={() => setEditing('new')}>
<Plus className="me-1.5 h-4 w-4" aria-hidden />
Add destination
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Label htmlFor="backup-schedule" className="text-sm">
Schedule
</Label>
<Select value={schedule} onValueChange={(v) => void changeSchedule(v as Schedule)}>
<SelectTrigger id="backup-schedule" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="daily">Daily (02:00)</SelectItem>
<SelectItem value="weekly">Weekly (Sun 02:00)</SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
Pushes to every enabled destination below.
</span>
</div>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden /> Loading
</div>
) : destinations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No destinations yet. Add one to enable automated offsite backups.
</p>
) : (
<ul className="divide-y rounded-md border">
{destinations.map((d) => {
const Icon = TYPE_META[d.type].icon;
return (
<li key={d.id} className="flex items-center justify-between gap-3 p-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
<span className="font-medium truncate">{d.name}</span>
{d.encryptBundle && (
<ShieldCheck
className="h-3.5 w-3.5 text-emerald-600"
aria-label="Encrypted"
/>
)}
{!d.enabled && (
<span className="text-xs rounded-full bg-muted px-1.5 py-0.5 text-muted-foreground">
disabled
</span>
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{TYPE_META[d.type].label}
{d.lastStatus && (
<>
{' · '}
<span className={STATUS_TONE[d.lastStatus] ?? ''}>
{d.lastStatus === 'ok' ? 'last OK' : 'last FAILED'}
</span>
{d.lastRunAt && ` ${new Date(d.lastRunAt).toLocaleString()}`}
{d.lastStatus === 'ok' && ` (${formatBytes(d.lastBackupBytes)})`}
</>
)}
{d.lastStatus === 'failed' && d.lastError && (
<span className="block text-rose-700 truncate">{d.lastError}</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
size="sm"
variant="ghost"
disabled={busyId === d.id}
onClick={() => void test(d.id)}
>
<PlugZap className="me-1 h-3.5 w-3.5" aria-hidden />
Test
</Button>
<Button
size="sm"
variant="outline"
disabled={busyId === d.id}
onClick={() => void runNow(d.id)}
>
{busyId === d.id ? (
<Loader2 className="me-1 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<UploadCloud className="me-1 h-3.5 w-3.5" aria-hidden />
)}
Back up now
</Button>
<Button size="icon" variant="ghost" onClick={() => setEditing(d)}>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
<Button
size="icon"
variant="ghost"
disabled={busyId === d.id}
onClick={() => void remove(d.id)}
>
<Trash2 className="h-3.5 w-3.5 text-rose-600" aria-hidden />
</Button>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
{editing && (
<DestinationDialog
destination={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
void load();
}}
/>
)}
</Card>
);
}
// ─── add/edit dialog ─────────────────────────────────────────────────────────
interface DialogProps {
destination: Destination | null;
onClose: () => void;
onSaved: () => void;
}
function DestinationDialog({ destination, onClose, onSaved }: DialogProps) {
const isEdit = Boolean(destination);
const cfg = (destination?.config ?? {}) as Record<string, unknown>;
const str = (k: string) => (typeof cfg[k] === 'string' ? (cfg[k] as string) : '');
const num = (k: string) => (typeof cfg[k] === 'number' ? String(cfg[k]) : '');
const [name, setName] = useState(destination?.name ?? '');
const [type, setType] = useState<DestType>(destination?.type ?? 'sftp');
const [enabled, setEnabled] = useState(destination?.enabled ?? true);
const [retention, setRetention] = useState(
destination?.retentionCount != null ? String(destination.retentionCount) : '',
);
const [encryptBundle, setEncryptBundle] = useState(destination?.encryptBundle ?? false);
const [encryptionKey, setEncryptionKey] = useState('');
const [saving, setSaving] = useState(false);
// Config fields (controlled). Secrets start blank on edit (kept server-side).
const [c, setC] = useState<Record<string, string>>({
directory: str('directory'),
host: str('host'),
port: num('port'),
username: str('username'),
password: '',
privateKey: '',
passphrase: '',
remoteDir: str('remoteDir'),
hostFingerprint: str('hostFingerprint'),
endpoint: str('endpoint'),
region: str('region'),
bucket: str('bucket'),
accessKey: str('accessKey'),
secretKey: '',
prefix: str('prefix'),
});
const set = (k: string, v: string) => setC((prev) => ({ ...prev, [k]: v }));
function buildConfig(): Record<string, unknown> {
if (type === 'filesystem') return { directory: c.directory };
if (type === 'sftp') {
return {
host: c.host,
...(c.port ? { port: Number(c.port) } : {}),
username: c.username,
...(c.password ? { password: c.password } : {}),
...(c.privateKey ? { privateKey: c.privateKey } : {}),
...(c.passphrase ? { passphrase: c.passphrase } : {}),
remoteDir: c.remoteDir,
...(c.hostFingerprint ? { hostFingerprint: c.hostFingerprint } : {}),
};
}
return {
endpoint: c.endpoint,
...(c.region ? { region: c.region } : {}),
bucket: c.bucket,
accessKey: c.accessKey,
...(c.secretKey ? { secretKey: c.secretKey } : {}),
...(c.prefix ? { prefix: c.prefix } : {}),
};
}
async function save() {
setSaving(true);
try {
const body = {
name,
type,
enabled,
config: buildConfig(),
retentionCount: retention ? Number(retention) : null,
encryptBundle,
...(encryptionKey ? { encryptionKey } : {}),
};
if (isEdit && destination) {
await apiFetch(`/api/v1/admin/backup/destinations/${destination.id}`, {
method: 'PUT',
body,
});
} else {
await apiFetch('/api/v1/admin/backup/destinations', {
method: 'POST',
body,
});
}
toast.success(isEdit ? 'Destination updated' : 'Destination added');
onSaved();
} catch (err) {
toastError(err);
} finally {
setSaving(false);
}
}
const secretPlaceholder = isEdit ? 'unchanged — leave blank to keep' : '';
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit destination' : 'Add backup destination'}</DialogTitle>
<DialogDescription>{TYPE_META[type].hint}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Field label="Name">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Hetzner box"
/>
</Field>
<Field label="Type">
<Select value={type} onValueChange={(v) => setType(v as DestType)} disabled={isEdit}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sftp">SFTP / SSH server</SelectItem>
<SelectItem value="s3">S3-compatible</SelectItem>
<SelectItem value="filesystem">Mounted path / NAS</SelectItem>
</SelectContent>
</Select>
</Field>
{type === 'filesystem' && (
<Field label="Directory">
<Input
value={c.directory}
onChange={(e) => set('directory', e.target.value)}
placeholder="/mnt/backups"
/>
</Field>
)}
{type === 'sftp' && (
<>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<Field label="Host">
<Input
value={c.host}
onChange={(e) => set('host', e.target.value)}
placeholder="backups.example.com"
/>
</Field>
</div>
<Field label="Port">
<Input
value={c.port}
onChange={(e) => set('port', e.target.value)}
placeholder="22"
/>
</Field>
</div>
<Field label="Username">
<Input value={c.username} onChange={(e) => set('username', e.target.value)} />
</Field>
<Field label="Password">
<Input
type="password"
value={c.password}
onChange={(e) => set('password', e.target.value)}
placeholder={secretPlaceholder}
/>
</Field>
<Field label="Private key (optional, instead of password)">
<textarea
className="w-full rounded-md border px-2 py-1.5 text-xs font-mono"
rows={3}
value={c.privateKey}
onChange={(e) => set('privateKey', e.target.value)}
placeholder={secretPlaceholder || '-----BEGIN OPENSSH PRIVATE KEY-----'}
/>
</Field>
<Field label="Remote directory">
<Input
value={c.remoteDir}
onChange={(e) => set('remoteDir', e.target.value)}
placeholder="/srv/pn-crm-backups"
/>
</Field>
<Field label="Host key fingerprint (optional, sha256 — pins the server)">
<Input
value={c.hostFingerprint}
onChange={(e) => set('hostFingerprint', e.target.value)}
placeholder="aa:bb:cc… or hex"
/>
</Field>
</>
)}
{type === 's3' && (
<>
<Field label="Endpoint">
<Input
value={c.endpoint}
onChange={(e) => set('endpoint', e.target.value)}
placeholder="https://s3.us-west.example.com"
/>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Bucket">
<Input value={c.bucket} onChange={(e) => set('bucket', e.target.value)} />
</Field>
<Field label="Region (optional)">
<Input
value={c.region}
onChange={(e) => set('region', e.target.value)}
placeholder="us-east-1"
/>
</Field>
</div>
<Field label="Access key">
<Input value={c.accessKey} onChange={(e) => set('accessKey', e.target.value)} />
</Field>
<Field label="Secret key">
<Input
type="password"
value={c.secretKey}
onChange={(e) => set('secretKey', e.target.value)}
placeholder={secretPlaceholder}
/>
</Field>
<Field label="Prefix (optional)">
<Input
value={c.prefix}
onChange={(e) => set('prefix', e.target.value)}
placeholder="crm-backups/"
/>
</Field>
</>
)}
<div className="grid grid-cols-2 gap-2">
<Field label="Keep last N (blank = all)">
<Input
value={retention}
onChange={(e) => setRetention(e.target.value)}
placeholder="7"
/>
</Field>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-sm">
<Switch checked={enabled} onCheckedChange={setEnabled} />
Enabled (auto-pushed)
</label>
</div>
</div>
<div className="rounded-md border p-3 space-y-2">
<label className="flex items-center gap-2 text-sm">
<Switch checked={encryptBundle} onCheckedChange={setEncryptBundle} />
Encrypt the bundle before sending (AES-256)
</label>
{encryptBundle && (
<Field label="Passphrase (needed to restore — store it safely)">
<Input
type="password"
value={encryptionKey}
onChange={(e) => setEncryptionKey(e.target.value)}
placeholder={
destination?.encryptionKeyIsSet ? 'unchanged — leave blank to keep' : ''
}
/>
</Field>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={() => void save()} disabled={saving || !name}>
{saving && <Loader2 className="me-1.5 h-4 w-4 animate-spin" aria-hidden />}
{isEdit ? 'Save changes' : 'Add destination'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}

View File

@@ -30,6 +30,7 @@ interface ConfigResp {
hasApiKey: boolean; hasApiKey: boolean;
useGlobal: boolean; useGlobal: boolean;
aiEnabled: boolean; aiEnabled: boolean;
manualEntry: boolean;
}; };
models: Record<Provider, string[]>; models: Record<Provider, string[]>;
} }
@@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) {
// Key the body on the loaded payload so useState initializers seed // Key the body on the loaded payload so useState initializers seed
// from server values cleanly. // from server values cleanly.
const sig = data?.data const sig = data?.data
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}` ? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}:${data.data.manualEntry}`
: 'loading'; : 'loading';
return ( return (
<SettingsBlockBody <SettingsBlockBody
@@ -89,6 +90,7 @@ function SettingsBlockBody({
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false); const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false); const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
const [manualEntry, setManualEntry] = useState(data?.data.manualEntry ?? false);
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>( const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
null, null,
); );
@@ -105,6 +107,7 @@ function SettingsBlockBody({
clearApiKey: Boolean(clearApiKey), clearApiKey: Boolean(clearApiKey),
useGlobal: scope === 'global' ? false : useGlobal, useGlobal: scope === 'global' ? false : useGlobal,
aiEnabled: scope === 'global' ? false : aiEnabled, aiEnabled: scope === 'global' ? false : aiEnabled,
manualEntry: scope === 'global' ? false : manualEntry,
}, },
}), }),
onSuccess: () => { onSuccess: () => {
@@ -190,6 +193,25 @@ function SettingsBlockBody({
</div> </div>
) : null} ) : null}
{scope === 'port' ? (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Checkbox
id={`manualEntry-${scope}`}
checked={manualEntry}
onCheckedChange={(v) => setManualEntry(v === true)}
/>
<div className="space-y-0.5">
<Label htmlFor={`manualEntry-${scope}`} className="text-sm font-medium">
Manual entry only (skip receipt scanning)
</Label>
<p className="text-xs text-muted-foreground">
When on, staff just attach a receipt photo and type the details by hand - no
on-device or AI parsing runs. Takes precedence over AI parsing above.
</p>
</div>
</div>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor={`provider-${scope}`}>Provider</Label> <Label htmlFor={`provider-${scope}`}>Provider</Label>

View File

@@ -50,8 +50,12 @@ export function OnboardingBanner() {
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Sparkles className="size-4 shrink-0" aria-hidden /> <Sparkles className="size-4 shrink-0" aria-hidden />
<span className="truncate"> <span className="truncate">
<strong>Setup is {data.percent}% complete</strong>. {data.completed} of {data.total} steps <strong>Setup is {data.percent}% complete</strong>
done.{' '} {/* Verbose progress + the "Next:" deep-link are hidden on mobile,
where they get clipped (R1) and duplicate the always-visible
"View checklist" button. Shown from sm: up. */}
<span className="hidden sm:inline">
. {data.completed} of {data.total} steps done.{' '}
{next ? ( {next ? (
<> <>
Next:{' '} Next:{' '}
@@ -65,6 +69,7 @@ export function OnboardingBanner() {
</> </>
) : null} ) : null}
</span> </span>
</span>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-xs"> <Button asChild size="sm" variant="ghost" className="h-7 px-2 text-xs">

View File

@@ -103,6 +103,10 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
delete: false, delete: false,
change_stage: false, change_stage: false,
}, },
inquiries: {
view: false,
manage: false,
},
}; };
const GROUP_LABELS: Record<string, string> = { const GROUP_LABELS: Record<string, string> = {
@@ -126,6 +130,7 @@ const GROUP_LABELS: Record<string, string> = {
admin: 'Administration', admin: 'Administration',
residential_clients: 'Residential Clients', residential_clients: 'Residential Clients',
residential_interests: 'Residential Interests', residential_interests: 'Residential Interests',
inquiries: 'Inquiries',
}; };
function formatAction(action: string): string { function formatAction(action: string): string {

View File

@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean', type: 'boolean',
defaultValue: true, defaultValue: true,
}, },
{
key: 'assignment_enabled',
label: 'Interest Assignment',
description:
'Allow assigning interests to sales users (the "Assigned to" owner chip + auto-assign on create). Off by default - turn on only when more than one person works the pipeline. Disabling hides the assignment UI and stops auto-assigning new interests; existing assignment data is preserved and reappears if you re-enable.',
type: 'boolean',
defaultValue: false,
},
{ {
key: 'tenancies_module_enabled', key: 'tenancies_module_enabled',
label: 'Tenancies Module', label: 'Tenancies Module',
@@ -72,6 +80,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean', type: 'boolean',
defaultValue: true, defaultValue: true,
}, },
{
key: 'maintenance_module_enabled',
label: 'Berth Maintenance Module',
description:
'Enable the per-berth maintenance log (the "Maintenance" tab on each berth detail page). On by default. Disabling hides the Maintenance tab everywhere and blocks its log routes; previously-recorded maintenance logs are preserved and reappear when you re-enable.',
type: 'boolean',
defaultValue: true,
},
{ {
key: 'ai_interest_scoring', key: 'ai_interest_scoring',
label: 'AI Interest Scoring', label: 'AI Interest Scoring',
@@ -133,9 +149,17 @@ const KNOWN_SETTINGS: Array<{
}, },
{ {
key: 'inquiry_notification_recipients', key: 'inquiry_notification_recipients',
label: 'Berth & contact inquiry alerts', label: 'Berth inquiry alerts',
description: description:
'Who receives staff alerts for new berth + contact-form inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.', 'Who receives staff alerts for new berth inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
type: 'recipients',
defaultValue: [],
},
{
key: 'contact_notification_recipients',
label: 'Contact-form alerts',
description:
'Who receives staff alerts for new website contact-form submissions: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Falls back to Inquiry Contact Email when empty.',
type: 'recipients', type: 'recipients',
defaultValue: [], defaultValue: [],
}, },
@@ -147,6 +171,14 @@ const KNOWN_SETTINGS: Array<{
type: 'recipients', type: 'recipients',
defaultValue: [], defaultValue: [],
}, },
{
key: 'signing_notification_recipients',
label: 'Document signing alerts',
description:
'Who gets emailed each time a party signs an EOI / contract and when a document is fully signed: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Add yourself and sales@ here. Falls back to the Reply-To address when empty.',
type: 'recipients',
defaultValue: [],
},
{ {
key: 'eoi_signers', key: 'eoi_signers',
label: 'EOI Signers', label: 'EOI Signers',

View File

@@ -29,7 +29,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { formatRole } from '@/lib/constants'; import { formatRole, NON_ASSIGNABLE_ROLE_NAMES } from '@/lib/constants';
interface Role { interface Role {
id: string; id: string;
@@ -78,12 +78,20 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
enabled: open, enabled: open,
}); });
const roles = rolesQuery.data?.data ?? []; const roles = rolesQuery.data?.data ?? [];
// Hide retired/owner-only system roles from the picker, but always keep the
// role the user being edited already holds so their record stays editable.
const selectableRoles = roles.filter(
(r) => !NON_ASSIGNABLE_ROLE_NAMES.has(r.name) || r.id === user?.role.id,
);
const [firstName, setFirstName] = useState(initialNames.first); const [firstName, setFirstName] = useState(initialNames.first);
const [lastName, setLastName] = useState(initialNames.last); const [lastName, setLastName] = useState(initialNames.last);
const [email, setEmail] = useState(user?.email ?? ''); const [email, setEmail] = useState(user?.email ?? '');
const [originalEmail] = useState(user?.email ?? ''); const [originalEmail] = useState(user?.email ?? '');
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false); const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
// New users: email them a set-password link by default rather than typing a
// password here. Toggle off to set one manually.
const [sendSetupEmail, setSendSetupEmail] = useState(true);
const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>( const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
user?.phone ? { e164: user.phone, country: 'US' } : null, user?.phone ? { e164: user.phone, country: 'US' } : null,
@@ -141,7 +149,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
firstName: firstName || null, firstName: firstName || null,
lastName: lastName || null, lastName: lastName || null,
email, email,
password, // Email mode omits the password entirely; manual mode sends it.
password: sendSetupEmail ? undefined : password,
sendSetupEmail,
displayName, displayName,
phone: phoneE164 ?? undefined, phone: phoneE164 ?? undefined,
roleId, roleId,
@@ -250,6 +260,23 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
</div> </div>
{!isEdit && ( {!isEdit && (
<>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-setup-email">Email a set-password link</Label>
<p className="text-xs text-muted-foreground">
The user gets an email to choose their own password. Turn off to set one
here instead.
</p>
</div>
<Switch
id="user-setup-email"
checked={sendSetupEmail}
onCheckedChange={setSendSetupEmail}
/>
</div>
{!sendSetupEmail && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="user-password">Password</Label> <Label htmlFor="user-password">Password</Label>
<Input <Input
@@ -263,6 +290,8 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
/> />
</div> </div>
)} )}
</>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label> <Label htmlFor="user-phone">Phone</Label>
@@ -281,7 +310,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
<SelectValue placeholder="Select a role" /> <SelectValue placeholder="Select a role" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{roles.map((r) => ( {selectableRoles.map((r) => (
<SelectItem key={r.id} value={r.id}> <SelectItem key={r.id} value={r.id}>
{formatRole(r.name)} {formatRole(r.name)}
</SelectItem> </SelectItem>

View File

@@ -62,7 +62,7 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
</div> </div>
</div> </div>
{!readOnly ? ( {!readOnly ? (
<div className="flex shrink-0 items-start gap-1 opacity-0 transition-opacity duration-base ease-spring group-hover:opacity-100 focus-within:opacity-100"> <div className="flex shrink-0 items-start gap-1 text-muted-foreground">
{!acknowledged ? ( {!acknowledged ? (
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -4,10 +4,11 @@ import { useState } from 'react';
import { ShieldAlert } from 'lucide-react'; import { ShieldAlert } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { AlertCard, AlertCardEmpty } from './alert-card'; import { AlertCard, AlertCardEmpty } from './alert-card';
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts'; import { useAlertCount, useAlertList, useAlertRealtime, useDismissAll } from './use-alerts';
import type { AlertStatus } from './types'; import type { AlertStatus } from './types';
/** /**
@@ -30,6 +31,7 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
const total = count?.total ?? 0; const total = count?.total ?? 0;
const alerts = data?.data ?? []; const alerts = data?.data ?? [];
const dismissAll = useDismissAll();
return ( return (
<div className={embedded ? 'space-y-3' : 'space-y-6'}> <div className={embedded ? 'space-y-3' : 'space-y-6'}>
@@ -62,6 +64,18 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
</TabsList> </TabsList>
<TabsContent value={tab} className="mt-4 space-y-2"> <TabsContent value={tab} className="mt-4 space-y-2">
{tab === 'open' && alerts.length > 0 ? (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => dismissAll.mutate({})}
disabled={dismissAll.isPending}
>
Dismiss all
</Button>
</div>
) : null}
{isLoading ? ( {isLoading ? (
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-20 w-full" /> <Skeleton className="h-20 w-full" />

View File

@@ -41,6 +41,15 @@ export function useAlertActions() {
return { acknowledge, dismiss }; return { acknowledge, dismiss };
} }
export function useDismissAll() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (filter: { ruleId?: string; severity?: string } = {}) =>
apiFetch('/api/v1/alerts/dismiss-all', { method: 'POST', body: filter }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alerts'] }),
});
}
export function useAlertRealtime() { export function useAlertRealtime() {
useRealtimeInvalidation({ useRealtimeInvalidation({
'alert:created': [['alerts']], 'alert:created': [['alerts']],

View File

@@ -13,6 +13,7 @@ import {
import { TagBadge } from '@/components/shared/tag-badge'; import { TagBadge } from '@/components/shared/tag-badge';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card'; import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
import { formatCurrency } from '@/lib/utils/currency'; import { formatCurrency } from '@/lib/utils/currency';
import type { BerthRow } from './berth-columns'; import type { BerthRow } from './berth-columns';
import { mooringLetterDot } from './mooring-letter-tone'; import { mooringLetterDot } from './mooring-letter-tone';
@@ -167,7 +168,9 @@ export function BerthCard({ berth }: BerthCardProps) {
{/* Status pill + tags */} {/* Status pill + tags */}
<div className="mt-1.5 flex flex-wrap items-center gap-1.5"> <div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<BerthStatusQuickEdit berthId={berth.id} currentStatus={berth.status}>
<StatusPill status={statusPill}>{statusLabel}</StatusPill> <StatusPill status={statusPill}>{statusLabel}</StatusPill>
</BerthStatusQuickEdit>
{tags.slice(0, 2).map((tag) => ( {tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} /> <TagBadge key={tag.id} name={tag.name} color={tag.color} />
))} ))}

View File

@@ -15,6 +15,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge'; import { TagBadge } from '@/components/shared/tag-badge';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
import { formatCurrency } from '@/lib/utils/currency'; import { formatCurrency } from '@/lib/utils/currency';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -342,7 +343,9 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
const isManualUnreconciled = isManual && !r.latestInterestStage; const isManualUnreconciled = isManual && !r.latestInterestStage;
return ( return (
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
<BerthStatusQuickEdit berthId={r.id} currentStatus={r.status}>
<StatusBadge status={r.status} /> <StatusBadge status={r.status} />
</BerthStatusQuickEdit>
{isManual ? <ManualBadge variant={isManualUnreconciled ? 'catchup' : 'pinned'} /> : null} {isManual ? <ManualBadge variant={isManualUnreconciled ? 'catchup' : 'pinned'} /> : null}
</div> </div>
); );

View File

@@ -8,7 +8,7 @@ import { DetailLayout } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found'; import { DetailNotFound } from '@/components/shared/detail-not-found';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useTenanciesModuleEnabled } from '@/providers/port-provider'; import { useTenanciesModuleEnabled, useMaintenanceModuleEnabled } from '@/providers/port-provider';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { BerthDetailHeader, type BerthDetailData } from './berth-detail-header'; import { BerthDetailHeader, type BerthDetailData } from './berth-detail-header';
import { BerthForm } from './berth-form'; import { BerthForm } from './berth-form';
@@ -22,6 +22,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const tenanciesModuleEnabled = useTenanciesModuleEnabled(); const tenanciesModuleEnabled = useTenanciesModuleEnabled();
const maintenanceModuleEnabled = useMaintenanceModuleEnabled();
const { data, isLoading, error } = useQuery<BerthDetailData>({ const { data, isLoading, error } = useQuery<BerthDetailData>({
queryKey: ['berth', berthId], queryKey: ['berth', berthId],
@@ -86,7 +87,9 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
<DetailLayout <DetailLayout
isLoading={isLoading} isLoading={isLoading}
header={berth ? <BerthDetailHeader berth={berth} /> : null} header={berth ? <BerthDetailHeader berth={berth} /> : null}
tabs={berth ? buildBerthTabs(berth, { tenanciesModuleEnabled }) : []} tabs={
berth ? buildBerthTabs(berth, { tenanciesModuleEnabled, maintenanceModuleEnabled }) : []
}
defaultTab="overview" defaultTab="overview"
/> />
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null} {berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}

View File

@@ -19,8 +19,10 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ChevronDown, ChevronRight, Download } from 'lucide-react';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
@@ -29,6 +31,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { PdfReconcileDialog } from './pdf-reconcile-dialog'; import { PdfReconcileDialog } from './pdf-reconcile-dialog';
// pdfjs-dist is ~150kb gzip — lazy-load so the berth page only pulls it
// in when a rep actually expands the spec-sheet preview. ssr:false
// because the pdfjs worker setup needs `window`.
const PdfViewer = dynamic(
() => import('@/components/files/pdf-viewer').then((m) => ({ default: m.PdfViewer })),
{
ssr: false,
loading: () => (
<div className="flex h-[600px] items-center justify-center text-sm text-muted-foreground">
Loading PDF viewer
</div>
),
},
);
interface PdfVersionRow { interface PdfVersionRow {
id: string; id: string;
versionNumber: number; versionNumber: number;
@@ -53,6 +70,7 @@ interface UploadUrlResponse {
export function BerthDocumentsTab({ berthId }: { berthId: string }) { export function BerthDocumentsTab({ berthId }: { berthId: string }) {
const qc = useQueryClient(); const qc = useQueryClient();
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [previewOpen, setPreviewOpen] = useState(true);
const [pendingDiff, setPendingDiff] = useState<{ const [pendingDiff, setPendingDiff] = useState<{
versionId: string; versionId: string;
autoApplied: Array<{ field: string; value: string | number }>; autoApplied: Array<{ field: string; value: string | number }>;
@@ -187,24 +205,45 @@ export function BerthDocumentsTab({ berthId }: { berthId: string }) {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0 text-sm"> <CardContent className="space-y-3 pt-0 text-sm">
{isLoading ? ( {isLoading ? (
<p className="text-muted-foreground">Loading</p> <p className="text-muted-foreground">Loading</p>
) : current ? ( ) : current ? (
<>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<a <button
href={current.downloadUrl} type="button"
target="_blank" onClick={() => setPreviewOpen((o) => !o)}
rel="noreferrer" className="inline-flex items-center gap-1 font-medium underline-offset-2 hover:underline"
className="font-medium underline underline-offset-2" aria-expanded={previewOpen}
> >
{previewOpen ? (
<ChevronDown className="size-3.5 shrink-0" aria-hidden />
) : (
<ChevronRight className="size-3.5 shrink-0" aria-hidden />
)}
{current.fileName} {current.fileName}
</a> </button>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB
</span> </span>
{current.parseEngine ? <ParseEngineBadge engine={current.parseEngine} /> : null} {current.parseEngine ? <ParseEngineBadge engine={current.parseEngine} /> : null}
<a
href={current.downloadUrl}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Download className="size-3.5" aria-hidden />
Download
</a>
</div> </div>
{previewOpen ? (
<div className="h-[600px] overflow-hidden rounded-md border bg-muted/20">
<PdfViewer url={current.downloadUrl} fileName={current.fileName} />
</div>
) : null}
</>
) : ( ) : (
<p className="text-muted-foreground">No PDF uploaded yet.</p> <p className="text-muted-foreground">No PDF uploaded yet.</p>
)} )}

View File

@@ -4,16 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import { Anchor, Archive, CircleDollarSign, Plus, Tag as TagIcon, TagsIcon } from 'lucide-react';
Anchor,
Archive,
CircleDollarSign,
Plus,
Rows3,
Rows4,
Tag as TagIcon,
TagsIcon,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -103,8 +94,10 @@ export function BerthList() {
// Persisted column visibility + row density + dimension unit - same // Persisted column visibility + row density + dimension unit - same
// pattern as ClientList / InterestList; density falls back to // pattern as ClientList / InterestList; density falls back to
// 'comfortable' and dimensionUnit to 'ft' for users who haven't picked. // 'comfortable' and dimensionUnit to 'ft' for users who haven't picked.
const { hidden, setHidden, density, setDensity, dimensionUnit, setDimensionUnit } = const { hidden, setHidden, dimensionUnit, setDimensionUnit } = useTablePreferences(
useTablePreferences('berths', BERTH_DEFAULT_HIDDEN); 'berths',
BERTH_DEFAULT_HIDDEN,
);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
const berthColumns = getBerthColumns(dimensionUnit); const berthColumns = getBerthColumns(dimensionUnit);
@@ -187,24 +180,11 @@ export function BerthList() {
applyView({ filters: savedFilters, sort: savedSort }); applyView({ filters: savedFilters, sort: savedSort });
}} }}
/> />
<Button {/* Table-only controls — hidden in card mode (<md, matching
type="button" DataTable's table/card switch). The BerthCard ignores the
size="sm" dimension unit + renders no column set, so these toggles have
variant="outline" no visible effect there. */}
onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')} <div className="hidden items-center gap-2 md:flex">
aria-label={
density === 'compact'
? 'Switch to comfortable row spacing'
: 'Switch to compact row spacing'
}
title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'}
>
{density === 'compact' ? (
<Rows3 className="h-4 w-4" aria-hidden />
) : (
<Rows4 className="h-4 w-4" aria-hidden />
)}
</Button>
<Button <Button
type="button" type="button"
size="sm" size="sm"
@@ -217,6 +197,7 @@ export function BerthList() {
{dimensionUnit === 'ft' ? 'ft' : 'm'} {dimensionUnit === 'ft' ? 'ft' : 'm'}
</Button> </Button>
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} /> <ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
</div>
<ExportListPdfButton kind="berths" /> <ExportListPdfButton kind="berths" />
{canBulkAdd && ( {canBulkAdd && (
<Button asChild size="sm" variant="default"> <Button asChild size="sm" variant="default">
@@ -232,7 +213,6 @@ export function BerthList() {
<DataTable<BerthRow> <DataTable<BerthRow>
columns={berthColumns} columns={berthColumns}
columnVisibility={columnVisibility} columnVisibility={columnVisibility}
density={density}
data={data} data={data}
isLoading={isLoading} isLoading={isLoading}
pagination={{ pagination={{

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { stageBadgeClass, stageLabel } from '@/lib/constants'; import { stageBadgeClass, stageLabel } from '@/lib/constants';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -80,27 +81,74 @@ export function BerthOccupancyChip({
competing.find((r) => r.isInEoiBundle) ?? competing.find((r) => r.isPrimary) ?? competing[0]!; competing.find((r) => r.isInEoiBundle) ?? competing.find((r) => r.isPrimary) ?? competing[0]!;
const extras = competing.length - 1; const extras = competing.length - 1;
const chipClass = cn(
'inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs text-amber-900 hover:bg-amber-100 transition-colors',
// Cap tight on narrow viewports, but give the name room on desktop
// so it isn't truncated to "Philippe Ca…" (UAT 2026-06-03).
compact && 'max-w-[200px] md:max-w-[460px]',
);
const stageChip = (stage: string) => (
<span className={cn('shrink-0 rounded-full px-1.5 text-xs', stageBadgeClass(stage))}>
{stageLabel(stage)}
</span>
);
// Single competing interest → the chip is a direct link to it.
if (competing.length === 1) {
return ( return (
<Link <Link
href={`/${portSlug}/interests/${primary.interestId}` as never} href={`/${portSlug}/interests/${primary.interestId}` as never}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className={cn( className={chipClass}
'inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs text-amber-900 hover:bg-amber-100 transition-colors',
compact && 'max-w-[200px]',
)}
title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`} title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`}
> >
<span className="font-medium">Under offer to:</span> <span className="font-medium">Under offer to:</span>
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span> <span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
<span {stageChip(primary.pipelineStage)}
className={cn(
'shrink-0 rounded-full px-1.5 text-xs',
stageBadgeClass(primary.pipelineStage),
)}
>
{stageLabel(primary.pipelineStage)}
</span>
{extras > 0 ? <span className="shrink-0 text-amber-700">+{extras} more</span> : null}
</Link> </Link>
); );
} }
// Multiple competing interests → the chip opens a popover that lists
// every competing deal so no name is hidden behind "+N more" (UAT
// 2026-06-03). Each row links to its interest.
return (
<Popover>
<PopoverTrigger asChild>
<button type="button" onClick={(e) => e.stopPropagation()} className={chipClass}>
<span className="font-medium">Under offer to:</span>
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
{stageChip(primary.pipelineStage)}
<span className="shrink-0 text-amber-700">+{extras} more</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-0" onClick={(e) => e.stopPropagation()}>
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground">
{competing.length} interests competing for this berth
</div>
<ul className="max-h-72 divide-y overflow-y-auto">
{competing.map((r) => (
<li key={r.interestId}>
<Link
href={`/${portSlug}/interests/${r.interestId}` as never}
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-muted/60"
>
<span className="min-w-0 flex-1 truncate">
{r.clientName}
{r.isInEoiBundle ? (
<span className="ml-1.5 text-xs text-amber-700">· in EOI</span>
) : r.isPrimary ? (
<span className="ml-1.5 text-xs text-muted-foreground">· primary</span>
) : null}
</span>
{stageChip(r.pipelineStage)}
</Link>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
/**
* Bulk berth price-reconcile table (CM-2 Part A).
*
* Lists the price parsed from each berth's current spec sheet next to the stored
* price, with per-row + select-all approval. Nothing is written until the rep
* approves — the apply mutation posts only the checked, changed rows.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { EmptyState } from '@/components/ui/empty-state';
interface Row {
berthId: string;
mooringNumber: string;
area: string | null;
currentPrice: number | null;
currentCurrency: string;
parsedPrice: number | null;
parsedCurrency: string | null;
status: 'changed' | 'matched' | 'needs_review' | 'no_pdf';
warning?: string;
}
const STATUS_STYLE: Record<Row['status'], string> = {
changed: 'bg-amber-100 text-amber-800',
matched: 'bg-muted text-muted-foreground',
needs_review: 'bg-red-100 text-red-700',
no_pdf: 'bg-slate-100 text-slate-500',
};
const STATUS_LABEL: Record<Row['status'], string> = {
changed: 'Changed',
matched: 'Matched',
needs_review: 'Needs review',
no_pdf: 'No PDF',
};
const fmt = (n: number | null, ccy: string | null) =>
n == null ? '—' : `${n.toLocaleString()} ${ccy ?? ''}`.trim();
export function BerthPriceReconcileTable() {
const qc = useQueryClient();
const { data, isLoading } = useQuery<{ data: Row[] }>({
queryKey: ['berths', 'price-reconcile'],
queryFn: () => apiFetch('/api/v1/berths/price-reconcile'),
});
const rows = useMemo(() => data?.data ?? [], [data]);
const selectable = useMemo(() => rows.filter((r) => r.status === 'changed'), [rows]);
const [checked, setChecked] = useState<Record<string, boolean>>({});
const apply = useMutation({
mutationFn: async (): Promise<{ data: { updated: number } }> => {
const approvals = selectable
.filter((r) => checked[r.berthId] && r.parsedPrice != null)
.map((r) => ({
berthId: r.berthId,
price: r.parsedPrice as number,
currency: r.parsedCurrency ?? r.currentCurrency,
}));
return apiFetch('/api/v1/berths/price-reconcile/apply', {
method: 'POST',
body: { approvals },
});
},
onSuccess: (res) => {
toast.success(`Updated ${res.data.updated} berth price(s).`);
setChecked({});
void qc.invalidateQueries({ queryKey: ['berths'] });
},
onError: (e: Error) => toastError(e),
});
if (isLoading) {
return <p className="p-6 text-sm text-muted-foreground">Parsing spec sheets</p>;
}
if (rows.length === 0) {
return (
<EmptyState title="No berths to reconcile" body="No active berths found for this port." />
);
}
const allChecked = selectable.length > 0 && selectable.every((r) => checked[r.berthId]);
const selectedCount = selectable.filter((r) => checked[r.berthId]).length;
const reviewCount = rows.filter((r) => r.status === 'needs_review').length;
const noPdfCount = rows.filter((r) => r.status === 'no_pdf').length;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
{selectable.length} changed · {reviewCount} need review · {noPdfCount} without a PDF
</p>
<Button
size="sm"
disabled={selectedCount === 0 || apply.isPending}
onClick={() => apply.mutate()}
>
{apply.isPending ? 'Applying…' : `Approve selected (${selectedCount})`}
</Button>
</div>
<div className="overflow-hidden rounded-md border bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-start text-xs text-muted-foreground">
<th className="w-10 p-2 ps-3">
<Checkbox
aria-label="Select all changed"
checked={allChecked}
onCheckedChange={(c) =>
setChecked(
c === true
? Object.fromEntries(selectable.map((r) => [r.berthId, true]))
: {},
)
}
/>
</th>
<th className="p-2">Mooring</th>
<th className="p-2">Area</th>
<th className="p-2 text-end">Current</th>
<th className="p-2 text-end">Parsed</th>
<th className="p-2">Status</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.berthId} className="border-b last:border-0">
<td className="p-2 ps-3">
{r.status === 'changed' ? (
<Checkbox
aria-label={`Approve ${r.mooringNumber}`}
checked={!!checked[r.berthId]}
onCheckedChange={(c) =>
setChecked((p) => ({ ...p, [r.berthId]: c === true }))
}
/>
) : null}
</td>
<td className="p-2 font-medium">{r.mooringNumber}</td>
<td className="p-2 text-muted-foreground">{r.area ?? '—'}</td>
<td className="p-2 text-end tabular-nums">
{fmt(r.currentPrice, r.currentCurrency)}
</td>
<td className="p-2 text-end tabular-nums">
{fmt(r.parsedPrice, r.parsedCurrency)}
</td>
<td className="p-2">
<span className={`rounded px-2 py-0.5 text-xs ${STATUS_STYLE[r.status]}`}>
{STATUS_LABEL[r.status]}
</span>
{r.warning ? (
<span className="ms-2 text-xs text-muted-foreground">{r.warning}</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,242 @@
'use client';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { FormErrorSummary } from '@/components/forms/form-error-summary';
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
import { PermissionGate } from '@/components/shared/permission-gate';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useVocabulary } from '@/hooks/use-vocabulary';
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
import { BERTH_STATUSES, stageLabel } from '@/lib/constants';
import { cn } from '@/lib/utils';
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
interface InterestOption {
id: string;
clientName: string;
pipelineStage: string;
}
/**
* Click-to-change berth status from the berths LIST. Wraps the status chip
* (passed as children) in a button that opens a compact change-status dialog
* — status dropdown + required reason (with quick-pick chips) + an optional
* interest link when moving to under_offer/sold. Same PATCH endpoint +
* validator + audit as the berth detail page. Reps without `berths.edit` see
* a plain, non-interactive chip via the PermissionGate fallback.
*/
export function BerthStatusQuickEdit({
berthId,
currentStatus,
children,
className,
}: {
berthId: string;
currentStatus: string;
children: ReactNode;
className?: string;
}) {
const [open, setOpen] = useState(false);
return (
<PermissionGate resource="berths" action="edit" fallback={<>{children}</>}>
<button
type="button"
onClick={(e) => {
// The chip usually sits inside a clickable list card/row — stop the
// click from also navigating to the berth detail page.
e.preventDefault();
e.stopPropagation();
setOpen(true);
}}
title="Change status"
aria-label="Change berth status"
className={cn(
'cursor-pointer rounded-full outline-none transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
className,
)}
>
{children}
</button>
{open && (
<BerthStatusQuickEditDialog
berthId={berthId}
currentStatus={currentStatus}
open={open}
onOpenChange={setOpen}
/>
)}
</PermissionGate>
);
}
function BerthStatusQuickEditDialog({
berthId,
currentStatus,
open,
onOpenChange,
}: {
berthId: string;
currentStatus: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const reasonChips = useVocabulary('berth_status_change_reasons');
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<UpdateBerthStatusInput>({
resolver: zodResolver(updateBerthStatusSchema),
defaultValues: { status: currentStatus as (typeof BERTH_STATUSES)[number], reason: '' },
});
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
const status = watch('status');
const interestId = watch('interestId');
const showInterestPicker = status === 'under_offer' || status === 'sold';
// Active interests for the picker — only fetched once the picker is shown.
const interestsQuery = useQuery<{ data: InterestOption[] }>({
queryKey: ['interests', 'status-link-picker'],
queryFn: () => apiFetch('/api/v1/interests?pageSize=200&sort=updatedAt&order=desc'),
enabled: open && showInterestPicker,
staleTime: 60_000,
});
const interestOptions = interestsQuery.data?.data ?? [];
async function onSubmit(data: UpdateBerthStatusInput) {
try {
await apiFetch(`/api/v1/berths/${berthId}/status`, { method: 'PATCH', body: data });
queryClient.invalidateQueries({ queryKey: ['berths'] });
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
toast.success('Status updated');
reset();
onOpenChange(false);
} catch (err) {
toastError(err);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change status</DialogTitle>
</DialogHeader>
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4">
<FormErrorSummary
errors={errors}
labels={{ status: 'Status', reason: 'Reason', interestId: 'Linked interest' }}
/>
<div className="space-y-2">
<Label>New status</Label>
<Select
value={status}
onValueChange={(v) => {
setValue('status', v as (typeof BERTH_STATUSES)[number]);
// Clear any stale interest pick when returning to available.
if (v === 'available') setValue('interestId', undefined);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BERTH_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{STATUS_LABELS[s] ?? s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason *</Label>
{reasonChips.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{reasonChips.map((chip) => (
<button
type="button"
key={chip}
onClick={() => setValue('reason', chip, { shouldDirty: true })}
className="rounded-full border border-muted-foreground/20 bg-muted px-2.5 py-0.5 text-xs hover:bg-accent"
>
{chip}
</button>
))}
</div>
)}
<Textarea {...register('reason')} placeholder="Reason for status change…" rows={3} />
</div>
{showInterestPicker && (
<div className="space-y-2">
<Label>Linked interest (optional)</Label>
<Select
value={interestId ?? '__none__'}
onValueChange={(v) => setValue('interestId', v === '__none__' ? undefined : v)}
>
<SelectTrigger>
<SelectValue placeholder="No interest" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No interest</SelectItem>
{interestOptions.map((o) => (
<SelectItem key={o.id} value={o.id}>
{`${o.clientName || '(unnamed)'} · ${stageLabel(o.pipelineStage)}`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Links this change to the interest it relates to it shows on that interest&apos;s
timeline and the berth attaches to it automatically.
</p>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Update status'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -427,7 +427,10 @@ function OverviewTab({ berth }: { berth: BerthData }) {
export function buildBerthTabs( export function buildBerthTabs(
berth: BerthData, berth: BerthData,
opts: { tenanciesModuleEnabled: boolean } = { tenanciesModuleEnabled: false }, opts: { tenanciesModuleEnabled: boolean; maintenanceModuleEnabled: boolean } = {
tenanciesModuleEnabled: false,
maintenanceModuleEnabled: true,
},
): DetailTab[] { ): DetailTab[] {
const tabs: DetailTab[] = [ const tabs: DetailTab[] = [
{ {
@@ -448,12 +451,15 @@ export function buildBerthTabs(
content: <BerthTenanciesTab berthId={berth.id} />, content: <BerthTenanciesTab berthId={berth.id} />,
}); });
} }
tabs.push(...buildBerthDetailRemainder(berth)); tabs.push(...buildBerthDetailRemainder(berth, opts));
return tabs; return tabs;
} }
function buildBerthDetailRemainder(berth: BerthData): DetailTab[] { function buildBerthDetailRemainder(
return [ berth: BerthData,
opts: { maintenanceModuleEnabled: boolean } = { maintenanceModuleEnabled: true },
): DetailTab[] {
const tabs: DetailTab[] = [
{ {
id: 'spec', id: 'spec',
label: 'Spec', label: 'Spec',
@@ -469,12 +475,15 @@ function buildBerthDetailRemainder(berth: BerthData): DetailTab[] {
label: 'Waiting List', label: 'Waiting List',
content: <WaitingListManager berthId={berth.id} />, content: <WaitingListManager berthId={berth.id} />,
}, },
{ ];
if (opts.maintenanceModuleEnabled) {
tabs.push({
id: 'maintenance', id: 'maintenance',
label: 'Maintenance', label: 'Maintenance',
content: <BerthMaintenanceTab berthId={berth.id} />, content: <BerthMaintenanceTab berthId={berth.id} />,
}, });
{ }
tabs.push({
id: 'activity', id: 'activity',
label: 'Activity', label: 'Activity',
content: ( content: (
@@ -483,6 +492,6 @@ function buildBerthDetailRemainder(berth: BerthData): DetailTab[] {
emptyText="No activity recorded for this berth yet." emptyText="No activity recorded for this berth yet."
/> />
), ),
}, });
]; return tabs;
} }

View File

@@ -0,0 +1,304 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { useParams, useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Copy, CopyCheck, Trash2, UserCog, Users } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { EmptyState } from '@/components/shared/empty-state';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface GroupMember {
clientId: string;
fullName: string;
email: string | null;
}
interface ClientOption {
id: string;
fullName: string;
primaryEmail: string | null;
}
async function copyToClipboard(text: string, successMsg: string) {
try {
await navigator.clipboard.writeText(text);
toast.success(successMsg);
} catch {
toast.error('Copy failed — clipboard unavailable');
}
}
export function ClientGroupDetail({ groupId }: { groupId: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const router = useRouter();
const qc = useQueryClient();
const [manageOpen, setManageOpen] = useState(false);
const { data: groupResp } = useQuery<{ data: { id: string; name: string; color: string } }>({
queryKey: ['client-group', groupId],
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}`),
});
const { data: membersResp, isLoading } = useQuery<{ data: GroupMember[] }>({
queryKey: ['client-group', groupId, 'members'],
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}/members`),
});
const group = groupResp?.data;
const members = useMemo(() => membersResp?.data ?? [], [membersResp]);
const emails = members.map((m) => m.email).filter((e): e is string => !!e);
const archive = useMutation({
mutationFn: () => apiFetch(`/api/v1/client-groups/${groupId}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Group archived');
qc.invalidateQueries({ queryKey: ['client-groups'] });
router.push(`/${portSlug}/client-groups` as Route);
},
onError: (err) => toastError(err),
});
return (
<div className="space-y-6">
<Link
href={`/${portSlug}/client-groups` as Route}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
All groups
</Link>
<PageHeader
title={group?.name ?? 'Group'}
eyebrow="Mailing group"
kpiLine={
<span className="inline-flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" aria-hidden />
{members.length} {members.length === 1 ? 'member' : 'members'}
{emails.length < members.length ? (
<span className="text-amber-700">
· {members.length - emails.length} without email
</span>
) : null}
</span>
}
variant="gradient"
actions={
<>
<Button
variant="outline"
disabled={emails.length === 0}
onClick={() =>
copyToClipboard(emails.join(', '), `Copied ${emails.length} email addresses`)
}
>
<CopyCheck className="me-1.5 h-4 w-4" aria-hidden />
Copy all emails
</Button>
<PermissionGate resource="client_groups" action="manage">
<Button variant="outline" onClick={() => setManageOpen(true)}>
<UserCog className="me-1.5 h-4 w-4" aria-hidden />
Manage members
</Button>
</PermissionGate>
<PermissionGate resource="client_groups" action="manage">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm('Archive this group? Members are kept; the group is hidden.')) {
archive.mutate();
}
}}
>
<Trash2 className="me-1.5 h-4 w-4" aria-hidden />
Archive
</Button>
</PermissionGate>
</>
}
/>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading members</p>
) : members.length === 0 ? (
<EmptyState
icon={Users}
title="No members yet"
description="Use “Manage members” to add clients to this group."
/>
) : (
<div className="overflow-hidden rounded-xl border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Client</th>
<th className="px-4 py-2 font-medium">Email</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{members.map((m) => (
<tr key={m.clientId} className="hover:bg-muted/30">
<td className="px-4 py-2">
<Link
href={`/${portSlug}/clients/${m.clientId}` as Route}
className="text-foreground hover:underline"
>
{m.fullName}
</Link>
</td>
<td className="px-4 py-2 text-muted-foreground">{m.email ?? '—'}</td>
<td className="px-4 py-2 text-end">
{m.email ? (
<button
type="button"
onClick={() => copyToClipboard(m.email!, 'Email copied')}
aria-label={`Copy ${m.email}`}
title="Copy email"
className="rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
>
<Copy className="h-4 w-4" aria-hidden />
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{manageOpen ? (
<ManageMembersDialog
groupId={groupId}
open={manageOpen}
onOpenChange={setManageOpen}
currentIds={members.map((m) => m.clientId)}
onSaved={() => {
qc.invalidateQueries({ queryKey: ['client-group', groupId, 'members'] });
qc.invalidateQueries({ queryKey: ['client-groups'] });
}}
/>
) : null}
</div>
);
}
function ManageMembersDialog({
groupId,
open,
onOpenChange,
currentIds,
onSaved,
}: {
groupId: string;
open: boolean;
onOpenChange: (v: boolean) => void;
currentIds: string[];
onSaved: () => void;
}) {
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
const { data, isLoading } = useQuery<{ data: ClientOption[] }>({
queryKey: ['clients', 'group-picker'],
queryFn: () => apiFetch('/api/v1/clients?limit=1000'),
enabled: open,
});
const clients = data?.data ?? [];
const filtered = clients.filter((c) =>
`${c.fullName} ${c.primaryEmail ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()),
);
const save = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/client-groups/${groupId}/members`, {
method: 'PUT',
body: { clientIds: Array.from(selected) },
}),
onSuccess: () => {
toast.success('Members updated');
onSaved();
onOpenChange(false);
},
onError: (err) => toastError(err),
});
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Manage members</DialogTitle>
<DialogDescription>
Tick the clients who belong in this group. {selected.size} selected.
</DialogDescription>
</DialogHeader>
<Input
placeholder="Search clients…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="max-h-80 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
{isLoading ? (
<p className="p-2 text-sm text-muted-foreground">Loading clients</p>
) : filtered.length === 0 ? (
<p className="p-2 text-sm text-muted-foreground">No matching clients.</p>
) : (
filtered.map((c) => (
<label
key={c.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<Checkbox checked={selected.has(c.id)} onCheckedChange={() => toggle(c.id)} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">{c.fullName}</span>
{c.primaryEmail ? (
<span className="block truncate text-xs text-muted-foreground">
{c.primaryEmail}
</span>
) : null}
</span>
</label>
))
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending}>
Save members
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { useParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Users } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { EmptyState } from '@/components/shared/empty-state';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface ClientGroupRow {
id: string;
name: string;
description: string | null;
color: string;
memberCount: number;
}
export function ClientGroupsList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState('#6B7280');
const { data, isLoading } = useQuery<{ data: ClientGroupRow[] }>({
queryKey: ['client-groups'],
queryFn: () => apiFetch('/api/v1/client-groups'),
});
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/client-groups', {
method: 'POST',
body: { name: name.trim(), description: description.trim() || null, color },
}),
onSuccess: () => {
toast.success('Group created');
qc.invalidateQueries({ queryKey: ['client-groups'] });
setOpen(false);
setName('');
setDescription('');
setColor('#6B7280');
},
onError: (err) => toastError(err),
});
const groups = data?.data ?? [];
return (
<div className="space-y-6">
<PageHeader
title="Client Groups"
eyebrow="Mailing"
description="Group clients into mailing lists. View members, copy their emails, and (once wired) sync to Mailchimp."
variant="gradient"
actions={
<PermissionGate resource="client_groups" action="manage">
<Button onClick={() => setOpen(true)}>
<Plus className="me-1.5 h-4 w-4" aria-hidden />
New group
</Button>
</PermissionGate>
}
/>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : groups.length === 0 ? (
<EmptyState
icon={Users}
title="No groups yet"
description="Create a group to start organising clients into mailing lists."
/>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{groups.map((g) => (
<Link
key={g.id}
href={`/${portSlug}/client-groups/${g.id}` as Route}
className="group rounded-xl border border-border bg-card p-4 transition-colors hover:border-brand/40 hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: g.color }}
aria-hidden
/>
<h3 className="truncate font-medium text-foreground">{g.name}</h3>
</div>
{g.description ? (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{g.description}</p>
) : null}
<p className="mt-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5" aria-hidden />
{g.memberCount} {g.memberCount === 1 ? 'member' : 'members'}
</p>
</Link>
))}
</div>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New client group</DialogTitle>
<DialogDescription>A named mailing/segment group for this port.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="cg-name">Name</Label>
<Input
id="cg-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Newsletter subscribers"
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cg-desc">Description (optional)</Label>
<Input
id="cg-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cg-color">Color</Label>
<input
id="cg-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-9 w-16 cursor-pointer rounded-md border border-border bg-background"
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={() => create.mutate()} disabled={!name.trim() || create.isPending}>
Create group
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -54,10 +54,21 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
const interest = client.latestInterest ?? null; const interest = client.latestInterest ?? null;
const interestCount = client.interestCount ?? 0; const interestCount = client.interestCount ?? 0;
const interestBerthLabel = interest // Show ALL berths the client has interests in (across every interest),
? interest.mooringNumber // not just the latest interest's primary mooring — matches the desktop
? `Berth ${interest.mooringNumber}` // table's Berths column + the interest header. Cap the inline list so
: 'General interest' // the card stays compact; overflow folds into a "+N" suffix.
const linkedBerths = client.linkedBerths ?? [];
const MAX_BERTHS_SHOWN = 4;
const shownMoorings = linkedBerths.slice(0, MAX_BERTHS_SHOWN).map((b) => b.mooringNumber);
const extraBerths = linkedBerths.length - shownMoorings.length;
const interestBerthLabel =
shownMoorings.length > 0
? `${linkedBerths.length === 1 ? 'Berth' : 'Berths'} ${shownMoorings.join(', ')}${
extraBerths > 0 ? ` +${extraBerths}` : ''
}`
: interest
? 'General interest'
: null; : null;
const interestStageLabel = interest ? stageLabel(interest.stage) : null; const interestStageLabel = interest ? stageLabel(interest.stage) : null;
const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null; const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null;

View File

@@ -3,11 +3,9 @@
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import type { Route } from 'next'; import type { Route } from 'next';
import { useState } from 'react'; import { useState } from 'react';
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react'; import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge'; import { TagBadge } from '@/components/shared/tag-badge';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
@@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const primaryEmail = const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ?? client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
client.contacts?.find((c) => c.channel === 'email')?.value; client.contacts?.find((c) => c.channel === 'email')?.value;
const primaryPhoneContact =
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'phone');
const primaryPhone = primaryPhoneContact?.value;
// wa.me requires the E.164 number without the leading "+". Strip from the
// canonical E.164 form when available; otherwise strip non-digits from the
// display value as a best-effort fallback.
const whatsappNumber = primaryPhoneContact?.valueE164
? primaryPhoneContact.valueE164.replace(/^\+/, '')
: primaryPhoneContact?.value
? primaryPhoneContact.value.replace(/[^\d]/g, '')
: null;
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null; const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
const addedLabel = client.createdAt const addedLabel = client.createdAt
@@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</p> </p>
) : null} ) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1"> {/* CM-4: Email/Call/WhatsApp deep-link pills removed at client
{primaryEmail ? ( request. GDPR export moved to the top-right action cluster.
<Button Portal-invite stays as the one primary CTA here. */}
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
<Mail />
Email
</a>
</Button>
) : null}
{primaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message ${primaryPhone} on WhatsApp`}
>
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>
) : null}
{!isArchived && client.clientPortalEnabled === true ? ( {!isArchived && client.clientPortalEnabled === true ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<div className="hidden sm:inline-flex"> <div className="hidden sm:inline-flex">
<PortalInviteButton <PortalInviteButton
clientId={client.id} clientId={client.id}
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
defaultEmail={primaryEmail} defaultEmail={primaryEmail}
/> />
</div> </div>
</div>
) : null} ) : null}
<div className="hidden sm:inline-flex">
<GdprExportButton clientId={client.id} />
</div>
</div>
{client.tags && client.tags.length > 0 && ( {client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
right perm) permanently-delete. Destructive actions sit out right perm) permanently-delete. Destructive actions sit out
of the primary action flow. */} of the primary action flow. */}
<div className="flex items-start gap-1"> <div className="flex items-start gap-1">
{/* CM-4: GDPR export relocated here as a compact icon trigger,
alongside reminder/archive/delete. Self-gates on permission. */}
<GdprExportButton clientId={client.id} variant="icon" />
{isArchived && ( {isArchived && (
<PermissionGate resource="admin" action="permanently_delete_clients"> <PermissionGate resource="admin" action="permanently_delete_clients">
<button <button

View File

@@ -11,6 +11,7 @@ import { RemindersInline } from '@/components/reminders/reminders-inline';
import { primaryTimezoneFor } from '@/lib/i18n/timezones'; import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { ProxyCard } from '@/components/shared/proxy-card';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
import { ClientInterestsTab } from '@/components/clients/client-interests-tab'; import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary'; import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
@@ -156,6 +157,9 @@ function OverviewTab({
<ClientPipelineSummary clientId={clientId} variant="panel" /> <ClientPipelineSummary clientId={clientId} variant="panel" />
</div> </div>
{/* CM-9: point-of-contact (default level for the client). */}
<ProxyCard entityType="client" entityId={clientId} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */} {/* Personal Info */}
<div className="space-y-1"> <div className="space-y-1">

View File

@@ -0,0 +1,119 @@
'use client';
import Link from 'next/link';
import type { Route } from 'next';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Globe } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { PageHeader } from '@/components/shared/page-header';
import { apiFetch } from '@/lib/api/client';
import { CountryFlag } from '@/components/shared/country-flag';
import { getCountryName } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
interface ClientsByCountryRow {
country: string;
count: number;
}
interface ClientsByCountryResponse {
data: ClientsByCountryRow[];
total: number;
}
/**
* Full per-country breakdown of the active (non-archived) client book — the
* "Show all" destination for the dashboard `ClientsByCountryWidget`, which
* only shows the top N. Same endpoint (it already returns every row); this
* page just renders the complete ranked list. Each row deep-links into the
* clients list filtered by that nationality.
*/
export function ClientsByCountryPage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<ClientsByCountryResponse>({
queryKey: ['dashboard', 'clients-by-country', 'all'],
queryFn: () => apiFetch<ClientsByCountryResponse>('/api/v1/dashboard/clients-by-country'),
staleTime: 60_000,
});
const rows = data?.data ?? [];
const total = data?.total ?? rows.reduce((s, r) => s + r.count, 0);
const maxCount = rows.reduce((m, r) => Math.max(m, r.count), 0) || 1;
return (
<div className="space-y-6">
<PageHeader
eyebrow="Clients"
title="Clients by country"
description="Every country represented in the active client book, ranked by client count. Select a row to view those clients."
kpiLine={
rows.length > 0
? `${total} client${total === 1 ? '' : 's'} across ${rows.length} ${
rows.length === 1 ? 'country' : 'countries'
}.`
: undefined
}
/>
<Card>
<CardContent className="p-4 sm:p-6">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" aria-hidden />
))}
</div>
) : rows.length === 0 ? (
<div className="flex h-40 flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
<Globe className="size-6" aria-hidden />
<p>No clients with a country recorded yet.</p>
</div>
) : (
<ol className="space-y-1">
{rows.map((row, i) => {
const pct = (row.count / maxCount) * 100;
const name = getCountryName(row.country) || row.country;
return (
<li key={row.country}>
<Link
href={
`/${portSlug}/clients?nationality=${encodeURIComponent(row.country)}` as Route
}
className="group flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-foreground/5"
title={`${row.count} client${row.count === 1 ? '' : 's'} in ${name}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="w-6 shrink-0 text-end text-xs tabular-nums text-muted-foreground">
{i + 1}
</span>
<CountryFlag code={row.country} className="h-3.5 w-5" decorative />
<span className="truncate text-sm font-medium">{name}</span>
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="h-1.5 w-32 overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full bg-brand-500')}
style={{ width: `${pct}%` }}
aria-hidden
/>
</div>
<span className="w-10 text-end text-sm tabular-nums text-foreground">
{row.count}
</span>
</div>
</Link>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -48,14 +48,22 @@ const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'des
failed: 'destructive', failed: 'destructive',
}; };
export function GdprExportButton({ clientId }: { clientId: string }) { export function GdprExportButton({
clientId,
variant = 'button',
}: {
clientId: string;
/** `button` = standalone outline button (default). `icon` = compact icon-only
* trigger for the detail-header top-right action cluster (CM-4). */
variant?: 'button' | 'icon';
}) {
const { can, isSuperAdmin } = usePermissions(); const { can, isSuperAdmin } = usePermissions();
const qc = useQueryClient(); const qc = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [emailToClient, setEmailToClient] = useState(false); const [emailToClient, setEmailToClient] = useState(false);
const [emailOverride, setEmailOverride] = useState(''); const [emailOverride, setEmailOverride] = useState('');
const allowed = isSuperAdmin || can('admin', 'manage_settings'); const allowed = isSuperAdmin || can('clients', 'gdpr_export');
const queryKey = ['gdpr-exports', clientId]; const queryKey = ['gdpr-exports', clientId];
const { data, isLoading } = useQuery<ListResp>({ const { data, isLoading } = useQuery<ListResp>({
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
{variant === 'icon' ? (
<button
type="button"
aria-label="GDPR export"
title="GDPR export"
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
>
<FileDown className="size-4" aria-hidden />
</button>
) : (
<Button variant="outline" size="sm" className="h-8"> <Button variant="outline" size="sm" className="h-8">
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden /> <FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
GDPR export GDPR export
</Button> </Button>
)}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import type { Route } from 'next'; import type { Route } from 'next';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Globe } from 'lucide-react'; import { ArrowRight, Globe } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -126,8 +126,14 @@ export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) {
); );
})} })}
{hiddenCount > 0 ? ( {hiddenCount > 0 ? (
<li className="pt-1 text-xs text-muted-foreground"> <li className="border-t pt-2 text-right">
+ {hiddenCount} more {hiddenCount === 1 ? 'country' : 'countries'} not shown. <Link
href={`/${portSlug}/clients/by-country` as Route}
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
Show all {rows.length} countries
<ArrowRight className="size-3" aria-hidden />
</Link>
</li> </li>
) : null} ) : null}
</ol> </ol>

View File

@@ -164,7 +164,10 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
label: 'Pipeline Value', label: 'Pipeline Value',
description: description:
'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.', 'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.',
render: (range) => <PipelineValueTile range={range} />, // Current-state snapshot: pipeline value = sum across ALL active deals,
// not "added in the selected window". Don't thread the range (UAT
// 2026-06-03 — windowing it dropped older deals + confused the headline).
render: () => <PipelineValueTile />,
// Lives in the chart grid (not the narrow rail) so the per-stage // Lives in the chart grid (not the narrow rail) so the per-stage
// breakdown rows have room to breathe alongside the headline numbers, // breakdown rows have room to breathe alongside the headline numbers,
// and the rail stays reserved for reminders / alerts / glance tiles. // and the rail stays reserved for reminders / alerts / glance tiles.

View File

@@ -299,7 +299,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
{isComplete && doc.signedFileId ? ( {isComplete && doc.signedFileId ? (
<> <>
<Button asChild size="sm"> <Button asChild size="sm">
<Link href={`/api/v1/files/${doc.signedFileId}/download`}> <Link href={`/api/v1/files/${doc.signedFileId}/download?redirect=1`}>
<Download className="mr-1.5 h-4 w-4" aria-hidden /> Download signed PDF <Download className="mr-1.5 h-4 w-4" aria-hidden /> Download signed PDF
</Link> </Link>
</Button> </Button>

View File

@@ -4,7 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react'; import {
ChevronDown,
ChevronRight,
Download,
FileText,
Folder,
Lock,
Plus,
Upload,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -16,9 +25,11 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api/client';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { FileUploadZone } from '@/components/files/file-upload-zone'; import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -336,6 +347,30 @@ function FlatFolderListing({
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined); const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
const [expandedDocId, setExpandedDocId] = useState<string | null>(null); const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
const [uploadOpen, setUploadOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
// File selected for inline preview. Clicking a file row opens the shared
// FilePreviewDialog rather than navigating the browser at the JSON-returning
// `/files/[id]/download` endpoint (which used to dump raw `{data:{url}}`).
const [previewFile, setPreviewFile] = useState<HubFile | null>(null);
// Force-download a stored file: the `/download` route returns a presigned
// URL (content-disposition=attachment) as a JSON envelope, so we fetch it
// then click a hidden anchor. Avoids navigating the tab to the raw JSON.
const downloadFile = useCallback(async (file: HubFile) => {
try {
const { data } = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = data.url;
a.rel = 'noopener';
a.download = file.originalName ?? file.filename;
document.body.appendChild(a);
a.click();
a.remove();
} catch {
// apiFetch surfaces its own toast on failure; nothing else to do here.
}
}, []);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const queryParams = useMemo(() => { const queryParams = useMemo(() => {
@@ -489,9 +524,10 @@ function FlatFolderListing({
}; };
// Uploaded-file row — simpler than a signature doc since there's no // Uploaded-file row — simpler than a signature doc since there's no
// signer/status concept. Links to the underlying file via download URL // signer/status concept. Clicking the name opens an inline preview
// and surfaces an "Uploaded" type pill so the rep distinguishes it // (FilePreviewDialog); a dedicated download button saves the file. An
// from signature workflows at a glance. // "Uploaded" type pill distinguishes it from signature workflows.
const fileLabel = (file: HubFile) => file.originalName ?? file.filename;
const renderFileRow = (file: HubFile) => { const renderFileRow = (file: HubFile) => {
return ( return (
<li <li
@@ -501,14 +537,14 @@ function FlatFolderListing({
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
{/* Empty action column to align with doc-row layout */} {/* Empty action column to align with doc-row layout */}
<span className="hidden h-[44px] w-[44px] sm:block" aria-hidden /> <span className="hidden h-[44px] w-[44px] sm:block" aria-hidden />
<a <button
href={`/api/v1/files/${file.id}/download`} type="button"
target="_blank" onClick={() => setPreviewFile(file)}
rel="noreferrer" className="min-w-0 truncate text-left font-medium text-foreground hover:text-brand"
className="min-w-0 truncate font-medium text-foreground hover:text-brand" title={`Preview ${fileLabel(file)}`}
> >
{file.originalName ?? file.filename} {fileLabel(file)}
</a> </button>
<span className="text-xs text-muted-foreground">Uploaded file</span> <span className="text-xs text-muted-foreground">Uploaded file</span>
<StatusPill status="completed" withDot> <StatusPill status="completed" withDot>
Stored Stored
@@ -516,9 +552,21 @@ function FlatFolderListing({
<span className="text-xs tabular-nums text-muted-foreground"> <span className="text-xs tabular-nums text-muted-foreground">
{(file.sizeBytes / 1024).toFixed(0)} KB {(file.sizeBytes / 1024).toFixed(0)} KB
</span> </span>
<div className="flex items-center gap-2">
<span className="text-xs tabular-nums text-muted-foreground"> <span className="text-xs tabular-nums text-muted-foreground">
{new Date(file.createdAt).toLocaleDateString(undefined)} {new Date(file.createdAt).toLocaleDateString(undefined)}
</span> </span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => void downloadFile(file)}
title={`Download ${fileLabel(file)}`}
aria-label={`Download ${fileLabel(file)}`}
>
<Download className="h-4 w-4" aria-hidden />
</Button>
</div>
</div> </div>
</li> </li>
); );
@@ -526,6 +574,15 @@ function FlatFolderListing({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(o) => {
if (!o) setPreviewFile(null);
}}
fileId={previewFile?.id}
fileName={previewFile ? fileLabel(previewFile) : undefined}
mimeType={previewFile?.mimeType ?? undefined}
/>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Input <Input
placeholder="Search by title..." placeholder="Search by title..."

View File

@@ -75,7 +75,7 @@ function ReceiptThumbnail({ fileId }: { fileId: string }) {
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground"> <div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span className="truncate">{mime || (isError ? 'Receipt' : 'File')}</span> <span className="truncate">{mime || (isError ? 'Receipt' : 'File')}</span>
<a <a
href={`/api/v1/files/${fileId}/download`} href={`/api/v1/files/${fileId}/download?redirect=1`}
className="inline-flex items-center gap-1 text-primary hover:underline" className="inline-flex items-center gap-1 text-primary hover:underline"
> >
<Download className="h-3 w-3" aria-hidden /> Download <Download className="h-3 w-3" aria-hidden /> Download

View File

@@ -0,0 +1,87 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
/**
* In-app .docx viewer.
*
* Renders Word OOXML (.docx) client-side via `docx-preview` (lazy-loaded
* so the ~library cost only lands on routes that actually preview a docx).
* We fetch the bytes from our own storage URL and render them in-browser —
* deliberately NOT delegating to Microsoft's hosted Office viewer, which
* requires a publicly-reachable URL and so can't render documents stored
* in our private object store.
*
* Legacy .doc / .xls / .xlsx are not handled here (docx-preview is OOXML-
* Word only); the preview dialog routes those to a download CTA instead.
*/
export function DocxViewer({ url, fileName }: { url: string; fileName?: string }) {
// Key-based remount on url change keeps render state (loading/error +
// the imperatively-populated container) re-initialised from scratch,
// mirroring PdfViewer.
return <DocxViewerBody key={url} url={url} fileName={fileName} />;
}
function DocxViewerBody({ url, fileName }: { url: string; fileName?: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function render() {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load document (${res.status})`);
const blob = await res.blob();
if (cancelled) return;
const { renderAsync } = await import('docx-preview');
const container = containerRef.current;
if (!container) return;
container.innerHTML = '';
await renderAsync(blob, container, undefined, {
className: 'docx',
inWrapper: true,
// Let the document flow to the container width rather than
// forcing fixed A4 page metrics that overflow the dialog.
ignoreWidth: true,
ignoreHeight: true,
breakPages: true,
});
if (!cancelled) setError(null);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to render document');
}
} finally {
if (!cancelled) setLoading(false);
}
}
void render();
return () => {
cancelled = true;
};
}, [url]);
return (
<div className="relative h-full overflow-auto bg-muted/30 p-4">
{loading && (
<div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
Rendering document
</div>
)}
{error && !loading && (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-destructive">
{error}
</div>
)}
<div
ref={containerRef}
aria-label={fileName ?? 'Document preview'}
className="mx-auto max-w-3xl [&_.docx-wrapper]:bg-transparent [&_.docx-wrapper]:p-0 [&_.docx-wrapper>section.docx]:mx-auto [&_.docx-wrapper>section.docx]:mb-4 [&_.docx-wrapper>section.docx]:bg-white [&_.docx-wrapper>section.docx]:shadow-sm"
/>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { isWordDocx } from '@/lib/constants/file-validation';
// yet-another-react-lightbox is ~50kb, lazy-load it. // yet-another-react-lightbox is ~50kb, lazy-load it.
const Lightbox = dynamic(() => import('yet-another-react-lightbox'), { ssr: false }); const Lightbox = dynamic(() => import('yet-another-react-lightbox'), { ssr: false });
@@ -30,6 +31,16 @@ const PdfViewer = dynamic(() => import('./pdf-viewer').then((m) => ({ default: m
), ),
}); });
// docx-preview is lazy-loaded the same way — only .docx previews pull it in.
const DocxViewer = dynamic(() => import('./docx-viewer').then((m) => ({ default: m.DocxViewer })), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading document viewer
</div>
),
});
interface FilePreviewDialogProps { interface FilePreviewDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -93,7 +104,7 @@ export function FilePreviewDialog({
// useQuery replaces the prior useEffect(fetch+setState) pattern. The // useQuery replaces the prior useEffect(fetch+setState) pattern. The
// request is gated on the dialog being open and a fileId being set. // request is gated on the dialog being open and a fileId being set.
const previewQuery = useQuery<{ data: { url: string } }>({ const previewQuery = useQuery<{ data: { url: string; mimeType?: string } }>({
queryKey: ['file-preview', fileId], queryKey: ['file-preview', fileId],
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`), queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
enabled: open && !!fileId, enabled: open && !!fileId,
@@ -102,7 +113,13 @@ export function FilePreviewDialog({
const loading = previewQuery.isLoading; const loading = previewQuery.isLoading;
const error = previewQuery.error ? 'Failed to load preview' : null; const error = previewQuery.error ? 'Failed to load preview' : null;
const kind = previewKindFor(mimeType, fileName); // Prefer the caller-supplied mime, but fall back to the server's resolved
// mime (getPreviewUrl returns it). Without this, callers that pass only a
// display name (e.g. the EOI tab passing "EOI - <client>") or files whose
// stored name lacks a `.pdf` extension (migration-backfilled EOIs) fall
// through to the "unknown" surface even though the server knows it's a PDF.
const resolvedMime = mimeType ?? previewQuery.data?.data.mimeType;
const kind = previewKindFor(resolvedMime, fileName);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -110,12 +127,24 @@ export function FilePreviewDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 truncate"> <DialogTitle className="flex items-center gap-2 truncate">
<span className="truncate">{fileName ?? 'Preview'}</span> <span className="truncate">{fileName ?? 'Preview'}</span>
{fileId && (
<a
href={`/api/v1/files/${fileId}/download?redirect=1`}
className="shrink-0 text-muted-foreground hover:text-foreground"
title="Download"
aria-label={`Download ${fileName ?? 'file'}`}
>
<Download className="h-4 w-4" />
</a>
)}
{previewUrl && ( {previewUrl && (
<a <a
href={previewUrl} href={previewUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground" className="shrink-0 text-muted-foreground hover:text-foreground"
title="Open in new tab"
aria-label="Open in new tab"
> >
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</a> </a>
@@ -185,24 +214,34 @@ export function FilePreviewDialog({
</div> </div>
)} )}
{!loading && !error && previewUrl && kind === 'office' && ( {!loading &&
// Office documents render via Microsoft's hosted Office viewer !error &&
// - public URL only; presigned download URLs include a token previewUrl &&
// in the query string so they work here even though the file kind === 'office' &&
// isn't world-public. The viewer streams the document and // Word .docx renders in-browser via docx-preview (fetches the
// renders a high-fidelity preview without us shipping a // bytes from our own storage — works with private MinIO/disk).
// headless LibreOffice. Falls back to "download to view" if // We do NOT use Microsoft's hosted Office viewer: it requires a
// the embed loads but renders nothing (e.g. CORS rejected) - // publicly-reachable URL, which our private object store isn't.
// detection is hard so we just keep the download CTA below. // Legacy .doc + spreadsheet formats can't be rendered client-
<iframe // side, so they fall through to a download CTA.
title={fileName ?? 'Office document preview'} (isWordDocx(mimeType, fileName) ? (
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent( <DocxViewer url={previewUrl} fileName={fileName} />
previewUrl, ) : (
)}`} <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
className="h-full w-full" <FileWarning className="size-8 text-muted-foreground" aria-hidden />
sandbox="allow-scripts allow-same-origin allow-popups" <p className="text-sm font-medium">In-browser preview isn&apos;t available</p>
/> <p className="max-w-xs text-xs text-muted-foreground">
)} This Office format ({mimeType ?? 'unknown'}) can&apos;t be rendered in the
browser. Download it to view locally.
</p>
<Button asChild>
<a href={previewUrl} download={fileName ?? 'download'}>
<Download className="mr-1.5 size-4" aria-hidden />
Download
</a>
</Button>
</div>
))}
{!loading && !error && previewUrl && kind === 'unknown' && ( {!loading && !error && previewUrl && kind === 'unknown' && (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center"> <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">

View File

@@ -8,6 +8,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell'; import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
import { ReminderList } from '@/components/reminders/reminder-list'; import { ReminderList } from '@/components/reminders/reminder-list';
import { useAlertCount } from '@/components/alerts/use-alerts'; import { useAlertCount } from '@/components/alerts/use-alerts';
import { usePermissions } from '@/hooks/use-permissions';
/** /**
* Merged "Inbox" surface - replaces the previously-separate /alerts and * Merged "Inbox" surface - replaces the previously-separate /alerts and
@@ -29,6 +30,11 @@ export function InboxPageShell() {
const [alertsOpen, setAlertsOpen] = useState(true); const [alertsOpen, setAlertsOpen] = useState(true);
const [remindersOpen, setRemindersOpen] = useState(true); const [remindersOpen, setRemindersOpen] = useState(true);
const { data: alertCount } = useAlertCount(); const { data: alertCount } = useAlertCount();
// The deal-alert feed (stale interests, overdue signers, …) is gated on
// interests.view — operational roles see it; external residential partners
// don't. Hide the whole section rather than letting its query 403.
const { can } = usePermissions();
const canSeeAlerts = can('interests', 'view');
// localStorage hydration on mount - canonical "read from external // localStorage hydration on mount - canonical "read from external
// store" pattern. setState in effect is intentional. // store" pattern. setState in effect is intentional.
@@ -95,6 +101,7 @@ export function InboxPageShell() {
) : null} ) : null}
</section> </section>
{canSeeAlerts ? (
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs"> <section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
<SectionHeader <SectionHeader
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />} icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
@@ -109,6 +116,7 @@ export function InboxPageShell() {
</div> </div>
) : null} ) : null}
</section> </section>
) : null}
</div> </div>
); );
} }

View File

@@ -0,0 +1,35 @@
'use client';
import Link from 'next/link';
import { formatDistanceToNowStrict } from 'date-fns';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { KIND_LABELS, TRIAGE_TONE, type InquiryRow } from '@/components/inquiries/inquiry-columns';
export function InquiryCard({ inquiry, portSlug }: { inquiry: InquiryRow; portSlug: string }) {
return (
<Link href={`/${portSlug}/inquiries/${inquiry.id}`} className="block">
<Card className="transition-shadow hover:shadow-sm">
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{inquiry.contactName || '(no name)'}</p>
{inquiry.contactEmail ? (
<p className="truncate text-sm text-muted-foreground">{inquiry.contactEmail}</p>
) : null}
</div>
<Badge className={TRIAGE_TONE[inquiry.triageState]}>{inquiry.triageState}</Badge>
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>{KIND_LABELS[inquiry.kind]}</span>
<span>·</span>
<span>
{formatDistanceToNowStrict(new Date(inquiry.receivedAt), { addSuffix: true })}
</span>
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,224 @@
'use client';
import Link from 'next/link';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { MoreHorizontal, UserCheck, X, ExternalLink } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export type InquiryKind = 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
export type InquiryTriageState = 'open' | 'assigned' | 'converted' | 'dismissed';
export interface InquiryRow {
id: string;
kind: InquiryKind;
contactName: string | null;
contactEmail: string | null;
receivedAt: string;
triageState: InquiryTriageState;
convertedClientId: string | null;
convertedInterestId: string | null;
sourceIp: string | null;
utmSource?: string | null;
}
export const KIND_LABELS: Record<InquiryKind, string> = {
berth_inquiry: 'Berth',
residence_inquiry: 'Residence',
contact_form: 'Contact',
};
const KIND_TONE: Record<InquiryKind, string> = {
berth_inquiry: 'bg-blue-100 text-blue-800',
residence_inquiry: 'bg-amber-100 text-amber-900',
contact_form: 'bg-slate-100 text-slate-800',
};
export const TRIAGE_TONE: Record<InquiryTriageState, string> = {
open: 'bg-blue-100 text-blue-800',
assigned: 'bg-amber-100 text-amber-900',
converted: 'bg-emerald-100 text-emerald-800',
dismissed: 'bg-slate-100 text-slate-600',
};
export const TRIAGE_LABELS: Record<InquiryTriageState, string> = {
open: 'Open',
assigned: 'Assigned',
converted: 'Converted',
dismissed: 'Dismissed',
};
export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'contactEmail', label: 'Email' },
{ id: 'kind', label: 'Type' },
{ id: 'triageState', label: 'Status' },
{ id: 'utmSource', label: 'UTM source' },
{ id: 'receivedAt', label: 'Received' },
];
export const INQUIRY_DEFAULT_HIDDEN: string[] = ['utmSource'];
interface GetColumnsOptions {
portSlug: string;
onTriage: (row: InquiryRow, state: InquiryTriageState) => void;
}
export function getInquiryColumns({
portSlug,
onTriage,
}: GetColumnsOptions): ColumnDef<InquiryRow, unknown>[] {
return [
{
id: 'contactName',
accessorKey: 'contactName',
header: 'Name',
cell: ({ row }) => (
<Link
href={`/${portSlug}/inquiries/${row.original.id}`}
className="truncate font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.contactName || '(no name)'}
</Link>
),
},
{
id: 'contactEmail',
accessorKey: 'contactEmail',
header: 'Email',
enableSorting: false,
cell: ({ getValue }) => {
const email = getValue() as string | null;
return email ? (
<span className="text-sm">{email}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
{
id: 'kind',
accessorKey: 'kind',
header: 'Type',
cell: ({ getValue }) => {
const kind = getValue() as InquiryKind;
return <Badge className={KIND_TONE[kind]}>{KIND_LABELS[kind]}</Badge>;
},
},
{
id: 'triageState',
accessorKey: 'triageState',
header: 'Status',
cell: ({ row }) => {
const state = row.original.triageState;
return (
<div className="flex items-center gap-1.5">
<Badge className={TRIAGE_TONE[state]}>{TRIAGE_LABELS[state]}</Badge>
{row.original.convertedInterestId ? (
<Link
href={`/${portSlug}/interests/${row.original.convertedInterestId}`}
className="text-primary hover:underline text-xs"
onClick={(e) => e.stopPropagation()}
>
interest
</Link>
) : row.original.convertedClientId ? (
<Link
href={`/${portSlug}/clients/${row.original.convertedClientId}`}
className="text-primary hover:underline text-xs"
onClick={(e) => e.stopPropagation()}
>
client
</Link>
) : null}
</div>
);
},
},
{
id: 'utmSource',
accessorKey: 'utmSource',
header: 'UTM source',
enableSorting: false,
cell: ({ getValue }) => {
const utm = getValue() as string | null;
return utm ? (
<span className="text-sm">{utm}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
{
id: 'receivedAt',
accessorKey: 'receivedAt',
header: 'Received',
cell: ({ getValue }) => {
const iso = getValue() as string;
const d = new Date(iso);
return (
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
{formatDistanceToNowStrict(d, { addSuffix: true })}
</span>
);
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => {
const isResolved =
row.original.triageState === 'converted' || row.original.triageState === 'dismissed';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Row actions for ${row.original.contactName ?? 'inquiry'}`}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/inquiries/${row.original.id}`}>
<ExternalLink className="mr-2 h-3.5 w-3.5" aria-hidden />
Open
</Link>
</DropdownMenuItem>
{!isResolved ? (
<>
<DropdownMenuItem onClick={() => onTriage(row.original, 'assigned')}>
<UserCheck className="mr-2 h-3.5 w-3.5" aria-hidden />
Assign to me
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onTriage(row.original, 'dismissed')}>
<X className="mr-2 h-3.5 w-3.5" aria-hidden />
Dismiss
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={() => onTriage(row.original, 'open')}>
Reopen
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useRouter } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ArrowRight, UserPlus, UserCheck, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PermissionGate } from '@/components/shared/permission-gate';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface InquiryConvertActionsProps {
portSlug: string;
inquiry: {
id: string;
triageState: string;
convertedClientId: string | null;
convertedInterestId: string | null;
};
}
export function InquiryConvertActions({ portSlug, inquiry }: InquiryConvertActionsProps) {
const router = useRouter();
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
};
const convert = useMutation({
mutationFn: (target: 'client' | 'interest') =>
apiFetch<{ data: { clientId: string; interestId: string | null } }>(
`/api/v1/inquiries/${inquiry.id}/convert`,
{ method: 'POST', body: { target } },
),
onSuccess: (res, target) => {
invalidate();
if (target === 'interest' && res.data.interestId) {
toast.success('Converted to interest.');
router.push(`/${portSlug}/interests/${res.data.interestId}`);
} else {
toast.success('Converted to client.');
router.push(`/${portSlug}/clients/${res.data.clientId}`);
}
},
onError: (err: unknown) => toastError(err, 'Convert failed'),
});
const triage = useMutation({
mutationFn: (state: 'open' | 'assigned' | 'dismissed') =>
apiFetch(`/api/v1/inquiries/${inquiry.id}/triage`, { method: 'PATCH', body: { state } }),
onSuccess: (_d, state) => {
invalidate();
toast.success(`Marked ${state}.`);
},
onError: (err: unknown) => toastError(err, 'Update failed'),
});
const busy = convert.isPending || triage.isPending;
const alreadyInterest = Boolean(inquiry.convertedInterestId);
return (
<PermissionGate resource="inquiries" action="manage">
<div className="flex flex-wrap items-center gap-2">
{alreadyInterest ? (
<Button variant="outline" size="sm" asChild>
<a href={`/${portSlug}/interests/${inquiry.convertedInterestId}`}>View interest</a>
</Button>
) : (
<Button size="sm" disabled={busy} onClick={() => convert.mutate('interest')}>
<ArrowRight className="mr-1.5 h-4 w-4" aria-hidden />
Convert to interest
</Button>
)}
{inquiry.convertedClientId ? (
<Button variant="outline" size="sm" asChild>
<a href={`/${portSlug}/clients/${inquiry.convertedClientId}`}>View client</a>
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => convert.mutate('client')}
>
<UserPlus className="mr-1.5 h-4 w-4" aria-hidden />
Convert to client
</Button>
)}
{inquiry.triageState === 'open' ? (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => triage.mutate('assigned')}
>
<UserCheck className="mr-1.5 h-4 w-4" aria-hidden />
Assign to me
</Button>
) : null}
{inquiry.triageState !== 'dismissed' && inquiry.triageState !== 'converted' ? (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => triage.mutate('dismissed')}
>
<X className="mr-1.5 h-4 w-4" aria-hidden />
Dismiss
</Button>
) : null}
</div>
</PermissionGate>
);
}

View File

@@ -0,0 +1,223 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { format } from 'date-fns';
import { DetailLayout, type DetailTab } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
import {
KIND_LABELS,
TRIAGE_LABELS,
TRIAGE_TONE,
type InquiryKind,
type InquiryTriageState,
} from '@/components/inquiries/inquiry-columns';
import { InquiryConvertActions } from '@/components/inquiries/inquiry-convert-actions';
interface InquiryDetailData {
id: string;
kind: InquiryKind;
contactName: string | null;
contactEmail: string | null;
payload: Record<string, unknown> | null;
receivedAt: string;
sourceIp: string | null;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
triageState: InquiryTriageState;
triagedAt: string | null;
convertedClientId: string | null;
convertedInterestId: string | null;
convertedClient: { id: string; fullName: string } | null;
convertedInterest: { id: string; pipelineStage: string } | null;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[140px_1fr] gap-2 py-1.5 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="min-w-0 break-words">
{value || <span className="text-muted-foreground"></span>}
</span>
</div>
);
}
export function InquiryDetail({ id }: { id: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { isSuperAdmin } = usePermissions();
const { data, isLoading, error } = useQuery<InquiryDetailData>({
queryKey: ['inquiries', id],
queryFn: () =>
apiFetch<{ data: InquiryDetailData }>(`/api/v1/inquiries/${id}`).then((r) => r.data),
retry: (count, err) => {
const status = (err as { status?: number })?.status;
return status === 404 || status === 403 ? false : count < 2;
},
});
if (error && !isLoading) {
const status = (error as { status?: number })?.status;
return (
<DetailNotFound
entity="inquiry"
backHref={`/${portSlug}/inquiries`}
backLabel="Back to inquiries"
status={status}
/>
);
}
const p = (data?.payload ?? {}) as Record<string, unknown>;
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
// Read a payload value that may be a string[] (e.g. residence_types, the
// contact form's interest[]) OR a lone string, and present it comma-joined.
const list = (k: string): string => {
const v = p[k];
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string').join(', ');
return typeof v === 'string' ? v : '';
};
// The free-text message a lead left. Website forms use different keys
// (contact form -> `comments`; others -> `message`/`comment`), so probe the
// common ones and surface it for every inquiry kind.
const comment = str('comments') || str('message') || str('comment') || str('notes');
// Preferred method of contact (register form: 'email' | 'phone'). Surfaced so
// reps honour the lead's stated contact request.
const preferredContactRaw = str('method_of_contact').toLowerCase();
const preferredContact =
preferredContactRaw === 'email'
? 'Email'
: preferredContactRaw === 'phone'
? 'Phone call back'
: '';
const residenceTypes = list('residence_types');
// Contact-form "type of interest" (owner/broker/investor/…), stored as an array.
const contactInterest = list('interest');
const tabs: DetailTab[] = [
{
id: 'overview',
label: 'Overview',
content: (
<div className="max-w-xl">
<Row label="Name" value={data?.contactName} />
<Row label="Email" value={data?.contactEmail} />
<Row label="Phone" value={str('phone')} />
{data?.kind === 'residence_inquiry' ? (
<Row label="Residence type(s)" value={residenceTypes} />
) : null}
{data?.kind === 'residence_inquiry' ? (
<Row label="Place of residence" value={str('address')} />
) : null}
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
{data?.kind === 'contact_form' && contactInterest ? (
<Row label="Type of interest" value={contactInterest} />
) : null}
{preferredContact ? <Row label="Preferred contact" value={preferredContact} /> : null}
{comment ? (
<Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} />
) : null}
<Row label="Type" value={data ? KIND_LABELS[data.kind] : ''} />
<Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} />
<Row label="Source IP" value={data?.sourceIp} />
<Row label="UTM source" value={data?.utmSource} />
<Row label="UTM medium" value={data?.utmMedium} />
<Row label="UTM campaign" value={data?.utmCampaign} />
</div>
),
},
{
id: 'tracking',
label: 'Tracking',
content: (
<div className="max-w-xl">
<Row
label="Status"
value={
data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : (
''
)
}
/>
<Row
label="Triaged at"
value={data?.triagedAt ? format(new Date(data.triagedAt), 'PPpp') : ''}
/>
<Row
label="Converted client"
value={
data?.convertedClient ? (
<a
href={`/${portSlug}/clients/${data.convertedClient.id}`}
className="text-primary hover:underline"
>
{data.convertedClient.fullName}
</a>
) : null
}
/>
<Row
label="Converted interest"
value={
data?.convertedInterest ? (
<a
href={`/${portSlug}/interests/${data.convertedInterest.id}`}
className="text-primary hover:underline"
>
View interest ({data.convertedInterest.pipelineStage})
</a>
) : null
}
/>
</div>
),
},
{
id: 'payload',
label: 'Raw payload',
content: (
<pre className="max-h-96 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
{JSON.stringify(data?.payload ?? {}, null, 2)}
</pre>
),
},
].filter((tab) => tab.id !== 'payload' || isSuperAdmin);
return (
<DetailLayout
isLoading={isLoading}
header={
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1>
{data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : null}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{data ? KIND_LABELS[data.kind] : ''} inquiry
{data?.contactEmail ? ` · ${data.contactEmail}` : ''}
</p>
</div>
{data ? <InquiryConvertActions portSlug={portSlug} inquiry={data} /> : null}
</div>
}
tabs={tabs}
defaultTab="overview"
/>
);
}

View File

@@ -0,0 +1,33 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
export const inquiryFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search name or email…',
},
{
key: 'kind',
label: 'Type',
type: 'select',
options: [
{ label: 'Berth', value: 'berth_inquiry' },
{ label: 'Residence', value: 'residence_inquiry' },
{ label: 'Contact', value: 'contact_form' },
],
},
{
key: 'state',
label: 'Status',
type: 'select',
options: [
{ label: 'Inbox (open + assigned)', value: 'inbox' },
{ label: 'Open', value: 'open' },
{ label: 'Assigned', value: 'assigned' },
{ label: 'Converted', value: 'converted' },
{ label: 'Dismissed', value: 'dismissed' },
{ label: 'All', value: 'all' },
],
},
];

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