13 Commits

Author SHA1 Message Date
Matt Ciaccio
21868ee5fc feat(berths,seed): polish detail display + prune ports to Port Nimara + Amador
Berth detail (src/components/berths/berth-tabs.tsx):
- Numeric display polish, exposed by the new NocoDB-sourced seed:
  - Power capacity now renders with kW unit (e.g. "330 kW")
  - Voltage now renders with V unit (e.g. "480 V")
  - All metric/imperial values rounded to <= 2 decimals
    (was: "62.999112 m" -> now: "62.99 m")
  - Nominal Boat Size shows full ft + m pair (was: ft only)

Seed ports (src/lib/db/seed.ts):
- Drop Marina Azzurra and Harbor Royale; install now seeds only:
  - Port Nimara  (the real install)
  - Port Amador  (secondary, for multi-tenant isolation tests / Panama
                  scaffolding)
- Existing dev DBs are not touched; this only affects fresh `pnpm db:seed`
  runs. Users wanting to migrate should drop existing rows in the obsolete
  ports manually before re-seeding.

Verification:
- lint clean, tsc unchanged from baseline (36 pre-existing errors), 858/858
  vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:59:36 +02:00
Matt Ciaccio
c7ab816c99 feat(seed): replace 12 hand-rolled berths with 117-row NocoDB snapshot
The old seed only had 12 berths with made-up area names ("North Pier",
"Central Basin", etc.) and placeholder dimensions. Devs now get the real
117 berths exported from the legacy NocoDB Berths table — every editable
column populated with real production values.

What's in the snapshot (src/lib/db/seed-data/berths.json):
- 117 berths total (61 available / 45 under_offer / 11 sold)
- Areas A through E (matches NocoDB single-select)
- All numeric fields filled: length / width / draft (ft + m), water depth,
  nominal boat size, power capacity (kW), voltage (V)
- All NocoDB single-selects filled where present: side pontoon,
  mooring type, cleat/bollard type+capacity, access
- Bow facing, status_override_mode, berth_approved carried forward as-is
- Status normalized to lowercase snake_case ("Under Offer" -> "under_offer")
- Mooring numbers reformatted A1 -> A-01 to keep the existing "Letter-NN"
  convention used elsewhere in the codebase

Pre-sorted to preserve seed semantics:
  idx 0..4   -> 5 available  (small)   -- "open" / "details_sent" interests
  idx 5..9   -> 5 under_offer (medium) -- "eoi_signed" / "deposit" / "contract"
  idx 10..11 -> 2 sold (large)         -- "completed" interests
This means existing interest/reservation seeds that index berthRows[0..11]
keep their semantic alignment without code changes.

End-to-end verified by clearing Marina Azzurra and re-seeding:
  Port "Marina Azzurra" -- 117 berths, 8 clients, 3 companies, 12 yachts,
                           15 interests, 8 reservations

