10 Commits

Author SHA1 Message Date
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
65 changed files with 3423 additions and 421 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

@@ -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

@@ -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;
@@ -72,6 +73,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>
@@ -161,6 +167,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',

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

@@ -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

@@ -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 () => {