Future devs running `pnpm db:seed` on a fresh DB will now get realistic
berth data automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:41:12 +02:00
Matt Ciaccio
e40b6c3d99 feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.

Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
  (NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
  forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
  strings convert cleanly

Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
  four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type

Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
  side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
  area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
  access
- power capacity / voltage become numeric inputs (with kW / V hints)

Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
  ("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set

Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
  on feat/mobile-foundation, none introduced)
- lint clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
Matt Ciaccio
e2398099c4 test(audit-fixes): cover the new permission and webhook surfaces
Adds integration coverage for the routes / handlers shipped in the
preceding audit-fix commits, plus refactors two route files to expose
inner handlers from a sibling `handlers.ts` (the pattern used elsewhere
in `src/app/api/v1`) so tests can call them without the
`withAuth(withPermission(…))` wrapper.

New tests (18 cases across 4 files):
- `tests/integration/portal-auth.test.ts` (6) — verifyPortalToken
  rejects tokens missing `aud: 'portal'` or `iss: 'pn-crm'`, with the
  wrong audience (CRM-session-replay shape) or wrong issuer, plus a
  round-trip happy path. Locks in the portal-vs-CRM token isolation.
- `tests/integration/api/saved-views-ownership.test.ts` (6) — patch
  and delete handlers return 403 for a different user, 404 for an
  unknown id or cross-port id, and 200 for the owner. Ownership is
  enforced at the route layer regardless of the service's internal
  filtering.
- `tests/integration/api/berth-reservations-list.test.ts` (3) — the
  new global list returns rows for the current port only and honors
  pagination params. A reservation in a different port never leaks.
- `tests/integration/documents-expired-webhook.test.ts` (3) —
  handleDocumentExpired flips the document to `expired`, also flips
  the linked interest's `eoiStatus`, writes a `documentEvents` row,
  and is a no-op (not a throw) when the documensoId is unknown.

Refactors:
- `src/app/api/v1/saved-views/[id]/route.ts` extracts `patchHandler` /
  `deleteHandler` (and the shared `assertViewOwner`) into
  `handlers.ts`. The route file is now a 4-line `withAuth(handler)`
  wrapper.
- `src/app/api/v1/berth-reservations/route.ts` extracts `listHandler`
  similarly. Tests import directly from `handlers.ts`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:17:08 +02:00
Matt Ciaccio
d364b09885 fix(realtime): keep socket through reconnects, stop re-subscribe storm
Two correctness bugs in the real-time stack — both silent failures, both
session-wide once they trigger.

(1) `SocketProvider` was setting the React context to null on every
`disconnect` event. socket.io's built-in reconnection re-establishes the
underlying transport and replays handlers, but the React tree had
already lost its reference to the socket — so every `useSocket()`
consumer saw null until a session/port change forced a remount. Effect:
after the first transient drop (laptop sleep, wifi blip, server
restart), realtime invalidation and toasts went dead session-wide with
no user-visible signal.

Fix: keep the socket reference stable for the lifetime of the
session+port, and surface a separate `isConnected` boolean for any UI
that wants to render an offline indicator. Exposed as a new
`useIsSocketConnected()` hook; `useSocket()` signature is unchanged.

(2) `useRealtimeInvalidation` captured `eventMap` as a useEffect
dependency. Every caller passes a fresh `{ ... }` object literal on each
render, so the effect re-ran every render → `socket.off`/`socket.on`
storm on pages with many subscribed events.

Fix: extract the subscription logic into a pure helper
(`realtime-invalidation-core.ts`, JSX-free for vitest). The hook now
keeps the latest map in a ref and only re-subscribes when the SET of
event names changes (joined-keys signature, not object identity). The
handler reads `ref.current` at fire time, so callers still see fresh
queryKey lists without re-binding.

Helper is unit-tested with a stub socket: registration count,
fire-time map lookup, cleanup deregistration, missing-event safety.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:11:52 +02:00
Matt Ciaccio
57a099acc4 fix(ui): humanize enum labels, format dates, resolve actor names, loading skeleton
- Documents hub signer status now renders via a label map (`Pending`,
  `Signed`, `Declined`, …) instead of the raw lowercase enum value.
- Invoice detail formats `dueDate` and `paymentDate` as `MMM d, yyyy`
  via `date-fns` instead of leaking raw `2025-03-14` ISO strings, and
  swaps the "Payment Method" free-text input for a `Select` of labelled
  options (`Bank transfer`, `Credit card`, …) so we never store
  `bank_transfer` from a hand-typed field again.
- Interest tabs `MilestoneSection` status badge uses a `humanizeStatus`
  helper so values like `waiting_for_signatures` show as
  `Waiting For Signatures` (correctly title-cased) instead of being a
  lower-snake-case fragment inside an ALL-CAPS pill.
- `OUTCOME_BADGE` in the interest header now has a fall-through that
  renders any unknown outcome as a closed-state badge, preventing a
  closed interest from looking open just because its enum was added
  upstream without a matching label entry.
- Interest timeline route joins the `user` table and returns
  `userName` alongside `userId`; the client renders the resolved name
  instead of a 36-char UUID. Falls back to `'a teammate'` if the user
  row was deleted.
- Invoice "New / Step 3 — Review" replaces the truncated UUID display
  with a server-resolved client/company name via a small `useQuery`,
  so users can confirm they picked the right billing entity before
  submitting.
- New `loading.tsx` for client detail renders a header / tab strip /
  card skeleton during the server-component / initial-query window
  that previously flashed empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:01:35 +02:00
Matt Ciaccio
a391934b73 feat(marina): end-reservation UI + global list, yacht tabs, dashboard distinct count
- End-reservation: API handler existed but had no UI surface. Adds an
  "End reservation" button + date dialog on the reservation detail page,
  visible only when status is `active`.
- New port-scoped `GET /api/v1/berth-reservations` list endpoint and
  `[portSlug]/berth-reservations` page so users can see all reservations
  across all berths from one place (was 404).
- Berths "Edit" menu pushed `/berths/{id}?edit=true` but the detail page
  never read the param — it now auto-opens the edit sheet on mount and
  strips `edit` from the URL.
- Reservation detail no longer shows raw 8-char UUIDs for Berth / Yacht
  / Client; reuses the lazy-fetching link components from the list view.
- Yacht "Interests" and "Reservations" tabs replaced their "Coming soon"
  stubs with real lists fetched from the existing service routes.
- Dashboard "Pipeline Value" KPI used `select(berthId, price)` and
  summed per active interest, so a berth with three open interests was
  counted three times. Switched to `selectDistinct(berthId, price)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:01:15 +02:00
Matt Ciaccio
e3e0e69c04 fix(documenso): expired event, real signer emails, query invalidation, double-fire
- Wire the `DOCUMENT_EXPIRED` webhook event to `handleDocumentExpired`.
  Previously the handler existed but was never called, leaving expired
  EOIs stuck in `sent` / `partially_signed` forever.
- `sendForSigning` now resolves real port-configured signer emails via
  `getPortEoiSigners(portId)` instead of fabricating
  `developer@{slug}.com` / `sales@{slug}.com`. The Documenso-template
  pathway was already using these; the upload-PDF pathway now matches.
- `handleRecipientSigned` logs a warning when the email match returns
  zero rows so a misconfigured signer isn't a silent no-op.
- `handleDocumentCompleted` skips berth-rule re-evaluation when the
  interest is already at or past `eoi_signed`, preventing a double-fire
  when `DOCUMENT_SIGNED` and `DOCUMENT_COMPLETED` arrive close together.
- EOI generate dialog now invalidates by predicate (any queryKey
  starting with `'documents'`) so the Documents tab and hub counts
  refresh after generation, instead of missing because the actual
  query key shape didn't match the targeted invalidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:00:58 +02:00
Matt Ciaccio
6af2ac9680 fix(auth): harden admin gate, X-Port-Id, portal JWT, saved-views
- Add server-side `<admin>/layout.tsx` that redirects non-super-admins to
  `/[portSlug]/dashboard`. Closes the gap where any authed user could
  guess the URL and reach Users / Roles / Audit Log / Backup.
- `withAuth` super-admin branch now 404s when the requested portId does
  not match a real port row, preventing a compromised super-admin
  session from operating against a fabricated portId.
- Portal JWTs now carry `aud: 'portal'` + `iss: 'pn-crm'` claims and
  `verifyPortalToken` requires both, so a portal token can no longer be
  replayed against the CRM session path or vice versa. In-flight tokens
  (≤24h) will be invalidated once on deploy.
- `saved-views/[id]` PATCH and DELETE now do an explicit ownership
  check before the service call, returning 403 instead of relying on
  the service's internal userId filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:00:42 +02:00
Matt Ciaccio
a767652d74 feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB
Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6,
#7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real
assignee column on interests, defer until ownership model lands) and #12
(keyboard shortcuts — explicit user call).

  Interest list (the rep's main triage surface):

    - Last activity column replaces Created (sortable by
      dateLastContact). Postgres NULLs-last on DESC means
      never-contacted leads sort to the bottom — exactly the right
      triage default.
    - Comment-icon next to client name when notesCount > 0, with a
      tooltip showing the count. Cheap, glanceable signal that the
      lead has correspondence to peek at.
    - Urgency badges under stage when criteria fire: "Silent Nd"
      for mid-funnel interests with no contact in 7+ days,
      "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd"
      for eoi_signed interests with no deposit after 21 days.
      Pure derived — no extra fetch, computed from the dates the
      row already returns.
    - Bulk select checkbox column with bulk-archive (existing
      DataTable.bulkActions API; just wired with a confirm-dialog
      and a Promise.all fan-out).
    - Mobile FAB (+) for new interest, anchored above the bottom-tab
      bar with safe-area inset awareness.

    All four signals mirrored on the mobile InterestCard (comment
    icon, urgency badges, last-activity footer).

  Interest detail:

    - Reminder bell badge in the header showing pending/snoozed
      reminder count linked to the interest. Surfaced via
      getInterestById's new `activeReminderCount`.
    - "Latest note" teaser on the Overview tab — truncated 3-line
      preview of the most recent threaded note + relative time +
      "View all" link to the Notes tab. Saves a click for the
      common "what was discussed last?" peek.
    - Color-block swatches in InlineStagePicker dropdown (rounded-sm
      mini-bars in the stage's progressive saturation color, replacing
      the previous tiny dots). Reads as a visual scan instead of a
      list.

  Dashboard:

    - MyRemindersRail on the right sidebar above the existing
      AlertRail. Shows pending+snoozed reminders for the current
      user (overdue first), each with priority pill, relative due
      time, and click-through to the linked interest/client/berth.

  Berth detail:

    - BerthInterestPulse card at the top of the Overview tab,
      replacing the old "buried in tab" pattern. Shows up to 5
      active interests with avatar, stage pill, urgency badges, and
      last-activity. Mirrors the old Nuxt CRM's beloved "Interested
      Parties" panel but with the new triage signals.

  Realtime toasts:

    - New <RealtimeToasts /> mounted inside SocketProvider in the
      dashboard layout. Subscribes to interest:stageChanged,
      document:completed, document:signer:signed, and
      interest:outcomeSet — fires sonner toasts so reps watching any
      page learn about pipeline events without refreshing.

  Service layer:

    - listInterests: notesCount per row (left join + count + groupBy).
    - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164
      (for the Email/Call/WhatsApp buttons added last commit; phone
      pieces were missing), notesCount, recentNote, activeReminderCount.
    - sortColumn switch handles 'dateLastContact' explicitly; default
      stays 'updatedAt'.

tsc clean. vitest 835/835 pass. ESLint clean on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:09:51 +02:00
Matt Ciaccio
c824b2df12 feat(interests): Email / Call / WhatsApp deep-links on interest header
The interest detail is the rep's workbench — but until now, calling or
emailing the lead meant navigating away to the client page first. Surface
the same Email / Call / WhatsApp affordances that already live on the
client header right where the work is happening.

  - getInterestById: extended to also resolve the linked client's primary
    phone (display value + canonical E.164 form for wa.me).
    `clientPrimaryEmail` is the same column we surfaced earlier for the
    EOI prereq checklist; this commit just adds the phone columns
    alongside it.

  - InterestDetailHeader: new contact-actions row tucked under the meta
    line. Each button is asChild over a real <a href> so middle-click,
    Cmd-click, and screen-readers behave correctly. Renders only the
    buttons whose underlying contact channel is present (Email-only when
    no phone is on file, etc.). The whole row is hidden when the client
    has no contacts at all.

  - WhatsApp number prefers the E.164 form; falls back to digits-stripped
    display value when the canonical form is missing.

tsc clean. vitest 835/835 pass. ESLint clean on every file touched.

Closes audit recommendation #1 (top-of-list — biggest sales-workflow
win per click saved).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:33:13 +02:00
Matt Ciaccio
d197f8b321 feat(eoi): align prerequisites with EOI document structure
Match the gate to the actual EOI's structure (Section 2 vs Section 3) so
the rep can generate the document the moment they have what they need —
and not before.

  Required (Section 2 — top paragraph):
    - Client name
    - Client primary email
    - Client primary address

  Optional (Section 3 — left blank when absent):
    - Linked yacht (name, dimensions)
    - Linked berth (mooring number)

Previously the dialog blocked generation unless yacht AND berth were both
linked, which was overzealous — early-stage EOIs are routinely sent before
a specific berth is pinned down.

  - eoi-context.ts: yacht and berth are now nullable in the returned
    context. The hard ValidationError is now driven by the EOI's Section
    2 fields (name/email/address) rather than yacht/berth presence. The
    owner block falls back to the interest's client when no yacht is
    linked, so signing parties remain resolvable.

  - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values
    render as empty strings when yacht or berth are absent, so the
    rendered PDF leaves those template inputs blank.

  - document-templates.ts: yacht.* and berth.* tokens fall back to
    empty strings; the legacy-fallback catch handler also recognises
    the new "missing required client details" error.

  - interests.service.ts: getInterestById now also returns
    `clientPrimaryEmail` and `clientHasAddress` so the Documents tab
    can compute the EOI prerequisites checklist client-side without an
    extra fetch.

  - eoi-generate-dialog.tsx: prereqs split into two groups visually —
    Required (with red ✗ when missing) and Optional (with grey – when
    absent). The Generate button only requires the Required block to
    pass. A small amber banner surfaces when Required is incomplete so
    the rep knows where to add the missing data.

Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/
berth" tests with parity coverage for the new behaviour ("builds a
valid context when yacht/berth missing", "throws when client email/
address missing"). Adds a payload test for the empty-Section-3 case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
Matt Ciaccio
76a7387dcc fix(ux): batch UX audit fixes across spine pages
Comprehensive audit findings rolled up into one pass.

Bugs:

  - dialog.tsx — sm-breakpoint centering classes (sm:left-[50%] /
    sm:top-[50%]) were being silently stripped by tailwind-merge because
    the base inset-0 + sm:inset-auto pair counted as a conflict. Replaced
    with explicit per-side utilities (top-0 right-0 bottom-0 left-0 +
    sm:right-auto sm:bottom-auto). Every Dialog instance now centers
    correctly on desktop. (Affected 16 dialog consumers.)

  - interest-documents-tab.tsx — useQuery shared the queryKey
    ['interests', interestId] with the parent InterestDetail's query but
    returned a different shape ({ data: ... } envelope vs unwrapped).
    They clobbered each other's cache on tab mount, degenerating the
    parent header to "Unknown Client" / "Open" briefly. Unified the
    queryFn shape so the cache stays consistent.

  - interest-tabs.tsx — milestone steps now derive done-state from
    PIPELINE_STAGES.indexOf(currentStage) >= step.advanceStage_idx as
    well as from the date stamp. Stage truth > date truth. Seeded /
    imported interests that arrived past `open` without per-step dates
    now correctly show their milestone steps as checked.

  - interest-detail.tsx — wires useMobileChrome so the mobile topbar
    shows the client name instead of the interest UUID.

  - interest-documents-tab.tsx — empty state restructured to a centered
    "No documents yet — Generate EOI" CTA card instead of a small
    primary button floating in the corner.

  - timeline/route.ts — synthesizes a "Created at <stage>" event when
    no audit-log rows exist for the interest, so the Activity tab
    isn't empty for seeded interests.

  - lead-source-chart.tsx — pie radii switched from fixed 90px/50px
    to "70%"/"40%" so the pie scales with the container instead of
    being clipped at narrow widths; reserved 40px for the legend.

Visual / clarity:

  - interest-detail-header.tsx — Won/Lost rendered as branded text
    buttons on desktop ("Mark won", "Close as lost") and icon-only on
    mobile via `hidden sm:inline`. Edit/Archive stay icon-only. Reopen
    promoted to a labeled button when the interest is closed. Added
    "Last contact Xd ago" to the meta row.

  - detail-header-strip.tsx — py-4 → py-3 (tighter strip).

  - interest-tabs.tsx — milestone cards: the next pending milestone
    gets a brand-blue ring + "NEXT" pill so the user can see at a
    glance which lifecycle to act on. Its primary action gets the
    filled button variant.

  - interest-tabs.tsx — Deposit milestone: invoice flow promoted to
    primary CTA ("Create deposit invoice"), manual stage advance
    demoted to a small text link ("Mark received manually"). Reflects
    the actual recommended path now that recordPayment auto-advances
    on payment.

  - inline-editable-field.tsx — pencil affordance shown faintly
    (opacity-20) at rest so users discover that fields are editable
    without having to hover-test every label. Lifts to opacity-60 on
    hover.

  - constants.ts — STAGE_SHORT_LABELS map for cramped contexts;
    pipeline-chart.tsx + pipeline-funnel-chart.tsx use them on mobile
    via useIsMobile, so the rotated 9-stage axis isn't a wall of
    overlap on a 393px screen.

  - client-pipeline-summary.tsx — StageStepper rebuilt as a single
    segmented progress bar instead of 9 micro-dots + connectors that
    rendered inconsistently at tight widths. Each stage is an equal
    slice that lights up as the interest reaches it; tooltips on hover
    give the full stage name. Also dropped a pre-existing dead `br`
    variable.

  - dashboard empty states — Lead Source, Revenue Breakdown, Pipeline
    Funnel, and Recent Activity now have helpful descriptions explaining
    what populates them, instead of bare "No interests in range".

  - use-paginated-query.ts — reuses `&` when the endpoint already has
    `?`, so callers like the documents hub don't generate
    `…?tab=eoi_queue&signatureOnly=true?page=1&limit=25` (which the API
    rejected as 400). Caught while testing the now-removed EOI route
    but applies broadly.

tsc clean. vitest 832/832 pass. eslint 0 errors (down from 1
pre-existing) on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:24:15 +02:00
76 changed files with 17864 additions and 653 deletions

View File

@@ -0,0 +1,36 @@
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema/users';
/**
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
*/
export default async function AdminLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect('/login');
}
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
});
if (!profile?.isSuperAdmin) {
redirect(`/${portSlug}/dashboard`);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,5 @@
import { BerthReservationsList } from '@/components/reservations/berth-reservations-list';
export default function BerthReservationsPage() {
return <BerthReservationsList />;
}

View File

@@ -0,0 +1,41 @@
import { Skeleton } from '@/components/ui/skeleton';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
/**
* Route-level loading UI for the client detail page. Renders while the
* server component resolves the session and the client component bootstraps
* its initial query — replaces the previous empty-header flash on direct
* URL visits.
*/
export default function Loading() {
return (
<div className="space-y-6">
{/* Header strip — title, badges, action buttons */}
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-56" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<div className="flex flex-wrap gap-2">
<Skeleton className="h-9 w-20 rounded-md" />
<Skeleton className="h-9 w-20 rounded-md" />
<Skeleton className="h-9 w-24 rounded-md" />
<Skeleton className="h-9 w-32 rounded-md" />
</div>
</div>
{/* Tab strip */}
<div className="flex gap-2 border-b border-border pb-1">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-20 rounded-md" />
))}
</div>
{/* Two-column overview */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CardSkeleton />
<CardSkeleton />
</div>
</div>
);
}

View File

@@ -97,6 +97,28 @@ export default function NewInvoicePage() {
const watchedValues = watch(); const watchedValues = watch();
const isDepositInvoice = watchedValues.kind === 'deposit'; const isDepositInvoice = watchedValues.kind === 'deposit';
// Resolve the selected billing entity to a human name so the review step
// shows "Acme Yacht Charters" instead of "company 4f2a1b…".
const billingEntityRef = watchedValues.billingEntity ?? null;
const { data: billingEntityName } = useQuery<{ name: string }>({
queryKey: ['billing-entity-name', billingEntityRef?.type, billingEntityRef?.id],
queryFn: async () => {
if (!billingEntityRef) return { name: '' };
const path =
billingEntityRef.type === 'company'
? `/api/v1/companies/${billingEntityRef.id}`
: `/api/v1/clients/${billingEntityRef.id}`;
const res = await apiFetch<{
data: { fullName?: string; name?: string };
}>(path);
return {
name: res?.data?.fullName ?? res?.data?.name ?? '',
};
},
enabled: !!billingEntityRef?.id,
staleTime: 60_000,
});
// Pre-fill the billing entity from the linked interest's client on launch. // Pre-fill the billing entity from the linked interest's client on launch.
useEffect(() => { useEffect(() => {
if (prefilledInterest?.data && !watchedValues.billingEntity) { if (prefilledInterest?.data && !watchedValues.billingEntity) {
@@ -356,9 +378,13 @@ export default function NewInvoicePage() {
<p className="font-medium mt-0.5"> <p className="font-medium mt-0.5">
{watchedValues.billingEntity ? ( {watchedValues.billingEntity ? (
<> <>
<span className="capitalize">{watchedValues.billingEntity.type}</span>{' '} {billingEntityName?.name ? (
<span className="text-xs opacity-60"> <span>{billingEntityName.name}</span>
{watchedValues.billingEntity.id.slice(0, 12)} ) : (
<span className="text-muted-foreground">Loading</span>
)}{' '}
<span className="text-xs text-muted-foreground capitalize">
({watchedValues.billingEntity.type})
</span> </span>
</> </>
) : ( ) : (

View File

@@ -13,6 +13,7 @@ import { PermissionsProvider } from '@/providers/permissions-provider';
import { Sidebar } from '@/components/layout/sidebar'; import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar'; import { Topbar } from '@/components/layout/topbar';
import { MobileLayout } from '@/components/layout/mobile/mobile-layout'; import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) { export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
@@ -38,6 +39,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}> <PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
<PermissionsProvider> <PermissionsProvider>
<SocketProvider> <SocketProvider>
<RealtimeToasts />
{/* Desktop shell — hidden by CSS on mobile */} {/* Desktop shell — hidden by CSS on mobile */}
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background"> <div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
<Sidebar <Sidebar

View File

@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import type { AuthContext } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listReservations } from '@/lib/services/berth-reservations.service';
import { listReservationsSchema } from '@/lib/validators/reservations';
/**
* Port-scoped global list of reservations across all berths. Inner handler
* lives here so it can be invoked directly from integration tests without
* the `withAuth(withPermission(...))` wrappers (matches the convention
* used throughout `src/app/api/v1/*`).
*/
export async function listHandler(req: Request, ctx: AuthContext): Promise<NextResponse> {
try {
const query = parseQuery(req as never, listReservationsSchema);
const result = await listReservations(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,4 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { listHandler } from './handlers';
export const GET = withAuth(withPermission('reservations', 'view', listHandler));

View File

@@ -7,6 +7,7 @@ import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
import { auditLogs } from '@/lib/db/schema/system'; import { auditLogs } from '@/lib/db/schema/system';
import { documents, documentEvents } from '@/lib/db/schema/documents'; import { documents, documentEvents } from '@/lib/db/schema/documents';
import { user } from '@/lib/db/schema/users';
import { stageLabel } from '@/lib/constants'; import { stageLabel } from '@/lib/constants';
const OUTCOME_LABELS: Record<string, string> = { const OUTCOME_LABELS: Record<string, string> = {
@@ -33,6 +34,10 @@ interface TimelineEvent {
action: string; action: string;
description: string; description: string;
userId: string | null; userId: string | null;
/** Resolved display name for `userId`. `'system'` for auto-events; null when
* the user has been deleted or the event has no actor. Falls back to
* email-localpart if the user has no display name. */
userName: string | null;
createdAt: Date; createdAt: Date;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
} }
@@ -81,6 +86,27 @@ export const GET = withAuth(
const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title])); const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title]));
// Resolve display names for any `userId` that is a real user row (the
// sentinel value 'system' is used for auto-events and isn't joined).
const realUserIds = Array.from(
new Set(auditRows.map((r) => r.userId).filter((u): u is string => !!u && u !== 'system')),
);
const userRows =
realUserIds.length > 0
? await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(inArray(user.id, realUserIds))
: [];
const userNameById = new Map<string, string>(
userRows.map((u) => [u.id, u.name?.trim() || u.email.split('@')[0] || 'User']),
);
const resolveUserName = (userId: string | null): string | null => {
if (!userId) return null;
if (userId === 'system') return 'system';
return userNameById.get(userId) ?? null;
};
// Union and sort // Union and sort
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({ const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
id: row.id, id: row.id,
@@ -93,6 +119,7 @@ export const GET = withAuth(
row.userId, row.userId,
), ),
userId: row.userId, userId: row.userId,
userName: resolveUserName(row.userId),
createdAt: row.createdAt, createdAt: row.createdAt,
metadata: (row.metadata as Record<string, unknown>) ?? {}, metadata: (row.metadata as Record<string, unknown>) ?? {},
})); }));
@@ -106,12 +133,35 @@ export const GET = withAuth(
action: row.eventType, action: row.eventType,
description: `Document "${title}" ${action}`, description: `Document "${title}" ${action}`,
userId: null, userId: null,
userName: null,
createdAt: row.createdAt, createdAt: row.createdAt,
metadata: (row.eventData as Record<string, unknown>) ?? {}, metadata: (row.eventData as Record<string, unknown>) ?? {},
}; };
}); });
const allEvents = [...auditEvents, ...docEvents]; const allEvents = [...auditEvents, ...docEvents];
// Fallback: when no audit-log entries exist for this interest (typical
// for seed/imported data inserted directly into the table without going
// through the service), synthesize a "Created at <stage>" event so the
// tab isn't empty when the interest is clearly past `open`.
const hasCreateAudit = allEvents.some((e) => e.action === 'create');
if (!hasCreateAudit) {
const stage = stageLabel(interest.pipelineStage);
const created = interest.createdAt ?? new Date();
allEvents.push({
id: `synth-${interest.id}-create`,
type: 'audit',
action: 'create',
description:
interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`,
userId: null,
userName: null,
createdAt: created,
metadata: { synthetic: true },
});
}
allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return NextResponse.json({ data: allEvents.slice(0, 50) }); return NextResponse.json({ data: allEvents.slice(0, 50) });

View File

@@ -0,0 +1,68 @@
import { and, eq } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import type { AuthContext } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { savedViews } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
import { savedViewsService } from '@/lib/services/saved-views.service';
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
/**
* Resolves the view and enforces ownership before mutating.
*
* Returns a 404 when the view does not exist (or lives in a different port)
* and a 403 when it belongs to a different user. The 404-before-403 split
* matches the rest of the API and avoids leaking the existence of another
* user's saved view via timing or status code.
*/
async function assertViewOwner(
id: string,
portId: string,
userId: string,
): Promise<NextResponse | null> {
const view = await db.query.savedViews.findFirst({
where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)),
});
if (!view) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (view.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return null;
}
export async function patchHandler(
req: Request,
ctx: AuthContext,
params: { id?: string },
): Promise<NextResponse> {
try {
const id = params.id ?? '';
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
if (denied) return denied;
const body = await parseBody(req as never, updateSavedViewSchema);
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
return NextResponse.json({ data: view });
} catch (error) {
return errorResponse(error);
}
}
export async function deleteHandler(
_req: Request,
ctx: AuthContext,
params: { id?: string },
): Promise<NextResponse> {
try {
const id = params.id ?? '';
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
if (denied) return denied;
await savedViewsService.delete(ctx.portId, ctx.userId, id);
return NextResponse.json({ data: null }, { status: 200 });
} catch (error) {
return errorResponse(error);
}
}

View File

@@ -1,28 +1,5 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers'; import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers'; import { patchHandler, deleteHandler } from './handlers';
import { errorResponse } from '@/lib/errors';
import { savedViewsService } from '@/lib/services/saved-views.service';
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
export const PATCH = withAuth(async (req, ctx, params) => { export const PATCH = withAuth(patchHandler);
try { export const DELETE = withAuth(deleteHandler);
const id = params.id ?? '';
const body = await parseBody(req, updateSavedViewSchema);
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
return NextResponse.json({ data: view });
} catch (error) {
return errorResponse(error);
}
});
export const DELETE = withAuth(async (_req, ctx, params) => {
try {
const id = params.id ?? '';
await savedViewsService.delete(ctx.portId, ctx.userId, id);
return NextResponse.json({ data: null }, { status: 200 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -6,6 +6,7 @@ import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
import { import {
handleRecipientSigned, handleRecipientSigned,
handleDocumentCompleted, handleDocumentCompleted,
handleDocumentExpired,
handleDocumentOpened, handleDocumentOpened,
handleDocumentRejected, handleDocumentRejected,
handleDocumentCancelled, handleDocumentCancelled,
@@ -139,6 +140,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
await handleDocumentCancelled({ documentId: documensoId, signatureHash }); await handleDocumentCancelled({ documentId: documensoId, signatureHash });
break; break;
case 'DOCUMENT_EXPIRED':
await handleDocumentExpired({ documentId: documensoId });
break;
default: default:
logger.info({ event }, 'Unhandled Documenso webhook event type'); logger.info({ event }, 'Unhandled Documenso webhook event type');
} }

View File

@@ -44,6 +44,17 @@ type BerthDetailData = {
draftFt: string | null; draftFt: string | null;
draftM: string | null; draftM: string | null;
widthIsMinimum: boolean | null; widthIsMinimum: boolean | null;
nominalBoatSize: string | null;
nominalBoatSizeM: string | null;
waterDepth: string | null;
waterDepthM: string | null;
waterDepthIsMinimum: boolean | null;
sidePontoon: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
bowFacing: string | null;
price: string | null; price: string | null;
priceCurrency: string; priceCurrency: string;
tenureType: string; tenureType: string;

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { DetailLayout } from '@/components/shared/detail-layout'; import { DetailLayout } from '@/components/shared/detail-layout';
@@ -8,6 +9,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { BerthDetailHeader } from './berth-detail-header'; import { BerthDetailHeader } from './berth-detail-header';
import { BerthForm } from './berth-form';
import { buildBerthTabs } from './berth-tabs'; import { buildBerthTabs } from './berth-tabs';
interface BerthDetailProps { interface BerthDetailProps {
@@ -35,15 +37,38 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
return () => setChrome({ title: null, showBackButton: false }); return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]); }, [titleForChrome, setChrome]);
// Auto-open edit sheet when ?edit=true is present in the URL
const searchParams = useSearchParams();
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
useEffect(() => {
if (searchParams.get('edit') === 'true') {
setEditOpen(true);
// Strip the param without adding a history entry
const params = new URLSearchParams(searchParams.toString());
params.delete('edit');
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
// typedRoutes can't statically validate this dynamic path; cast is safe
// because we're always replacing within the same route segment.
router.replace(newUrl as never);
}
// Only run once on mount / when searchParams changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const berth = data as any; const berth = data as any;
return ( return (
<>
<DetailLayout <DetailLayout
isLoading={isLoading} isLoading={isLoading}
header={berth ? <BerthDetailHeader berth={berth} /> : null} header={berth ? <BerthDetailHeader berth={berth} /> : null}
tabs={berth ? buildBerthTabs(berth) : []} tabs={berth ? buildBerthTabs(berth) : []}
defaultTab="overview" defaultTab="overview"
/> />
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}
</>
); );
} }

View File

@@ -16,18 +16,22 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { TagPicker } from '@/components/shared/tag-picker'; import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths'; import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
import {
BERTH_AREAS,
BERTH_SIDE_PONTOON_OPTIONS,
BERTH_MOORING_TYPES,
BERTH_CLEAT_TYPES,
BERTH_CLEAT_CAPACITIES,
BERTH_BOLLARD_TYPES,
BERTH_BOLLARD_CAPACITIES,
BERTH_ACCESS_OPTIONS,
} from '@/lib/constants';
interface BerthFormProps { interface BerthFormProps {
berth: { berth: {
@@ -42,16 +46,27 @@ interface BerthFormProps {
draftFt: string | null; draftFt: string | null;
draftM: string | null; draftM: string | null;
widthIsMinimum: boolean | null; widthIsMinimum: boolean | null;
nominalBoatSize: string | null;
nominalBoatSizeM: string | null;
waterDepth: string | null;
waterDepthM: string | null;
waterDepthIsMinimum: boolean | null;
sidePontoon: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
access: string | null;
bowFacing: string | null;
price: string | null; price: string | null;
priceCurrency: string; priceCurrency: string;
tenureType: string; tenureType: string;
tenureYears: number | null; tenureYears: number | null;
tenureStartDate: string | null; tenureStartDate: string | null;
tenureEndDate: string | null; tenureEndDate: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
access: string | null;
berthApproved: boolean | null; berthApproved: boolean | null;
tags: Array<{ id: string; name: string; color: string }>; tags: Array<{ id: string; name: string; color: string }>;
}; };
@@ -59,10 +74,42 @@ interface BerthFormProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
/** Optional select that allows clearing back to "no value". */
function SelectOrEmpty({
value,
onChange,
options,
placeholder = 'Select…',
}: {
value: string | undefined;
onChange: (next: string | undefined) => void;
options: readonly string[];
placeholder?: string;
}) {
const NONE = '__none';
return (
<Select value={value ?? NONE} onValueChange={(v) => onChange(v === NONE ? undefined : v)}>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
{options.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) { export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id)); const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id));
const numOrUndef = (v: string | null) => (v != null && v !== '' ? Number(v) : undefined);
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -73,23 +120,34 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
resolver: zodResolver(updateBerthSchema), resolver: zodResolver(updateBerthSchema),
defaultValues: { defaultValues: {
area: berth.area ?? undefined, area: berth.area ?? undefined,
lengthFt: berth.lengthFt ? Number(berth.lengthFt) : undefined, lengthFt: numOrUndef(berth.lengthFt),
lengthM: berth.lengthM ? Number(berth.lengthM) : undefined, lengthM: numOrUndef(berth.lengthM),
widthFt: berth.widthFt ? Number(berth.widthFt) : undefined, widthFt: numOrUndef(berth.widthFt),
widthM: berth.widthM ? Number(berth.widthM) : undefined, widthM: numOrUndef(berth.widthM),
draftFt: berth.draftFt ? Number(berth.draftFt) : undefined, draftFt: numOrUndef(berth.draftFt),
draftM: berth.draftM ? Number(berth.draftM) : undefined, draftM: numOrUndef(berth.draftM),
widthIsMinimum: berth.widthIsMinimum ?? false, widthIsMinimum: berth.widthIsMinimum ?? false,
price: berth.price ? Number(berth.price) : undefined, nominalBoatSize: numOrUndef(berth.nominalBoatSize),
nominalBoatSizeM: numOrUndef(berth.nominalBoatSizeM),
waterDepth: numOrUndef(berth.waterDepth),
waterDepthM: numOrUndef(berth.waterDepthM),
waterDepthIsMinimum: berth.waterDepthIsMinimum ?? false,
sidePontoon: berth.sidePontoon ?? undefined,
powerCapacity: numOrUndef(berth.powerCapacity),
voltage: numOrUndef(berth.voltage),
mooringType: berth.mooringType ?? undefined,
cleatType: berth.cleatType ?? undefined,
cleatCapacity: berth.cleatCapacity ?? undefined,
bollardType: berth.bollardType ?? undefined,
bollardCapacity: berth.bollardCapacity ?? undefined,
access: berth.access ?? undefined,
bowFacing: berth.bowFacing ?? undefined,
price: numOrUndef(berth.price),
priceCurrency: berth.priceCurrency, priceCurrency: berth.priceCurrency,
tenureType: berth.tenureType as 'permanent' | 'fixed_term', tenureType: berth.tenureType as 'permanent' | 'fixed_term',
tenureYears: berth.tenureYears ?? undefined, tenureYears: berth.tenureYears ?? undefined,
tenureStartDate: berth.tenureStartDate ?? undefined, tenureStartDate: berth.tenureStartDate ?? undefined,
tenureEndDate: berth.tenureEndDate ?? undefined, tenureEndDate: berth.tenureEndDate ?? undefined,
powerCapacity: berth.powerCapacity ?? undefined,
voltage: berth.voltage ?? undefined,
mooringType: berth.mooringType ?? undefined,
access: berth.access ?? undefined,
berthApproved: berth.berthApproved ?? false, berthApproved: berth.berthApproved ?? false,
}, },
}); });
@@ -120,6 +178,14 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
} }
const tenureType = watch('tenureType'); const tenureType = watch('tenureType');
const area = watch('area');
const sidePontoon = watch('sidePontoon');
const mooringType = watch('mooringType');
const cleatType = watch('cleatType');
const cleatCapacity = watch('cleatCapacity');
const bollardType = watch('bollardType');
const bollardCapacity = watch('bollardCapacity');
const access = watch('access');
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
@@ -136,18 +202,18 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="area">Area</Label> <Label>Area</Label>
<Input id="area" {...register('area')} placeholder="e.g. Marina A" /> <SelectOrEmpty
value={area}
onChange={(v) => setValue('area', v)}
options={BERTH_AREAS}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="mooringType">Mooring Type</Label> <Label htmlFor="bowFacing">Bow Facing</Label>
<Input id="mooringType" {...register('mooringType')} /> <Input id="bowFacing" {...register('bowFacing')} placeholder="e.g. East" />
</div> </div>
</div> </div>
<div className="space-y-2">
<Label htmlFor="access">Access</Label>
<Input id="access" {...register('access')} />
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="berthApproved" id="berthApproved"
@@ -168,29 +234,46 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Length (ft)</Label> <Label>Length (ft)</Label>
<Input type="number" step="0.1" {...register('lengthFt')} /> <Input type="number" step="0.01" {...register('lengthFt')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Length (m)</Label> <Label>Length (m)</Label>
<Input type="number" step="0.1" {...register('lengthM')} /> <Input type="number" step="0.01" {...register('lengthM')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Width (ft)</Label> <Label>Width (ft)</Label>
<Input type="number" step="0.1" {...register('widthFt')} /> <Input type="number" step="0.01" {...register('widthFt')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Width (m)</Label> <Label>Width (m)</Label>
<Input type="number" step="0.1" {...register('widthM')} /> <Input type="number" step="0.01" {...register('widthM')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Draft (ft)</Label> <Label>Draft (ft)</Label>
<Input type="number" step="0.1" {...register('draftFt')} /> <Input type="number" step="0.01" {...register('draftFt')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Draft (m)</Label> <Label>Draft (m)</Label>
<Input type="number" step="0.1" {...register('draftM')} /> <Input type="number" step="0.01" {...register('draftM')} />
</div>
<div className="space-y-2">
<Label>Nominal Boat Size (ft)</Label>
<Input type="number" step="1" {...register('nominalBoatSize')} />
</div>
<div className="space-y-2">
<Label>Nominal Boat Size (m)</Label>
<Input type="number" step="0.01" {...register('nominalBoatSizeM')} />
</div>
<div className="space-y-2">
<Label>Water Depth (ft)</Label>
<Input type="number" step="0.01" {...register('waterDepth')} />
</div>
<div className="space-y-2">
<Label>Water Depth (m)</Label>
<Input type="number" step="0.01" {...register('waterDepthM')} />
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="widthIsMinimum" id="widthIsMinimum"
@@ -199,6 +282,107 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
/> />
<Label htmlFor="widthIsMinimum">Width is minimum</Label> <Label htmlFor="widthIsMinimum">Width is minimum</Label>
</div> </div>
<div className="flex items-center gap-2">
<Switch
id="waterDepthIsMinimum"
checked={watch('waterDepthIsMinimum') ?? false}
onCheckedChange={(v) => setValue('waterDepthIsMinimum', v)}
/>
<Label htmlFor="waterDepthIsMinimum">Water depth is minimum</Label>
</div>
</div>
</div>
<Separator />
{/* Mooring & Hardware */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Mooring &amp; Hardware
</h3>
<div className="space-y-2">
<Label>Side Pontoon</Label>
<SelectOrEmpty
value={sidePontoon}
onChange={(v) => setValue('sidePontoon', v)}
options={BERTH_SIDE_PONTOON_OPTIONS}
/>
</div>
<div className="space-y-2">
<Label>Mooring Type</Label>
<SelectOrEmpty
value={mooringType}
onChange={(v) => setValue('mooringType', v)}
options={BERTH_MOORING_TYPES}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Cleat Type</Label>
<SelectOrEmpty
value={cleatType}
onChange={(v) => setValue('cleatType', v)}
options={BERTH_CLEAT_TYPES}
/>
</div>
<div className="space-y-2">
<Label>Cleat Capacity</Label>
<SelectOrEmpty
value={cleatCapacity}
onChange={(v) => setValue('cleatCapacity', v)}
options={BERTH_CLEAT_CAPACITIES}
/>
</div>
<div className="space-y-2">
<Label>Bollard Type</Label>
<SelectOrEmpty
value={bollardType}
onChange={(v) => setValue('bollardType', v)}
options={BERTH_BOLLARD_TYPES}
/>
</div>
<div className="space-y-2">
<Label>Bollard Capacity</Label>
<SelectOrEmpty
value={bollardCapacity}
onChange={(v) => setValue('bollardCapacity', v)}
options={BERTH_BOLLARD_CAPACITIES}
/>
</div>
</div>
</div>
<Separator />
{/* Power */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Power
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Power Capacity (kW)</Label>
<Input type="number" step="1" {...register('powerCapacity')} />
</div>
<div className="space-y-2">
<Label>Voltage (V at 60Hz)</Label>
<Input type="number" step="1" {...register('voltage')} />
</div>
</div>
</div>
<Separator />
{/* Access */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Access
</h3>
<SelectOrEmpty
value={access}
onChange={(v) => setValue('access', v)}
options={BERTH_ACCESS_OPTIONS}
/>
</div> </div>
<Separator /> <Separator />
@@ -262,25 +446,6 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
<Separator /> <Separator />
{/* Infrastructure */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Infrastructure
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Power Capacity</Label>
<Input {...register('powerCapacity')} />
</div>
<div className="space-y-2">
<Label>Voltage</Label>
<Input {...register('voltage')} />
</div>
</div>
</div>
<Separator />
{/* Tags */} {/* Tags */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> <h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">

View File

@@ -0,0 +1,166 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { ChevronRight, Users } from 'lucide-react';
import { formatDistanceToNowStrict } from 'date-fns';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { apiFetch } from '@/lib/api/client';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
import { computeUrgencyBadges } from '@/components/interests/urgency';
import type { InterestRow } from '@/components/interests/interest-columns';
import { cn } from '@/lib/utils';
interface InterestsResponse {
data: InterestRow[];
}
const PREVIEW_LIMIT = 5;
/**
* Top-of-overview pulse for the berth detail page. Lists the active
* interested parties with their stage + last activity, so the rep can do
* berth-level triage ("who's on this slip and how warm are they?")
* without clicking into the Interests tab.
*
* Borrows from the old Nuxt CRM's BerthDetailsModal "Interested Parties"
* pattern but uses the new at-a-glance signals (urgency badges, last
* activity).
*/
export function BerthInterestPulse({ berthId }: { berthId: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<InterestsResponse>({
queryKey: ['interests', { berthId, sort: 'dateLastContact', order: 'desc' }],
queryFn: () =>
apiFetch<InterestsResponse>(
`/api/v1/interests?berthId=${berthId}&limit=10&sort=dateLastContact&order=desc`,
),
staleTime: 30_000,
});
const all = data?.data ?? [];
const active = all.filter((i) => !i.archivedAt && !i.outcome);
const preview = active.slice(0, PREVIEW_LIMIT);
const more = active.length - preview.length;
if (isLoading) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Interested parties</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-10 animate-pulse rounded-md bg-muted/40" />
))}
</div>
</CardContent>
</Card>
);
}
if (active.length === 0) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-1.5 text-sm font-medium">
<Users className="size-3.5" />
Interested parties
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground">No active interests on this berth.</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3 space-y-0">
<CardTitle className="flex items-center gap-1.5 text-sm font-medium">
<Users className="size-3.5" />
Interested parties
<span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{active.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<ul className="divide-y divide-border">
{preview.map((i) => {
const lastIso = i.dateLastContact ?? i.updatedAt ?? null;
const lastActivity = lastIso
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
: null;
const urgency = computeUrgencyBadges(i);
const initials = (i.clientName ?? '?')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]!.toUpperCase())
.join('');
return (
<li key={i.id}>
<Link
href={`/${portSlug}/interests/${i.id}`}
className="group flex items-center gap-3 px-1 py-2.5 transition-colors hover:bg-foreground/5 rounded-md -mx-1"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-700">
{initials || '?'}
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="truncate text-sm font-medium text-foreground">
{i.clientName ?? 'Unknown'}
</span>
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
stageBadgeClass(i.pipelineStage),
)}
>
{stageLabel(i.pipelineStage)}
</span>
{urgency.map((b) => (
<span
key={b.id}
title={b.detail}
className={cn(
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
b.className,
)}
>
{b.label}
</span>
))}
</div>
{lastActivity ? (
<p className="text-[11px] tabular-nums text-muted-foreground">
Last activity {lastActivity}
</p>
) : null}
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5" />
</Link>
</li>
);
})}
</ul>
{more > 0 ? (
<Link
href={`/${portSlug}/berths/${berthId}?tab=interests`}
className="mt-2 inline-flex text-xs font-medium text-primary hover:underline"
>
View all {active.length} interests
</Link>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TagBadge } from '@/components/shared/tag-badge'; import { TagBadge } from '@/components/shared/tag-badge';
import { BerthReservationsTab } from './berth-reservations-tab'; import { BerthReservationsTab } from './berth-reservations-tab';
import { BerthInterestsTab } from './berth-interests-tab'; import { BerthInterestsTab } from './berth-interests-tab';
import { BerthInterestPulse } from './berth-interest-pulse';
type BerthData = { type BerthData = {
id: string; id: string;
@@ -56,13 +57,45 @@ function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
} }
function OverviewTab({ berth }: { berth: BerthData }) { function OverviewTab({ berth }: { berth: BerthData }) {
// Round to at most 2 decimals; trim trailing zeros so "5.00" -> "5".
const fmt = (v: string | null, fractionDigits = 2): string | null => {
if (v == null || v === '') return null;
const n = Number(v);
if (Number.isNaN(n)) return v;
return n.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits,
});
};
const formatDim = (ft: string | null, m: string | null) => { const formatDim = (ft: string | null, m: string | null) => {
const parts = []; const parts = [];
if (ft) parts.push(`${ft} ft`); const ftFmt = fmt(ft);
if (m) parts.push(`${m} m`); const mFmt = fmt(m);
if (ftFmt) parts.push(`${ftFmt} ft`);
if (mFmt) parts.push(`${mFmt} m`);
return parts.length > 0 ? parts.join(' / ') : null; return parts.length > 0 ? parts.join(' / ') : null;
}; };
const formatNominalBoatSize = (ft: string | null, m: string | null): string | null => {
const ftFmt = fmt(ft, 0);
const mFmt = fmt(m);
const parts: string[] = [];
if (ftFmt) parts.push(`${ftFmt} ft`);
if (mFmt) parts.push(`${mFmt} m`);
return parts.length > 0 ? parts.join(' / ') : null;
};
const formatPower = (kw: string | null) => {
const v = fmt(kw, 0);
return v ? `${v} kW` : null;
};
const formatVoltage = (v: string | null) => {
const fv = fmt(v, 0);
return fv ? `${fv} V` : null;
};
const price = berth.price const price = berth.price
? new Intl.NumberFormat('en-US', { ? new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',
@@ -72,6 +105,11 @@ function OverviewTab({ berth }: { berth: BerthData }) {
: null; : null;
return ( return (
<div className="space-y-6">
{/* Sales pulse — top-of-page so reps doing berth-level triage can see
who's interested + how warm without clicking into the Interests tab. */}
<BerthInterestPulse berthId={berth.id} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Specifications */} {/* Specifications */}
<Card> <Card>
@@ -91,7 +129,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} /> <SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
<SpecRow <SpecRow
label="Nominal Boat Size" label="Nominal Boat Size"
value={berth.nominalBoatSize || berth.nominalBoatSizeM} value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)}
/> />
<SpecRow <SpecRow
label="Water Depth" label="Water Depth"
@@ -116,8 +154,8 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle> <CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0 divide-y"> <CardContent className="pt-0 divide-y">
<SpecRow label="Power Capacity" value={berth.powerCapacity} /> <SpecRow label="Power Capacity" value={formatPower(berth.powerCapacity)} />
<SpecRow label="Voltage" value={berth.voltage} /> <SpecRow label="Voltage" value={formatVoltage(berth.voltage)} />
<SpecRow label="Cleat Type" value={berth.cleatType} /> <SpecRow label="Cleat Type" value={berth.cleatType} />
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} /> <SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
<SpecRow label="Bollard Type" value={berth.bollardType} /> <SpecRow label="Bollard Type" value={berth.bollardType} />
@@ -161,6 +199,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
)} )}
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -0,0 +1,311 @@
'use client';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import type { Route } from 'next';
import { useQuery } from '@tanstack/react-query';
import { ArrowRight, ChevronRight } from 'lucide-react';
import { formatDistanceToNowStrict } from 'date-fns';
import { apiFetch } from '@/lib/api/client';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import {
PIPELINE_STAGES,
STAGE_BADGE,
STAGE_DOT,
STAGE_LABELS,
safeStage,
type PipelineStage,
} from '@/components/clients/pipeline-constants';
export interface ClientInterestRow {
id: string;
pipelineStage: string;
archivedAt: string | null;
updatedAt: string;
dateLastContact: string | null;
berthMooringNumber?: string | null;
yachtName?: string | null;
}
interface InterestsResponse {
data: ClientInterestRow[];
}
export function useClientInterests(clientId: string) {
return useQuery<InterestsResponse>({
queryKey: ['interests', { clientId }],
queryFn: () => apiFetch<InterestsResponse>(`/api/v1/interests?clientId=${clientId}&limit=50`),
});
}
export function StageStepper({
current,
size = 'sm',
}: {
current: PipelineStage;
size?: 'xs' | 'sm';
}) {
const idx = PIPELINE_STAGES.indexOf(current);
// Segmented progress bar: each stage is a slice of equal width that
// lights up once the interest has reached it. Reads at-a-glance, scales
// to any container width, and works with 9 stages without becoming
// micro-dots that vanish under cramped layouts.
const height = size === 'xs' ? 'h-1' : 'h-1.5';
return (
<div
className={cn('flex w-full overflow-hidden rounded-full bg-muted', height)}
role="progressbar"
aria-label="Pipeline progress"
aria-valuenow={idx + 1}
aria-valuemin={1}
aria-valuemax={PIPELINE_STAGES.length}
>
{PIPELINE_STAGES.map((stage, i) => {
const isReached = i <= idx;
const isCurrent = i === idx;
return (
<div
key={stage}
title={`${STAGE_LABELS[stage]}${isCurrent ? ' (current)' : ''}`}
className={cn(
'flex-1 transition-colors',
isReached ? STAGE_DOT[stage] : 'bg-transparent',
i > 0 ? 'border-l border-card' : '',
)}
/>
);
})}
</div>
);
}
function pickHighest(interests: ClientInterestRow[]): ClientInterestRow | null {
const active = interests.filter((i) => !i.archivedAt);
if (active.length === 0) return null;
return [...active].sort((a, b) => {
const ai = PIPELINE_STAGES.indexOf(safeStage(a.pipelineStage));
const bi = PIPELINE_STAGES.indexOf(safeStage(b.pipelineStage));
if (ai !== bi) return bi - ai;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
})[0]!;
}
function lastActivityLabel(interests: ClientInterestRow[]): string | null {
const candidates = interests
.flatMap((i) => [i.dateLastContact, i.updatedAt])
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime())
.filter((t) => !Number.isNaN(t));
if (candidates.length === 0) return null;
const latest = new Date(Math.max(...candidates));
return `${formatDistanceToNowStrict(latest)} ago`;
}
interface PipelineSummaryProps {
clientId: string;
/**
* `hero` — single-line pulse for the detail header (highest active stage only).
* `panel` — compact list of every active interest, for the Overview tab.
*/
variant?: 'hero' | 'panel';
}
function HeroVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) {
const pathname = usePathname();
const { data, isLoading } = useClientInterests(clientId);
const interests = data?.data ?? [];
const top = pickHighest(interests);
const activeCount = interests.filter((i) => !i.archivedAt).length;
const activity = lastActivityLabel(interests);
const interestsTabHref = `${pathname}?tab=interests` as Route;
if (isLoading) {
return (
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-2 w-48" />
</div>
);
}
if (!top) {
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium text-foreground">No active interests</p>
<p className="text-xs text-muted-foreground">
Start one to begin tracking the sales process.
</p>
</div>
<Link
href={`/${portSlug}/interests/new` as Route}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
Start interest <ArrowRight className="size-3" />
</Link>
</div>
);
}
const stage = safeStage(top.pipelineStage);
const berthLabel = top.berthMooringNumber
? `Berth ${top.berthMooringNumber}`
: 'General interest';
const detailsHref = `/${portSlug}/interests/${top.id}` as Route;
return (
<div className="space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Sales pipeline
</span>
{activeCount > 1 ? (
<span className="text-[10px] font-medium text-muted-foreground">
· {activeCount} active
</span>
) : null}
</div>
<Link
href={detailsHref}
className="group -m-1 block rounded-lg p-1 transition-colors hover:bg-foreground/5"
>
<div className="flex items-center gap-2 flex-wrap">
<span className="truncate text-sm font-semibold text-foreground">{berthLabel}</span>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
STAGE_BADGE[stage],
)}
>
{STAGE_LABELS[stage]}
</span>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</div>
<div className="mt-1.5">
<StageStepper current={stage} size="xs" />
</div>
</Link>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{activity ? `Last activity ${activity}` : 'No activity recorded'}</span>
{activeCount > 1 ? (
<Link
href={interestsTabHref}
className="font-medium text-primary hover:underline"
scroll={false}
>
View all {activeCount}
</Link>
) : null}
</div>
</div>
);
}
function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) {
const pathname = usePathname();
const { data, isLoading } = useClientInterests(clientId);
const interests = (data?.data ?? []).filter((i) => !i.archivedAt);
const interestsTabHref = `${pathname}?tab=interests` as Route;
if (isLoading) {
return (
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-2 w-48" />
</div>
);
}
if (interests.length === 0) {
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium text-foreground">No active interests</p>
<p className="text-xs text-muted-foreground">
Start one to begin tracking the sales process.
</p>
</div>
<Link
href={`/${portSlug}/interests/new` as Route}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
Start interest <ArrowRight className="size-3" />
</Link>
</div>
);
}
const sorted = [...interests].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Sales pipeline · {interests.length} active
</span>
<Link
href={interestsTabHref}
className="text-xs font-medium text-primary hover:underline"
scroll={false}
>
Manage
</Link>
</div>
<ul className="space-y-2">
{sorted.map((i) => {
const stage = safeStage(i.pipelineStage);
const berthLabel = i.berthMooringNumber
? `Berth ${i.berthMooringNumber}`
: 'General interest';
const href = `/${portSlug}/interests/${i.id}` as Route;
return (
<li key={i.id}>
<Link
href={href}
className="group flex items-center gap-3 rounded-lg p-2 -m-2 transition-colors hover:bg-foreground/5"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="truncate text-sm font-medium text-foreground">
{berthLabel}
</span>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium',
STAGE_BADGE[stage],
)}
>
{STAGE_LABELS[stage]}
</span>
</div>
<div className="mt-1">
<StageStepper current={stage} size="xs" />
</div>
</div>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
</li>
);
})}
</ul>
</div>
);
}
export function ClientPipelineSummary({ clientId, variant = 'panel' }: PipelineSummaryProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
return variant === 'hero' ? (
<HeroVariant clientId={clientId} portSlug={portSlug} />
) : (
<PanelVariant clientId={clientId} portSlug={portSlug} />
);
}

View File

@@ -57,7 +57,10 @@ function ActivityFeedInner() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent activity.</p> <p className="text-sm text-muted-foreground">
No recent activity yet your team&apos;s actions (interests created, stages changed,
invoices sent) will appear here.
</p>
) : ( ) : (
<div className="max-h-80 overflow-y-auto space-y-3 pr-1"> <div className="max-h-80 overflow-y-auto space-y-3 pr-1">
{items.map((item) => ( {items.map((item) => (

View File

@@ -11,6 +11,7 @@ import { PipelineFunnelChart } from './pipeline-funnel-chart';
import { OccupancyTimelineChart } from './occupancy-timeline-chart'; import { OccupancyTimelineChart } from './occupancy-timeline-chart';
import { RevenueBreakdownChart } from './revenue-breakdown-chart'; import { RevenueBreakdownChart } from './revenue-breakdown-chart';
import { LeadSourceChart } from './lead-source-chart'; import { LeadSourceChart } from './lead-source-chart';
import { MyRemindersRail } from './my-reminders-rail';
import { WidgetErrorBoundary } from './widget-error-boundary'; import { WidgetErrorBoundary } from './widget-error-boundary';
import { AlertRail } from '@/components/alerts/alert-rail'; import { AlertRail } from '@/components/alerts/alert-rail';
import type { DateRange } from '@/lib/services/analytics.service'; import type { DateRange } from '@/lib/services/analytics.service';
@@ -49,7 +50,7 @@ export function DashboardShell() {
actions={<DateRangePicker value={range} onChange={setRange} />} actions={<DateRangePicker value={range} onChange={setRange} />}
/> />
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 grid-cols-2 sm:gap-4 lg:grid-cols-4">
<KpiCardsWithBoundary /> <KpiCardsWithBoundary />
</div> </div>
@@ -68,7 +69,10 @@ export function DashboardShell() {
<LeadSourceChart range={range} /> <LeadSourceChart range={range} />
</WidgetErrorBoundary> </WidgetErrorBoundary>
</div> </div>
<aside className="min-w-0"> <aside className="min-w-0 space-y-4">
<WidgetErrorBoundary>
<MyRemindersRail />
</WidgetErrorBoundary>
<WidgetErrorBoundary> <WidgetErrorBoundary>
<AlertRail /> <AlertRail />
</WidgetErrorBoundary> </WidgetErrorBoundary>

View File

@@ -54,18 +54,24 @@ export function LeadSourceChart({ range }: Props) {
{isLoading ? ( {isLoading ? (
<CardSkeleton /> <CardSkeleton />
) : !slices.length ? ( ) : !slices.length ? (
<EmptyState title="No interests in range" /> <EmptyState
title="No interests in range"
description="Lights up once new interests are created — tracks where each came from (website, referral, broker)."
/>
) : ( ) : (
<ResponsiveContainer width="100%" height={260}> // Percentage radii + center-anchored chart so the pie scales with
// the container instead of being clipped to a constant 90px ring at
// narrow widths. Legend is reserved a fixed footer height.
<ResponsiveContainer width="100%" height={280}>
<PieChart> <PieChart>
<Pie <Pie
data={chartData} data={chartData}
dataKey="value" dataKey="value"
nameKey="name" nameKey="name"
cx="50%" cx="50%"
cy="50%" cy="45%"
outerRadius={90} outerRadius="70%"
innerRadius={50} innerRadius="40%"
paddingAngle={2} paddingAngle={2}
> >
{chartData.map((_, i) => ( {chartData.map((_, i) => (
@@ -80,7 +86,11 @@ export function LeadSourceChart({ range }: Props) {
fontSize: 12, fontSize: 12,
}} }}
/> />
<Legend wrapperStyle={{ fontSize: 12 }} /> <Legend
verticalAlign="bottom"
height={40}
wrapperStyle={{ fontSize: 12, paddingTop: 4 }}
/>
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
)} )}

View File

@@ -0,0 +1,153 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNowStrict, isAfter, isBefore } from 'date-fns';
import { AlarmClock, ChevronRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface ReminderRow {
id: string;
title: string;
dueAt: string;
status: string;
priority?: string | null;
interestId?: string | null;
clientId?: string | null;
entityType?: string | null;
entityId?: string | null;
}
interface MyRemindersResponse {
data: ReminderRow[];
}
const PRIORITY_BADGE: Record<string, string> = {
high: 'bg-rose-100 text-rose-700',
medium: 'bg-amber-100 text-amber-700',
low: 'bg-slate-100 text-slate-700',
};
/**
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned
* to the current user (overdue first, then upcoming). Each item links to its
* subject — interest preferred, then client, then the generic entity ref.
*
* Limited to 6 items; "View all" routes to /reminders.
*/
export function MyRemindersRail() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<MyRemindersResponse>({
queryKey: ['reminders', 'my'],
queryFn: () => apiFetch<MyRemindersResponse>('/api/v1/reminders/my'),
staleTime: 60_000,
});
const items = data?.data ?? [];
const now = new Date();
// Overdue first, then upcoming, capped at 6 for the rail.
const sorted = [...items]
.sort((a, b) => new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime())
.slice(0, 6);
const overdueCount = items.filter((r) => isBefore(new Date(r.dueAt), now)).length;
function hrefFor(r: ReminderRow): string {
if (r.interestId) return `/${portSlug}/interests/${r.interestId}`;
if (r.clientId) return `/${portSlug}/clients/${r.clientId}`;
if (r.entityType === 'client' && r.entityId) return `/${portSlug}/clients/${r.entityId}`;
if (r.entityType === 'interest' && r.entityId) return `/${portSlug}/interests/${r.entityId}`;
if (r.entityType === 'berth' && r.entityId) return `/${portSlug}/berths/${r.entityId}`;
return `/${portSlug}/reminders`;
}
return (
<Card className="h-full">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-0.5">
<CardTitle className="flex items-center gap-1.5 text-base">
<AlarmClock className="size-4" />
Reminders
</CardTitle>
{overdueCount > 0 ? (
<p className="text-xs text-rose-700">{overdueCount} overdue</p>
) : items.length > 0 ? (
<p className="text-xs text-muted-foreground">{items.length} pending</p>
) : null}
</div>
<Link
href={`/${portSlug}/reminders` as never}
className="text-xs font-medium text-primary hover:underline"
>
View all
</Link>
</CardHeader>
<CardContent className="pt-0">
{isLoading ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-9 animate-pulse rounded-md bg-muted/40" />
))}
</div>
) : sorted.length === 0 ? (
<p className="py-3 text-center text-sm text-muted-foreground">
All caught up no reminders.
</p>
) : (
<ul className="space-y-1">
{sorted.map((r) => {
const due = new Date(r.dueAt);
const isOverdue = isBefore(due, now);
const isUpcoming = isAfter(due, now);
return (
<li key={r.id}>
<Link
href={hrefFor(r) as never}
className={cn(
'group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
'hover:bg-foreground/5',
)}
>
<span
aria-hidden
className={cn(
'size-1.5 shrink-0 rounded-full',
isOverdue ? 'bg-rose-500' : 'bg-amber-400',
)}
/>
<span className="min-w-0 flex-1 truncate">{r.title}</span>
{r.priority && r.priority !== 'low' ? (
<Badge
variant="outline"
className={cn(
'border-transparent text-[10px]',
PRIORITY_BADGE[r.priority] ?? 'bg-muted text-muted-foreground',
)}
>
{r.priority}
</Badge>
) : null}
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{isOverdue
? formatDistanceToNowStrict(due) + ' overdue'
: isUpcoming
? 'in ' + formatDistanceToNowStrict(due)
: 'now'}
</span>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5" />
</Link>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@@ -6,7 +6,8 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CardSkeleton } from '@/components/shared/loading-skeleton'; import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { stageLabel } from '@/lib/constants'; import { useIsMobile } from '@/hooks/use-is-mobile';
import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
import { WidgetErrorBoundary } from './widget-error-boundary'; import { WidgetErrorBoundary } from './widget-error-boundary';
interface PipelineRow { interface PipelineRow {
@@ -15,6 +16,7 @@ interface PipelineRow {
} }
function PipelineChartInner() { function PipelineChartInner() {
const isMobile = useIsMobile();
const { data, isLoading } = useQuery<PipelineRow[]>({ const { data, isLoading } = useQuery<PipelineRow[]>({
queryKey: ['dashboard', 'pipeline'], queryKey: ['dashboard', 'pipeline'],
queryFn: () => apiFetch<PipelineRow[]>('/api/v1/dashboard/pipeline'), queryFn: () => apiFetch<PipelineRow[]>('/api/v1/dashboard/pipeline'),
@@ -27,7 +29,7 @@ function PipelineChartInner() {
} }
const chartData = (data ?? []).map((row) => ({ const chartData = (data ?? []).map((row) => ({
stage: stageLabel(row.stage), stage: isMobile ? STAGE_SHORT_LABELS[safeStage(row.stage)] : stageLabel(row.stage),
count: row.count, count: row.count,
})); }));

View File

@@ -4,7 +4,8 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi
import { CardSkeleton } from '@/components/shared/loading-skeleton'; import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state'; import { EmptyState } from '@/components/shared/empty-state';
import { stageLabel } from '@/lib/constants'; import { useIsMobile } from '@/hooks/use-is-mobile';
import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
import { ChartCard } from './chart-card'; import { ChartCard } from './chart-card';
import { useFunnel } from './use-analytics'; import { useFunnel } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service'; import type { DateRange } from '@/lib/services/analytics.service';
@@ -15,10 +16,12 @@ interface Props {
export function PipelineFunnelChart({ range }: Props) { export function PipelineFunnelChart({ range }: Props) {
const { data, isLoading } = useFunnel(range); const { data, isLoading } = useFunnel(range);
const isMobile = useIsMobile();
const stages = data?.stages ?? []; const stages = data?.stages ?? [];
// Use short labels on mobile so the rotated axis isn't a wall of overlap.
const chartData = stages.map((s) => ({ const chartData = stages.map((s) => ({
stage: stageLabel(s.stage), stage: isMobile ? STAGE_SHORT_LABELS[safeStage(s.stage)] : stageLabel(s.stage),
count: s.count, count: s.count,
conversionPct: s.conversionPct, conversionPct: s.conversionPct,
})); }));
@@ -41,7 +44,10 @@ export function PipelineFunnelChart({ range }: Props) {
{isLoading ? ( {isLoading ? (
<CardSkeleton /> <CardSkeleton />
) : allZero ? ( ) : allZero ? (
<EmptyState title="No interests in range" description="Try a longer date range." /> <EmptyState
title="No interests in range"
description="Conversion through Open → EOI → Deposit → Contract appears here. Try a longer date range, or add an interest to see it."
/>
) : ( ) : (
<ResponsiveContainer width="100%" height={260}> <ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}> <BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>

View File

@@ -47,7 +47,10 @@ export function RevenueBreakdownChart({ range }: Props) {
{isLoading ? ( {isLoading ? (
<CardSkeleton /> <CardSkeleton />
) : !bars.length ? ( ) : !bars.length ? (
<EmptyState title="No invoices in range" description="Invoices appear here once issued." /> <EmptyState
title="No invoices in range"
description="Issued, paid, and overdue totals appear here once you create invoices."
/>
) : ( ) : (
<ResponsiveContainer width="100%" height={260}> <ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}> <BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>

View File

@@ -25,6 +25,9 @@ interface DocumentRow {
interface DocumentListProps { interface DocumentListProps {
interestId?: string; interestId?: string;
clientId?: string; clientId?: string;
/** Override the default empty state ("No documents yet.") with a contextual
* CTA — e.g. on the interest Documents tab we render a Generate EOI prompt. */
emptyState?: React.ReactNode;
} }
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -44,7 +47,7 @@ const TYPE_LABELS: Record<string, string> = {
other: 'Other', other: 'Other',
}; };
export function DocumentList({ interestId, clientId }: DocumentListProps) { export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
@@ -83,10 +86,13 @@ export function DocumentList({ interestId, clientId }: DocumentListProps) {
}; };
if (isLoading) { if (isLoading) {
return <div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>; return (
<div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>
);
} }
if (!data || data.length === 0) { if (!data || data.length === 0) {
if (emptyState) return <>{emptyState}</>;
return <div className="py-8 text-center text-sm text-muted-foreground">No documents yet.</div>; return <div className="py-8 text-center text-sm text-muted-foreground">No documents yet.</div>;
} }

View File

@@ -74,6 +74,15 @@ const STATUS_PILL_MAP: Record<string, StatusPillStatus> = {
rejected: 'rejected', rejected: 'rejected',
}; };
const SIGNER_STATUS_LABELS: Record<string, string> = {
pending: 'Pending',
sent: 'Sent',
signed: 'Signed',
declined: 'Declined',
expired: 'Expired',
cancelled: 'Cancelled',
};
interface DocumentsHubProps { interface DocumentsHubProps {
portSlug: string; portSlug: string;
initialTab?: DocumentsHubTab; initialTab?: DocumentsHubTab;
@@ -187,7 +196,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
<span className="truncate text-muted-foreground">{signer.signerEmail}</span> <span className="truncate text-muted-foreground">{signer.signerEmail}</span>
</div> </div>
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}> <StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
{signer.status} {SIGNER_STATUS_LABELS[signer.status] ?? signer.status}
</StatusPill> </StatusPill>
</li> </li>
))} ))}

View File

@@ -22,8 +22,14 @@ import {
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
/** Required for the EOI's top paragraph (Section 2) — without these the
* document is unsignable, so generation is blocked. Yacht and berth fields
* belong to Section 3 and may be left blank. */
interface EoiPrerequisites { interface EoiPrerequisites {
hasName: boolean; hasName: boolean;
hasEmail: boolean;
hasAddress: boolean;
/** Optional — info-only checks. Generation proceeds without them. */
hasYacht: boolean; hasYacht: boolean;
hasBerth: boolean; hasBerth: boolean;
} }
@@ -35,10 +41,15 @@ interface EoiGenerateDialogProps {
prerequisites: EoiPrerequisites; prerequisites: EoiPrerequisites;
} }
const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [ const REQUIRED_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
{ key: 'hasName', label: 'Client has full name' }, { key: 'hasName', label: 'Client name' },
{ key: 'hasYacht', label: 'Yacht linked to interest' }, { key: 'hasAddress', label: 'Client address' },
{ key: 'hasBerth', label: 'Berth linked to interest' }, { key: 'hasEmail', label: 'Client email' },
];
const OPTIONAL_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
{ key: 'hasYacht', label: 'Yacht linked (name + dimensions)' },
{ key: 'hasBerth', label: 'Berth linked (mooring number)' },
]; ];
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template'; const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
@@ -65,7 +76,7 @@ export function EoiGenerateDialog({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE); const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
const allMet = Object.values(prerequisites).every(Boolean); const requiredMet = REQUIRED_LABELS.every(({ key }) => prerequisites[key]);
// Load in-app EOI templates so the operator can pick one as an alternative // Load in-app EOI templates so the operator can pick one as an alternative
// to the Documenso external-signing flow. // to the Documenso external-signing flow.
@@ -79,7 +90,7 @@ export function EoiGenerateDialog({
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]); const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
const handleGenerate = async () => { const handleGenerate = async () => {
if (!allMet) return; if (!requiredMet) return;
setIsGenerating(true); setIsGenerating(true);
setError(null); setError(null);
@@ -98,7 +109,13 @@ export function EoiGenerateDialog({
}, },
}); });
queryClient.invalidateQueries({ queryKey: ['documents', { interestId }] }); // Invalidate all document list queries (hub counts + per-interest lists).
// The DocumentList component uses ['documents', { interestId, clientId }]
// and the hub uses ['documents', 'hub', ...] / ['documents', 'hub-counts'].
// Using a predicate avoids key-shape drift between callers.
queryClient.invalidateQueries({
predicate: (q) => q.queryKey[0] === 'documents',
});
onOpenChange(false); onOpenChange(false);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate EOI'); setError(err instanceof Error ? err.message : 'Failed to generate EOI');
@@ -138,10 +155,13 @@ export function EoiGenerateDialog({
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">Prerequisites</p> <div className="space-y-1.5">
{PREREQUISITE_LABELS.map(({ key, label }) => ( <p className="text-xs font-medium text-muted-foreground">
<div key={key} className="flex items-center gap-3"> Required (Section 2 of the EOI)
</p>
{REQUIRED_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3 text-sm">
<span <span
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${ className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700' prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
@@ -149,12 +169,46 @@ export function EoiGenerateDialog({
> >
{prerequisites[key] ? '✓' : '✗'} {prerequisites[key] ? '✓' : '✗'}
</span> </span>
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}> <span
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
>
{label} {label}
</span> </span>
</div> </div>
))} ))}
</div> </div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Optional (Section 3 left blank if absent)
</p>
{OPTIONAL_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3 text-sm">
<span
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
prerequisites[key]
? 'bg-green-100 text-green-700'
: 'bg-muted text-muted-foreground'
}`}
>
{prerequisites[key] ? '✓' : ''}
</span>
<span
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
>
{label}
</span>
</div>
))}
</div>
{!requiredMet ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Add the missing required details on the client&apos;s record before generating the
EOI.
</p>
) : null}
</div>
</div> </div>
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
@@ -163,7 +217,7 @@ export function EoiGenerateDialog({
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleGenerate} disabled={!allMet || isGenerating}> <Button onClick={handleGenerate} disabled={!requiredMet || isGenerating}>
{isGenerating ? 'Generating…' : 'Generate EOI'} {isGenerating ? 'Generating…' : 'Generate EOI'}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -0,0 +1,163 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Check, ChevronDown, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import {
PIPELINE_STAGES,
STAGE_BADGE,
STAGE_DOT,
STAGE_LABELS,
safeStage,
type PipelineStage,
} from '@/components/clients/pipeline-constants';
interface InlineStagePickerProps {
interestId: string;
currentStage: string;
/** Whether to render the chevron after the stage label. Default true. */
showChevron?: boolean;
/** Stop the parent's click propagation when used inside a clickable card. */
stopPropagation?: boolean;
className?: string;
}
/**
* Click-to-change stage chip. Replaces the modal-based InterestStagePicker
* for inline editing — user clicks the chip, picks a new stage from the
* popover (with optional reason), commits in one click. The popover stays
* compact: a small reason field above the stage list, and clicking any stage
* fires the mutation immediately.
*/
export function InlineStagePicker({
interestId,
currentStage,
showChevron = true,
stopPropagation = false,
className,
}: InlineStagePickerProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const [pendingStage, setPendingStage] = useState<string | null>(null);
const stage = safeStage(currentStage);
const mutation = useMutation({
mutationFn: async (next: PipelineStage) =>
apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH',
body: { pipelineStage: next, reason: reason.trim() || undefined },
}),
onSuccess: (_data, next) => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setOpen(false);
setReason('');
setPendingStage(null);
toast.success(`Stage moved to ${STAGE_LABELS[next]}`);
},
onError: (err) => {
setPendingStage(null);
toast.error(err instanceof Error ? err.message : 'Failed to change stage');
},
});
function pick(next: PipelineStage) {
if (next === stage) {
setOpen(false);
return;
}
setPendingStage(next);
mutation.mutate(next);
}
return (
<Popover
open={open}
onOpenChange={(o) => {
if (!mutation.isPending) setOpen(o);
}}
>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => {
if (stopPropagation) e.stopPropagation();
}}
className={cn(
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-medium',
'transition-colors hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
STAGE_BADGE[stage],
className,
)}
aria-label={`Pipeline stage: ${STAGE_LABELS[stage]}. Click to change.`}
>
<span>{STAGE_LABELS[stage]}</span>
{mutation.isPending ? (
<Loader2 className="size-3 animate-spin" />
) : showChevron ? (
<ChevronDown className="size-3 opacity-70" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-64 p-0"
onClick={(e) => stopPropagation && e.stopPropagation()}
>
<div className="border-b px-2 py-1">
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason (optional)…"
rows={1}
className="min-h-0 resize-none border-none bg-transparent px-0 py-0.5 text-xs leading-tight shadow-none focus-visible:ring-0"
disabled={mutation.isPending}
/>
</div>
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
{PIPELINE_STAGES.map((s) => {
const isCurrent = s === stage;
const isPending = pendingStage === s && mutation.isPending;
return (
<li key={s}>
<button
type="button"
role="option"
aria-selected={isCurrent}
disabled={mutation.isPending}
onClick={() => pick(s)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
'transition-colors hover:bg-muted/60 disabled:opacity-60',
isCurrent && 'font-medium',
)}
>
{/* Colored chip (mirrors the inline stage badge) — turns
the picker into a visual scan rather than just a list. */}
<span
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
aria-hidden
/>
<span className="flex-1">{STAGE_LABELS[s]}</span>
{isPending ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : isCurrent ? (
<Check className="size-3.5 text-muted-foreground" />
) : null}
</button>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { Anchor, Archive, Compass, MoreHorizontal, Pencil } from 'lucide-react'; import { Anchor, Archive, Compass, MessageSquare, MoreHorizontal, Pencil } from 'lucide-react';
import { formatDistanceToNowStrict } from 'date-fns';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -18,6 +19,7 @@ import {
} from '@/components/shared/list-card'; } from '@/components/shared/list-card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants'; import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants';
import { computeUrgencyBadges } from '@/components/interests/urgency';
import type { InterestRow } from './interest-columns'; import type { InterestRow } from './interest-columns';
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
@@ -48,9 +50,15 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null; const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null;
const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null; const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null;
const tags = interest.tags ?? []; const tags = interest.tags ?? [];
const notesCount = interest.notesCount ?? 0;
const urgencyBadges = computeUrgencyBadges(interest);
const clientName = interest.clientName ?? 'Unknown client'; const clientName = interest.clientName ?? 'Unknown client';
const berthLabel = interest.berthMooringNumber; const berthLabel = interest.berthMooringNumber;
const lastIso = interest.dateLastContact ?? interest.updatedAt ?? null;
const lastActivity = lastIso
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
: null;
return ( return (
<ListCard <ListCard
@@ -86,11 +94,22 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<ListCardAvatar initials={deriveInitials(clientName)} /> <ListCardAvatar initials={deriveInitials(clientName)} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{/* Title row: name + spacer for the absolutely-positioned actions menu */} {/* Title row: name + comment-icon when notes exist + spacer for actions */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground"> <h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{clientName} {clientName}
</h3> </h3>
{notesCount > 0 ? (
<span
title={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
aria-label={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
className="inline-flex shrink-0 items-center text-muted-foreground"
>
<MessageSquare className="size-3.5" />
</span>
) : null}
</div>
<span aria-hidden className="block h-9 w-9 shrink-0" /> <span aria-hidden className="block h-9 w-9 shrink-0" />
</div> </div>
@@ -135,6 +154,23 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
) : null} ) : null}
</div> </div>
{urgencyBadges.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{urgencyBadges.map((b) => (
<span
key={b.id}
title={b.detail}
className={cn(
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
b.className,
)}
>
{b.label}
</span>
))}
</div>
) : null}
{tags.length > 0 ? ( {tags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{tags.slice(0, 2).map((tag) => ( {tags.slice(0, 2).map((tag) => (
@@ -147,6 +183,12 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
) : null} ) : null}
</div> </div>
) : null} ) : null}
{lastActivity ? (
<p className="mt-1.5 text-[11px] text-muted-foreground tabular-nums">
Last activity {lastActivity}
</p>
) : null}
</div> </div>
</div> </div>
</ListCard> </ListCard>

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { format } from 'date-fns'; import { format, formatDistanceToNowStrict } from 'date-fns';
import { MoreHorizontal, Pencil, Archive } from 'lucide-react'; import { MoreHorizontal, Pencil, Archive, MessageSquare } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -15,6 +15,7 @@ import {
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 { stageBadgeClass, stageLabel } from '@/lib/constants'; import { stageBadgeClass, stageLabel } from '@/lib/constants';
import { computeUrgencyBadges, type InterestUrgencyInput } from '@/components/interests/urgency';
export interface InterestRow { export interface InterestRow {
id: string; id: string;
@@ -27,6 +28,15 @@ export interface InterestRow {
source: string | null; source: string | null;
archivedAt: string | null; archivedAt: string | null;
createdAt: string; createdAt: string;
/** Surfaced by listInterests for the row-level sales-triage signals
* (last-activity relative time, comment-icon, urgency badges). */
updatedAt?: string;
dateLastContact?: string | null;
dateEoiSent?: string | null;
dateDepositReceived?: string | null;
eoiStatus?: string | null;
outcome?: string | null;
notesCount?: number;
tags?: Array<{ id: string; name: string; color: string }>; tags?: Array<{ id: string; name: string; color: string }>;
} }
@@ -59,15 +69,29 @@ export function getInterestColumns({
id: 'clientName', id: 'clientName',
accessorKey: 'clientName', accessorKey: 'clientName',
header: 'Client', header: 'Client',
cell: ({ row }) => ( cell: ({ row }) => {
const notesCount = row.original.notesCount ?? 0;
return (
<div className="flex items-center gap-1.5 min-w-0">
<Link <Link
href={`/${portSlug}/clients/${row.original.clientId}`} href={`/${portSlug}/clients/${row.original.clientId}`}
className="font-medium text-primary hover:underline" className="truncate font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{row.original.clientName ?? '—'} {row.original.clientName ?? '—'}
</Link> </Link>
), {notesCount > 0 ? (
<span
title={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
aria-label={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
className="inline-flex items-center text-muted-foreground"
>
<MessageSquare className="size-3.5" />
</span>
) : null}
</div>
);
},
}, },
{ {
id: 'berthMooringNumber', id: 'berthMooringNumber',
@@ -92,14 +116,31 @@ export function getInterestColumns({
id: 'pipelineStage', id: 'pipelineStage',
accessorKey: 'pipelineStage', accessorKey: 'pipelineStage',
header: 'Stage', header: 'Stage',
cell: ({ getValue }) => { cell: ({ row }) => {
const stage = getValue() as string; const stage = row.original.pipelineStage;
const badges = computeUrgencyBadges(row.original satisfies InterestUrgencyInput);
return ( return (
<div className="flex flex-col gap-1 items-start">
<span <span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`} className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`}
> >
{stageLabel(stage)} {stageLabel(stage)}
</span> </span>
{badges.length > 0 ? (
<div className="flex flex-wrap gap-1">
{badges.map((b) => (
<span
key={b.id}
title={b.detail}
aria-label={b.detail}
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${b.className}`}
>
{b.label}
</span>
))}
</div>
) : null}
</div>
); );
}, },
}, },
@@ -153,14 +194,24 @@ export function getInterestColumns({
}, },
}, },
{ {
id: 'createdAt', // Sales-triage default: prefer the explicit dateLastContact, fall back
accessorKey: 'createdAt', // to updatedAt. Sortable on dateLastContact server-side; the column
header: 'Created', // header label ("Last activity") makes the fallback semantics clear.
cell: ({ getValue }) => ( id: 'dateLastContact',
<span className="text-muted-foreground text-sm"> accessorKey: 'dateLastContact',
{format(new Date(getValue() as string), 'MMM d, yyyy')} header: 'Last activity',
cell: ({ row }) => {
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
if (!lastIso) {
return <span className="text-muted-foreground text-sm"></span>;
}
const d = new Date(lastIso);
return (
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
{formatDistanceToNowStrict(d, { addSuffix: true })}
</span> </span>
), );
},
}, },
{ {
id: 'actions', id: 'actions',

View File

@@ -2,9 +2,21 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive, RotateCcw, Trophy, XCircle, RefreshCcw } from 'lucide-react'; import {
Pencil,
Archive,
RotateCcw,
Trophy,
XCircle,
RefreshCcw,
Mail,
MessageCircle,
Phone,
AlarmClock,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
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 { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
@@ -24,6 +36,20 @@ const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' }, cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
}; };
// Catch-all so an unknown outcome (e.g. a future `lost_no_berth` enum) still
// renders as a closed-state badge instead of falling back to the open-state
// stage picker. Lost-* gets a rose tint; everything else gets neutral slate.
function resolveOutcomeBadge(outcome: string | null | undefined) {
if (!outcome) return null;
const known = OUTCOME_BADGE[outcome];
if (known) return known;
const isLoss = outcome.startsWith('lost');
return {
label: outcome.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
className: isLoss ? 'bg-rose-100 text-rose-700' : 'bg-slate-200 text-slate-700',
};
}
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General', general_interest: 'General',
specific_qualified: 'Specific Qualified', specific_qualified: 'Specific Qualified',
@@ -36,6 +62,16 @@ interface InterestDetailHeaderProps {
id: string; id: string;
clientId: string; clientId: string;
clientName: string | null; clientName: string | null;
/** Primary contact channels resolved from the linked client. The header
* uses these to render Email / Call / WhatsApp buttons so the rep
* doesn't have to navigate to the client page just to reach out. */
clientPrimaryEmail?: string | null;
clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null;
/** Pending/snoozed reminders attached to this interest. Drives the
* alarm-bell badge on the header — surfaces follow-ups so the rep
* doesn't have to remember to check /reminders. */
activeReminderCount?: number;
berthId: string | null; berthId: string | null;
berthMooringNumber: string | null; berthMooringNumber: string | null;
pipelineStage: string; pipelineStage: string;
@@ -47,10 +83,20 @@ interface InterestDetailHeaderProps {
archivedAt: string | null; archivedAt: string | null;
outcome?: string | null; outcome?: string | null;
outcomeReason?: string | null; outcomeReason?: string | null;
dateLastContact?: string | null;
tags?: Array<{ id: string; name: string; color: string }>; tags?: Array<{ id: string; name: string; color: string }>;
}; };
} }
function formatLastContactAge(iso: string): string {
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 30) return `${days}d ago`;
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
return `${Math.floor(days / 365)}y ago`;
}
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) { export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
@@ -58,9 +104,19 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null); const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
const isArchived = !!interest.archivedAt; const isArchived = !!interest.archivedAt;
const outcomeBadge = interest.outcome ? OUTCOME_BADGE[interest.outcome] : null; const outcomeBadge = resolveOutcomeBadge(interest.outcome);
const isClosed = !!interest.outcome; const isClosed = !!interest.outcome;
// Contact deep-links — resolved from the linked client's primary channels.
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
// stripping non-digits from the display value when the canonical form is
// missing.
const whatsappNumber = interest.clientPrimaryPhoneE164
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
: interest.clientPrimaryPhone
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
: null;
const reopenMutation = useMutation({ const reopenMutation = useMutation({
mutationFn: () => mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }), apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
@@ -114,6 +170,16 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
node: <span className="capitalize">{interest.source}</span>, node: <span className="capitalize">{interest.source}</span>,
}); });
} }
if (interest.dateLastContact) {
meta.push({
key: 'last',
node: (
<span className="text-foreground/70">
Last contact {formatLastContactAge(interest.dateLastContact)}
</span>
),
});
}
return ( return (
<> <>
@@ -156,6 +222,17 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
/> />
</PermissionGate> </PermissionGate>
)} )}
{(interest.activeReminderCount ?? 0) > 0 ? (
<span
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800"
title={`${interest.activeReminderCount} pending reminder${
interest.activeReminderCount === 1 ? '' : 's'
}`}
>
<AlarmClock className="size-3" />
{interest.activeReminderCount}
</span>
) : null}
</div> </div>
{meta.length > 0 ? ( {meta.length > 0 ? (
@@ -180,25 +257,85 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
))} ))}
</div> </div>
)} )}
{/* Contact deep-links — let the rep email / call / WhatsApp the
client without leaving the interest workspace. Resolved from
the linked client's primary contact channels (server-side
fetch in getInterestById). */}
{interest.clientPrimaryEmail || interest.clientPrimaryPhone || whatsappNumber ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientPrimaryEmail ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`mailto:${interest.clientPrimaryEmail}`}
aria-label={`Email ${interest.clientPrimaryEmail}`}
>
<Mail />
Email
</a>
</Button>
) : null}
{interest.clientPrimaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`tel:${interest.clientPrimaryPhone}`}
aria-label={`Call ${interest.clientPrimaryPhone}`}
>
<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 on WhatsApp`}
>
<MessageCircle />
WhatsApp
</a>
</Button>
) : null}
</div>
) : null}
</div> </div>
{/* Top-right icon-only actions — no stacking, no labels eating room. */} {/* Top-right actions. Won/Lost are sales-critical and read as text
<div className="flex shrink-0 items-center gap-0.5"> buttons on desktop; Edit/Archive stay icon-only. On mobile,
Won/Lost shrink to icon buttons to keep the cluster from
wrapping. */}
<div className="flex shrink-0 items-center gap-1">
<PermissionGate resource="interests" action="change_stage"> <PermissionGate resource="interests" action="change_stage">
{isClosed ? ( {isClosed ? (
<button <button
type="button" type="button"
onClick={() => reopenMutation.mutate()} onClick={() => reopenMutation.mutate()}
disabled={reopenMutation.isPending} disabled={reopenMutation.isPending}
aria-label="Reopen interest"
title="Reopen interest"
className={cn( className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors', 'inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground transition-colors',
'hover:bg-foreground/5 hover:text-foreground', 'hover:bg-foreground/5 disabled:opacity-50',
'disabled:opacity-50',
)} )}
> >
<RefreshCcw className="size-4" /> <RefreshCcw className="size-3.5" />
Reopen
</button> </button>
) : ( ) : (
<> <>
@@ -206,25 +343,27 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
type="button" type="button"
onClick={() => setOutcomeDialog('won')} onClick={() => setOutcomeDialog('won')}
aria-label="Mark as won" aria-label="Mark as won"
title="Mark as won"
className={cn( className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors', 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
'hover:bg-emerald-50 hover:text-emerald-700', 'border border-emerald-200 bg-emerald-50 text-emerald-700',
'hover:bg-emerald-100',
)} )}
> >
<Trophy className="size-4" /> <Trophy className="size-3.5" />
<span className="hidden sm:inline">Mark won</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setOutcomeDialog('lost')} onClick={() => setOutcomeDialog('lost')}
aria-label="Close as lost" aria-label="Close as lost"
title="Close as lost"
className={cn( className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors', 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
'hover:bg-rose-50 hover:text-rose-700', 'border border-rose-200 text-rose-700',
'hover:bg-rose-50',
)} )}
> >
<XCircle className="size-4" /> <XCircle className="size-3.5" />
<span className="hidden sm:inline">Close as lost</span>
</button> </button>
</> </>
)} )}

View File

@@ -1,11 +1,13 @@
'use client'; 'use client';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { DetailLayout } from '@/components/shared/detail-layout'; import { DetailLayout } from '@/components/shared/detail-layout';
import { InterestDetailHeader } from '@/components/interests/interest-detail-header'; import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
import { getInterestTabs } from '@/components/interests/interest-tabs'; import { getInterestTabs } from '@/components/interests/interest-tabs';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -14,6 +16,29 @@ interface InterestData {
portId: string; portId: string;
clientId: string; clientId: string;
clientName: string | null; clientName: string | null;
/** Linked client's primary email (display value). Powers the header
* "Email" button and the EOI prereq checklist. */
clientPrimaryEmail: string | null;
/** Linked client's primary phone (display value). Powers the header
* "Call" button. */
clientPrimaryPhone: string | null;
/** Linked client's primary phone in E.164 form ("+1XXXXXXXXXX"). Used
* by wa.me to assemble the WhatsApp deep-link. */
clientPrimaryPhoneE164: string | null;
/** True when the linked client has any primary address row. Used by
* the EOI prereq checklist on the Documents tab. */
clientHasAddress: boolean;
/** Surfaced for the bell badge on the detail header (pending/snoozed
* reminders linked to this interest). */
activeReminderCount?: number;
/** Surfaced for the most-recent-note teaser on the Overview tab. */
notesCount?: number;
recentNote?: {
id: string;
content: string;
authorId: string;
createdAt: string;
} | null;
berthId: string | null; berthId: string | null;
berthMooringNumber: string | null; berthMooringNumber: string | null;
pipelineStage: string; pipelineStage: string;
@@ -37,6 +62,8 @@ interface InterestData {
archivedAt: string | null; archivedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
outcome?: string | null;
outcomeReason?: string | null;
tags: Array<{ id: string; name: string; color: string }>; tags: Array<{ id: string; name: string; color: string }>;
} }
@@ -52,9 +79,7 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
const { data, isLoading } = useQuery<InterestData>({ const { data, isLoading } = useQuery<InterestData>({
queryKey: ['interests', interestId], queryKey: ['interests', interestId],
queryFn: () => queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then( apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
(r) => r.data,
),
}); });
useRealtimeInvalidation({ useRealtimeInvalidation({
@@ -65,17 +90,18 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
'interest:berthUnlinked': [['interests', interestId]], 'interest:berthUnlinked': [['interests', interestId]],
}); });
const tabs = data const { setChrome } = useMobileChrome();
? getInterestTabs({ interestId, currentUserId, interest: data }) const titleForChrome: string | null = data?.clientName ?? null;
: []; useEffect(() => {
setChrome({ title: titleForChrome, showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
const tabs = data ? getInterestTabs({ interestId, currentUserId, interest: data }) : [];
return ( return (
<DetailLayout <DetailLayout
header={ header={data ? <InterestDetailHeader portSlug={portSlug} interest={data} /> : null}
data ? (
<InterestDetailHeader portSlug={portSlug} interest={data} />
) : null
}
tabs={tabs} tabs={tabs}
defaultTab="overview" defaultTab="overview"
isLoading={isLoading} isLoading={isLoading}

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { FileSignature } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DocumentList } from '@/components/documents/document-list'; import { DocumentList } from '@/components/documents/document-list';
@@ -17,20 +18,29 @@ interface InterestData {
yachtId?: string | null; yachtId?: string | null;
berthId?: string | null; berthId?: string | null;
clientName?: string | null; clientName?: string | null;
/** Surfaced by getInterestById for the EOI prerequisites checklist. */
clientPrimaryEmail?: string | null;
clientHasAddress?: boolean;
} }
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) { export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
const [eoiDialogOpen, setEoiDialogOpen] = useState(false); const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
const { data: interestRes } = useQuery({ // Same query key + queryFn shape as InterestDetail's parent query, so the
// cache is consistent. (Mismatched shapes on the same key clobber each other
// and the parent header degenerates to "Unknown Client".)
const { data: interest } = useQuery<InterestData>({
queryKey: ['interests', interestId], queryKey: ['interests', interestId],
queryFn: () => apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`), queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
}); });
const interest = interestRes?.data;
const prerequisites = { const prerequisites = {
// Required (EOI Section 2 — top paragraph): name, address, email.
hasName: Boolean(interest?.clientName), hasName: Boolean(interest?.clientName),
hasEmail: Boolean(interest?.clientPrimaryEmail),
hasAddress: Boolean(interest?.clientHasAddress),
// Optional (EOI Section 3): yacht + berth. Render blank when absent.
hasYacht: Boolean(interest?.yachtId), hasYacht: Boolean(interest?.yachtId),
hasBerth: Boolean(interest?.berthId), hasBerth: Boolean(interest?.berthId),
}; };
@@ -39,12 +49,30 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3> <h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
<Button size="sm" onClick={() => setEoiDialogOpen(true)}> <Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
Generate EOI Generate EOI
</Button> </Button>
</div> </div>
<DocumentList interestId={interestId} /> <DocumentList
interestId={interestId}
emptyState={
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-10 text-center">
<div className="flex size-10 items-center justify-center rounded-full bg-background text-muted-foreground">
<FileSignature className="size-5" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">No documents yet</p>
<p className="text-xs text-muted-foreground">
Generate the EOI to send it for signing in one click.
</p>
</div>
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
Generate EOI
</Button>
</div>
}
/>
<EoiGenerateDialog <EoiGenerateDialog
interestId={interestId} interestId={interestId}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { Plus, LayoutList, Kanban } from 'lucide-react'; import { Plus, LayoutList, Kanban, Archive } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -69,6 +69,18 @@ export function InterestList() {
}, },
}); });
const bulkArchiveMutation = useMutation({
mutationFn: async (ids: string[]) => {
// Concurrent fan-out — small batches in practice (page size cap = 100).
// If a single delete fails the others still run; the rejected one
// surfaces a toast via the standard apiFetch error path.
await Promise.all(ids.map((id) => apiFetch(`/api/v1/interests/${id}`, { method: 'DELETE' })));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests'] });
},
});
const columns = getInterestColumns({ const columns = getInterestColumns({
portSlug, portSlug,
onEdit: (interest) => setEditInterest(interest), onEdit: (interest) => setEditInterest(interest),
@@ -146,6 +158,24 @@ export function InterestList() {
onSortChange={setSort} onSortChange={setSort}
isLoading={isFetching && !isLoading} isLoading={isFetching && !isLoading}
getRowId={(row) => row.id} getRowId={(row) => row.id}
bulkActions={[
{
label: 'Archive',
icon: Archive,
variant: 'destructive',
onClick: (ids) => {
if (ids.length === 0) return;
if (
!window.confirm(
`Archive ${ids.length} interest${ids.length === 1 ? '' : 's'}? This can be undone from the archived list.`,
)
) {
return;
}
bulkArchiveMutation.mutate(ids);
},
},
]}
cardRender={(row) => ( cardRender={(row) => (
<InterestCard <InterestCard
interest={row.original} interest={row.original}
@@ -164,6 +194,20 @@ export function InterestList() {
/> />
)} )}
{/* Mobile FAB — primary "New interest" affordance for the bottom-tab UX.
Sits above the bottom nav (pb-safe-bottom + 70px tab height + 16px
gap). Hidden on lg+ where the header button already does the job. */}
<PermissionGate resource="interests" action="create">
<button
type="button"
onClick={() => setCreateOpen(true)}
aria-label="New interest"
className="fixed bottom-[calc(env(safe-area-inset-bottom)+86px)] right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 lg:hidden"
>
<Plus className="h-6 w-6" />
</button>
</PermissionGate>
<InterestForm open={createOpen} onOpenChange={setCreateOpen} /> <InterestForm open={createOpen} onOpenChange={setCreateOpen} />
{editInterest && ( {editInterest && (

View File

@@ -15,7 +15,7 @@ import { RecommendationList } from '@/components/interests/recommendation-list';
import { InterestTimeline } from '@/components/interests/interest-timeline'; import { InterestTimeline } from '@/components/interests/interest-timeline';
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab'; import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
import { InterestFilesTab } from '@/components/interests/interest-files-tab'; import { InterestFilesTab } from '@/components/interests/interest-files-tab';
import { LEAD_CATEGORIES } from '@/lib/constants'; import { LEAD_CATEGORIES, PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -26,10 +26,17 @@ const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
label: c.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()), label: c.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
})); }));
// Convert raw enum values like `waiting_for_signatures` → `Waiting For Signatures`.
function humanizeStatus(value: string | null): string | null {
if (!value) return null;
return value.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase());
}
interface InterestTabsOptions { interface InterestTabsOptions {
interestId: string; interestId: string;
currentUserId?: string; currentUserId?: string;
interest: { interest: {
pipelineStage: string;
leadCategory: string | null; leadCategory: string | null;
source: string | null; source: string | null;
eoiStatus: string | null; eoiStatus: string | null;
@@ -47,6 +54,15 @@ interface InterestTabsOptions {
reminderDays: number | null; reminderDays: number | null;
reminderLastFired: string | null; reminderLastFired: string | null;
notes: string | null; notes: string | null;
/** Surfaced by getInterestById for the Overview "most recent note"
* teaser — saves a click into the Notes tab to peek at the latest. */
notesCount?: number;
recentNote?: {
id: string;
content: string;
authorId: string;
createdAt: string;
} | null;
tags?: Array<{ id: string; name: string; color: string }>; tags?: Array<{ id: string; name: string; color: string }>;
}; };
} }
@@ -120,10 +136,23 @@ interface MilestoneSectionProps {
advanceStage?: string; advanceStage?: string;
/** Optional override for the action label. */ /** Optional override for the action label. */
actionLabel?: string; actionLabel?: string;
/** Suppress the inline "Mark as…" button for this step. Use when the
* parent supplies a richer CTA via `footer` (e.g. Deposit, where we
* want the invoice flow to be the primary path). */
hideAutoButton?: boolean;
}>; }>;
status: string | null; status: string | null;
onAdvance: (stage: string) => void; onAdvance: (stage: string) => void;
isPending: boolean; isPending: boolean;
/** Current pipelineStage. Used to mark steps as done when the pipeline has
* moved past their advanceStage even if the date stamp is missing — e.g.
* a seed-data interest that started already at eoi_signed will show both
* EOI sub-steps as done. Stage truth > date truth. */
currentStage: string;
/** When true, this milestone is the next one the user should act on:
* card gets a brand-accent ring and the next-step CTA becomes a primary
* button. Computed by the parent based on currentStage. */
isActive?: boolean;
/** Extra nodes (e.g. "Create deposit invoice" link) rendered below the steps. */ /** Extra nodes (e.g. "Create deposit invoice" link) rendered below the steps. */
footer?: React.ReactNode; footer?: React.ReactNode;
} }
@@ -143,27 +172,51 @@ function MilestoneSection({
status, status,
onAdvance, onAdvance,
isPending, isPending,
currentStage,
isActive,
footer, footer,
}: MilestoneSectionProps) { }: MilestoneSectionProps) {
const firstUnsetIdx = steps.findIndex((s) => !s.date); const currentStageIdx = PIPELINE_STAGES.indexOf(currentStage as PipelineStage);
// A step counts as done if either:
// (a) its `advanceStage` is at or behind the current pipeline stage, OR
// (b) it has an explicit date stamp (from a manual mark or webhook).
// (a) handles seeded/imported interests that arrived at a later stage
// without per-step dates.
const doneFlags = steps.map((step) => {
if (step.date) return true;
if (!step.advanceStage) return false;
const stepIdx = PIPELINE_STAGES.indexOf(step.advanceStage as PipelineStage);
return stepIdx !== -1 && currentStageIdx !== -1 && currentStageIdx >= stepIdx;
});
const firstUnsetIdx = doneFlags.findIndex((d) => !d);
return ( return (
<section className="rounded-xl border border-border bg-card p-4 shadow-sm"> <section
className={cn(
'rounded-xl border bg-card p-4 shadow-sm transition-colors',
isActive ? 'border-brand-300 bg-brand-50/40 ring-1 ring-brand-200' : 'border-border',
)}
>
<header className="mb-3 flex items-center justify-between gap-2"> <header className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="size-4 text-muted-foreground" /> <Icon className={cn('size-4', isActive ? 'text-brand-600' : 'text-muted-foreground')} />
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3> <h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
{isActive ? (
<span className="rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-700">
Next
</span>
) : null}
</div> </div>
{status ? ( {status ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{status.replace(/_/g, ' ')} {humanizeStatus(status)}
</span> </span>
) : null} ) : null}
</header> </header>
<ol className="space-y-2"> <ol className="space-y-2">
{steps.map((step, i) => { {steps.map((step, i) => {
const done = !!step.date; const done = doneFlags[i] ?? false;
const isNext = !done && i === firstUnsetIdx; const isNext = !done && i === firstUnsetIdx;
return ( return (
<li key={step.label} className="flex items-start gap-2 text-sm"> <li key={step.label} className="flex items-start gap-2 text-sm">
@@ -197,10 +250,10 @@ function MilestoneSection({
</span> </span>
) : null} ) : null}
</div> </div>
{isNext && step.advanceStage ? ( {isNext && step.advanceStage && !step.hideAutoButton ? (
<Button <Button
type="button" type="button"
variant="outline" variant={isActive ? 'default' : 'outline'}
size="sm" size="sm"
disabled={isPending} disabled={isPending}
onClick={() => onAdvance(step.advanceStage!)} onClick={() => onAdvance(step.advanceStage!)}
@@ -236,6 +289,23 @@ function OverviewTab({
const advance = (stage: string) => const advance = (stage: string) =>
stageMutation.mutate({ stage, reason: 'Marked from overview' }); stageMutation.mutate({ stage, reason: 'Marked from overview' });
// Which milestone is the next one to act on? "EOI Signed" → Deposit is next;
// "Deposit 10%" → Contract is next; "Contract Signed" / "Completed" → none.
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
let activeMilestone: 'eoi' | 'deposit' | 'contract' | null = null;
if (stageIdx === -1 || stageIdx >= contractSignedIdx) {
activeMilestone = null;
} else if (stageIdx < eoiSignedIdx) {
activeMilestone = 'eoi';
} else if (stageIdx < depositIdx) {
activeMilestone = 'deposit';
} else {
activeMilestone = 'contract';
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Sales-process milestones — the heart of the system. Each section is a {/* Sales-process milestones — the heart of the system. Each section is a
@@ -250,6 +320,8 @@ function OverviewTab({
status={interest.eoiStatus} status={interest.eoiStatus}
isPending={stageMutation.isPending} isPending={stageMutation.isPending}
onAdvance={advance} onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === 'eoi'}
steps={[ steps={[
{ {
label: 'EOI sent', label: 'EOI sent',
@@ -271,23 +343,36 @@ function OverviewTab({
status={interest.depositStatus} status={interest.depositStatus}
isPending={stageMutation.isPending} isPending={stageMutation.isPending}
onAdvance={advance} onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === 'deposit'}
steps={[ steps={[
{ {
label: 'Deposit received', label: 'Deposit received',
date: interest.dateDepositReceived, date: interest.dateDepositReceived,
advanceStage: 'deposit_10pct', advanceStage: 'deposit_10pct',
actionLabel: 'Mark deposit received', // The richer invoice-first CTA lives in `footer`. We still pass
// advanceStage so the milestone derives its done-state correctly.
hideAutoButton: true,
}, },
]} ]}
footer={ footer={
!interest.dateDepositReceived ? ( !interest.dateDepositReceived ? (
<Link <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`} <Button asChild size="sm" className="h-7 px-2.5 text-xs">
className="inline-flex items-center gap-1.5 text-foreground/80 hover:text-foreground" <Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
>
<Plus className="size-3.5" /> <Plus className="size-3.5" />
Create deposit invoice Create deposit invoice
</Link> </Link>
</Button>
<button
type="button"
onClick={() => advance('deposit_10pct')}
disabled={stageMutation.isPending}
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
>
Mark received manually
</button>
</div>
) : null ) : null
} }
/> />
@@ -297,6 +382,8 @@ function OverviewTab({
status={interest.contractStatus} status={interest.contractStatus}
isPending={stageMutation.isPending} isPending={stageMutation.isPending}
onAdvance={advance} onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === 'contract'}
steps={[ steps={[
{ {
label: 'Contract sent', label: 'Contract sent',
@@ -359,6 +446,37 @@ function OverviewTab({
</div> </div>
)} )}
{/* Most-recent threaded note teaser. Saves a click into the Notes
tab when the rep just wants to peek at "what was discussed last."
Hidden when there's nothing to show. */}
{interest.recentNote ? (
<div className="space-y-1 md:col-span-2">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium">Latest note</h3>
<Link
href={`/${portSlug}/interests/${interestId}?tab=notes`}
className="text-xs font-medium text-primary hover:underline"
>
View all
{interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}
</Link>
</div>
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
{interest.recentNote.content}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
addSuffix: true,
})}
{interest.recentNote.authorId
? ` · ${interest.recentNote.authorId === 'system' ? 'system' : interest.recentNote.authorId}`
: ''}
</p>
</div>
</div>
) : null}
{/* Notes (editable, multiline) */} {/* Notes (editable, multiline) */}
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3> <h3 className="text-sm font-medium mb-2">Notes</h3>

View File

@@ -23,6 +23,8 @@ interface TimelineEvent {
action: string; action: string;
description: string; description: string;
userId: string | null; userId: string | null;
/** Resolved display name (server-side join). Falls back to userId when null. */
userName?: string | null;
createdAt: string; createdAt: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
} }
@@ -56,10 +58,14 @@ function eventIcon(event: TimelineEvent) {
return <Pencil className="h-4 w-4 text-muted-foreground" />; return <Pencil className="h-4 w-4 text-muted-foreground" />;
} }
function actorLabel(userId: string | null): string | null { function actorLabel(event: TimelineEvent): string | null {
if (!userId) return null; if (event.userName) return event.userName;
if (userId === 'system') return 'system'; if (!event.userId) return null;
return userId; if (event.userId === 'system') return 'system';
// Last-resort fallback when the user row was deleted: show a short token
// instead of a 36-char UUID. The server-side join is authoritative; this
// path should be rare in practice.
return 'a teammate';
} }
export function InterestTimeline({ interestId }: InterestTimelineProps) { export function InterestTimeline({ interestId }: InterestTimelineProps) {
@@ -100,7 +106,7 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" /> <div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
{events.map((event) => { {events.map((event) => {
const actor = actorLabel(event.userId); const actor = actorLabel(event);
const isAuto = event.userId === 'system'; const isAuto = event.userId === 'system';
return ( return (
<div key={event.id} className="relative flex gap-4 pb-6"> <div key={event.id} className="relative flex gap-4 pb-6">

View File

@@ -0,0 +1,91 @@
/**
* Sales-triage urgency badges for interest list rows + cards.
*
* Derived purely from the dates we already return on the row, so this is a
* pure function — no DB hits, no extra fetch. Mirrors the logic the
* server-side alert-rules engine uses, but for at-a-glance rendering on
* the list itself.
*/
const SILENT_DAYS_THRESHOLD = 7;
const EOI_AWAITING_DAYS_THRESHOLD = 14;
const DEPOSIT_PENDING_DAYS_THRESHOLD = 21;
const ACTIVE_MID_FUNNEL_STAGES = new Set(['details_sent', 'in_communication']);
export interface InterestUrgencyInput {
pipelineStage: string;
outcome?: string | null;
archivedAt?: string | null;
dateLastContact?: string | null;
updatedAt?: string;
dateEoiSent?: string | null;
eoiStatus?: string | null;
dateDepositReceived?: string | null;
}
export interface UrgencyBadge {
/** Stable id for keying. */
id: 'silent' | 'eoi_awaiting' | 'deposit_pending';
label: string;
/** Long form for tooltip / aria-label. */
detail: string;
/** Tailwind classes for the pill. */
className: string;
}
function daysSince(iso: string | null | undefined): number | null {
if (!iso) return null;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return null;
return Math.floor((Date.now() - t) / 86_400_000);
}
export function computeUrgencyBadges(row: InterestUrgencyInput): UrgencyBadge[] {
// Closed / archived interests don't need triage signals.
if (row.archivedAt || row.outcome) return [];
const badges: UrgencyBadge[] = [];
// Silent in mid-funnel stages — most actionable.
if (ACTIVE_MID_FUNNEL_STAGES.has(row.pipelineStage)) {
const lastTouchIso = row.dateLastContact ?? row.updatedAt ?? null;
const days = daysSince(lastTouchIso);
if (days !== null && days >= SILENT_DAYS_THRESHOLD) {
badges.push({
id: 'silent',
label: `Silent ${days}d`,
detail: `No contact in ${days} days`,
className: 'bg-amber-100 text-amber-800 border border-amber-200',
});
}
}
// EOI sent but not signed for too long.
if (row.eoiStatus === 'waiting_for_signatures') {
const days = daysSince(row.dateEoiSent);
if (days !== null && days >= EOI_AWAITING_DAYS_THRESHOLD) {
badges.push({
id: 'eoi_awaiting',
label: `EOI ${days}d`,
detail: `EOI awaiting signature for ${days} days`,
className: 'bg-orange-100 text-orange-800 border border-orange-200',
});
}
}
// EOI signed but deposit not received.
if (row.pipelineStage === 'eoi_signed' && !row.dateDepositReceived && row.dateEoiSent) {
const days = daysSince(row.dateEoiSent);
if (days !== null && days >= DEPOSIT_PENDING_DAYS_THRESHOLD) {
badges.push({
id: 'deposit_pending',
label: `Deposit ${days}d`,
detail: `Awaiting deposit for ${days} days since EOI sent`,
className: 'bg-rose-100 text-rose-800 border border-rose-200',
});
}
}
return badges;
}

View File

@@ -6,12 +6,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Send, CreditCard } from 'lucide-react'; import { Loader2, Send, CreditCard } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { InvoicePdfPreview } from './invoice-pdf-preview'; import { InvoicePdfPreview } from './invoice-pdf-preview';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -26,6 +34,40 @@ const STATUS_COLORS: Record<string, string> = {
cancelled: 'bg-gray-100 text-gray-500 border-gray-200', cancelled: 'bg-gray-100 text-gray-500 border-gray-200',
}; };
// Display labels for snake_case enum values stored in the DB.
const PAYMENT_METHOD_LABELS: Record<string, string> = {
bank_transfer: 'Bank transfer',
credit_card: 'Credit card',
cash: 'Cash',
cheque: 'Cheque',
check: 'Check',
wire: 'Wire',
other: 'Other',
};
const PAYMENT_METHOD_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'bank_transfer', label: 'Bank transfer' },
{ value: 'credit_card', label: 'Credit card' },
{ value: 'cash', label: 'Cash' },
{ value: 'cheque', label: 'Cheque' },
{ value: 'wire', label: 'Wire' },
{ value: 'other', label: 'Other' },
];
function formatPaymentMethod(method: string | null | undefined): string {
if (!method) return '—';
return PAYMENT_METHOD_LABELS[method] ?? method.replace(/_/g, ' ');
}
function formatDateOnly(value: string | null | undefined): string {
if (!value) return '—';
// Stored values are typically YYYY-MM-DD or ISO. Treat as date-only to avoid TZ shift.
const isoDate = value.length === 10 ? value + 'T00:00:00' : value;
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return value;
return format(d, 'MMM d, yyyy');
}
interface InvoiceDetailProps { interface InvoiceDetailProps {
invoiceId: string; invoiceId: string;
} }
@@ -155,7 +197,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<CardTitle className="text-sm font-medium">Due Date</CardTitle> <CardTitle className="text-sm font-medium">Due Date</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm">{invoice.dueDate}</p> <p className="text-sm">{formatDateOnly(invoice.dueDate)}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -291,11 +333,11 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<span className="text-muted-foreground">Payment Date</span> <span className="text-muted-foreground">Payment Date</span>
<p className="mt-0.5">{invoice.paymentDate ?? '—'}</p> <p className="mt-0.5">{formatDateOnly(invoice.paymentDate)}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Method</span> <span className="text-muted-foreground">Method</span>
<p className="mt-0.5 capitalize">{invoice.paymentMethod ?? '—'}</p> <p className="mt-0.5">{formatPaymentMethod(invoice.paymentMethod)}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Reference</span> <span className="text-muted-foreground">Reference</span>
@@ -325,11 +367,23 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="paymentMethod">Payment Method</Label> <Label htmlFor="paymentMethod">Payment Method</Label>
<Input <Select
id="paymentMethod" value={paymentForm.watch('paymentMethod') ?? ''}
placeholder="e.g. bank_transfer, credit_card" onValueChange={(v) =>
{...paymentForm.register('paymentMethod')} paymentForm.setValue('paymentMethod', v, { shouldValidate: true })
/> }
>
<SelectTrigger id="paymentMethod">
<SelectValue placeholder="Select a method" />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHOD_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="paymentReference">Reference / Transaction ID</Label> <Label htmlFor="paymentReference">Reference / Transaction ID</Label>

View File

@@ -0,0 +1,54 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { PageHeader } from '@/components/shared/page-header';
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { apiFetch } from '@/lib/api/client';
interface ReservationsApiResponse {
data: ReservationRow[];
pagination: { total: number; page: number; pageSize: number };
}
export function BerthReservationsList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<ReservationsApiResponse>({
queryKey: ['berth-reservations', 'list'],
queryFn: () => apiFetch('/api/v1/berth-reservations?page=1&limit=100&order=desc'),
});
return (
<div className="flex flex-col gap-6">
<PageHeader
eyebrow="Marina"
title="Berth Reservations"
description="All reservations across all berths"
actions={
<Link
href={`/${portSlug}/berths`}
className="text-sm text-muted-foreground hover:text-foreground"
>
View berths
</Link>
}
/>
{isLoading ? (
<TableSkeleton />
) : (
<ReservationList
reservations={data?.data ?? []}
showBerth
portSlug={portSlug}
emptyMessage="No reservations found."
/>
)}
</div>
);
}

View File

@@ -1,17 +1,28 @@
'use client'; 'use client';
import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import type { Route } from 'next'; import type { Route } from 'next';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Bell, Download, FileSignature, Mail } from 'lucide-react'; import { ArrowLeft, Bell, Download, FileSignature, Mail, StopCircle } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
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 { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { ClientLink, YachtLink, BerthLink } from '@/components/reservations/reservation-list';
interface ReservationDoc { interface ReservationDoc {
id: string; id: string;
@@ -42,12 +53,77 @@ const RESERVATION_PILL: Record<string, StatusPillStatus> = {
cancelled: 'cancelled', cancelled: 'cancelled',
}; };
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
interface EndReservationDialogProps {
reservationId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function EndReservationDialog({ reservationId, open, onOpenChange }: EndReservationDialogProps) {
const qc = useQueryClient();
const [endDate, setEndDate] = useState(todayIso);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
try {
await apiFetch(`/api/v1/berth-reservations/${reservationId}`, {
method: 'PATCH',
body: { action: 'end', endDate },
});
qc.invalidateQueries({ queryKey: ['reservation', reservationId] });
toast.success('Reservation ended');
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to end reservation');
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>End reservation</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="end-date">End date</Label>
<Input
id="end-date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" variant="destructive" disabled={submitting}>
{submitting ? 'Ending…' : 'End reservation'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
interface ReservationDetailProps { interface ReservationDetailProps {
reservationId: string; reservationId: string;
portSlug: string; portSlug: string;
} }
export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) { export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) {
const [endDialogOpen, setEndDialogOpen] = useState(false);
const reservation = useQuery<{ data: ReservationData }>({ const reservation = useQuery<{ data: ReservationData }>({
queryKey: ['reservation', reservationId], queryKey: ['reservation', reservationId],
queryFn: () => apiFetch(`/api/v1/berth-reservations/${reservationId}`), queryFn: () => apiFetch(`/api/v1/berth-reservations/${reservationId}`),
@@ -215,11 +291,19 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
</> </>
} }
actions={ actions={
<div className="flex items-center gap-2">
{res.status === 'active' && (
<Button variant="outline" size="sm" onClick={() => setEndDialogOpen(true)}>
<StopCircle className="mr-1.5 h-4 w-4" />
End reservation
</Button>
)}
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/${portSlug}/berths`}> <Link href={`/${portSlug}/berths`}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths <ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths
</Link> </Link>
</Button> </Button>
</div>
} }
variant="gradient" variant="gradient"
/> />
@@ -233,35 +317,20 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
<dl className="grid grid-cols-2 gap-3 text-sm"> <dl className="grid grid-cols-2 gap-3 text-sm">
<div> <div>
<dt className="text-xs text-muted-foreground">Berth</dt> <dt className="text-xs text-muted-foreground">Berth</dt>
<dd> <dd className="font-medium">
<Link <BerthLink berthId={res.berthId} portSlug={portSlug} />
href={`/${portSlug}/berths/${res.berthId}` as Route}
className="font-medium text-brand hover:underline"
>
{res.berthId.slice(0, 8)}
</Link>
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-xs text-muted-foreground">Yacht</dt> <dt className="text-xs text-muted-foreground">Yacht</dt>
<dd> <dd className="font-medium">
<Link <YachtLink yachtId={res.yachtId} portSlug={portSlug} />
href={`/${portSlug}/yachts/${res.yachtId}` as Route}
className="font-medium text-brand hover:underline"
>
{res.yachtId.slice(0, 8)}
</Link>
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-xs text-muted-foreground">Client</dt> <dt className="text-xs text-muted-foreground">Client</dt>
<dd> <dd className="font-medium">
<Link <ClientLink clientId={res.clientId} portSlug={portSlug} />
href={`/${portSlug}/clients/${res.clientId}` as Route}
className="font-medium text-brand hover:underline"
>
{res.clientId.slice(0, 8)}
</Link>
</dd> </dd>
</div> </div>
<div> <div>
@@ -287,6 +356,12 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
</section> </section>
</div> </div>
</div> </div>
<EndReservationDialog
reservationId={reservationId}
open={endDialogOpen}
onOpenChange={setEndDialogOpen}
/>
</div> </div>
); );
} }

View File

@@ -41,7 +41,7 @@ export interface ReservationListProps {
* Renders a client's name as a link by fetching the client record. * Renders a client's name as a link by fetching the client record.
* Uses TanStack Query cache for memoization of repeated clientId queries. * Uses TanStack Query cache for memoization of repeated clientId queries.
*/ */
function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) { export function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
const { data } = useQuery<{ fullName: string }>({ const { data } = useQuery<{ fullName: string }>({
queryKey: ['clients', clientId, 'name-only'], queryKey: ['clients', clientId, 'name-only'],
queryFn: () => queryFn: () =>
@@ -62,7 +62,7 @@ function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string
/** /**
* Renders a yacht's name as a link by fetching the yacht record. * Renders a yacht's name as a link by fetching the yacht record.
*/ */
function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) { export function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
const { data } = useQuery<{ name: string }>({ const { data } = useQuery<{ name: string }>({
queryKey: ['yachts', yachtId, 'name-only'], queryKey: ['yachts', yachtId, 'name-only'],
queryFn: () => queryFn: () =>
@@ -83,7 +83,7 @@ function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string })
/** /**
* Renders a berth's mooring number as a link by fetching the berth record. * Renders a berth's mooring number as a link by fetching the berth record.
*/ */
function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) { export function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
const { data } = useQuery<{ mooringNumber: string }>({ const { data } = useQuery<{ mooringNumber: string }>({
queryKey: ['berths', berthId, 'name-only'], queryKey: ['berths', berthId, 'name-only'],
queryFn: () => queryFn: () =>

View File

@@ -10,7 +10,7 @@ export function DetailHeaderStrip({ children, className }: DetailHeaderStripProp
return ( return (
<div <div
className={cn( className={cn(
'rounded-xl border border-border bg-gradient-brand-soft px-5 py-4 shadow-xs', 'rounded-xl border border-border bg-gradient-brand-soft px-5 py-3 shadow-xs',
className, className,
)} )}
> >

View File

@@ -262,7 +262,9 @@ function ReadButton({
{!disabled && ( {!disabled && (
<Pencil <Pencil
className={cn( className={cn(
'h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50', // Show the pencil faintly at rest so users discover the field is
// editable without having to hover-and-test every label.
'h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60',
multiline && 'mt-1 shrink-0', multiline && 'mt-1 shrink-0',
)} )}
/> />

View File

@@ -0,0 +1,84 @@
'use client';
import { useEffect } from 'react';
import { toast } from 'sonner';
import { useSocket } from '@/providers/socket-provider';
import { stageLabel } from '@/lib/constants';
/**
* App-wide subscriber that surfaces high-signal sales events as sonner
* toasts. Mounted once inside SocketProvider so reps see "EOI signed",
* "Deposit recorded", "Stage advanced" without having to refresh.
*
* Render-only — no children. Intentionally narrow in scope: only toast on
* events that are noteworthy *to a user staring at any page*. Per-page
* cache invalidations stay in `useRealtimeInvalidation`.
*/
export function RealtimeToasts() {
const socket = useSocket();
useEffect(() => {
if (!socket) return;
function onStageChanged(payload: {
newStage?: string;
oldStage?: string | null;
clientName?: string;
}) {
if (!payload?.newStage) return;
const who = payload.clientName?.trim() || 'an interest';
toast.success(`${who}${stageLabel(payload.newStage)}`, {
description: payload.oldStage
? `Advanced from ${stageLabel(payload.oldStage)}.`
: 'Pipeline stage advanced.',
});
}
function onDocumentCompleted(payload: { type?: string }) {
// Kick a generic "fully signed" — the type-specific message is
// friendlier when we can identify it as an EOI.
if (payload?.type === 'eoi') {
toast.success('EOI fully signed', {
description: 'All required signatures received.',
});
} else {
toast.success('Document fully signed');
}
}
function onSignerSigned(payload: { signerName?: string; documentTitle?: string }) {
const who = payload?.signerName?.trim();
const title = payload?.documentTitle?.trim();
if (who && title) {
toast.message(`${who} signed "${title}"`);
} else if (who) {
toast.message(`${who} signed a document`);
} else {
toast.message('Signer added a signature');
}
}
function onOutcomeSet(payload: { outcome?: string }) {
if (!payload?.outcome) return;
const isWon = payload.outcome === 'won';
const label = payload.outcome.replace(/_/g, ' ');
const fn = isWon ? toast.success : toast.message;
fn(`Interest closed — ${label}`);
}
socket.on('interest:stageChanged', onStageChanged);
socket.on('document:completed', onDocumentCompleted);
socket.on('document:signer:signed', onSignerSigned);
socket.on('interest:outcomeSet', onOutcomeSet);
return () => {
socket.off('interest:stageChanged', onStageChanged);
socket.off('document:completed', onDocumentCompleted);
socket.off('document:signer:signed', onSignerSigned);
socket.off('interest:outcomeSet', onOutcomeSet);
};
}, [socket]);
return null;
}

View File

@@ -38,7 +38,15 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-0 z-50 grid w-full gap-4 border-0 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:left-[50%] sm:top-[50%] sm:inset-auto sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%]', // Mobile: full-screen sheet anchored to all four sides via individual
// top/right/bottom/left utilities. Desktop (sm+): override each side
// individually so tailwind-merge doesn't collapse our centering classes.
// (Don't use `inset-0` + `sm:inset-auto` here — twMerge sees that as a
// conflict and silently strips `sm:left-[50%]` / `sm:top-[50%]`.)
'fixed top-0 right-0 bottom-0 left-0 z-50 grid w-full gap-4 border-0 bg-background p-6 shadow-lg duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg',
'sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%]',
className, className,
)} )}
{...props} {...props}

View File

@@ -1,12 +1,13 @@
'use client'; 'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import type { DetailTab } from '@/components/shared/detail-layout'; import type { DetailTab } from '@/components/shared/detail-layout';
import { EmptyState } from '@/components/shared/empty-state';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
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 { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history'; import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -206,6 +207,70 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
); );
} }
function YachtInterestsTab({ yachtId }: { yachtId: string }) {
const { data, isLoading } = useQuery<{
data: Array<{
id: string;
pipelineStage: string;
clientName: string | null;
berthMooringNumber: string | null;
updatedAt: string;
}>;
}>({
queryKey: ['interests', 'by-yacht', yachtId],
queryFn: () => apiFetch(`/api/v1/interests?yachtId=${yachtId}&limit=50&order=desc`),
});
const interests = data?.data ?? [];
if (isLoading) return <p className="text-sm text-muted-foreground">Loading</p>;
if (interests.length === 0) {
return <p className="text-sm text-muted-foreground">No interests linked to this yacht.</p>;
}
return (
<ul className="space-y-2">
{interests.map((i) => (
<li
key={i.id}
className="flex items-center gap-3 rounded-md border bg-muted/30 p-3 text-sm"
>
<span className="w-36 shrink-0 text-xs font-medium uppercase text-muted-foreground">
{i.pipelineStage.replace(/_/g, ' ')}
</span>
<span className="flex-1 truncate">{i.clientName ?? '—'}</span>
{i.berthMooringNumber && (
<span className="shrink-0 text-xs text-muted-foreground">
Berth {i.berthMooringNumber}
</span>
)}
</li>
))}
</ul>
);
}
function YachtReservationsTab({ yachtId }: { yachtId: string }) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const { data, isLoading } = useQuery<{ data: ReservationRow[] }>({
queryKey: ['berth-reservations', 'by-yacht', yachtId],
queryFn: () => apiFetch(`/api/v1/berth-reservations?yachtId=${yachtId}&limit=50&order=desc`),
});
if (isLoading) return <p className="text-sm text-muted-foreground">Loading</p>;
return (
<ReservationList
reservations={data?.data ?? []}
showBerth
portSlug={portSlug}
emptyMessage="No reservations for this yacht."
/>
);
}
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] { export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
return [ return [
{ {
@@ -221,12 +286,12 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions
{ {
id: 'interests', id: 'interests',
label: 'Interests', label: 'Interests',
content: <EmptyState title="Interests" description="Coming soon" />, content: <YachtInterestsTab yachtId={yachtId} />,
}, },
{ {
id: 'reservations', id: 'reservations',
label: 'Reservations', label: 'Reservations',
content: <EmptyState title="Reservations" description="Coming soon" />, content: <YachtReservationsTab yachtId={yachtId} />,
}, },
{ {
id: 'notes', id: 'notes',

View File

@@ -0,0 +1,52 @@
import type { QueryClient, QueryKey } from '@tanstack/react-query';
/** Minimum surface of socket.io's client we use here. Kept loose so the
* helper can be unit-tested with a stub object without dragging the full
* socket.io dependency into the test runtime. */
export interface SocketLike {
on(event: string, handler: (...args: unknown[]) => void): unknown;
off(event: string, handler: (...args: unknown[]) => void): unknown;
}
export type EventMap = Record<string, QueryKey[]>;
/**
* Pure subscription logic for `useRealtimeInvalidation`. Registers one
* handler per event key. Each handler reads the latest eventMap from the
* supplied getter so callers can pass a fresh object literal on every render
* without re-subscribing.
*
* Returns a cleanup function that removes the registered handlers.
*
* Lives in its own JSX-free file so it can be unit-tested under vitest's
* node environment without dragging the React provider into the bundle.
*/
export function subscribeRealtimeInvalidations(
socket: SocketLike,
eventKeys: string[],
queryClient: Pick<QueryClient, 'invalidateQueries'>,
getEventMap: () => EventMap,
): () => void {
const handlers: Array<{ event: string; handler: (...args: unknown[]) => void }> = [];
for (const event of eventKeys) {
const handler = () => {
// Read the LATEST map at fire-time — not at subscription time — so
// callers passing inline `{ 'client:created': [...] }` literals don't
// bind a stale snapshot if they re-render.
const queryKeys = getEventMap()[event];
if (!queryKeys) return;
for (const key of queryKeys) {
queryClient.invalidateQueries({ queryKey: key });
}
};
socket.on(event, handler);
handlers.push({ event, handler });
}
return () => {
for (const { event, handler } of handlers) {
socket.off(event, handler);
}
};
}

View File

@@ -1,14 +1,20 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { useQueryClient, type QueryKey } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useSocket } from '@/providers/socket-provider'; import { useSocket } from '@/providers/socket-provider';
import { subscribeRealtimeInvalidations, type EventMap } from '@/hooks/realtime-invalidation-core';
// Re-export for convenience so callers don't need to know about the split.
export type { EventMap, SocketLike } from '@/hooks/realtime-invalidation-core';
/** /**
* Subscribes to socket events and invalidates React Query caches. * Subscribes to socket events and invalidates React Query caches.
* *
* @param eventMap - Maps socket event names to arrays of query keys to invalidate. * Safe to call with an inline-literal `eventMap` — the hook only re-subscribes
* when the SET of event keys actually changes (not when the object identity
* changes). The latest query-key list is read at event fire-time via a ref.
* *
* @example * @example
* useRealtimeInvalidation({ * useRealtimeInvalidation({
@@ -17,31 +23,29 @@ import { useSocket } from '@/providers/socket-provider';
* 'client:archived': [['clients']], * 'client:archived': [['clients']],
* }); * });
*/ */
export function useRealtimeInvalidation( export function useRealtimeInvalidation(eventMap: EventMap) {
eventMap: Record<string, QueryKey[]>,
) {
const socket = useSocket(); const socket = useSocket();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Stash the latest map in a ref so handlers always see fresh queryKeys
// without re-subscribing.
const eventMapRef = useRef(eventMap);
eventMapRef.current = eventMap;
// Re-subscribe ONLY when the set of event names changes. Object identity
// of `eventMap` flips on every caller render; the joined key signature
// doesn't.
const eventKeysSig = Object.keys(eventMap).sort().join('|');
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
// eventMapRef is intentionally not in deps — it's a ref; we only want to
const handlers: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; // re-run when the socket, queryClient, or the event-key SET changes.
return subscribeRealtimeInvalidations(
for (const [event, queryKeys] of Object.entries(eventMap)) { socket,
const handler = () => { eventKeysSig.length > 0 ? eventKeysSig.split('|') : [],
for (const key of queryKeys) { queryClient,
queryClient.invalidateQueries({ queryKey: key }); () => eventMapRef.current,
} );
}; }, [socket, queryClient, eventKeysSig]);
socket.on(event, handler);
handlers.push({ event, handler });
}
return () => {
for (const { event, handler } of handlers) {
socket.off(event, handler);
}
};
}, [socket, queryClient, eventMap]);
} }

View File

@@ -181,10 +181,15 @@ export function withAuth(
} }
} else if (profile.isSuperAdmin && portId) { } else if (profile.isSuperAdmin && portId) {
// Super admin still needs portSlug for response context. // Super admin still needs portSlug for response context.
// We also validate the portId actually exists — a super-admin session
// must not be able to operate against a fabricated portId.
const port = await db.query.ports.findFirst({ const port = await db.query.ports.findFirst({
where: eq(ports.id, portId), where: eq(ports.id, portId),
}); });
portSlug = port?.slug ?? ''; if (!port) {
return NextResponse.json({ error: 'Port not found' }, { status: 404 });
}
portSlug = port.slug;
} }
const ctx: AuthContext = { const ctx: AuthContext = {

View File

@@ -26,6 +26,19 @@ export const STAGE_LABELS: Record<PipelineStage, string> = {
completed: 'Completed', completed: 'Completed',
}; };
// Compact labels for cramped contexts (mobile chart axes, dense tables).
export const STAGE_SHORT_LABELS: Record<PipelineStage, string> = {
open: 'Open',
details_sent: 'Details',
in_communication: 'Comms',
eoi_sent: 'EOI →',
eoi_signed: 'EOI ✓',
deposit_10pct: 'Dep.',
contract_sent: 'Ctr →',
contract_signed: 'Ctr ✓',
completed: 'Done',
};
export const STAGE_BADGE: Record<PipelineStage, string> = { export const STAGE_BADGE: Record<PipelineStage, string> = {
open: 'bg-slate-100 text-slate-700', open: 'bg-slate-100 text-slate-700',
details_sent: 'bg-blue-100 text-blue-700', details_sent: 'bg-blue-100 text-blue-700',
@@ -110,6 +123,49 @@ export const BERTH_STATUSES = ['available', 'under_offer', 'sold'] as const;
export type BerthStatus = (typeof BERTH_STATUSES)[number]; export type BerthStatus = (typeof BERTH_STATUSES)[number];
// ─── Berth single-select catalogues (mirror NocoDB) ──────────────────────────
// Stored as free text in the DB so legacy values still load, but the form
// presents only the canonical options below.
export const BERTH_AREAS = ['A', 'B', 'C', 'D', 'E'] as const;
export const BERTH_SIDE_PONTOON_OPTIONS = [
'No',
'Quay SB',
'Quay PT',
'Quay SB, Yes PT',
'Quay PT, Yes SB',
'Yes SB',
'Yes PT',
'Yes SB, PT',
'Finger SB',
'Finger PT',
] as const;
export const BERTH_MOORING_TYPES = [
'Side Pier / Med Mooring',
'2x Med Mooring',
'Side Pier / Finger',
'Finger / Med Mooring',
'2x Finger',
] as const;
export const BERTH_CLEAT_TYPES = ['A3', 'A5'] as const;
export const BERTH_CLEAT_CAPACITIES = ['10-14 ton break load', '20-24 ton break load'] as const;
export const BERTH_BOLLARD_TYPES = ['Bull bollard type A', 'Bull bollard type B'] as const;
export const BERTH_BOLLARD_CAPACITIES = ['20 ton break load', '40 ton break load'] as const;
export const BERTH_ACCESS_OPTIONS = [
'Car to Vessel',
'Car to Quai, Cart to Vessel',
'Cart to Vessel',
'Car (3t) to Vessel',
'Car (3.5t) to Vessel',
] as const;
// ─── Lead Categories ───────────────────────────────────────────────────────── // ─── Lead Categories ─────────────────────────────────────────────────────────
export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const; export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;

View File

@@ -0,0 +1,15 @@
-- Convert text columns to numeric. NULLs survive; empty strings become NULL;
-- whitespace is trimmed before casting so legacy data with stray spaces converts cleanly.
ALTER TABLE "berths"
ALTER COLUMN "nominal_boat_size" SET DATA TYPE numeric
USING NULLIF(TRIM("nominal_boat_size"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths"
ALTER COLUMN "nominal_boat_size_m" SET DATA TYPE numeric
USING NULLIF(TRIM("nominal_boat_size_m"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths"
ALTER COLUMN "power_capacity" SET DATA TYPE numeric
USING NULLIF(TRIM("power_capacity"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths"
ALTER COLUMN "voltage" SET DATA TYPE numeric
USING NULLIF(TRIM("voltage"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths" ADD COLUMN "status_override_mode" text;

File diff suppressed because it is too large Load Diff

View File

@@ -141,6 +141,13 @@
"when": 1777671562738, "when": 1777671562738,
"tag": "0019_lazy_vampiro", "tag": "0019_lazy_vampiro",
"breakpoints": true "breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1777814682110,
"tag": "0020_medical_betty_brant",
"breakpoints": true
} }
] ]
} }

View File

@@ -33,14 +33,15 @@ export const berths = pgTable(
widthM: numeric('width_m'), widthM: numeric('width_m'),
draftM: numeric('draft_m'), draftM: numeric('draft_m'),
widthIsMinimum: boolean('width_is_minimum').default(false), widthIsMinimum: boolean('width_is_minimum').default(false),
nominalBoatSize: text('nominal_boat_size'), // Numeric: ft (legacy NocoDB stored as plain numbers, no units in value).
nominalBoatSizeM: text('nominal_boat_size_m'), nominalBoatSize: numeric('nominal_boat_size'),
nominalBoatSizeM: numeric('nominal_boat_size_m'),
waterDepth: numeric('water_depth'), waterDepth: numeric('water_depth'),
waterDepthM: numeric('water_depth_m'), waterDepthM: numeric('water_depth_m'),
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false), waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
sidePontoon: text('side_pontoon'), sidePontoon: text('side_pontoon'),
powerCapacity: text('power_capacity'), powerCapacity: numeric('power_capacity'), // kW
voltage: text('voltage'), voltage: numeric('voltage'), // V at 60Hz
mooringType: text('mooring_type'), mooringType: text('mooring_type'),
cleatType: text('cleat_type'), cleatType: text('cleat_type'),
cleatCapacity: text('cleat_capacity'), cleatCapacity: text('cleat_capacity'),
@@ -58,6 +59,9 @@ export const berths = pgTable(
statusLastChangedBy: text('status_last_changed_by'), // user ID statusLastChangedBy: text('status_last_changed_by'), // user ID
statusLastChangedReason: text('status_last_changed_reason'), statusLastChangedReason: text('status_last_changed_reason'),
statusLastModified: timestamp('status_last_modified', { withTimezone: true }), statusLastModified: timestamp('status_last_modified', { withTimezone: true }),
// Optional override flag carried over from NocoDB ("auto" or null in legacy data).
// Reserved for future "manual override" semantics; not surfaced in the UI today.
statusOverrideMode: text('status_override_mode'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, },

View File

@@ -4,7 +4,13 @@
* Exports `seedPortData(portId, portSlug)` — creates a realistic, * Exports `seedPortData(portId, portSlug)` — creates a realistic,
* multi-cardinality data fixture for one port: * multi-cardinality data fixture for one port:
* *
* - 12 berths (5 available / 5 reserved-active / 2 sold) * - 117 berths imported from a snapshot of the legacy NocoDB Berths
* table (`src/lib/db/seed-data/berths.json`). The snapshot is reordered
* so the first 12 entries satisfy the index assumptions used further
* down for interest/reservation linkage:
* idx 0..4 — available (small)
* idx 5..9 — under_offer (medium)
* idx 10..11 — sold (large)
* - 3 companies (2 active, 1 dissolved) with primary billing addresses * - 3 companies (2 active, 1 dissolved) with primary billing addresses
* - 8 clients + contacts + primary addresses * - 8 clients + contacts + primary addresses
* - Memberships tying clients to companies (incl. multi-company + ended) * - Memberships tying clients to companies (incl. multi-company + ended)
@@ -39,6 +45,44 @@ import {
getStandardEoiTemplateHtml, getStandardEoiTemplateHtml,
STANDARD_EOI_MERGE_FIELDS, STANDARD_EOI_MERGE_FIELDS,
} from '@/lib/pdf/templates/eoi-standard-inapp'; } from '@/lib/pdf/templates/eoi-standard-inapp';
import berthSnapshot from './seed-data/berths.json';
// ─── Berth snapshot ──────────────────────────────────────────────────────────
// 117 rows imported from the legacy NocoDB Berths table on 2026-05-03.
// Refresh by re-running the snapshot script (see git history of this file).
type SeedBerth = {
legacyId: number;
mooringNumber: string;
legacyMooringNumber: string;
area: string | null;
status: 'available' | 'under_offer' | 'sold';
lengthFt: number | null;
widthFt: number | null;
draftFt: number | null;
lengthM: number | null;
widthM: number | null;
draftM: number | null;
widthIsMinimum: boolean;
nominalBoatSize: number | null;
nominalBoatSizeM: number | null;
waterDepth: number | null;
waterDepthM: number | null;
waterDepthIsMinimum: boolean;
sidePontoon: string | null;
powerCapacity: number | null;
voltage: number | null;
mooringType: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
access: string | null;
price: number | null;
bowFacing: string | null;
berthApproved: boolean;
statusOverrideMode: string | null;
};
const BERTH_SNAPSHOT = berthSnapshot as SeedBerth[];
// ─── Tunables ──────────────────────────────────────────────────────────────── // ─── Tunables ────────────────────────────────────────────────────────────────
@@ -77,144 +121,44 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
return withTransaction(async (tx) => { return withTransaction(async (tx) => {
// ── 1. Berths ────────────────────────────────────────────────────────── // ── 1. Berths ──────────────────────────────────────────────────────────
// 12 berths: [0..4] available, [5..9] will be reserved-active, [10..11] sold. // 117 berths seeded from the legacy NocoDB Berths snapshot.
// We mark 5..9 as 'under_offer' (closest to "reserved via active reservation") // The JSON file is pre-sorted so the first 12 indexes satisfy the
// and 10..11 as 'sold'; 0..4 remain 'available'. // status semantics expected by the interest/reservation seeds:
const BERTH_SPECS: Array<{ // idx 0..4 available, idx 5..9 under_offer, idx 10..11 sold.
mooring: string;
area: string;
lengthM: string;
widthM: string;
draftM: string;
price: string;
status: 'available' | 'under_offer' | 'sold';
}> = [
{
mooring: 'A-01',
area: 'North Pier',
lengthM: '15',
widthM: '5',
draftM: '2.5',
price: '250000',
status: 'available',
},
{
mooring: 'A-02',
area: 'North Pier',
lengthM: '18',
widthM: '5.5',
draftM: '2.8',
price: '320000',
status: 'available',
},
{
mooring: 'A-03',
area: 'North Pier',
lengthM: '20',
widthM: '6',
draftM: '3.0',
price: '420000',
status: 'available',
},
{
mooring: 'B-01',
area: 'Central Basin',
lengthM: '25',
widthM: '7',
draftM: '3.5',
price: '580000',
status: 'available',
},
{
mooring: 'B-02',
area: 'Central Basin',
lengthM: '30',
widthM: '8',
draftM: '4.0',
price: '780000',
status: 'available',
},
{
mooring: 'B-03',
area: 'Central Basin',
lengthM: '35',
widthM: '8.5',
draftM: '4.2',
price: '950000',
status: 'under_offer',
},
{
mooring: 'C-01',
area: 'South Marina',
lengthM: '40',
widthM: '9',
draftM: '4.5',
price: '1250000',
status: 'under_offer',
},
{
mooring: 'C-02',
area: 'South Marina',
lengthM: '45',
widthM: '10',
draftM: '4.8',
price: '1600000',
status: 'under_offer',
},
{
mooring: 'C-03',
area: 'South Marina',
lengthM: '50',
widthM: '11',
draftM: '5.0',
price: '2100000',
status: 'under_offer',
},
{
mooring: 'D-01',
area: 'Superyacht Dock',
lengthM: '60',
widthM: '13',
draftM: '5.5',
price: '3200000',
status: 'under_offer',
},
{
mooring: 'D-02',
area: 'Superyacht Dock',
lengthM: '70',
widthM: '14',
draftM: '6.0',
price: '4500000',
status: 'sold',
},
{
mooring: 'D-03',
area: 'Superyacht Dock',
lengthM: '80',
widthM: '15',
draftM: '6.5',
price: '6800000',
status: 'sold',
},
];
const berthRows = await tx const berthRows = await tx
.insert(berths) .insert(berths)
.values( .values(
BERTH_SPECS.map((b) => ({ BERTH_SNAPSHOT.map((b) => ({
portId, portId,
mooringNumber: b.mooring, mooringNumber: b.mooringNumber,
area: b.area, area: b.area,
status: b.status, status: b.status,
lengthM: b.lengthM, lengthFt: b.lengthFt != null ? String(b.lengthFt) : null,
widthM: b.widthM, widthFt: b.widthFt != null ? String(b.widthFt) : null,
draftM: b.draftM, draftFt: b.draftFt != null ? String(b.draftFt) : null,
lengthFt: (Number(b.lengthM) * 3.28084).toFixed(2), lengthM: b.lengthM != null ? String(b.lengthM) : null,
widthFt: (Number(b.widthM) * 3.28084).toFixed(2), widthM: b.widthM != null ? String(b.widthM) : null,
draftFt: (Number(b.draftM) * 3.28084).toFixed(2), draftM: b.draftM != null ? String(b.draftM) : null,
price: b.price, widthIsMinimum: b.widthIsMinimum,
nominalBoatSize: b.nominalBoatSize != null ? String(b.nominalBoatSize) : null,
nominalBoatSizeM: b.nominalBoatSizeM != null ? String(b.nominalBoatSizeM) : null,
waterDepth: b.waterDepth != null ? String(b.waterDepth) : null,
waterDepthM: b.waterDepthM != null ? String(b.waterDepthM) : null,
waterDepthIsMinimum: b.waterDepthIsMinimum,
sidePontoon: b.sidePontoon,
powerCapacity: b.powerCapacity != null ? String(b.powerCapacity) : null,
voltage: b.voltage != null ? String(b.voltage) : null,
mooringType: b.mooringType,
cleatType: b.cleatType,
cleatCapacity: b.cleatCapacity,
bollardType: b.bollardType,
bollardCapacity: b.bollardCapacity,
access: b.access,
price: b.price != null ? String(b.price) : null,
priceCurrency: 'USD', priceCurrency: 'USD',
bowFacing: b.bowFacing,
berthApproved: b.berthApproved,
statusOverrideMode: b.statusOverrideMode,
tenureType: 'permanent' as const, tenureType: 'permanent' as const,
})), })),
) )

File diff suppressed because it is too large Load Diff

View File

@@ -2,16 +2,16 @@
* Seed script for Port Nimara CRM. * Seed script for Port Nimara CRM.
* *
* Top-level orchestrator: * Top-level orchestrator:
* 1. Create 3 ports (idempotent): * 1. Create the operational ports (idempotent):
* - Port Nimara * - Port Nimara (primary install — the real marina)
* - Marina Azzurra * - Port Amador (secondary, kept for multi-tenant isolation tests
* - Harbor Royale * and as scaffolding for a future Panama install)
* 2. Create 5 system roles with full permission maps * 2. Create 5 system roles with full permission maps
* 3. Create the super admin user profile placeholder (matt@portnimara.com) * 3. Create the super admin user profile placeholder (matt@portnimara.com)
* 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts * 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts
* to produce the realistic multi-cardinality fixture * to produce the realistic multi-cardinality fixture
* (berths, clients, companies, yachts, memberships, interests, * (117 berths from the NocoDB snapshot, plus clients, companies, yachts,
* reservations, ownership-transfer history). * memberships, interests, reservations, ownership-transfer history).
* 5. Print a summary. * 5. Print a summary.
* *
* Run with: pnpm db:seed * Run with: pnpm db:seed
@@ -186,7 +186,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
generate_eoi: true, generate_eoi: true,
export: true, export: true,
}, },
berths: { view: true, edit: false, import: false, manage_waiting_list: true }, berths: { view: true, edit: true, import: false, manage_waiting_list: true },
documents: { documents: {
view: true, view: true,
create: true, create: true,
@@ -260,7 +260,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
generate_eoi: true, generate_eoi: true,
export: true, export: true,
}, },
berths: { view: true, edit: false, import: false, manage_waiting_list: true }, berths: { view: true, edit: true, import: false, manage_waiting_list: true },
documents: { documents: {
view: true, view: true,
create: true, create: true,
@@ -413,19 +413,15 @@ const PORT_DEFINITIONS: Array<{
defaultCurrency: 'USD', defaultCurrency: 'USD',
timezone: 'America/Anguilla', timezone: 'America/Anguilla',
}, },
// Second port kept for multi-tenant isolation tests (cross-port scoping,
// permission boundaries). Drop or rename if the production install is
// single-port.
{ {
name: 'Marina Azzurra', name: 'Port Amador',
slug: 'marina-azzurra', slug: 'port-amador',
primaryColor: '#2E86AB', primaryColor: '#D97706',
defaultCurrency: 'EUR', defaultCurrency: 'USD',
timezone: 'Europe/Rome', timezone: 'America/Panama',
},
{
name: 'Harbor Royale',
slug: 'harbor-royale',
primaryColor: '#8B1E3F',
defaultCurrency: 'GBP',
timezone: 'Europe/London',
}, },
]; ];

View File

@@ -80,11 +80,13 @@ export async function fillEoiFormFields(
setText(form, 'Name', context.client.fullName); setText(form, 'Name', context.client.fullName);
setText(form, 'Email', context.client.primaryEmail ?? ''); setText(form, 'Email', context.client.primaryEmail ?? '');
setText(form, 'Address', formatAddress(context.client.address)); setText(form, 'Address', formatAddress(context.client.address));
setText(form, 'Yacht Name', context.yacht.name); // Yacht + berth (EOI Section 3) are optional — leave the AcroForm fields
setText(form, 'Length', context.yacht.lengthFt ?? ''); // blank when the interest hasn't been linked to either.
setText(form, 'Width', context.yacht.widthFt ?? ''); setText(form, 'Yacht Name', context.yacht?.name ?? '');
setText(form, 'Draft', context.yacht.draftFt ?? ''); setText(form, 'Length', context.yacht?.lengthFt ?? '');
setText(form, 'Berth Number', context.berth.mooringNumber); setText(form, 'Width', context.yacht?.widthFt ?? '');
setText(form, 'Draft', context.yacht?.draftFt ?? '');
setText(form, 'Berth Number', context.berth?.mooringNumber ?? '');
setCheckbox(form, 'Purchase', true); setCheckbox(form, 'Purchase', true);
setCheckbox(form, 'Lease_10', false); setCheckbox(form, 'Lease_10', false);

View File

@@ -4,6 +4,12 @@ import { cookies } from 'next/headers';
const PORTAL_SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET); const PORTAL_SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET);
export const PORTAL_COOKIE = 'portal_session'; export const PORTAL_COOKIE = 'portal_session';
// BREAKING CHANGE (intentional): tokens issued before this change lack aud/iss
// and will be rejected by verifyPortalToken. Portal tokens are 24h-lived so
// existing sessions will be invalidated on deploy. Users simply re-login.
const PORTAL_AUD = 'portal';
const PORTAL_ISS = 'pn-crm';
export interface PortalSession { export interface PortalSession {
clientId: string; clientId: string;
portId: string; portId: string;
@@ -13,6 +19,8 @@ export interface PortalSession {
export async function createPortalToken(session: PortalSession): Promise<string> { export async function createPortalToken(session: PortalSession): Promise<string> {
return new SignJWT(session as unknown as Record<string, unknown>) return new SignJWT(session as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setAudience(PORTAL_AUD)
.setIssuer(PORTAL_ISS)
.setExpirationTime('24h') .setExpirationTime('24h')
.setIssuedAt() .setIssuedAt()
.sign(PORTAL_SECRET); .sign(PORTAL_SECRET);
@@ -20,7 +28,10 @@ export async function createPortalToken(session: PortalSession): Promise<string>
export async function verifyPortalToken(token: string): Promise<PortalSession | null> { export async function verifyPortalToken(token: string): Promise<PortalSession | null> {
try { try {
const { payload } = await jwtVerify(token, PORTAL_SECRET); const { payload } = await jwtVerify(token, PORTAL_SECRET, {
audience: PORTAL_AUD,
issuer: PORTAL_ISS,
});
return payload as unknown as PortalSession; return payload as unknown as PortalSession;
} catch { } catch {
return null; return null;

View File

@@ -180,14 +180,14 @@ export async function updateBerth(
draftFt: n(data.draftFt), draftFt: n(data.draftFt),
draftM: n(data.draftM), draftM: n(data.draftM),
widthIsMinimum: data.widthIsMinimum, widthIsMinimum: data.widthIsMinimum,
nominalBoatSize: data.nominalBoatSize, nominalBoatSize: n(data.nominalBoatSize),
nominalBoatSizeM: data.nominalBoatSizeM, nominalBoatSizeM: n(data.nominalBoatSizeM),
waterDepth: n(data.waterDepth), waterDepth: n(data.waterDepth),
waterDepthM: n(data.waterDepthM), waterDepthM: n(data.waterDepthM),
waterDepthIsMinimum: data.waterDepthIsMinimum, waterDepthIsMinimum: data.waterDepthIsMinimum,
sidePontoon: data.sidePontoon, sidePontoon: data.sidePontoon,
powerCapacity: data.powerCapacity, powerCapacity: n(data.powerCapacity),
voltage: data.voltage, voltage: n(data.voltage),
mooringType: data.mooringType, mooringType: data.mooringType,
cleatType: data.cleatType, cleatType: data.cleatType,
cleatCapacity: data.cleatCapacity, cleatCapacity: data.cleatCapacity,
@@ -481,8 +481,8 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
priceCurrency: data.priceCurrency ?? 'USD', priceCurrency: data.priceCurrency ?? 'USD',
tenureType: data.tenureType ?? 'permanent', tenureType: data.tenureType ?? 'permanent',
mooringType: data.mooringType, mooringType: data.mooringType,
powerCapacity: data.powerCapacity, powerCapacity: data.powerCapacity?.toString(),
voltage: data.voltage, voltage: data.voltage?.toString(),
access: data.access, access: data.access,
bowFacing: data.bowFacing, bowFacing: data.bowFacing,
sidePontoon: data.sidePontoon, sidePontoon: data.sidePontoon,

View File

@@ -27,9 +27,11 @@ export async function getKpis(portId: string) {
.from(interests) .from(interests)
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest)); .where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest));
// Pipeline value: SUM berths.price via JOIN from non-archived interests with berthId // Pipeline value: SUM each berth's price ONCE regardless of how many active
// interests reference it. A berth with multiple interests would otherwise be
// counted multiple times, inflating the total.
const pipelineRows = await db const pipelineRows = await db
.select({ price: berths.price }) .selectDistinct({ berthId: interests.berthId, price: berths.price })
.from(interests) .from(interests)
.innerJoin(berths, eq(interests.berthId, berths.id)) .innerJoin(berths, eq(interests.berthId, berths.id))
.where( .where(

View File

@@ -128,11 +128,13 @@ export function buildDocumensoPayload(
Name: context.client.fullName, Name: context.client.fullName,
Email: context.client.primaryEmail ?? '', Email: context.client.primaryEmail ?? '',
Address: formatAddress(context.client.address), Address: formatAddress(context.client.address),
'Yacht Name': context.yacht.name, // Yacht + berth are optional EOI fields; when not linked, render as
Length: context.yacht.lengthFt ?? '', // empty strings so the corresponding template inputs stay blank.
Width: context.yacht.widthFt ?? '', 'Yacht Name': context.yacht?.name ?? '',
Draft: context.yacht.draftFt ?? '', Length: context.yacht?.lengthFt ?? '',
'Berth Number': context.berth.mooringNumber, Width: context.yacht?.widthFt ?? '',
Draft: context.yacht?.draftFt ?? '',
'Berth Number': context.berth?.mooringNumber ?? '',
Lease_10: false, Lease_10: false,
Purchase: true, Purchase: true,
}, },

View File

@@ -237,18 +237,20 @@ export async function resolveTemplate(
tokenMap['{{client.phone}}'] = eoi.client.primaryPhone ?? ''; tokenMap['{{client.phone}}'] = eoi.client.primaryPhone ?? '';
tokenMap['{{client.nationality}}'] = eoi.client.nationality ?? ''; tokenMap['{{client.nationality}}'] = eoi.client.nationality ?? '';
// Yacht tokens // Yacht tokens — `eoi.yacht` is null when no yacht is linked
tokenMap['{{yacht.name}}'] = eoi.yacht.name; // (Section 3 of the EOI is optional). Tokens render as empty strings
tokenMap['{{yacht.hullNumber}}'] = eoi.yacht.hullNumber ?? ''; // in that case so the template still produces output.
tokenMap['{{yacht.flag}}'] = eoi.yacht.flag ?? ''; tokenMap['{{yacht.name}}'] = eoi.yacht?.name ?? '';
tokenMap['{{yacht.hullNumber}}'] = eoi.yacht?.hullNumber ?? '';
tokenMap['{{yacht.flag}}'] = eoi.yacht?.flag ?? '';
tokenMap['{{yacht.yearBuilt}}'] = tokenMap['{{yacht.yearBuilt}}'] =
eoi.yacht.yearBuilt != null ? String(eoi.yacht.yearBuilt) : ''; eoi.yacht?.yearBuilt != null ? String(eoi.yacht.yearBuilt) : '';
tokenMap['{{yacht.lengthFt}}'] = eoi.yacht.lengthFt ?? ''; tokenMap['{{yacht.lengthFt}}'] = eoi.yacht?.lengthFt ?? '';
tokenMap['{{yacht.widthFt}}'] = eoi.yacht.widthFt ?? ''; tokenMap['{{yacht.widthFt}}'] = eoi.yacht?.widthFt ?? '';
tokenMap['{{yacht.draftFt}}'] = eoi.yacht.draftFt ?? ''; tokenMap['{{yacht.draftFt}}'] = eoi.yacht?.draftFt ?? '';
tokenMap['{{yacht.lengthM}}'] = eoi.yacht.lengthM ?? ''; tokenMap['{{yacht.lengthM}}'] = eoi.yacht?.lengthM ?? '';
tokenMap['{{yacht.widthM}}'] = eoi.yacht.widthM ?? ''; tokenMap['{{yacht.widthM}}'] = eoi.yacht?.widthM ?? '';
tokenMap['{{yacht.draftM}}'] = eoi.yacht.draftM ?? ''; tokenMap['{{yacht.draftM}}'] = eoi.yacht?.draftM ?? '';
// EoiContext doesn't expose the yacht.registration column — look it up // EoiContext doesn't expose the yacht.registration column — look it up
// separately (cheap, indexed fetch) so the token resolves when present. // separately (cheap, indexed fetch) so the token resolves when present.
@@ -281,29 +283,31 @@ export async function resolveTemplate(
tokenMap['{{owner.name}}'] = eoi.owner.name; tokenMap['{{owner.name}}'] = eoi.owner.name;
tokenMap['{{owner.legalName}}'] = eoi.owner.legalName ?? ''; tokenMap['{{owner.legalName}}'] = eoi.owner.legalName ?? '';
// Berth tokens (from EoiContext) // Berth tokens — also optional. Render empty when no berth is linked.
tokenMap['{{berth.mooringNumber}}'] = eoi.berth.mooringNumber; tokenMap['{{berth.mooringNumber}}'] = eoi.berth?.mooringNumber ?? '';
tokenMap['{{berth.area}}'] = eoi.berth.area ?? ''; tokenMap['{{berth.area}}'] = eoi.berth?.area ?? '';
tokenMap['{{berth.lengthFt}}'] = eoi.berth.lengthFt ?? ''; tokenMap['{{berth.lengthFt}}'] = eoi.berth?.lengthFt ?? '';
tokenMap['{{berth.price}}'] = eoi.berth.price ?? ''; tokenMap['{{berth.price}}'] = eoi.berth?.price ?? '';
tokenMap['{{berth.priceCurrency}}'] = eoi.berth.priceCurrency; tokenMap['{{berth.priceCurrency}}'] = eoi.berth?.priceCurrency ?? '';
tokenMap['{{berth.tenureType}}'] = eoi.berth.tenureType; tokenMap['{{berth.tenureType}}'] = eoi.berth?.tenureType ?? '';
// Interest tokens // Interest tokens
tokenMap['{{interest.stage}}'] = eoi.interest.stage; tokenMap['{{interest.stage}}'] = eoi.interest.stage;
tokenMap['{{interest.leadCategory}}'] = eoi.interest.leadCategory ?? ''; tokenMap['{{interest.leadCategory}}'] = eoi.interest.leadCategory ?? '';
tokenMap['{{interest.berthNumber}}'] = eoi.berth.mooringNumber; tokenMap['{{interest.berthNumber}}'] = eoi.berth?.mooringNumber ?? '';
tokenMap['{{interest.dateFirstContact}}'] = eoi.interest.dateFirstContact tokenMap['{{interest.dateFirstContact}}'] = eoi.interest.dateFirstContact
? eoi.interest.dateFirstContact.toLocaleDateString('en-GB') ? eoi.interest.dateFirstContact.toLocaleDateString('en-GB')
: ''; : '';
tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? ''; tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? '';
} catch (err) { } catch (err) {
// buildEoiContext throws ValidationError when the interest has no yacht // buildEoiContext throws ValidationError when the EOI's required client
// or berth; non-EOI templates don't need those. Fall through to the // fields (name/email/address — Section 2) are missing. For non-EOI
// legacy resolution path below. Re-throw anything else. // templates (correspondence, welcome letters, etc.) those gates don't
// apply — fall through to the legacy resolution path below. Re-throw
// anything else.
if ( if (
!(err instanceof ValidationError) || !(err instanceof ValidationError) ||
!/interest has no (yacht|berth)/i.test(err.message) !/missing required client details|interest has no (yacht|berth)/i.test(err.message)
) { ) {
throw err; throw err;
} }

View File

@@ -24,6 +24,7 @@ import { minioClient, buildStoragePath } from '@/lib/minio';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { evaluateRule } from '@/lib/services/berth-rules-engine'; import { evaluateRule } from '@/lib/services/berth-rules-engine';
import { PIPELINE_STAGES } from '@/lib/constants';
import { advanceStageIfBehind } from '@/lib/services/interests.service'; import { advanceStageIfBehind } from '@/lib/services/interests.service';
import { import {
createDocument as documensoCreate, createDocument as documensoCreate,
@@ -31,6 +32,7 @@ import {
downloadSignedPdf, downloadSignedPdf,
voidDocument as documensoVoid, voidDocument as documensoVoid,
} from '@/lib/services/documenso-client'; } from '@/lib/services/documenso-client';
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
import type { import type {
CreateDocumentInput, CreateDocumentInput,
UpdateDocumentInput, UpdateDocumentInput,
@@ -506,6 +508,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
if (!port) throw new NotFoundError('Port'); if (!port) throw new NotFoundError('Port');
// Resolve port-configured signer emails from system settings; fall back to
// legacy defaults only if the setting is absent. Fabricated slug-based
// addresses (developer@{slug}.com) are no longer used here because they
// never match real port users and cause silent no-ops in handleRecipientSigned.
const eoiSigners = await getPortEoiSigners(portId);
// BR-021: Create 3 signers — client (1), developer (2), sales/approver (3) // BR-021: Create 3 signers — client (1), developer (2), sales/approver (3)
const signerRecords = await db const signerRecords = await db
.insert(documentSigners) .insert(documentSigners)
@@ -520,16 +528,16 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
}, },
{ {
documentId, documentId,
signerName: port.name, signerName: eoiSigners.developer.name,
signerEmail: `developer@${port.slug}.com`, signerEmail: eoiSigners.developer.email,
signerRole: 'developer', signerRole: 'developer',
signingOrder: 2, signingOrder: 2,
status: 'pending', status: 'pending',
}, },
{ {
documentId, documentId,
signerName: `${port.name} Sales`, signerName: eoiSigners.approver.name,
signerEmail: `sales@${port.slug}.com`, signerEmail: eoiSigners.approver.email,
signerRole: 'approver', signerRole: 'approver',
signingOrder: 3, signingOrder: 3,
status: 'pending', status: 'pending',
@@ -552,10 +560,15 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
// Create document in Documenso + send // Create document in Documenso + send
const documensoDoc = await documensoCreate(doc.title, pdfBase64, [ const documensoDoc = await documensoCreate(doc.title, pdfBase64, [
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 }, { name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
{ name: port.name, email: `developer@${port.slug}.com`, role: 'SIGNER', signingOrder: 2 },
{ {
name: `${port.name} Sales`, name: eoiSigners.developer.name,
email: `sales@${port.slug}.com`, email: eoiSigners.developer.email,
role: 'SIGNER',
signingOrder: 2,
},
{
name: eoiSigners.approver.name,
email: eoiSigners.approver.email,
role: 'SIGNER', role: 'SIGNER',
signingOrder: 3, signingOrder: 3,
}, },
@@ -788,6 +801,22 @@ export async function handleRecipientSigned(eventData: {
) )
.returning(); .returning();
if (!signer) {
// Email mismatch: the address Documenso has on the recipient doesn't match
// any row in documentSigners. This happens when the local signers were
// created with fabricated / stale addresses. Log a warning so operators can
// investigate and fix the port's eoi_signers system setting.
logger.warn(
{
documensoId: eventData.documentId,
documentId: doc.id,
recipientEmail: eventData.recipientEmail,
},
'handleRecipientSigned: no matching signer row for recipient email — ' +
'check eoi_signers system setting for this port',
);
}
// Update document to partially_signed if eoi type // Update document to partially_signed if eoi type
if (doc.documentType === 'eoi' && doc.status === 'sent') { if (doc.documentType === 'eoi' && doc.status === 'sent') {
await db await db
@@ -896,7 +925,19 @@ export async function handleDocumentCompleted(eventData: { documentId: string })
ipAddress: '0.0.0.0', ipAddress: '0.0.0.0',
userAgent: 'webhook', userAgent: 'webhook',
}; };
// Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple times
// (webhook retries) or follow a DOCUMENT_SIGNED that already advanced the
// stage. advanceStageIfBehind handles the pipeline guard internally, but
// evaluateRule has no idempotency — skip it if the interest is already at
// eoi_signed or beyond to prevent duplicate berth-rule side effects.
const currentStageIdx = PIPELINE_STAGES.indexOf(
interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
);
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
if (currentStageIdx < eoiSignedIdx) {
void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta); void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta);
}
// Advance to eoi_signed (no-op if interest already past it). // Advance to eoi_signed (no-op if interest already past it).
void advanceStageIfBehind( void advanceStageIfBehind(

View File

@@ -20,6 +20,7 @@ export type EoiContext = {
primaryPhone: string | null; primaryPhone: string | null;
address: { street: string; city: string; country: string } | null; address: { street: string; city: string; country: string } | null;
}; };
/** Optional. The EOI's Section 3 yacht block is left blank when null. */
yacht: { yacht: {
name: string; name: string;
lengthFt: string | null; lengthFt: string | null;
@@ -31,18 +32,22 @@ export type EoiContext = {
hullNumber: string | null; hullNumber: string | null;
flag: string | null; flag: string | null;
yearBuilt: number | null; yearBuilt: number | null;
}; } | null;
company: { company: {
name: string; name: string;
legalName: string | null; legalName: string | null;
taxId: string | null; taxId: string | null;
billingAddress: string | null; billingAddress: string | null;
} | null; } | null;
/** Inferred from the yacht's polymorphic owner. Falls back to the interest's
* client when no yacht is linked (so the EOI's signing party is still
* resolvable). */
owner: { owner: {
type: 'client' | 'company'; type: 'client' | 'company';
name: string; name: string;
legalName?: string; legalName?: string;
}; };
/** Optional. The EOI's Section 3 berth-number is left blank when null. */
berth: { berth: {
mooringNumber: string; mooringNumber: string;
area: string | null; area: string | null;
@@ -50,7 +55,7 @@ export type EoiContext = {
price: string | null; price: string | null;
priceCurrency: string; priceCurrency: string;
tenureType: string; tenureType: string;
}; } | null;
interest: { interest: {
stage: string; stage: string;
leadCategory: string | null; leadCategory: string | null;
@@ -77,8 +82,10 @@ export type EoiContext = {
* Pure read-only: no audit logs, no socket emits, no mutations. * Pure read-only: no audit logs, no socket emits, no mutations.
* *
* Tenant-scoped: every fetch is gated by `portId`, and missing rows surface * Tenant-scoped: every fetch is gated by `portId`, and missing rows surface
* as NotFoundError. Missing yacht/berth references on the interest surface as * as NotFoundError. The hard gate matches the EOI document's top paragraph
* ValidationError, because EOI flows cannot proceed without them. * (Section 2 — name, address, email): without those the EOI is unsignable
* and we throw. Yacht and berth (Section 3) are optional — the rendered PDF
* leaves those fields blank when not set.
*/ */
export async function buildEoiContext(interestId: string, portId: string): Promise<EoiContext> { export async function buildEoiContext(interestId: string, portId: string): Promise<EoiContext> {
// 1. Interest (tenant-scoped) // 1. Interest (tenant-scoped)
@@ -89,24 +96,19 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
throw new NotFoundError('Interest'); throw new NotFoundError('Interest');
} }
// 2. Yacht reference must exist on the interest // Parallelise independent reads. Yacht and berth are both nullable —
if (!interest.yachtId) { // the EOI's Section 3 stays blank when they're absent.
throw new ValidationError('interest has no yacht');
}
// 3. Berth reference must exist on the interest
if (!interest.berthId) {
throw new ValidationError('interest has no berth');
}
// 2 + 3 + 4 + 9: parallelise independent reads.
const [yacht, berth, client, port] = await Promise.all([ const [yacht, berth, client, port] = await Promise.all([
db.query.yachts.findFirst({ interest.yachtId
? db.query.yachts.findFirst({
where: and(eq(yachts.id, interest.yachtId), eq(yachts.portId, portId)), where: and(eq(yachts.id, interest.yachtId), eq(yachts.portId, portId)),
}), })
db.query.berths.findFirst({ : Promise.resolve(undefined),
interest.berthId
? db.query.berths.findFirst({
where: and(eq(berths.id, interest.berthId), eq(berths.portId, portId)), where: and(eq(berths.id, interest.berthId), eq(berths.portId, portId)),
}), })
: Promise.resolve(undefined),
db.query.clients.findFirst({ db.query.clients.findFirst({
where: and(eq(clients.id, interest.clientId), eq(clients.portId, portId)), where: and(eq(clients.id, interest.clientId), eq(clients.portId, portId)),
}), }),
@@ -115,8 +117,6 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
}), }),
]); ]);
if (!yacht) throw new NotFoundError('Yacht');
if (!berth) throw new NotFoundError('Berth');
if (!client) throw new NotFoundError('Client'); if (!client) throw new NotFoundError('Client');
if (!port) throw new NotFoundError('Port'); if (!port) throw new NotFoundError('Port');
@@ -157,11 +157,28 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
} }
: null; : null;
// 7 + 8. Yacht owner (polymorphic) + optional company billing address. // EOI hard gate: the document's top paragraph (Section 2) requires Name,
// Address, and Email. Without these the rendered EOI is unsignable. Yacht
// and berth (Section 3) are intentionally optional and may be left blank.
const missing: string[] = [];
if (!client.fullName?.trim()) missing.push('client name');
if (!firstEmail?.value?.trim()) missing.push('client email');
if (!clientAddress || !clientAddress.street.trim()) missing.push('client address');
if (missing.length > 0) {
throw new ValidationError(
`Cannot generate EOI — missing required client details: ${missing.join(', ')}.`,
);
}
// Owner block. When a yacht is linked, derive from the yacht's polymorphic
// owner. When no yacht is linked, fall back to the interest's client so the
// EOI's signing party is still resolvable.
let ownerBlock: EoiContext['owner']; let ownerBlock: EoiContext['owner'];
let companyBlock: EoiContext['company'] = null; let companyBlock: EoiContext['company'] = null;
if (yacht.currentOwnerType === 'client') { if (!yacht) {
ownerBlock = { type: 'client', name: client.fullName };
} else if (yacht.currentOwnerType === 'client') {
// The yacht-owning client may or may not be the same as the interest's client. // The yacht-owning client may or may not be the same as the interest's client.
const ownerClient = const ownerClient =
yacht.currentOwnerId === client.id yacht.currentOwnerId === client.id
@@ -228,7 +245,8 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
primaryPhone: firstPhone?.value ?? null, primaryPhone: firstPhone?.value ?? null,
address: clientAddress, address: clientAddress,
}, },
yacht: { yacht: yacht
? {
name: yacht.name, name: yacht.name,
lengthFt: yacht.lengthFt, lengthFt: yacht.lengthFt,
widthFt: yacht.widthFt, widthFt: yacht.widthFt,
@@ -239,17 +257,20 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
hullNumber: yacht.hullNumber, hullNumber: yacht.hullNumber,
flag: yacht.flag, flag: yacht.flag,
yearBuilt: yacht.yearBuilt, yearBuilt: yacht.yearBuilt,
}, }
: null,
company: companyBlock, company: companyBlock,
owner: ownerBlock, owner: ownerBlock,
berth: { berth: berth
? {
mooringNumber: berth.mooringNumber, mooringNumber: berth.mooringNumber,
area: berth.area, area: berth.area,
lengthFt: berth.lengthFt, lengthFt: berth.lengthFt,
price: berth.price, price: berth.price,
priceCurrency: berth.priceCurrency, priceCurrency: berth.priceCurrency,
tenureType: berth.tenureType, tenureType: berth.tenureType,
}, }
: null,
interest: { interest: {
stage: interest.pipelineStage, stage: interest.pipelineStage,
leadCategory: interest.leadCategory, leadCategory: interest.leadCategory,

View File

@@ -1,8 +1,9 @@
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'; import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { interests, interestTags } from '@/lib/db/schema/interests'; import { interests, interestTags, interestNotes } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients'; import { reminders } from '@/lib/db/schema/operations';
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths'; import { berths } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts'; import { yachts } from '@/lib/db/schema/yachts';
import { companyMemberships } from '@/lib/db/schema/companies'; import { companyMemberships } from '@/lib/db/schema/companies';
@@ -182,6 +183,11 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
return interests.leadCategory; return interests.leadCategory;
case 'createdAt': case 'createdAt':
return interests.createdAt; return interests.createdAt;
case 'dateLastContact':
// Postgres sorts NULLs last on DESC by default, which is the right
// behaviour for triage (recently-contacted first, never-contacted
// at the bottom).
return interests.dateLastContact;
default: default:
return interests.updatedAt; return interests.updatedAt;
} }
@@ -221,6 +227,7 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
let clientsMap: Record<string, string> = {}; let clientsMap: Record<string, string> = {};
let berthsMap: Record<string, string> = {}; let berthsMap: Record<string, string> = {};
const tagsByInterestId: Record<string, Array<{ id: string; name: string; color: string }>> = {}; const tagsByInterestId: Record<string, Array<{ id: string; name: string; color: string }>> = {};
const notesCountByInterestId: Record<string, number> = {};
if (clientIds.length > 0) { if (clientIds.length > 0) {
const clientRows = await db const clientRows = await db
@@ -254,6 +261,19 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
if (!tagsByInterestId[row.interestId]) tagsByInterestId[row.interestId] = []; if (!tagsByInterestId[row.interestId]) tagsByInterestId[row.interestId] = [];
tagsByInterestId[row.interestId]!.push({ id: row.id, name: row.name, color: row.color }); tagsByInterestId[row.interestId]!.push({ id: row.id, name: row.name, color: row.color });
} }
// Note counts per interest, for the comment-icon row affordance.
const noteCountRows = await db
.select({
interestId: interestNotes.interestId,
count: sql<number>`count(*)::int`,
})
.from(interestNotes)
.where(inArray(interestNotes.interestId, interestIds))
.groupBy(interestNotes.interestId);
for (const row of noteCountRows) {
notesCountByInterestId[row.interestId] = row.count;
}
} }
const data = (result.data as Array<Record<string, unknown>>).map((i) => ({ const data = (result.data as Array<Record<string, unknown>>).map((i) => ({
@@ -261,6 +281,7 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
clientName: clientsMap[i.clientId as string] ?? null, clientName: clientsMap[i.clientId as string] ?? null,
berthMooringNumber: i.berthId ? (berthsMap[i.berthId as string] ?? null) : null, berthMooringNumber: i.berthId ? (berthsMap[i.berthId as string] ?? null) : null,
tags: tagsByInterestId[i.id as string] ?? [], tags: tagsByInterestId[i.id as string] ?? [],
notesCount: notesCountByInterestId[i.id as string] ?? 0,
})); }));
return { data, total: result.total }; return { data, total: result.total };
@@ -282,6 +303,37 @@ export async function getInterestById(id: string, portId: string) {
.from(clients) .from(clients)
.where(eq(clients.id, interest.clientId)); .where(eq(clients.id, interest.clientId));
// EOI prerequisites + interest-detail header contact actions: surface the
// linked client's primary email/phone (and the canonical E.164 form for
// wa.me) so the header can render Email / Call / WhatsApp buttons without
// a second fetch, and the Documents tab can show the EOI prereq checklist.
const [emailContact] = await db
.select({ value: clientContacts.value })
.from(clientContacts)
.where(and(eq(clientContacts.clientId, interest.clientId), eq(clientContacts.channel, 'email')))
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
.limit(1);
const [phoneContact] = await db
.select({ value: clientContacts.value, valueE164: clientContacts.valueE164 })
.from(clientContacts)
.where(
and(
eq(clientContacts.clientId, interest.clientId),
inArray(clientContacts.channel, ['phone', 'whatsapp']),
),
)
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
.limit(1);
const [addressRow] = await db
.select({ id: clientAddresses.id })
.from(clientAddresses)
.where(
and(eq(clientAddresses.clientId, interest.clientId), eq(clientAddresses.isPrimary, true)),
)
.limit(1);
let berthMooringNumber: string | null = null; let berthMooringNumber: string | null = null;
if (interest.berthId) { if (interest.berthId) {
const [berthRow] = await db const [berthRow] = await db
@@ -297,11 +349,46 @@ export async function getInterestById(id: string, portId: string) {
.innerJoin(tags, eq(interestTags.tagId, tags.id)) .innerJoin(tags, eq(interestTags.tagId, tags.id))
.where(eq(interestTags.interestId, id)); .where(eq(interestTags.interestId, id));
// Most-recent note preview for the Overview tab (the "do you have anything
// outstanding on this lead?" peek). Returns the latest note's truncated
// content + author/timestamp so the UI can render a one-line teaser.
const [recentNote] = await db
.select({
id: interestNotes.id,
content: interestNotes.content,
authorId: interestNotes.authorId,
createdAt: interestNotes.createdAt,
})
.from(interestNotes)
.where(eq(interestNotes.interestId, id))
.orderBy(desc(interestNotes.createdAt))
.limit(1);
const [{ count: notesCount } = { count: 0 }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(interestNotes)
.where(eq(interestNotes.interestId, id));
// Active reminder count for the interest's bell badge. Counts reminders
// directly linked via interestId — `pending` and `snoozed` only;
// completed/dismissed don't surface.
const [{ count: activeReminderCount } = { count: 0 }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(reminders)
.where(and(eq(reminders.interestId, id), inArray(reminders.status, ['pending', 'snoozed'])));
return { return {
...interest, ...interest,
clientName: clientRow?.fullName ?? null, clientName: clientRow?.fullName ?? null,
clientPrimaryEmail: emailContact?.value ?? null,
clientPrimaryPhone: phoneContact?.value ?? null,
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
clientHasAddress: !!addressRow,
berthMooringNumber, berthMooringNumber,
tags: tagRows, tags: tagRows,
notesCount,
recentNote: recentNote ?? null,
activeReminderCount,
}; };
} }

View File

@@ -18,8 +18,8 @@ export const createBerthSchema = z.object({
status: z.enum(BERTH_STATUSES).default('available'), status: z.enum(BERTH_STATUSES).default('available'),
tenureType: z.enum(['permanent', 'fixed_term']).optional(), tenureType: z.enum(['permanent', 'fixed_term']).optional(),
mooringType: z.string().optional(), mooringType: z.string().optional(),
powerCapacity: z.string().optional(), powerCapacity: z.coerce.number().optional(), // kW
voltage: z.string().optional(), voltage: z.coerce.number().optional(), // V at 60Hz
access: z.string().optional(), access: z.string().optional(),
bowFacing: z.string().optional(), bowFacing: z.string().optional(),
sidePontoon: z.string().optional(), sidePontoon: z.string().optional(),
@@ -38,14 +38,14 @@ export const updateBerthSchema = z.object({
draftFt: z.coerce.number().optional(), draftFt: z.coerce.number().optional(),
draftM: z.coerce.number().optional(), draftM: z.coerce.number().optional(),
widthIsMinimum: z.boolean().optional(), widthIsMinimum: z.boolean().optional(),
nominalBoatSize: z.string().optional(), nominalBoatSize: z.coerce.number().optional(), // ft
nominalBoatSizeM: z.string().optional(), nominalBoatSizeM: z.coerce.number().optional(), // m
waterDepth: z.coerce.number().optional(), waterDepth: z.coerce.number().optional(),
waterDepthM: z.coerce.number().optional(), waterDepthM: z.coerce.number().optional(),
waterDepthIsMinimum: z.boolean().optional(), waterDepthIsMinimum: z.boolean().optional(),
sidePontoon: z.string().optional(), sidePontoon: z.string().optional(),
powerCapacity: z.string().optional(), powerCapacity: z.coerce.number().optional(), // kW
voltage: z.string().optional(), voltage: z.coerce.number().optional(), // V at 60Hz
mooringType: z.string().optional(), mooringType: z.string().optional(),
cleatType: z.string().optional(), cleatType: z.string().optional(),
cleatCapacity: z.string().optional(), cleatCapacity: z.string().optional(),

View File

@@ -13,7 +13,20 @@ import { io, type Socket } from 'socket.io-client';
import { useSession } from '@/lib/auth/client'; import { useSession } from '@/lib/auth/client';
import { usePortStore } from '@/stores/ui-store'; import { usePortStore } from '@/stores/ui-store';
const SocketContext = createContext<Socket | null>(null); interface SocketContextValue {
/** Stable socket instance reference. Persists across reconnects — socket.io's
* built-in reconnection re-establishes the underlying transport without
* changing the JS object, so this stays valid as long as the session and
* port are unchanged. Consumers should NOT null-check this for "is online";
* use `isConnected` instead. */
socket: Socket | null;
/** Live transport state. Flips false on disconnect and back to true on
* reconnect. Use this if you need to surface offline UX; the socket itself
* stays subscribed to the same event handlers. */
isConnected: boolean;
}
const SocketContext = createContext<SocketContextValue>({ socket: null, isConnected: false });
/** Returns true once the component has mounted on the client. Avoids calling /** Returns true once the component has mounted on the client. Avoids calling
* better-auth's `useSession()` (which dispatches React hooks via nanostores) * better-auth's `useSession()` (which dispatches React hooks via nanostores)
@@ -32,7 +45,9 @@ export function SocketProvider({ children }: { children: ReactNode }) {
return hasMounted ? ( return hasMounted ? (
<SocketProviderClient>{children}</SocketProviderClient> <SocketProviderClient>{children}</SocketProviderClient>
) : ( ) : (
<SocketContext.Provider value={null}>{children}</SocketContext.Provider> <SocketContext.Provider value={{ socket: null, isConnected: false }}>
{children}
</SocketContext.Provider>
); );
} }
@@ -40,9 +55,14 @@ function SocketProviderClient({ children }: { children: ReactNode }) {
const { data: session } = useSession(); const { data: session } = useSession();
const currentPortId = usePortStore((s) => s.currentPortId); const currentPortId = usePortStore((s) => s.currentPortId);
const [socket, setSocket] = useState<Socket | null>(null); const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => { useEffect(() => {
if (!session?.user || !currentPortId) return; if (!session?.user || !currentPortId) {
setSocket(null);
setIsConnected(false);
return;
}
const s = io(process.env.NEXT_PUBLIC_APP_URL!, { const s = io(process.env.NEXT_PUBLIC_APP_URL!, {
path: '/socket.io/', path: '/socket.io/',
@@ -51,18 +71,38 @@ function SocketProviderClient({ children }: { children: ReactNode }) {
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
}); });
s.on('connect', () => setSocket(s)); // Set the socket reference immediately and keep it stable across the
s.on('disconnect', () => setSocket(null)); // session+port lifetime. socket.io reconnects internally; the same
// instance survives transient drops, and any handlers registered via
// `socket.on(...)` stay attached. Previously we set/unset `socket` on
// connect/disconnect, which made the React context flip to null on every
// network blip and silently killed every `useRealtimeInvalidation`
// subscription session-wide.
setSocket(s);
s.on('connect', () => setIsConnected(true));
s.on('disconnect', () => setIsConnected(false));
return () => { return () => {
s.disconnect(); s.disconnect();
setSocket(null); setSocket(null);
setIsConnected(false);
}; };
}, [session?.user, currentPortId]); }, [session?.user, currentPortId]);
return <SocketContext.Provider value={socket}>{children}</SocketContext.Provider>; return (
<SocketContext.Provider value={{ socket, isConnected }}>{children}</SocketContext.Provider>
);
} }
export function useSocket() { /** Returns the Socket.IO client instance. The reference is stable for the
return useContext(SocketContext); * duration of a session+port, even across transient disconnects. */
export function useSocket(): Socket | null {
return useContext(SocketContext).socket;
}
/** True while the socket transport is connected. Flips false on disconnect,
* back to true on reconnect. Useful for surfacing an "offline" indicator. */
export function useIsSocketConnected(): boolean {
return useContext(SocketContext).isConnected;
} }

View File

@@ -0,0 +1,102 @@
/**
* Port-scoped global reservations list — locks in feat(marina): the new
* `GET /api/v1/berth-reservations` endpoint that powers the
* `[portSlug]/berth-reservations` page. The route is thin (parseQuery →
* listReservations); the test guarantees port scoping at the handler
* boundary so a future refactor of the service can't accidentally leak
* cross-port rows.
*/
import { describe, it, expect } from 'vitest';
import { listHandler } from '@/app/api/v1/berth-reservations/handlers';
import { createHandler as createReservationHandler } from '@/app/api/v1/berths/[id]/reservations/handlers';
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
import {
makeBerth,
makeClient,
makeFullPermissions,
makePort,
makeYacht,
} from '../../helpers/factories';
async function seedReservation(portId: string) {
const berth = await makeBerth({ portId });
const client = await makeClient({ portId });
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id });
const ctx = makeMockCtx({ portId, permissions: makeFullPermissions() });
const res = await createReservationHandler(
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, {
body: {
clientId: client.id,
yachtId: yacht.id,
startDate: new Date().toISOString(),
},
}),
ctx,
{ id: berth.id },
);
return (await res.json()).data as { id: string; berthId: string };
}
describe('GET /api/v1/berth-reservations', () => {
it('returns all reservations for the requesting port', async () => {
const port = await makePort();
const r1 = await seedReservation(port.id);
const r2 = await seedReservation(port.id);
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const res = await listHandler(
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations'),
ctx,
);
expect(res.status).toBe(200);
const body = await res.json();
const ids = (body.data as Array<{ id: string }>).map((r) => r.id).sort();
expect(ids).toEqual([r1.id, r2.id].sort());
expect(body.pagination).toMatchObject({ page: 1, total: 2 });
});
it('does not leak reservations from a different port', async () => {
const portA = await makePort();
const portB = await makePort();
const reservationInB = await seedReservation(portB.id);
// Caller is operating in portA; portB's reservation must not appear.
const ctx = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() });
const res = await listHandler(
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations'),
ctx,
);
expect(res.status).toBe(200);
const body = await res.json();
const ids = (body.data as Array<{ id: string }>).map((r) => r.id);
expect(ids).not.toContain(reservationInB.id);
});
it('honors pagination via query params', async () => {
const port = await makePort();
await seedReservation(port.id);
await seedReservation(port.id);
await seedReservation(port.id);
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const res = await listHandler(
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations?page=1&limit=2'),
ctx,
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toHaveLength(2);
expect(body.pagination).toMatchObject({
page: 1,
pageSize: 2,
total: 3,
totalPages: 2,
hasNextPage: true,
hasPreviousPage: false,
});
});
});

View File

@@ -0,0 +1,126 @@
/**
* Saved-views ownership enforcement — locks in the 403/404 split shipped
* in fix(auth). The route handlers preflight `assertViewOwner` BEFORE the
* service call, so even if the service's internal userId filter is later
* refactored, the route still rejects cross-user mutations.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { db } from '@/lib/db';
import { savedViewsService } from '@/lib/services/saved-views.service';
import { patchHandler, deleteHandler } from '@/app/api/v1/saved-views/[id]/handlers';
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
import { makePort } from '../../helpers/factories';
describe('saved-views ownership enforcement', () => {
let portId: string;
let viewId: string;
const ownerUserId = 'user-owner';
const otherUserId = 'user-other';
beforeAll(async () => {
const port = await makePort();
portId = port.id;
const view = await savedViewsService.create(portId, ownerUserId, {
entityType: 'clients',
name: 'Hot leads',
filters: { stage: 'hot_lead' } as Record<string, unknown>,
isShared: false,
isDefault: false,
});
if (!view) throw new Error('seed view failed');
viewId = view.id;
});
it('PATCH from owner: 200', async () => {
const ctx = makeMockCtx({ portId, userId: ownerUserId });
const res = await patchHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
body: { name: 'Renamed by owner' },
}),
ctx,
{ id: viewId },
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.name).toBe('Renamed by owner');
});
it('PATCH from a different user: 403 (not 404)', async () => {
const ctx = makeMockCtx({ portId, userId: otherUserId });
const res = await patchHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
body: { name: 'Hostile rename' },
}),
ctx,
{ id: viewId },
);
expect(res.status).toBe(403);
// Verify the row was not mutated.
const row = await db.query.savedViews.findFirst({
where: (sv, { eq }) => eq(sv.id, viewId),
});
expect(row?.name).toBe('Renamed by owner');
});
it('DELETE from a different user: 403 and view still exists', async () => {
const ctx = makeMockCtx({ portId, userId: otherUserId });
const res = await deleteHandler(
makeMockRequest('DELETE', `http://localhost/api/v1/saved-views/${viewId}`),
ctx,
{ id: viewId },
);
expect(res.status).toBe(403);
const row = await db.query.savedViews.findFirst({
where: (sv, { eq }) => eq(sv.id, viewId),
});
expect(row).toBeTruthy();
});
it('PATCH on a non-existent id: 404', async () => {
const ctx = makeMockCtx({ portId, userId: ownerUserId });
const res = await patchHandler(
makeMockRequest(
'PATCH',
'http://localhost/api/v1/saved-views/00000000-0000-0000-0000-000000000000',
{ body: { name: 'no-op' } },
),
ctx,
{ id: '00000000-0000-0000-0000-000000000000' },
);
expect(res.status).toBe(404);
});
it('PATCH on a view in a different port: 404 (cross-port enumeration is blocked)', async () => {
// The view exists in `portId` but the auth context says we're operating
// in a different port. The lookup is scoped to `(id, portId)` so the row
// is invisible — should 404, not 403.
const otherPort = await makePort();
const ctx = makeMockCtx({ portId: otherPort.id, userId: ownerUserId });
const res = await patchHandler(
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
body: { name: 'cross-port attempt' },
}),
ctx,
{ id: viewId },
);
expect(res.status).toBe(404);
});
it('DELETE from owner: 200 and view is gone', async () => {
const ctx = makeMockCtx({ portId, userId: ownerUserId });
const res = await deleteHandler(
makeMockRequest('DELETE', `http://localhost/api/v1/saved-views/${viewId}`),
ctx,
{ id: viewId },
);
expect(res.status).toBe(200);
const row = await db.query.savedViews.findFirst({
where: (sv, { eq }) => eq(sv.id, viewId),
});
expect(row).toBeUndefined();
});
});

View File

@@ -268,6 +268,21 @@ describe('resolveTemplate — company-owned yacht', () => {
portId: port.id, portId: port.id,
overrides: { fullName: 'Bob Contact' }, overrides: { fullName: 'Bob Contact' },
}); });
// EOI gate now requires client primary email + address.
await db.insert(clientContacts).values({
clientId: client.id,
channel: 'email',
value: 'bob@example.com',
isPrimary: true,
});
await db.insert(clientAddresses).values({
clientId: client.id,
portId: port.id,
streetAddress: '1 Marina Way',
city: 'Anguilla',
countryIso: 'AI',
isPrimary: true,
});
const yacht = await makeYacht({ const yacht = await makeYacht({
portId: port.id, portId: port.id,
ownerType: 'company', ownerType: 'company',

View File

@@ -0,0 +1,92 @@
/**
* DOCUMENT_EXPIRED webhook handling — locks in fix(documenso). The handler
* was previously defined but never wired to the route's event switch, so
* expired EOIs stayed in `sent` / `partially_signed` forever.
*/
import { describe, expect, it } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documents, documentEvents } from '@/lib/db/schema/documents';
import { interests } from '@/lib/db/schema/interests';
import { handleDocumentExpired } from '@/lib/services/documents.service';
import { makeBerth, makeClient, makePort } from '../helpers/factories';
describe('handleDocumentExpired', () => {
it('flips a sent EOI to expired and writes a documentEvents row', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const documensoId = `documenso-test-${Date.now()}`;
const [doc] = await db
.insert(documents)
.values({
portId: port.id,
clientId: client.id,
documentType: 'eoi',
title: 'Expiring EOI',
status: 'sent',
documensoId,
createdBy: 'seed',
})
.returning();
await handleDocumentExpired({ documentId: documensoId });
const after = await db.query.documents.findFirst({
where: eq(documents.id, doc!.id),
});
expect(after?.status).toBe('expired');
const events = await db
.select()
.from(documentEvents)
.where(eq(documentEvents.documentId, doc!.id));
expect(events.map((e) => e.eventType)).toContain('expired');
});
it('also flips the linked interest eoiStatus to expired', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const berth = await makeBerth({ portId: port.id });
const [interest] = await db
.insert(interests)
.values({
portId: port.id,
clientId: client.id,
berthId: berth.id,
pipelineStage: 'eoi_sent',
leadCategory: 'hot_lead',
eoiStatus: 'sent',
})
.returning();
const documensoId = `documenso-test-${Date.now()}-i`;
await db.insert(documents).values({
portId: port.id,
clientId: client.id,
interestId: interest!.id,
documentType: 'eoi',
title: 'Expiring EOI for interest',
status: 'sent',
documensoId,
createdBy: 'seed',
});
await handleDocumentExpired({ documentId: documensoId });
const updatedInterest = await db.query.interests.findFirst({
where: eq(interests.id, interest!.id),
});
expect(updatedInterest?.eoiStatus).toBe('expired');
});
it('is a no-op when the documensoId does not match any document', async () => {
// Should NOT throw — the handler logs a warning and returns. Verify no
// exception propagates up to the webhook route.
await expect(
handleDocumentExpired({ documentId: 'definitely-not-a-real-doc' }),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,84 @@
/**
* Portal JWT verification — locks in the audience/issuer hardening shipped
* in fix(auth): a token without `aud: 'portal'` + `iss: 'pn-crm'` claims
* must NOT verify, even if it's signed with the correct shared secret.
*
* Without these claims the CRM (better-auth) and portal sessions are
* structurally identical, so a portal token could be replayed against any
* `verifyPortalToken` consumer (and vice versa).
*/
import { describe, expect, it } from 'vitest';
import { SignJWT } from 'jose';
import { createPortalToken, verifyPortalToken } from '@/lib/portal/auth';
const SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET);
const SESSION = {
clientId: '11111111-1111-1111-1111-111111111111',
portId: '22222222-2222-2222-2222-222222222222',
email: 'client@example.com',
};
describe('portal JWT', () => {
it('round-trips a token signed with createPortalToken', async () => {
const token = await createPortalToken(SESSION);
const verified = await verifyPortalToken(token);
expect(verified).toMatchObject(SESSION);
});
it('rejects a token missing the `aud: portal` claim', async () => {
// Issuer present, audience absent — exactly the shape an old (pre-fix)
// portal session would have.
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })
.setIssuer('pn-crm')
.setExpirationTime('24h')
.setIssuedAt()
.sign(SECRET);
expect(await verifyPortalToken(token)).toBeNull();
});
it('rejects a token missing the `iss: pn-crm` claim', async () => {
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })
.setAudience('portal')
.setExpirationTime('24h')
.setIssuedAt()
.sign(SECRET);
expect(await verifyPortalToken(token)).toBeNull();
});
it('rejects a token with the wrong audience (CRM session replay shape)', async () => {
// What a better-auth session token might roughly look like — same secret,
// different audience. Must not verify against the portal path.
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })
.setAudience('crm')
.setIssuer('pn-crm')
.setExpirationTime('24h')
.setIssuedAt()
.sign(SECRET);
expect(await verifyPortalToken(token)).toBeNull();
});
it('rejects a token with the wrong issuer', async () => {
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })
.setAudience('portal')
.setIssuer('attacker')
.setExpirationTime('24h')
.setIssuedAt()
.sign(SECRET);
expect(await verifyPortalToken(token)).toBeNull();
});
it('rejects garbage', async () => {
expect(await verifyPortalToken('not.a.jwt')).toBeNull();
expect(await verifyPortalToken('')).toBeNull();
});
});

View File

@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from 'vitest';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import {
subscribeRealtimeInvalidations,
type EventMap,
type SocketLike,
} from '@/hooks/realtime-invalidation-core';
/**
* Pure-logic tests for the realtime-invalidation subscription helper. The
* React hook (`useRealtimeInvalidation`) is just a thin wrapper around this
* function — verifying the handler-registration / fire-time-lookup behavior
* here is sufficient to lock in the bug fixes:
* 1. Re-subscribe storm (caller passing inline literals)
* 2. Fresh queryKeys read at fire-time
*
* The `useSocket` provider fix (don't null-context on disconnect) is verified
* separately by manual smoke + the existing socket integration coverage.
*/
function makeStubSocket() {
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const onCalls: Array<{ event: string }> = [];
const offCalls: Array<{ event: string }> = [];
const socket: SocketLike = {
on(event, handler) {
onCalls.push({ event });
const arr = listeners.get(event) ?? [];
arr.push(handler);
listeners.set(event, arr);
},
off(event, handler) {
offCalls.push({ event });
const arr = listeners.get(event) ?? [];
listeners.set(
event,
arr.filter((h) => h !== handler),
);
},
};
function emit(event: string, ...args: unknown[]) {
for (const h of listeners.get(event) ?? []) h(...args);
}
return { socket, emit, onCalls, offCalls, listeners };
}
function makeStubQueryClient() {
const calls: QueryKey[] = [];
const queryClient = {
invalidateQueries: vi.fn(({ queryKey }: { queryKey: QueryKey }) => {
calls.push(queryKey);
return Promise.resolve();
}),
} as unknown as QueryClient;
return { queryClient, calls };
}
describe('subscribeRealtimeInvalidations', () => {
it('registers one .on() per event key', () => {
const { socket, onCalls } = makeStubSocket();
const { queryClient } = makeStubQueryClient();
const map: EventMap = {
'client:created': [['clients']],
'client:updated': [['clients'], ['clients', 'abc']],
};
subscribeRealtimeInvalidations(socket, Object.keys(map), queryClient, () => map);
expect(onCalls.map((c) => c.event).sort()).toEqual(['client:created', 'client:updated']);
});
it('invalidates each queryKey for the matching event', () => {
const { socket, emit } = makeStubSocket();
const { queryClient, calls } = makeStubQueryClient();
const map: EventMap = {
'client:updated': [['clients'], ['clients', 'abc']],
};
subscribeRealtimeInvalidations(socket, Object.keys(map), queryClient, () => map);
emit('client:updated');
expect(calls).toEqual([['clients'], ['clients', 'abc']]);
});
it('reads the LATEST eventMap at fire time, not at subscription time', () => {
// This is the core of the re-subscribe-storm fix: callers can swap in a
// new eventMap object without re-subscribing, and the handler still sees
// the fresh queryKey list.
const { socket, emit } = makeStubSocket();
const { queryClient, calls } = makeStubQueryClient();
let currentMap: EventMap = {
'client:updated': [['clients']],
};
subscribeRealtimeInvalidations(socket, ['client:updated'], queryClient, () => currentMap);
// First fire: see the original map
emit('client:updated');
expect(calls).toEqual([['clients']]);
// Caller re-renders with a fresh literal that includes more queryKeys
currentMap = {
'client:updated': [['clients'], ['clients', 'abc']],
};
emit('client:updated');
expect(calls).toEqual([['clients'], ['clients'], ['clients', 'abc']]);
});
it('cleanup deregisters every handler it registered', () => {
const { socket, emit, offCalls, listeners } = makeStubSocket();
const { queryClient, calls } = makeStubQueryClient();
const map: EventMap = {
'a:event': [['a']],
'b:event': [['b']],
};
const cleanup = subscribeRealtimeInvalidations(
socket,
Object.keys(map),
queryClient,
() => map,
);
cleanup();
expect(offCalls.map((c) => c.event).sort()).toEqual(['a:event', 'b:event']);
// All listeners removed — emitting after cleanup invalidates nothing.
emit('a:event');
emit('b:event');
expect(calls).toEqual([]);
// Defensive: the listener list should be empty after cleanup.
expect(listeners.get('a:event')?.length ?? 0).toBe(0);
expect(listeners.get('b:event')?.length ?? 0).toBe(0);
});
it('silently ignores events that have no entry in the current map', () => {
// If the caller swaps an event OUT mid-session, the registered handler
// still fires (we don't re-subscribe) but should be a no-op rather than
// throw.
const { socket, emit } = makeStubSocket();
const { queryClient, calls } = makeStubQueryClient();
let currentMap: EventMap = {
'client:updated': [['clients']],
};
subscribeRealtimeInvalidations(socket, ['client:updated'], queryClient, () => currentMap);
// Wipe the entry — handler will fire but find nothing to invalidate.
currentMap = {};
expect(() => emit('client:updated')).not.toThrow();
expect(calls).toEqual([]);
});
});

View File

@@ -91,8 +91,9 @@ describe('buildDocumensoPayload', () => {
}); });
it('defaults missing yacht dimensions to empty strings', () => { it('defaults missing yacht dimensions to empty strings', () => {
const baseYacht = makeContext().yacht!;
const ctx = makeContext({ const ctx = makeContext({
yacht: { ...makeContext().yacht, lengthFt: null, widthFt: null, draftFt: null }, yacht: { ...baseYacht, lengthFt: null, widthFt: null, draftFt: null },
}); });
const payload = buildDocumensoPayload(ctx, OPTIONS); const payload = buildDocumensoPayload(ctx, OPTIONS);
expect(payload.formValues.Length).toBe(''); expect(payload.formValues.Length).toBe('');
@@ -100,6 +101,16 @@ describe('buildDocumensoPayload', () => {
expect(payload.formValues.Draft).toBe(''); expect(payload.formValues.Draft).toBe('');
}); });
it('renders empty Section 3 when yacht and berth are not linked', () => {
const ctx = makeContext({ yacht: null, berth: null });
const payload = buildDocumensoPayload(ctx, OPTIONS);
expect(payload.formValues['Yacht Name']).toBe('');
expect(payload.formValues.Length).toBe('');
expect(payload.formValues.Width).toBe('');
expect(payload.formValues.Draft).toBe('');
expect(payload.formValues['Berth Number']).toBe('');
});
it('formats empty address when client has no address', () => { it('formats empty address when client has no address', () => {
const ctx = makeContext({ client: { ...makeContext().client, address: null } }); const ctx = makeContext({ client: { ...makeContext().client, address: null } });
const payload = buildDocumensoPayload(ctx, OPTIONS); const payload = buildDocumensoPayload(ctx, OPTIONS);

View File

@@ -28,6 +28,33 @@ async function insertInterest(args: {
return row!; return row!;
} }
/**
* Adds the EOI-required client details (primary email + primary address) so
* `buildEoiContext` clears its hard gate. Tests that exercise non-EOI-gating
* behavior should call this once per client they create.
*/
async function seedClientEoiPrereqs(args: {
clientId: string;
portId: string;
email?: string;
street?: string;
}) {
await db.insert(clientContacts).values({
clientId: args.clientId,
channel: 'email',
value: args.email ?? `client-${args.clientId.slice(0, 8)}@example.com`,
isPrimary: true,
});
await db.insert(clientAddresses).values({
clientId: args.clientId,
portId: args.portId,
streetAddress: args.street ?? '1 Harbour Way',
city: 'Anguilla',
countryIso: 'AI',
isPrimary: true,
});
}
// ─── Tests ──────────────────────────────────────────────────────────────────── // ─── Tests ────────────────────────────────────────────────────────────────────
describe('buildEoiContext', () => { describe('buildEoiContext', () => {
@@ -107,13 +134,13 @@ describe('buildEoiContext', () => {
}); });
// Yacht assertions. // Yacht assertions.
expect(ctx.yacht.name).toBe('Sea Breeze'); expect(ctx.yacht?.name).toBe('Sea Breeze');
expect(ctx.yacht.hullNumber).toBe('HN-1'); expect(ctx.yacht?.hullNumber).toBe('HN-1');
expect(ctx.yacht.yearBuilt).toBe(2020); expect(ctx.yacht?.yearBuilt).toBe(2020);
// Berth assertions. // Berth assertions.
expect(ctx.berth.mooringNumber).toBe('M-42'); expect(ctx.berth?.mooringNumber).toBe('M-42');
expect(ctx.berth.area).toBe('North'); expect(ctx.berth?.area).toBe('North');
// Interest assertions. // Interest assertions.
expect(ctx.interest.stage).toBe('in_communication'); expect(ctx.interest.stage).toBe('in_communication');
@@ -144,6 +171,7 @@ describe('buildEoiContext', () => {
portId: port.id, portId: port.id,
overrides: { fullName: 'Bob Contact' }, overrides: { fullName: 'Bob Contact' },
}); });
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
const yacht = await makeYacht({ const yacht = await makeYacht({
portId: port.id, portId: port.id,
@@ -187,6 +215,7 @@ describe('buildEoiContext', () => {
}); });
const client = await makeClient({ portId: port.id }); const client = await makeClient({ portId: port.id });
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
const yacht = await makeYacht({ const yacht = await makeYacht({
portId: port.id, portId: port.id,
ownerType: 'company', ownerType: 'company',
@@ -211,9 +240,10 @@ describe('buildEoiContext', () => {
expect(ctx.company!.billingAddress).toContain('Anguilla'); expect(ctx.company!.billingAddress).toContain('Anguilla');
}); });
it('throws ValidationError when interest has no yacht', async () => { it('builds a valid context when yacht is missing (Section 3 left blank)', async () => {
const port = await makePort(); const port = await makePort();
const client = await makeClient({ portId: port.id }); const client = await makeClient({ portId: port.id });
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
const berth = await makeBerth({ portId: port.id }); const berth = await makeBerth({ portId: port.id });
const interest = await insertInterest({ const interest = await insertInterest({
@@ -223,13 +253,18 @@ describe('buildEoiContext', () => {
berthId: berth.id, berthId: berth.id,
}); });
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError); const ctx = await buildEoiContext(interest.id, port.id);
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/interest has no yacht/i); expect(ctx.yacht).toBeNull();
expect(ctx.berth?.mooringNumber).toBe(berth.mooringNumber);
// Owner falls back to the interest's client when no yacht is linked.
expect(ctx.owner.type).toBe('client');
expect(ctx.owner.name).toBe(client.fullName);
}); });
it('throws ValidationError when interest has no berth', async () => { it('builds a valid context when berth is missing (Section 3 left blank)', async () => {
const port = await makePort(); const port = await makePort();
const client = await makeClient({ portId: port.id }); const client = await makeClient({ portId: port.id });
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
const yacht = await makeYacht({ const yacht = await makeYacht({
portId: port.id, portId: port.id,
ownerType: 'client', ownerType: 'client',
@@ -243,8 +278,45 @@ describe('buildEoiContext', () => {
berthId: null, berthId: null,
}); });
const ctx = await buildEoiContext(interest.id, port.id);
expect(ctx.berth).toBeNull();
expect(ctx.yacht?.name).toBe(yacht.name);
});
it('throws ValidationError when client has no email', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// Address only, no email — gate should fail.
await db.insert(clientAddresses).values({
clientId: client.id,
portId: port.id,
streetAddress: '1 Harbour Way',
city: 'Anguilla',
countryIso: 'AI',
isPrimary: true,
});
const interest = await insertInterest({ portId: port.id, clientId: client.id });
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError); await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError);
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/interest has no berth/i); await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client email/i);
});
it('throws ValidationError when client has no primary address', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// Email only, no address — gate should fail.
await db.insert(clientContacts).values({
clientId: client.id,
channel: 'email',
value: 'test@example.com',
isPrimary: true,
});
const interest = await insertInterest({ portId: port.id, clientId: client.id });
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError);
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client address/i);
}); });
it('throws NotFoundError for non-existent interest', async () => { it('throws NotFoundError for non-existent interest', async () => {