56 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
Matt Ciaccio
868b1f40c0 fix(nav): drop dedicated EOI route + alerts sidebar entry, fix paginated-URL bug
Trimmed two surfaces that didn't earn their nav weight:

  - The /[port]/documents/eoi route added in the previous commit was
    redundant with the per-interest EOI status milestones already on
    the interest detail and the existing eoi_queue tab inside the
    Documents hub. Removed the route + the "EOI queue" sidebar entry.
  - The Alerts sidebar entry was promoting a mostly-empty page that
    duplicated the dashboard alert rail. Dropped the entry; the
    /[port]/alerts route stays accessible via the dashboard rail's
    "View all" link and the topbar bell, which is enough for the
    audit-trail use case.

While testing the EOI tab, found and fixed a real bug: usePaginatedQuery
was producing malformed URLs like `…?tab=eoi_queue&signatureOnly=true?page=1&limit=25`
(two `?` separators) when the endpoint string already carried query
params. The API rejected those with 400, so the EOI tab in the
documents hub was silently broken. The hook now uses `&` when the
endpoint already contains a `?`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:30:27 +02:00
Matt Ciaccio
dbbd03fd22 feat(sales): admin-configurable EOI signers + richer timeline events
1. Per-port EOI signer config

     - New `eoi_signers` system_settings key (JSON: { developer, approver },
       each `{ name, email }`). Settings UI exposes it under Admin → Settings.
     - getPortEoiSigners(portId) reads the setting with a typed validator;
       falls back to the legacy David Mizrahi / Abbie May defaults if the
       row is missing or malformed (so older ports keep working until an
       admin saves a value).
     - Both EOI generation pathways now read from the helper instead of
       hardcoded constants:
         * documenso-template path (generateAndSignViaDocumensoTemplate)
         * in-app PDF-fill path (generateAndSignViaInApp)

  2. Timeline upgrades

     The interest detail Activity tab now distinguishes the new automation
     events that arrived with sessions 1+2:

     - Stage auto-advances (userId='system') get a small "Auto" pill and
       carry their reason into the description (e.g. "Stage advanced to
       EOI Signed (auto-advanced — EOI signed via Documenso)").
     - outcome_set events show "Marked as Won" / "Marked as Lost — went
       to another marina" with optional reason; trophy/X icons.
     - outcome_cleared events show "Reopened to {stage}" with a refresh
       icon.
     - Document events humanized: "Document 'X' fully signed" instead
       of "Document X: completed".
     - Stage labels run through stageLabel() so the timeline shows the
       human label, not the enum key.
     - Timestamps switched to relative-time with full-date tooltip.
     - "by system" is rendered plainly (no longer the literal user-id).

tsc clean. vitest 832/832 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:19:55 +02:00
Matt Ciaccio
ba5fb6db5e feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes
Three independent strengthenings of the sales spine that the prior coherence
sweep made it possible to do cleanly.

  1. EOI queue page

     - Sidebar entry under Documents → "EOI queue".
     - Route /[port]/documents/eoi renders DocumentsHub with the existing
       eoi_queue tab pre-selected (filters in-flight EOIs only).
     - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi
       route is no longer silently excluded.

  2. Invoice ↔ deposit link

     - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind
       ('general' | 'deposit'). Indexed on (port_id, interest_id).
     - createInvoiceSchema requires interestId when kind === 'deposit';
       the service validates the linked interest belongs to the same port
       before insert.
     - recordPayment auto-advances pipelineStage to deposit_10pct (via
       advanceStageIfBehind) when a paid invoice is kind=deposit and has
       an interestId. No-op if the interest is already further along.
     - "Create deposit invoice" link added to the Deposit milestone on the
       interest detail. Links to /invoices/new?interestId=…&kind=deposit;
       the form prefills the billing entity from the linked interest's
       client and shows a context banner.

  3. Won / lost terminal outcomes

     - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified'
       | 'lost_no_response' | 'cancelled') + outcomeReason text +
       outcomeAt timestamp. Indexed on (port_id, outcome).
     - setInterestOutcome / clearInterestOutcome services + POST/DELETE
       /api/v1/interests/:id/outcome endpoints (gated by change_stage
       permission). Setting an outcome moves the interest to `completed`
       in the same write; clearing reopens to `in_communication` (or a
       caller-specified stage).
     - Mark Won / Mark Lost icon buttons on the interest detail header,
       plus an outcome badge that replaces the stage pill once a terminal
       outcome is set, plus a Reopen button.
     - Funnel + dashboard math updated to exclude lost/cancelled outcomes
       from active calculations (KPIs.activeInterests, pipelineValueUsd,
       getPipelineCounts, computePipelineFunnel, getRevenueForecast).
       The funnel now also returns a `lost` summary so callers can
       surface leakage without polluting conversion percentages.

Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB
manually via psql because drizzle-kit push hits a pre-existing zod
parsing issue on the companies index. Dev server may need a restart
to flush prepared-statement caches.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
Matt Ciaccio
886119cbde refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.

This commit closes both gaps:

  1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
     STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
     stageLabel / stageBadgeClass / stageDotClass / safeStage /
     canTransitionStage helpers. components/clients/pipeline-constants.ts
     becomes a re-export shim so existing imports keep working.

  2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
     form, stage picker), pipeline board, client card, berth interests tab,
     portal client interests page, dashboard pipeline / funnel / revenue-
     forecast charts, settings pipeline_weights default, dashboard.service
     weights, analytics.service funnel stages, alert-rules stale-interest
     filter, interest-scoring stage rank.

  3. Documents tab wired into interest detail — replaced the placeholder in
     interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
     EOI launcher is back where salespeople work.

  4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
     (forward-only, no-op if interest is already past the target). Called
     from documents.service.ts on send (→ eoi_sent), Documenso completed
     webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).

  5. Transition guard — canTransitionStage() blocks egregious skips
     (e.g. completed → open, open → contract_signed). Enforced in
     changeInterestStage before the DB write.

Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
Matt Ciaccio
0d357731ad chore(dev): install and wire react-grab
react-grab lets you point at any DOM element on the page and press
Cmd+C to copy the file name, React component, and HTML source — then
paste straight into a coding agent (Claude Code, Cursor, etc.) for
much higher-fidelity context.

Wiring (auto-detected by `npx grab@latest init --force`): a Next
<Script> tag in src/app/layout.tsx that loads the bundle from unpkg
in development only. Production builds skip the script entirely so
no extra weight ships to end users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:37:40 +02:00
Matt Ciaccio
a75d4f5d69 feat(mobile): redesign topbar + collapse cumbersome page-header on mobile
Topbar (mobile-topbar.tsx):
  - Bumped to 56px to match standard mobile-app proportions.
  - Deep-navy gradient surface (#1e2844 -> #171f35) with white type —
    matches the desktop sidebar identity, gives the app a premium
    finish instead of generic white-with-text.
  - Brand "PN" wordmark mark on the left when no back affordance is
    needed (rounded brand-blue square, inset highlight + drop shadow).
  - Soft glow shadow underneath for elevation depth instead of a hard
    bottom border.
  - White-on-navy back arrow with active-state translucent fill.

PageHeader (page-header.tsx):
  - On mobile, the gradient hero strip + duplicate title + description
    block now collapses entirely — the topbar already shows the title,
    so duplicating it in the body wasted a third of the viewport.
  - The actions slot remains rendered as a flush right-aligned row so
    primary buttons (date-range pickers, "+ New X") stay accessible.
  - Desktop rendering is untouched.

Mobile shell (mobile-layout.tsx):
  - Top buffer 16px below the topbar so content doesn't ride flush.
  - Bottom buffer 32px above the tab bar so the last card breathes.

CSS (globals.css):
  - Hide the react-query-devtools floating button below lg: — it was
    overlapping the bottom-tab bar's "More" affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:34:28 +02:00
Matt Ciaccio
0fb7920db5 fix(auth/mobile): support LAN-IP access in dev + edge-to-edge auth bg
- branded-auth-shell: split the background image into a separate
    fixed-positioned layer behind the layout. Previously the bg was on
    a min-h-screen container and iOS Safari left visible whitespace at
    the top/bottom when the URL bar showed/hid (the container's height
    didn't match the visual viewport). Now the bg pins to the actual
    visible viewport via `fixed inset-0`. min-h-[100dvh] also added
    so the layout layer matches.
  - auth client: derive baseURL from window.location.origin instead of
    NEXT_PUBLIC_APP_URL. Same dev build now works whether opened on
    localhost (Mac) or the LAN IP (iPhone on Wi-Fi).
  - auth server: dynamic trustedOrigins function that allows
    localhost / 127.x / 192.168.x / 10.x in dev (function form
    inspects the incoming request's Origin). Production stays locked
    to NEXT_PUBLIC_APP_URL.
  - new dev helper: scripts/dev-set-password.ts to set a user's
    better-auth password directly (bypasses the email-reset flow);
    used to bootstrap matt@letsbe.solutions for mobile testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:21:59 +02:00
Matt Ciaccio
16ad61ce15 fix(mobile): hide duplicate detail-header title on mobile
The mobile topbar already shows the entity name pushed via
useMobileChrome, so the gradient detail-header strip was rendering it
a second time. Hides the inline h1 below sm: while keeping the source
/ email / phone meta and action buttons visible — the strip's
practical content (actions + meta) stays where users need it, and the
title responsibility moves cleanly to the topbar.

Affects: clients, yachts, companies, berths detail headers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:09:32 +02:00
Matt Ciaccio
d080bc52fa feat(mobile): touch up new-invoice + scan-receipt forms
- new invoice: push "New Invoice" to mobile topbar, hide the
    redundant inline back+title row on mobile.
  - scan receipt: dedicated "Take photo" primary button on mobile
    (uses input capture="environment" to open the camera directly)
    plus "Choose from library" secondary. Drop-zone framing kept on
    desktop. Push "Scan Receipt" title to mobile topbar.

Both forms now take their entity title from the topbar and free up
real-estate at the top for actual content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:06:09 +02:00
Matt Ciaccio
a653c8e039 fix(mobile): wrap detail-header actions on narrow viewports
Action buttons in entity detail headers (Invite/GDPR/Archive on
clients, similar sets elsewhere) overflowed off-screen at 393px
because the actions row was flex without flex-wrap. Adds flex-wrap
so buttons drop to a second/third row instead of clipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:48:51 +02:00
Matt Ciaccio
7e8110b2ff feat(mobile): show entity name in mobile topbar on detail pages
Detail pages (clients, yachts, companies, berths, invoices, expenses)
now push their entity name + a back-button toggle to the mobile
topbar via useMobileChrome, replacing the URL UUID fallback that was
rendering before.

Supporting changes:
  - useMobileChrome() no longer throws when called outside the
    MobileLayoutProvider — desktop-tree consumers get a no-op
    setChrome so callers don't have to branch on shell type.
  - setChrome is now stable across renders (useCallback) so callers'
    useEffect dependency arrays don't infinite-loop.
  - DetailPageShell now also pushes its entityName + cleans up on
    unmount, and hides its desktop-only sticky header on mobile so it
    doesn't double up with the topbar (no current callers, prep for
    Phase 4 migration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:46:32 +02:00
Matt Ciaccio
9eadaf035e fix(mobile): widen ListCard href type to Route
Project has experimental.typedRoutes enabled; passing template-literal
URLs through the Link href prop requires the wider Route type. Cast
at the Link boundary inside ListCard so callers can keep the simpler
string-typed href API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:40:52 +02:00
Matt Ciaccio
bcea28cd71 feat(mobile): mobile cards for reminders, audit log, users
Three new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender.

  - ReminderCard:   Bell icon, related-entity subtitle (User/Anchor/
                    FileText icon by entity type), due-date meta with
                    past-due flag, accent bar (rose=past-due,
                    amber=pending, slate=snoozed, emerald=done).
                    Snooze/Complete/Edit/Delete in actions menu.
  - AuditLogCard:   Action icon (Plus/Pencil/Trash2/Eye), entity
                    title, "{verb} by {actor}" subtitle, timestamp
                    meta, optional changed-field chip line. Accent
                    bar by action (created=emerald, updated=blue,
                    deleted=rose). Immutable, no actions menu.
  - UserCard:       Initials avatar, displayName/email, role meta
                    (Shield icon), last-login distance, "Inactive"
                    pill when deactivated. Accent bar (violet=
                    super_admin, slate=inactive, none=active).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:39:06 +02:00
Matt Ciaccio
722491a9dd feat(mobile): mobile cards for yachts, companies, berths, invoices, expenses
Five new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender. Desktop view
(lg+) is unchanged.

  - YachtCard:    Ship icon, owner subtitle (User/Building2 icon by
                  ownerType), dimensions in meters preferred, hull #,
                  status pill. No accent bar (status is free-text).
  - CompanyCard:  Building2 icon, legalName subtitle, country (MapPin)
                  + tax id (Hash) meta, member/yacht count line.
  - BerthCard:    Anchor icon, area subtitle (MapPin), dimensions
                  meta, status pill. Status-encoded accent bar
                  (emerald=available, amber=under_offer, slate=sold).
  - InvoiceCard:  FileText icon, client subtitle, due date (Calendar)
                  meta, prominent currency-formatted amount. Status
                  accent bar (emerald=paid, orange=overdue, ...).
  - ExpenseCard:  Receipt icon, category subtitle, expense date meta,
                  prominent amount, payment-status pill, "Possible
                  duplicate" pill when duplicateOf is set. Accent bar
                  by paymentStatus, overridden to amber when flagged
                  as duplicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:34:04 +02:00
Matt Ciaccio
6009ccb7de feat(mobile): mobile card view for clients + interests lists
Adds optional cardRender prop to <DataTable> that switches the layout
to a vertical card list below lg: while keeping the same TanStack
table instance powering both views (pagination, sort, selection).

New shared shell:
  - <ListCard>          rounded card with optional left status accent bar,
                        whole-card link to detail page, top-right actions
                        slot, and tactile hover/active states.
  - <ListCardAvatar>    40px brand-tinted circle (initials or domain icon).
  - <ListCardMeta>      inline icon + muted text segment.
  - deriveInitials()    shared helper that ignores numeric tokens (so
                        "Recovery Test 1777" -> "RT", not "R1").

Clients and interests pages now render mobile cards via cardRender
using this shell; desktop view (lg+) is unchanged. Interests cards
encode pipeline stage as a left-edge accent strip whose saturation
deepens with pipeline progression (open -> completed). Berths display
with an Anchor icon; null-berth interests fall back to a Compass +
"General interest" italic label. Hot leads get a discreet "Hot" pill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:27:53 +02:00
Matt Ciaccio
71da6e8fdc feat(mobile): swap admin page headers to PageHeader
Mechanical sweep replacing the plain h1+p header markup with the
mobile-aware PageHeader primitive across 12 admin pages: index,
backup, branding, documenso, email, import, invitations, monitoring,
onboarding, reminders, reports, webhooks. Webhooks "Add Webhook"
button preserved via the actions slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:57:52 +02:00
Matt Ciaccio
c405124bc3 feat(mobile): swap reports header to PageHeader
Plain h1 + p replaced with the mobile-aware PageHeader primitive so
the reports landing matches dashboard/settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:55:11 +02:00
Matt Ciaccio
53cbee1d3d fix(mobile): tighten Card padding on mobile (p-4 sm:p-6)
CardHeader/CardContent/CardFooter were uniformly p-6 (24px), which on
top of the mobile shell's 16px outer padding pushed form content 40px
inward — making cards feel content-shifted on a 393px viewport. Drops
to p-4 (16px) below sm and keeps p-6 from sm+ so desktop is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:55:09 +02:00
Matt Ciaccio
ac7f1db62c fix(mobile): add horizontal padding to mobile shell main
Content cards/lists were rendering edge-to-edge on mobile because the
mobile shell's <main> had no horizontal padding (only safe-area top/
bottom). Adds px-4 to match the breathing room desktop gets from p-6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:45:20 +02:00
Matt Ciaccio
5d44f3cfa4 fix(test): raise mobile-audit timeout to 30min for 4-viewport runs
Task 24 audit run hit the 10-minute test.setTimeout ceiling after capturing
2 of 4 viewport passes (iphone-se complete, iphone-16 complete-ish, 16-pro
partial, pro-max not started). 4 viewports × ~45 routes × slowMo: 200 needs
more headroom than 600s gave. 30min is comfortable headroom; the per-test
project timeout is matched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:15:26 +02:00
Matt Ciaccio
d0540dca55 fix(build): extract route.ts handlers to handlers.ts (CLAUDE.md convention)
8 API route files were exporting handler functions directly from route.ts,
which Next.js 15 rejects with "$NAME is not a valid Route export field".
Per CLAUDE.md convention, service-tested handler functions live in sibling
handlers.ts files and route.ts only re-exports the GET/POST/etc. wrapped
in withAuth(withPermission(...)).

Discovered during the mobile-foundation Task 24 build validation; the route
files predate this branch but the build was never re-run on data-model.

Files:
- berth-reservations/[id], companies/autocomplete, companies/[id]/members
  + nested mid/set-primary, yachts/autocomplete, yachts/[id]/transfer,
  yachts/[id]/ownership-history
- Integration tests updated to import from handlers.ts (companies,
  memberships, reservations, yachts-detail)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:14:40 +02:00
Matt Ciaccio
0e9c24e222 test(visual): add mobile shell snapshot baselines (dashboard + more sheet)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 14:47:26 +02:00
Matt Ciaccio
3aba2181dc feat(test): extract anchor iPhone device descriptors to shared fixture
Move the four iPhone viewport descriptors (SE, 15/16, 16/17 Pro, Pro Max)
into tests/e2e/fixtures/devices.ts so the upcoming visual spec (Task 23)
can share the same anchors. The mobile-audit spec now spreads each
descriptor and adds a slug `name` plus a human `label` for the run header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:31:51 +02:00
Matt Ciaccio
6237ad1567 feat(mobile): add FilterChips primitive (horizontal chip row with Add-filter trigger) 2026-04-29 14:28:33 +02:00
Matt Ciaccio
34916d855e feat(mobile): add DataView (TanStack table on lg+, card list below) with cardRender callback 2026-04-29 14:27:17 +02:00
Matt Ciaccio
41ae8a328f feat(mobile): add DetailPageShell with sticky header + mobile sticky-action shelf 2026-04-29 14:25:45 +02:00
Matt Ciaccio
1ff3160eac feat(mobile): add ActionRow with horizontal-scroll-snap on mobile, wrap on desktop 2026-04-29 14:24:43 +02:00
Matt Ciaccio
5698d742d3 feat(mobile): make PageHeader mobile-aware (stack below sm, hide description when actions present) 2026-04-29 14:23:40 +02:00
Matt Ciaccio
e6ce265be0 fix(mobile): drop positive display rule that overrode desktop shell's flex layout 2026-04-29 14:20:11 +02:00
Matt Ciaccio
19bc2f2a54 feat(mobile): mount MobileLayout alongside desktop shell, remove legacy sidebar mobile-drawer 2026-04-29 14:18:28 +02:00
Matt Ciaccio
b0a11f1785 feat(mobile): add MobileLayout shell composing topbar + content + bottom tabs + more sheet 2026-04-29 14:16:30 +02:00
Matt Ciaccio
3cbf2444fe feat(mobile): add MoreSheet (3-column grid of long-tail nav items in a bottom drawer) 2026-04-29 14:15:25 +02:00
Matt Ciaccio
0330be1312 feat(mobile): add Drawer (vaul wrapper) for native-feel bottom sheets 2026-04-29 14:14:18 +02:00
Matt Ciaccio
210360738d feat(mobile): add MobileBottomTabs with 5 fixed tabs (Dashboard/Clients/Yachts/Berths/More) 2026-04-29 14:13:09 +02:00
Matt Ciaccio
4df04e1a58 feat(mobile): add MobileTopbar with title, back-button, and primary-action slots 2026-04-29 14:12:15 +02:00
Matt Ciaccio
0c3baf04c5 feat(mobile): add MobileLayoutProvider context + useMobileChrome hook 2026-04-29 14:11:27 +02:00
Matt Ciaccio
79667b24da chore(pwa): add placeholder icons (icon-192/512/512-maskable, apple-touch-icon) 2026-04-29 14:10:14 +02:00
Matt Ciaccio
c4fdb29bbe feat(mobile): render Dialog full-screen below sm, centered modal at sm+ 2026-04-29 14:08:14 +02:00
Matt Ciaccio
38527d71fc feat(mobile): bump touch-target heights on Button/Input/Textarea, keep 16px to prevent iOS zoom 2026-04-29 14:06:59 +02:00
Matt Ciaccio
3fbfba6598 chore(deps): add vaul for native-feel bottom sheets 2026-04-29 14:05:10 +02:00
Matt Ciaccio
e3a835675b feat(mobile): add useIsMobile() hook backed by matchMedia (visual-test-only) 2026-04-29 14:04:02 +02:00
Matt Ciaccio
1b085f81ed feat(mobile): add CSS rules to switch shells based on data-form-factor + viewport 2026-04-29 14:00:49 +02:00
Matt Ciaccio
9f786fbcf3 feat(mobile): set data-form-factor body attr from User-Agent in root layout 2026-04-29 13:59:03 +02:00
Matt Ciaccio
906127a292 feat(mobile): add safe-area spacing utilities (pt-safe-top, pb-safe-bottom, etc.) 2026-04-29 13:56:53 +02:00
Matt Ciaccio
737b43589b feat(mobile): add viewport meta, theme-color, and PWA metadata to root layout 2026-04-29 13:55:37 +02:00
Matt Ciaccio
fbb1f1f366 scaffold(mobile): branch setup — audit harness, spec, plan, gitignore + client-portal cleanup
Pre-execution baseline for the mobile foundation PR:

- Mobile audit harness (tests/e2e/audit/mobile.spec.ts + mobile-audit Playwright project) — visits every page at four anchor iPhone viewports (375/393/402/440), screenshots full-page to .audit/mobile/, generates index.md
- Design spec (docs/superpowers/specs/2026-04-29-mobile-optimization-design.md) — adaptive shell + responsive content; full active-iPhone-range coverage; foundation + per-page migration phases
- Implementation plan (docs/superpowers/plans/2026-04-29-mobile-foundation.md) — 24 TDD tasks for the foundation PR
- .gitignore: ignore /client-portal/ (legacy nested Nuxt repo) and /.audit/ (regenerable screenshots)
- Remove phantom client-portal gitlink (mode 160000 with no .gitmodules)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:49:38 +02:00
197 changed files with 21570 additions and 1618 deletions

9
.gitignore vendored
View File

@@ -20,10 +20,17 @@ tsconfig.tsbuildinfo
docker-compose.override.yml docker-compose.override.yml
.remember/ .remember/
.DS_Store .DS_Store
eoi/ # Root-only ad-hoc EOI scratch dir; routes under src/app/.../eoi/ must NOT match.
/eoi/
# Brainstorming companion mockup files # Brainstorming companion mockup files
.superpowers/ .superpowers/
# Ad-hoc screenshots / scratch artifacts at repo root # Ad-hoc screenshots / scratch artifacts at repo root
/*.png /*.png
# Legacy Nuxt portal — kept on disk for reference, not tracked here
/client-portal/
# Mobile audit screenshots — generated locally, regenerable
/.audit/

Submodule client-portal deleted from 84f89f9409

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
# Mobile Optimization Design
**Status**: Design approved 2026-04-29 — pending plan.
**Plan decomposition**: Foundation PR (§3) is one implementation plan; per-page migration phases (§5) become follow-up plans, scoped per phase.
**Branch base**: stacks on `refactor/data-model`.
**Out of scope**: Phase B/C features, desktop redesign, Capacitor wrapper, swipe-actions on rows, native menus, server-driven UI.
---
## 1. Background
The CRM was built desktop-first. A 2026-04-29 mobile audit captured every authenticated and public page across the active iPhone viewport range. Findings:
1. **No `viewport` meta in the root layout** (one exists only in the scanner PWA sub-layout, `src/app/(scanner)/[portSlug]/scan/layout.tsx`). Without it, iOS Safari renders pages at the default 980px logical width and zooms out to fit — text becomes unreadable and touch targets sub-tappable. Playwright's `isMobile` emulation in the audit forces 393px-wide rendering, which exposes the layout breakage you'd otherwise have to discover by pinching to zoom.
2. **Topbar overflows**. Search input + port switcher + sign-out button cram into one row; sign-out clips off the right edge as a half-visible blue bar on every authenticated page.
3. **Tables render as desktop tables**. Every list page (clients, yachts, companies, invoices, expenses, interests, audit, users, etc.) shows truncated columns with horizontal scroll.
4. **Page headers don't downsize**. Titles like "Dashboard" truncate to "Dash..."; primary action buttons (`+ New Client`) overlap their subtitles.
5. **Detail page action chips overflow**. The chip row ("Invite to portal | GDPR export | Archive | …") horizontally overflows on every detail page.
6. **One half-good pattern**: detail pages already collapse their tabs to a `<select>` dropdown on small screens. Worth extending.
7. **Auth + scanner pages are already mobile-first** (`/login`, `/[portSlug]/scan`). Reference for the "what good looks like" target.
The audit harness (`tests/e2e/audit/mobile.spec.ts` + `mobile-audit` Playwright project) is added on this branch (not yet committed); re-runs regenerate `.audit/mobile/` (gitignored).
## 2. Approach
**Adaptive shell + responsive content** — chosen over (a) per-page conditional render, (b) a separate `(mobile)` route group, and (c) Tailwind-only responsive.
The "native feel" the user wants comes from the chrome — bottom tab bar, sheet modals, sticky compact header, safe-area awareness. Page content (forms, lists, details) doesn't need duplication; it gets responsive via shared mobile-aware primitives. This concentrates the dedicated-mobile work in ~10 components and keeps content single-source.
**Breakpoint**: Tailwind `lg` (1024px). Below `lg`, the mobile shell renders. At and above, the existing desktop shell is untouched.
### 2.1 Target iPhone viewport range
The mobile shell + content primitives must look correct across the full active iPhone viewport range (portrait):
| Tier | Models | Viewport |
| ------------------------------------------ | ----------------------------------------------- | -------- |
| Narrowest | iPhone SE 2nd / 3rd gen | 375×667 |
| Standard | iPhone 12/13/14 (and Mini) | 390×844 |
| Standard newer | iPhone 15 / 15 Pro / 16 | 393×852 |
| Pro newer (Dynamic Island, thinner bezels) | iPhone 16 Pro / 17 Pro | 402×874 |
| Plus / older Max | iPhone 14 Plus / 15 Plus / 15 Pro Max / 16 Plus | 430×932 |
| Pro Max | iPhone 16 Pro Max / 17 Pro Max | 440×956 |
**Anchors used by audit and design validation**: 375×667 (worst-case narrow + short), 393×852 (most common current), 402×874 (current Pro), 440×956 (current Pro Max). Models within ±5px of an anchor (390, 430) are skipped — primitives that look correct at the anchors will look correct at neighbors.
**Dynamic Island**: iPhone 14 Pro and later have a larger top safe-area inset (~59px vs ~47px on classic-notch models). The CSS `env(safe-area-inset-top)` we expose as `pt-safe` handles this transparently — no per-model code paths.
**Landscape**: out of scope for this design. Phones in landscape are rare for CRM-style work; if needed later, the mobile shell at landscape widths would still fall under `lg` and would just stretch. Tablet landscape is addressed in the §5 tablet-pass phase.
**Routing**: no new route group. URLs and middleware unchanged. RBAC, services, queries, validators, RHF/zod forms, TanStack Query stores, socket.io — all unchanged.
## 3. Foundation PR
A single branch lands the infra + shell + primitives before any per-page work. After this merges, every authenticated page already gains: real viewport meta, no clipped topbar, bottom tab navigation, safe-area handling, and 44px touch targets — without any per-page edits.
### 3.1 Infrastructure
- `viewport` export in `src/app/layout.tsx``width=device-width, initial-scale=1, viewport-fit=cover`.
- `theme-color` meta + `apple-mobile-web-app-capable` meta + `apple-mobile-web-app-status-bar-style` for PWA-ish status-bar integration.
- Safe-area CSS variables (`env(safe-area-inset-*)`) exposed as Tailwind utilities (`pt-safe`, `pb-safe`, `pl-safe`, `pr-safe`).
- `useIsMobile()` hook in `src/hooks/use-is-mobile.ts` — backed by `window.matchMedia('(max-width: 1023.98px)')`, no resize listener.
- Server-side body-class detection: the root layout (`src/app/layout.tsx`) reads the `user-agent` request header via `next/headers`'s `headers()`, runs a small known-mobile-token check (Mobile / iPhone / iPad / Android — no library), and renders `<body data-form-factor="mobile|desktop">`. No middleware needed. CSS `[data-form-factor="mobile"]` reveals the mobile shell. The CSS media-query fallback (`@media (max-width: 1023.98px)`) handles UA misclassification (e.g., desktop browser resized to narrow width, or stripped UA).
### 3.2 Mobile shell
Both desktop and mobile shells are rendered to the DOM by the root layout; CSS reveals one and hides the other based on `[data-form-factor="mobile"]` plus a `@media (max-width: 1023.98px)` fallback. The existing `<Sidebar>` and `<Topbar>` components stay unchanged for the desktop shell. The mobile shell is wholly new:
- **`<MobileLayout>`** (`src/components/layout/mobile/mobile-layout.tsx`)
Fixed 52px compact topbar (safe-area aware) + scrollable content + fixed 56px bottom tab bar (safe-area inset). Renders instead of the desktop sidebar+topbar shell when the form factor resolves to mobile.
- **`<MobileTopbar>`**
Page title (auto-truncating, single-line) + back button when route depth > 1 + single primary action slot (passed via context from the page) + port-switcher behind a `<Sheet>` trigger.
- **`<MobileBottomTabs>`**
Fixed 5 tabs: **Dashboard / Clients / Yachts / Berths / More**. Active state from current path. Lucide icons (no emoji). Badge support for the alerts count.
- **`<MoreSheet>`**
Bottom sheet opened by the More tab. Holds the long tail in a scrollable list grouped by section: Companies, Interests, Invoices, Expenses, Documents, Email, Alerts, Reports, Reminders, Settings, Admin (with admin nesting one level deep into a child sheet).
- **`<MobileLayoutProvider>`**
React context that lets each page push its title, back button, and primary action slot to `<MobileTopbar>` via a hook (`useMobileChrome({ title, action })`).
### 3.3 Primitives
All built once in `src/components/shared/`. Render desktop-style above `lg`, mobile-style below.
- **`<Sheet>`** — vaul-based bottom sheet on mobile, falls through to existing Radix `<Dialog>` on desktop. Same API as `<Dialog>` so adoption is mechanical.
- **`<DataView>`** — accepts the same column defs the codebase uses today via TanStack Table. Above `lg`: renders the existing table. Below `lg`: renders a card list with a per-row `cardRender({ row }) => ReactNode` callback. Filter chips stay above the list; sort moves into a `<Sheet>` opened by a sort button.
- **`<PageHeader>`** — title + optional subtitle + actions. Truncates title to one line, stacks actions to a second row on mobile, hides subtitle below `sm` if action row is present.
- **`<ActionRow>`** — chip-style action group; `flex-nowrap overflow-x-auto scroll-smooth snap-x` on mobile, no overflow on desktop.
- **`<DetailPageShell>`** — wraps detail pages with: sticky compact header (entity name, primary status), tab dropdown selector (existing pattern, extracted), scrollable content area, optional sticky bottom action bar (Save / Archive / etc.) on mobile that pins above the bottom tab bar.
- **`<FilterChips>`** — chip-row filter UI used by `<DataView>`. Active filters are dismissable chips; "Add filter" opens a `<Sheet>`.
### 3.4 Default style adjustments
- `<Button>` and `<Input>` defaults: `min-h-11` (44px, Apple HIG touch-target).
- `<Input>` and `<Textarea>` body text: `text-base` (16px) so iOS doesn't zoom on focus.
- `<Dialog>` default base styling tweaked so any remaining unmigrated dialogs render full-screen on mobile (until they get migrated to `<Sheet>`).
### 3.5 Bundle impact
Both shells render server-side and switch via the `data-form-factor` body attribute, so both ship to every client (dynamic-importing one would cause a hydration flash). Rough estimate ~40KB gzipped added to the layout subtree for the mobile shell + new primitives (vaul ≈ 5KB gz, the rest is in-house components). Verify post-build with `pnpm build` and adjust if it's materially higher. Acceptable trade for no flash and no UA-based render-time branching.
### 3.6 PWA assets
The PWA scanner already references `icon-192.png`, `icon-512.png`, `icon-512-maskable.png` from `public/`, but those files don't exist yet (separate flagged blocker). The mobile shell adds an `apple-touch-icon` reference too. The Foundation PR includes placeholder PNGs so home-screen install works; production-quality icons can replace them without a code change.
## 4. Per-page playbook
Once foundation lands, each page follows the same workflow:
1. Open the page in headed Playwright at the anchor viewports per §2.1 (start at 393×852 for the iteration loop, spot-check 375 and 440 before declaring done).
2. Replace any `<Dialog>` with `<Sheet>`.
3. If list page: wrap the table in `<DataView>` and provide a `cardRender` callback. The 2-3 fields shown on the card are decided per page during migration with the user.
4. Replace the ad-hoc page header with `<PageHeader>`.
5. Replace ad-hoc action button rows with `<ActionRow>`.
6. Touch up any custom embedded widgets the page uses (rare for simple pages, common for `email`, `documents`, `expenses/scan`).
7. User reviews live in the headed browser, points out tweaks, iterate.
Most pages take 515 minutes in this loop. Heavy pages (email inbox, documents hub) may take 3060 because the embedded widgets need their own mobile treatment beyond the primitives.
## 5. Migration sequence
After foundation PR:
1. **Quick-win sweep** (~half day) — pages mostly fixed by foundation alone. Just need `<PageHeader>` swap-in (no list-card conversion, no detail-shell wrap):
`dashboard` (overview), `settings` (user-profile), `reports`, and the admin sub-pages that are forms or stat cards: `admin/settings`, `admin/branding`, `admin/forms`, `admin/ocr`, `admin/roles`, `admin/tags`, `admin/documenso`, `admin/templates`, `admin/custom-fields`, `admin/monitoring`, `admin/backup`, `admin/webhooks`, `admin/import`, `admin/ports`.
2. **List pages** (~12 days) — convert via `<DataView>` + per-page `cardRender`:
`clients`, `yachts`, `companies`, `berths`, `interests`, `invoices`, `expenses`, `alerts`, `reminders`, `admin/audit`, `admin/users`.
3. **Heavy pages** (~1 day each) — embedded widgets need their own mobile treatment beyond the primitives:
`documents` (sig-tracking + filters from Phase A), `email` (thread list + reader + composer).
4. **Detail pages** (~12 days) — wrap in `<DetailPageShell>`, extend the tab-dropdown pattern, add sticky bottom action shelf:
`clients/[clientId]`, `yachts/[yachtId]`, `companies/[companyId]`, `berths/[berthId]`, `invoices/[id]`, `expenses/[id]`.
5. **Forms & wizards** — touch-up only, since `<Input>`/`<Button>` defaults handle the bulk:
`invoices/new` (3-step wizard), `expenses/scan` (already mobile-first, just verify).
6. **Portal** — same patterns, smaller scope:
authenticated: `portal/dashboard`, `portal/invoices`, `portal/my-yachts`, `portal/documents`, `portal/interests`, `portal/my-reservations`. Public: `portal/login`, `portal/activate`, `portal/forgot-password`, `portal/reset-password` (already styled by `<BrandedAuthShell>` — just verify).
7. **Tablet pass** — re-audit at iPad Air 11" portrait (820×1180) and landscape (1180×820), iPad Air 13" portrait (1024×1366) and landscape (1366×1024). The 820 portrait case will hit the mobile shell (820 < 1024) and probably want a "tablet-portrait" treatment with sidebar visible — flagged for design refinement at that phase, not now. The other three viewports fall above `lg` and use the desktop shell unchanged.
## 6. Testing
- **Mobile audit project** (`mobile-audit` in `playwright.config.ts`) is the regression harness. Re-runs after every page-migration PR; output goes to `.audit/mobile/` (gitignored). Audit covers the four anchor viewports defined in §2.1: 375×667, 393×852, 402×874, 440×956. Run time ~14 min headed.
- **Smoke project** gets a curated mobile-viewport variant (~10 pages at the 393×852 anchor) — adds ~2 min to CI; full audit stays out of CI to avoid the ~14 min cost.
- **Visual baselines** — `visual` project gets new mobile snapshots at the 393×852 anchor for: dashboard, clients-list, clients-detail, invoices-list, invoices-new, scan, documents, login. Regenerate with `--update-snapshots` after intentional changes (existing convention).
- **Anchor device descriptors** lifted into a shared fixture at `tests/e2e/fixtures/devices.ts` (one per anchor in §2.1) so specs don't redefine viewport.
- **No new unit tests** for the primitives — they are presentational. Coverage comes from visual + integration runs.
## 7. Open questions
- **Bottom-tab taxonomy**: locked at Dashboard / Clients / Yachts / Berths / More for now. The More sheet holds everything else losslessly, so this is reversible — if real usage suggests a different top-5 (e.g., Interests or Invoices in the tabs), swap them later without code restructure.
- **`refactor/data-model` push order**: 155 commits unpushed. Foundation PR can stack on top and rebase, or wait until that branch merges. Decision deferred to user.
- **Desktop touch-target adjustments**: bumping `<Button>`/`<Input>` to `min-h-11` will affect desktop too. Verify visually that no desktop layout breaks; if any does, scope the bump to mobile-only via the `data-form-factor` attribute.
## 8. Files to create
```
src/hooks/use-is-mobile.ts
src/components/layout/mobile/
mobile-layout.tsx
mobile-topbar.tsx
mobile-bottom-tabs.tsx
more-sheet.tsx
mobile-layout-provider.tsx
src/components/shared/
sheet.tsx (new — vaul wrapper)
data-view.tsx (new — table↔card)
page-header.tsx (new)
action-row.tsx (new)
detail-page-shell.tsx (new)
filter-chips.tsx (new)
src/app/layout.tsx (modified — viewport export, theme-color, UA-derived data-form-factor body attribute via headers())
public/icon-192.png (placeholder PWA asset)
public/icon-512.png (placeholder PWA asset)
public/icon-512-maskable.png (placeholder PWA asset)
public/apple-touch-icon.png (placeholder PWA asset)
tailwind.config.ts (modified — safe-area utilities, touch-target defaults)
tests/e2e/fixtures/devices.ts (new — shared device descriptors)
```
## 9. Files to modify per page
Per the playbook in §4, each page typically needs:
- One swap of header markup → `<PageHeader>`.
- For list pages: one wrap of table → `<DataView>` + add `cardRender` callback.
- For detail pages: wrap in `<DetailPageShell>`.
- Replace `<Dialog>` imports with `<Sheet>`.
- No service, validator, query, or schema changes anywhere.

View File

@@ -87,6 +87,7 @@
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"vaul": "^1.1.2",
"zod": "^3.24.0", "zod": "^3.24.0",
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
@@ -112,6 +113,7 @@
"lint-staged": "^15.2.0", "lint-staged": "^15.2.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"prettier": "^3.4.0", "prettier": "^3.4.0",
"react-grab": "^0.1.32",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",

View File

@@ -75,6 +75,24 @@ export default defineConfig({
viewport: { width: 1440, height: 900 }, viewport: { width: 1440, height: 900 },
}, },
}, },
{
// Mobile / tablet audit — visits every page in headed Chromium at iPhone
// viewports (portrait), screenshots full-page to .audit/mobile/<viewport>/,
// and writes an index.md. Depends on `setup` for seeded admin + port-role.
name: 'mobile-audit',
testMatch: /audit\/mobile\.spec\.ts/,
dependencies: ['setup'],
// Single test walks 4 viewports × ~45 routes sequentially with slowMo;
// 30 min headroom keeps us well under the wall-clock cost.
timeout: 1_800_000,
use: {
headless: false,
launchOptions: { slowMo: 200 },
screenshot: 'off',
video: 'off',
trace: 'off',
},
},
], ],
// Don't start the dev server — we expect it to already be running // Don't start the dev server — we expect it to already be running

159
pnpm-lock.yaml generated
View File

@@ -206,6 +206,9 @@ importers:
tesseract.js: tesseract.js:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0 version: 7.0.0
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
zod: zod:
specifier: ^3.24.0 specifier: ^3.24.0
version: 3.25.76 version: 3.25.76
@@ -276,6 +279,9 @@ importers:
prettier: prettier:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.8.1 version: 3.8.1
react-grab:
specifier: ^0.1.32
version: 0.1.32(react@19.2.4)
tailwindcss: tailwindcss:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
@@ -339,6 +345,10 @@ packages:
peerDependencies: peerDependencies:
react: '>=16.9.0' react: '>=16.9.0'
'@antfu/ni@0.23.2':
resolution: {integrity: sha512-FSEVWXvwroExDXUu8qV6Wqp2X3D1nJ0Li4LFymCyvCVrm7I3lNfG0zZWSWvGU1RE7891eTnFTyh31L3igOwNKQ==}
hasBin: true
'@babel/helper-string-parser@7.27.1': '@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -2077,6 +2087,10 @@ packages:
react: '>=16.9.0' react: '>=16.9.0'
react-dom: '>=16.9.0' react-dom: '>=16.9.0'
'@react-grab/cli@0.1.32':
resolution: {integrity: sha512-TI4SHATLH2yM1DMRXgH3dt/8b3Rj51BplDOqOQiHQKAMOuKVAR9WE2WGWJRT3LwFpl8BXR9ytAM9vrGDrB7QGw==}
hasBin: true
'@reduxjs/toolkit@2.11.2': '@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies: peerDependencies:
@@ -2848,6 +2862,11 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'} engines: {node: '>=8'}
bippy@0.5.39:
resolution: {integrity: sha512-8hE8rKSl8JWyeaY+JjpnmceWAZPpLEyzOZQpWXM5Rc7861c5WotMJHy2aRZKZrGA8nMpvLNF01t4yQQ+HcZG3w==}
peerDependencies:
react: '>=17.0.1'
block-stream2@2.1.0: block-stream2@2.1.0:
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
@@ -2953,6 +2972,10 @@ packages:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'} engines: {node: '>=18'}
cli-spinners@2.9.2:
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
engines: {node: '>=6'}
cli-truncate@4.0.0: cli-truncate@4.0.0:
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3008,6 +3031,10 @@ packages:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'} engines: {node: '>=18'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@4.1.1: commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -3982,6 +4009,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-interactive@2.0.0:
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
engines: {node: '>=12'}
is-map@2.0.3: is-map@2.0.3:
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4030,6 +4061,14 @@ packages:
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-unicode-supported@1.3.0:
resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==}
engines: {node: '>=12'}
is-unicode-supported@2.1.0:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
is-url@1.2.4: is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
@@ -4121,6 +4160,9 @@ packages:
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
hasBin: true hasBin: true
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
jsx-ast-utils@3.3.5: jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
@@ -4128,6 +4170,10 @@ packages:
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
kysely@0.28.11: kysely@0.28.11:
resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -4274,6 +4320,10 @@ packages:
lodash@4.17.23: lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
log-symbols@6.0.0:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'}
log-update@6.1.0: log-update@6.1.0:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4582,6 +4632,10 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
ora@8.2.0:
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
engines: {node: '>=18'}
own-keys@1.0.1: own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4781,6 +4835,10 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'} engines: {node: '>= 0.6.0'}
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -5071,6 +5129,15 @@ packages:
react-fast-compare@3.2.2: react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
react-grab@0.1.32:
resolution: {integrity: sha512-ODZkzu4zjwX/5a1VxTdIkagPD6uPnp8IkSN2v5FDgFMZkH5r/YEMq43hIsdpHV5/R2ymqS9zLxp4H7SNSRx5ng==}
hasBin: true
peerDependencies:
react: '>=17.0.0'
peerDependenciesMeta:
react:
optional: true
react-hook-form@7.71.2: react-hook-form@7.71.2:
resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==} resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -5357,6 +5424,9 @@ packages:
simple-swizzle@0.2.4: simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
slice-ansi@5.0.0: slice-ansi@5.0.0:
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -5369,6 +5439,10 @@ packages:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
smol-toml@1.6.1:
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'}
socket.io-adapter@2.5.6: socket.io-adapter@2.5.6:
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
@@ -5431,6 +5505,10 @@ packages:
std-env@4.0.0: std-env@4.0.0:
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
stdin-discarder@0.2.2:
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
engines: {node: '>=18'}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5763,6 +5841,12 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
vaul@1.1.2:
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
victory-vendor@37.3.6: victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
@@ -6065,6 +6149,8 @@ snapshots:
resize-observer-polyfill: 1.5.1 resize-observer-polyfill: 1.5.1
throttle-debounce: 5.0.2 throttle-debounce: 5.0.2
'@antfu/ni@0.23.2': {}
'@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.28.5': {}
@@ -7467,6 +7553,17 @@ snapshots:
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
'@react-grab/cli@0.1.32':
dependencies:
'@antfu/ni': 0.23.2
commander: 14.0.3
ignore: 7.0.5
jsonc-parser: 3.3.1
ora: 8.2.0
picocolors: 1.1.1
prompts: 2.4.2
smol-toml: 1.6.1
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
@@ -8233,6 +8330,10 @@ snapshots:
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
bippy@0.5.39(react@19.2.4):
dependencies:
react: 19.2.4
block-stream2@2.1.0: block-stream2@2.1.0:
dependencies: dependencies:
readable-stream: 3.6.2 readable-stream: 3.6.2
@@ -8353,6 +8454,8 @@ snapshots:
dependencies: dependencies:
restore-cursor: 5.1.0 restore-cursor: 5.1.0
cli-spinners@2.9.2: {}
cli-truncate@4.0.0: cli-truncate@4.0.0:
dependencies: dependencies:
slice-ansi: 5.0.0 slice-ansi: 5.0.0
@@ -8409,6 +8512,8 @@ snapshots:
commander@13.1.0: {} commander@13.1.0: {}
commander@14.0.3: {}
commander@4.1.1: {} commander@4.1.1: {}
component-classes@1.2.6: component-classes@1.2.6:
@@ -9561,6 +9666,8 @@ snapshots:
dependencies: dependencies:
is-extglob: 2.1.1 is-extglob: 2.1.1
is-interactive@2.0.0: {}
is-map@2.0.3: {} is-map@2.0.3: {}
is-negative-zero@2.0.3: {} is-negative-zero@2.0.3: {}
@@ -9604,6 +9711,10 @@ snapshots:
dependencies: dependencies:
which-typed-array: 1.1.20 which-typed-array: 1.1.20
is-unicode-supported@1.3.0: {}
is-unicode-supported@2.1.0: {}
is-url@1.2.4: {} is-url@1.2.4: {}
is-weakmap@2.0.2: {} is-weakmap@2.0.2: {}
@@ -9685,6 +9796,8 @@ snapshots:
dependencies: dependencies:
minimist: 1.2.8 minimist: 1.2.8
jsonc-parser@3.3.1: {}
jsx-ast-utils@3.3.5: jsx-ast-utils@3.3.5:
dependencies: dependencies:
array-includes: 3.1.9 array-includes: 3.1.9
@@ -9696,6 +9809,8 @@ snapshots:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
kleur@3.0.3: {}
kysely@0.28.11: {} kysely@0.28.11: {}
language-subtag-registry@0.3.23: {} language-subtag-registry@0.3.23: {}
@@ -9823,6 +9938,11 @@ snapshots:
lodash@4.17.23: {} lodash@4.17.23: {}
log-symbols@6.0.0:
dependencies:
chalk: 5.6.2
is-unicode-supported: 1.3.0
log-update@6.1.0: log-update@6.1.0:
dependencies: dependencies:
ansi-escapes: 7.3.0 ansi-escapes: 7.3.0
@@ -10121,6 +10241,18 @@ snapshots:
type-check: 0.4.0 type-check: 0.4.0
word-wrap: 1.2.5 word-wrap: 1.2.5
ora@8.2.0:
dependencies:
chalk: 5.6.2
cli-cursor: 5.0.0
cli-spinners: 2.9.2
is-interactive: 2.0.0
is-unicode-supported: 2.1.0
log-symbols: 6.0.0
stdin-discarder: 0.2.2
string-width: 7.2.0
strip-ansi: 7.2.0
own-keys@1.0.1: own-keys@1.0.1:
dependencies: dependencies:
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
@@ -10313,6 +10445,11 @@ snapshots:
process@0.11.10: {} process@0.11.10: {}
prompts@2.4.2:
dependencies:
kleur: 3.0.3
sisteransi: 1.0.5
prop-types@15.8.1: prop-types@15.8.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -10728,6 +10865,13 @@ snapshots:
react-fast-compare@3.2.2: {} react-fast-compare@3.2.2: {}
react-grab@0.1.32(react@19.2.4):
dependencies:
'@react-grab/cli': 0.1.32
bippy: 0.5.39(react@19.2.4)
optionalDependencies:
react: 19.2.4
react-hook-form@7.71.2(react@19.2.4): react-hook-form@7.71.2(react@19.2.4):
dependencies: dependencies:
react: 19.2.4 react: 19.2.4
@@ -11075,6 +11219,8 @@ snapshots:
dependencies: dependencies:
is-arrayish: 0.3.4 is-arrayish: 0.3.4
sisteransi@1.0.5: {}
slice-ansi@5.0.0: slice-ansi@5.0.0:
dependencies: dependencies:
ansi-styles: 6.2.3 ansi-styles: 6.2.3
@@ -11087,6 +11233,8 @@ snapshots:
smart-buffer@4.2.0: {} smart-buffer@4.2.0: {}
smol-toml@1.6.1: {}
socket.io-adapter@2.5.6: socket.io-adapter@2.5.6:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -11167,6 +11315,8 @@ snapshots:
std-env@4.0.0: {} std-env@4.0.0: {}
stdin-discarder@0.2.2: {}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -11584,6 +11734,15 @@ snapshots:
vary@1.1.2: {} vary@1.1.2: {}
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
victory-vendor@37.3.6: victory-vendor@37.3.6:
dependencies: dependencies:
'@types/d3-array': 3.2.2 '@types/d3-array': 3.2.2

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,40 @@
/**
* Dev helper: set a user's password directly (bypasses email reset).
* Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>
*/
import 'dotenv/config';
import { hashPassword } from 'better-auth/crypto';
import { eq, and } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, account } from '@/lib/db/schema/users';
async function main() {
const [, , email, password] = process.argv;
if (!email || !password) {
console.error('Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>');
process.exit(1);
}
const u = await db.query.user.findFirst({ where: eq(user.email, email) });
if (!u) {
console.error(`User not found: ${email}`);
process.exit(1);
}
const hash = await hashPassword(password);
const result = await db
.update(account)
.set({ password: hash, updatedAt: new Date() })
.where(and(eq(account.userId, u.id), eq(account.providerId, 'credential')))
.returning({ id: account.id });
if (result.length === 0) {
console.error(`No credential account row for ${email}`);
process.exit(1);
}
console.log(`Updated password for ${email} (account id ${result[0]?.id}).`);
process.exit(0);
}
main();

View File

@@ -1,10 +1,9 @@
import { PageHeader } from '@/components/shared/page-header';
export default function BackupManagementPage() { export default function BackupManagementPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader title="Backup Management" description="Manage system backups and restoration" />
<h1 className="text-2xl font-bold text-foreground">Backup Management</h1>
<p className="text-muted-foreground">Manage system backups and restoration</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p> <p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -2,6 +2,7 @@ import {
SettingsFormCard, SettingsFormCard,
type SettingFieldDef, type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card'; } from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
const FIELDS: SettingFieldDef[] = [ const FIELDS: SettingFieldDef[] = [
{ {
@@ -47,13 +48,10 @@ const FIELDS: SettingFieldDef[] = [
export default function BrandingSettingsPage() { export default function BrandingSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Branding</h1> title="Branding"
<p className="text-sm text-muted-foreground"> description="Logo, primary color, app name, and email header/footer HTML used by the branded auth shell and outgoing email templates."
Logo, primary color, app name, and email header/footer HTML used by the branded auth shell />
and outgoing email templates.
</p>
</div>
<SettingsFormCard <SettingsFormCard
title="Identity" title="Identity"
description="App name, logo, and primary color." description="App name, logo, and primary color."

View File

@@ -3,6 +3,7 @@ import {
type SettingFieldDef, type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card'; } from '@/components/admin/shared/settings-form-card';
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button'; import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
import { PageHeader } from '@/components/shared/page-header';
const API_FIELDS: SettingFieldDef[] = [ const API_FIELDS: SettingFieldDef[] = [
{ {
@@ -48,13 +49,10 @@ const EOI_FIELDS: SettingFieldDef[] = [
export default function DocumensoSettingsPage() { export default function DocumensoSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Documenso & EOI</h1> title="Documenso & EOI"
<p className="text-sm text-muted-foreground"> description="API credentials and default EOI generation pathway. Use the test-connection button to verify a saved configuration before relying on it."
API credentials and default EOI generation pathway. Use the test-connection button to />
verify a saved configuration before relying on it.
</p>
</div>
<SettingsFormCard <SettingsFormCard
title="Documenso API" title="Documenso API"

View File

@@ -2,6 +2,7 @@ import {
SettingsFormCard, SettingsFormCard,
type SettingFieldDef, type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card'; } from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
const FIELDS: SettingFieldDef[] = [ const FIELDS: SettingFieldDef[] = [
{ {
@@ -79,13 +80,10 @@ const FIELDS: SettingFieldDef[] = [
export default function EmailSettingsPage() { export default function EmailSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Email Settings</h1> title="Email Settings"
<p className="text-sm text-muted-foreground"> description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank."
Per-port outgoing email configuration. SMTP credentials and the From address default to />
environment variables when these fields are blank.
</p>
</div>
<SettingsFormCard <SettingsFormCard
title="From address & signature" title="From address & signature"
description="Identity headers and shared HTML used by system-generated emails." description="Identity headers and shared HTML used by system-generated emails."

View File

@@ -1,10 +1,9 @@
import { PageHeader } from '@/components/shared/page-header';
export default function DataImportPage() { export default function DataImportPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader title="Data Import" description="Import data from external sources" />
<h1 className="text-2xl font-bold text-foreground">Data Import</h1>
<p className="text-muted-foreground">Import data from external sources</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p> <p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -1,15 +1,13 @@
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager'; import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
import { PageHeader } from '@/components/shared/page-header';
export default function InvitationsPage() { export default function InvitationsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Invitations</h1> title="Invitations"
<p className="text-sm text-muted-foreground"> description="Send a single-use invitation to a new CRM user. The recipient sets their own password via the link in the email."
Send a single-use invitation to a new CRM user. The recipient sets their own password via />
the link in the email.
</p>
</div>
<InvitationsManager /> <InvitationsManager />
</div> </div>
); );

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

@@ -1,10 +1,9 @@
import { PageHeader } from '@/components/shared/page-header';
export default function OnboardingPage() { export default function OnboardingPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader title="Onboarding" description="Guided setup for new port configurations" />
<h1 className="text-2xl font-bold text-foreground">Onboarding</h1>
<p className="text-muted-foreground">Guided setup for new port configurations</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p> <p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -20,6 +20,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/shared/page-header';
interface AdminSection { interface AdminSection {
href: string; href: string;
@@ -165,13 +166,10 @@ export default async function AdminLandingPage({
const { portSlug } = await params; const { portSlug } = await params;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Administration</h1> title="Administration"
<p className="text-sm text-muted-foreground"> description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
Per-port configuration and system administration. Each card below opens a dedicated />
settings page.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{SECTIONS.map((s) => { {SECTIONS.map((s) => {
const Icon = s.icon; const Icon = s.icon;

View File

@@ -2,6 +2,7 @@ import {
SettingsFormCard, SettingsFormCard,
type SettingFieldDef, type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card'; } from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
const DEFAULT_FIELDS: SettingFieldDef[] = [ const DEFAULT_FIELDS: SettingFieldDef[] = [
{ {
@@ -53,14 +54,10 @@ const DIGEST_FIELDS: SettingFieldDef[] = [
export default function ReminderSettingsPage() { export default function ReminderSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-semibold">Reminders</h1> title="Reminders"
<p className="text-sm text-muted-foreground"> description="Default reminder behaviour for new interests and the optional daily-digest delivery window. Individual users can still configure their own digest preferences in Notifications → Preferences."
Default reminder behaviour for new interests and the optional daily-digest delivery />
window. Individual users can still configure their own digest preferences in Notifications
Preferences.
</p>
</div>
<SettingsFormCard <SettingsFormCard
title="Defaults for new interests" title="Defaults for new interests"

View File

@@ -1,10 +1,12 @@
import { PageHeader } from '@/components/shared/page-header';
export default function ScheduledReportsPage() { export default function ScheduledReportsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <PageHeader
<h1 className="text-2xl font-bold text-foreground">Scheduled Reports</h1> title="Scheduled Reports"
<p className="text-muted-foreground">Configure and manage automated report delivery</p> description="Configure and manage automated report delivery"
</div> />
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p> <p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
AlertDialog, AlertDialog,
@@ -36,7 +37,11 @@ export default function WebhooksPage() {
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null); const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [regenerating, setRegenerating] = useState<string | null>(null); const [regenerating, setRegenerating] = useState<string | null>(null);
const [newSecret, setNewSecret] = useState<{ webhookId: string; secret: string; masked: string } | null>(null); const [newSecret, setNewSecret] = useState<{
webhookId: string;
secret: string;
masked: string;
} | null>(null);
const loadWebhooks = useCallback(async () => { const loadWebhooks = useCallback(async () => {
try { try {
@@ -98,15 +103,20 @@ export default function WebhooksPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <PageHeader
<div> title="Webhooks"
<h1 className="text-2xl font-bold text-foreground">Webhooks</h1> description="Configure outgoing webhook integrations"
<p className="text-muted-foreground">Configure outgoing webhook integrations</p> actions={
</div> <Button
<Button onClick={() => { setEditTarget(null); setFormOpen(true); }}> onClick={() => {
Add Webhook setEditTarget(null);
</Button> setFormOpen(true);
</div> }}
>
Add Webhook
</Button>
}
/>
{loading ? ( {loading ? (
<p className="text-sm text-muted-foreground">Loading...</p> <p className="text-sm text-muted-foreground">Loading...</p>
@@ -116,7 +126,13 @@ export default function WebhooksPage() {
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Add a webhook to receive real-time notifications of CRM events. Add a webhook to receive real-time notifications of CRM events.
</p> </p>
<Button className="mt-4" onClick={() => { setEditTarget(null); setFormOpen(true); }}> <Button
className="mt-4"
onClick={() => {
setEditTarget(null);
setFormOpen(true);
}}
>
Add Webhook Add Webhook
</Button> </Button>
</div> </div>
@@ -141,17 +157,16 @@ export default function WebhooksPage() {
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Button <Button variant="ghost" size="sm" onClick={() => handleToggleActive(webhook)}>
variant="ghost"
size="sm"
onClick={() => handleToggleActive(webhook)}
>
{webhook.isActive ? 'Disable' : 'Enable'} {webhook.isActive ? 'Disable' : 'Enable'}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { setEditTarget(webhook); setFormOpen(true); }} onClick={() => {
setEditTarget(webhook);
setFormOpen(true);
}}
> >
Edit Edit
</Button> </Button>
@@ -163,11 +178,7 @@ export default function WebhooksPage() {
> >
Delete Delete
</Button> </Button>
<Button <Button variant="ghost" size="sm" onClick={() => toggleExpand(webhook.id)}>
variant="ghost"
size="sm"
onClick={() => toggleExpand(webhook.id)}
>
{expandedId === webhook.id ? 'Collapse' : 'Details'} {expandedId === webhook.id ? 'Collapse' : 'Details'}
</Button> </Button>
</div> </div>
@@ -228,18 +239,26 @@ export default function WebhooksPage() {
onSuccess={loadWebhooks} onSuccess={loadWebhooks}
/> />
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}> <AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete Webhook</AlertDialogTitle> <AlertDialogTitle>Delete Webhook</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Delete &quot;{deleteTarget?.name}&quot;? This will also delete all delivery history. This action Delete &quot;{deleteTarget?.name}&quot;? This will also delete all delivery history.
cannot be undone. This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground"> <AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground"
>
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

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

@@ -20,6 +20,7 @@ import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog'; import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { ExpenseCard } from '@/components/expenses/expense-card';
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters'; import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns'; import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -60,8 +61,7 @@ export default function ExpensesPage() {
}); });
const archiveMutation = useMutation({ const archiveMutation = useMutation({
mutationFn: (id: string) => mutationFn: (id: string) => apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] }); queryClient.invalidateQueries({ queryKey: ['expenses'] });
setArchiveExpense(null); setArchiveExpense(null);
@@ -151,6 +151,14 @@ export default function ExpensesPage() {
onSortChange={setSort} onSortChange={setSort}
isLoading={isFetching && !isLoading} isLoading={isFetching && !isLoading}
getRowId={(row) => row.id} getRowId={(row) => row.id}
cardRender={(row) => (
<ExpenseCard
expense={row.original}
portSlug={portSlug}
onEdit={setEditExpense}
onArchive={setArchiveExpense}
/>
)}
emptyState={ emptyState={
<EmptyState <EmptyState
title="No expenses found" title="No expenses found"

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Upload, Loader2, ScanLine } from 'lucide-react'; import { Camera, Loader2, ScanLine, Upload } from 'lucide-react';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -33,9 +35,16 @@ export default function ScanReceiptPage() {
const router = useRouter(); const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null); const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Scan Receipt', showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// Editable fields from scan // Editable fields from scan
const [establishment, setEstablishment] = useState(''); const [establishment, setEstablishment] = useState('');
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
@@ -94,7 +103,7 @@ export default function ScanReceiptPage() {
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
<div> <div className="hidden sm:block">
<h1 className="text-2xl font-bold">Scan Receipt</h1> <h1 className="text-2xl font-bold">Scan Receipt</h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
Upload a receipt image and we will extract the expense details automatically. Upload a receipt image and we will extract the expense details automatically.
@@ -109,28 +118,44 @@ export default function ScanReceiptPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div {previewUrl ? (
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors" <div
onClick={() => fileInputRef.current?.click()} className="border-2 border-dashed rounded-lg p-4 text-center cursor-pointer hover:bg-muted/50 transition-colors"
> onClick={() => fileInputRef.current?.click()}
{previewUrl ? ( >
<img <img
src={previewUrl} src={previewUrl}
alt="Receipt preview" alt="Receipt preview"
className="max-h-64 mx-auto rounded object-contain" className="max-h-64 mx-auto rounded object-contain"
/> />
) : ( </div>
<div className="space-y-2"> ) : (
<Upload className="h-8 w-8 mx-auto text-muted-foreground" /> <div className="grid gap-2 sm:grid-cols-2">
<p className="text-sm text-muted-foreground"> <Button
Click to upload or drag and drop type="button"
</p> size="lg"
<p className="text-xs text-muted-foreground"> className="w-full h-14 sm:hidden"
JPEG, PNG, WebP up to 10MB onClick={() => cameraInputRef.current?.click()}
</p> >
</div> <Camera className="mr-2 h-5 w-5" />
)} Take photo
</div> </Button>
<Button
type="button"
variant="outline"
size="lg"
className="w-full h-14"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mr-2 h-5 w-5" />
<span className="sm:hidden">Choose from library</span>
<span className="hidden sm:inline">Click to upload or drag and drop</span>
</Button>
<p className="text-xs text-muted-foreground sm:col-span-2 text-center">
JPEG, PNG, WebP up to 10MB
</p>
</div>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -138,6 +163,14 @@ export default function ScanReceiptPage() {
className="hidden" className="hidden"
onChange={handleFileChange} onChange={handleFileChange}
/> />
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={handleFileChange}
/>
{scanMutation.isPending && ( {scanMutation.isPending && (
<div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground"> <div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground">
@@ -222,25 +255,18 @@ export default function ScanReceiptPage() {
</div> </div>
{saveMutation.isError && ( {saveMutation.isError && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">{(saveMutation.error as Error).message}</p>
{(saveMutation.error as Error).message}
</p>
)} )}
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button <Button variant="outline" onClick={() => router.push(`/${params.portSlug}/expenses`)}>
variant="outline"
onClick={() => router.push(`/${params.portSlug}/expenses`)}
>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={() => saveMutation.mutate()} onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !amount} disabled={saveMutation.isPending || !amount}
> >
{saveMutation.isPending && ( {saveMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save as Expense Save as Expense
</Button> </Button>
</div> </div>

View File

@@ -1,11 +1,13 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Check, Loader2 } from 'lucide-react'; import { ChevronLeft, ChevronRight, Check, Loader2, Wallet } from 'lucide-react';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -43,9 +45,35 @@ export default function NewInvoicePage() {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const prefilledInterestId = searchParams.get('interestId') ?? undefined;
const prefilledKind =
searchParams.get('kind') === 'deposit' ? ('deposit' as const) : ('general' as const);
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'New Invoice', showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
// fetch enough of the interest to display "Deposit for {client} — Berth {n}" in
// the review step. Doubles as the source of truth for the billing entity prefill.
const { data: prefilledInterest } = useQuery<{
data: {
id: string;
clientId: string;
clientName: string | null;
berthMooringNumber: string | null;
};
}>({
queryKey: ['interest-prefill', prefilledInterestId],
queryFn: () => apiFetch(`/api/v1/interests/${prefilledInterestId}`),
enabled: !!prefilledInterestId,
});
const methods = useForm<CreateInvoiceInput>({ const methods = useForm<CreateInvoiceInput>({
resolver: zodResolver(createInvoiceSchema), resolver: zodResolver(createInvoiceSchema),
defaultValues: { defaultValues: {
@@ -53,6 +81,8 @@ export default function NewInvoicePage() {
currency: 'USD', currency: 'USD',
lineItems: [], lineItems: [],
expenseIds: [], expenseIds: [],
interestId: prefilledInterestId,
kind: prefilledKind,
}, },
}); });
@@ -65,6 +95,43 @@ export default function NewInvoicePage() {
} = methods; } = methods;
const watchedValues = watch(); const watchedValues = watch();
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.
useEffect(() => {
if (prefilledInterest?.data && !watchedValues.billingEntity) {
setValue(
'billingEntity',
{ type: 'client', id: prefilledInterest.data.clientId },
{ shouldValidate: true },
);
}
// We only want this to run when the interest data first arrives.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefilledInterest?.data?.clientId]);
const lineItems = watchedValues.lineItems ?? []; const lineItems = watchedValues.lineItems ?? [];
const subtotal = lineItems.reduce( const subtotal = lineItems.reduce(
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0), (sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
@@ -117,8 +184,8 @@ export default function NewInvoicePage() {
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
{/* Header */} {/* Header — desktop only; mobile gets the title from the topbar */}
<div className="flex items-center gap-3"> <div className="hidden sm:flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}> <Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
@@ -157,6 +224,23 @@ export default function NewInvoicePage() {
<CardTitle className="text-base">Client Information</CardTitle> <CardTitle className="text-base">Client Information</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{isDepositInvoice ? (
<div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<Wallet className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">Deposit invoice</p>
<p className="text-xs text-amber-800">
{prefilledInterest?.data
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
prefilledInterest.data.berthMooringNumber
? ` — Berth ${prefilledInterest.data.berthMooringNumber}`
: ''
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}
</p>
</div>
</div>
) : null}
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>
Billing entity <span className="text-destructive">*</span> Billing entity <span className="text-destructive">*</span>
@@ -294,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

@@ -12,6 +12,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state'; import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { InvoiceCard } from '@/components/invoices/invoice-card';
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters'; import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns'; import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -63,8 +64,7 @@ export default function InvoicesPage() {
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['invoices'] });
setDeleteTarget(null); setDeleteTarget(null);
@@ -72,8 +72,7 @@ export default function InvoicesPage() {
}); });
const sendMutation = useMutation({ const sendMutation = useMutation({
mutationFn: (id: string) => mutationFn: (id: string) => apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['invoices'] });
}, },
@@ -82,8 +81,7 @@ export default function InvoicesPage() {
const columns = getInvoiceColumns({ const columns = getInvoiceColumns({
portSlug, portSlug,
onSend: (invoice) => sendMutation.mutate(invoice.id), onSend: (invoice) => sendMutation.mutate(invoice.id),
onRecordPayment: (invoice) => onRecordPayment: (invoice) => router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
onDelete: (invoice) => setDeleteTarget(invoice), onDelete: (invoice) => setDeleteTarget(invoice),
}); });
@@ -141,6 +139,17 @@ export default function InvoicesPage() {
onSortChange={setSort} onSortChange={setSort}
isLoading={isFetching && !isLoading} isLoading={isFetching && !isLoading}
getRowId={(row) => row.id} getRowId={(row) => row.id}
cardRender={(row) => (
<InvoiceCard
invoice={row.original}
portSlug={portSlug}
onSend={(invoice) => sendMutation.mutate(invoice.id)}
onRecordPayment={(invoice) =>
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`)
}
onDelete={setDeleteTarget}
/>
)}
emptyState={ emptyState={
<EmptyState <EmptyState
title="No invoices found" title="No invoices found"
@@ -161,15 +170,11 @@ export default function InvoicesPage() {
<h3 className="font-semibold">Delete Invoice?</h3> <h3 className="font-semibold">Delete Invoice?</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This will permanently delete invoice{' '} This will permanently delete invoice{' '}
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. <span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>. This
This action cannot be undone. action cannot be undone.
</p> </p>
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<Button <Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
variant="outline"
size="sm"
onClick={() => setDeleteTarget(null)}
>
Cancel Cancel
</Button> </Button>
<Button <Button

View File

@@ -12,6 +12,8 @@ import { PortProvider } from '@/providers/port-provider';
import { PermissionsProvider } from '@/providers/permissions-provider'; 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 { 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() });
@@ -37,7 +39,9 @@ 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>
<div className="flex h-screen overflow-hidden bg-background"> <RealtimeToasts />
{/* Desktop shell — hidden by CSS on mobile */}
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
<Sidebar <Sidebar
portRoles={portRoles} portRoles={portRoles}
isSuperAdmin={profile?.isSuperAdmin ?? false} isSuperAdmin={profile?.isSuperAdmin ?? false}
@@ -57,6 +61,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main> <main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
</div> </div>
</div> </div>
{/* Mobile shell — hidden by CSS on desktop */}
<MobileLayout>{children}</MobileLayout>
</SocketProvider> </SocketProvider>
</PermissionsProvider> </PermissionsProvider>
</PortProvider> </PortProvider>

View File

@@ -5,28 +5,19 @@ import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth'; import { getPortalSession } from '@/lib/portal/auth';
import { getClientInterests } from '@/lib/services/portal.service'; import { getClientInterests } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { stageLabel, safeStage, type PipelineStage } from '@/lib/constants';
export const metadata: Metadata = { title: 'Interests' }; export const metadata: Metadata = { title: 'Interests' };
const STAGE_LABELS: Record<string, string> = { const STAGE_VARIANT: Record<PipelineStage, 'default' | 'secondary' | 'destructive' | 'outline'> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'EOI / NDA Signed',
deposit_10pct: 'Deposit Received',
contract: 'Contract Stage',
completed: 'Completed',
};
const STAGE_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
open: 'secondary', open: 'secondary',
details_sent: 'secondary', details_sent: 'secondary',
in_communication: 'default', in_communication: 'default',
visited: 'default', eoi_sent: 'default',
signed_eoi_nda: 'default', eoi_signed: 'default',
deposit_10pct: 'default', deposit_10pct: 'default',
contract: 'default', contract_sent: 'default',
contract_signed: 'default',
completed: 'outline', completed: 'outline',
}; };
@@ -40,9 +31,7 @@ export default async function PortalInterestsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1> <h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">Your berth enquiries and applications</p>
Your berth enquiries and applications
</p>
</div> </div>
{interests.length === 0 ? ( {interests.length === 0 ? (
@@ -56,10 +45,7 @@ export default async function PortalInterestsPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{interests.map((interest) => ( {interests.map((interest) => (
<div <div key={interest.id} className="bg-white rounded-lg border p-5">
key={interest.id}
className="bg-white rounded-lg border p-5"
>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@@ -98,8 +84,8 @@ export default async function PortalInterestsPage() {
)} )}
</div> </div>
</div> </div>
<Badge variant={STAGE_COLORS[interest.pipelineStage] ?? 'default'}> <Badge variant={STAGE_VARIANT[safeStage(interest.pipelineStage)]}>
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage} {stageLabel(interest.pipelineStage)}
</Badge> </Badge>
</div> </div>
</div> </div>

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { clearInterestOutcome, setInterestOutcome } from '@/lib/services/interests.service';
import { clearOutcomeSchema, setOutcomeSchema } from '@/lib/validators/interests';
export const POST = withAuth(
withPermission('interests', 'change_stage', async (req, ctx, params) => {
try {
const body = await parseBody(req, setOutcomeSchema);
const result = await setInterestOutcome(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('interests', 'change_stage', async (req, ctx, params) => {
try {
const body = await parseBody(req, clearOutcomeSchema);
const result = await clearInterestOutcome(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -7,6 +7,26 @@ 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';
const OUTCOME_LABELS: Record<string, string> = {
won: 'Won',
lost_other_marina: 'Lost — went to another marina',
lost_unqualified: 'Lost — unqualified',
lost_no_response: 'Lost — no response',
cancelled: 'Cancelled',
};
const DOC_EVENT_LABELS: Record<string, string> = {
sent: 'sent for signing',
completed: 'fully signed',
signed: 'signed by recipient',
rejected: 'rejected',
expired: 'expired',
cancelled: 'cancelled',
reminder_sent: 'reminder sent',
};
interface TimelineEvent { interface TimelineEvent {
id: string; id: string;
@@ -14,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>;
} }
@@ -33,12 +57,7 @@ export const GET = withAuth(
const auditRows = await db const auditRows = await db
.select() .select()
.from(auditLogs) .from(auditLogs)
.where( .where(and(eq(auditLogs.entityType, 'interest'), eq(auditLogs.entityId, interestId)))
and(
eq(auditLogs.entityType, 'interest'),
eq(auditLogs.entityId, interestId),
),
)
.orderBy(desc(auditLogs.createdAt)) .orderBy(desc(auditLogs.createdAt))
.limit(50); .limit(50);
@@ -67,28 +86,82 @@ 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,
type: 'audit', type: 'audit',
action: row.action, action: row.action,
description: buildAuditDescription(row.action, row.newValue as Record<string, unknown> | null), description: buildAuditDescription(
row.action,
row.newValue as Record<string, unknown> | null,
(row.metadata as Record<string, unknown>) ?? {},
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>) ?? {},
})); }));
const docEvents: TimelineEvent[] = docEventRows.map((row) => ({ const docEvents: TimelineEvent[] = docEventRows.map((row) => {
id: row.id, const title = docTitles[row.documentId] ?? row.documentId;
type: 'document_event', const action = DOC_EVENT_LABELS[row.eventType] ?? row.eventType;
action: row.eventType, return {
description: `Document "${docTitles[row.documentId] ?? row.documentId}": ${row.eventType}`, id: row.id,
userId: null, type: 'document_event',
createdAt: row.createdAt, action: row.eventType,
metadata: (row.eventData as Record<string, unknown>) ?? {}, description: `Document "${title}" ${action}`,
})); userId: null,
userName: null,
createdAt: row.createdAt,
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) });
@@ -101,12 +174,39 @@ export const GET = withAuth(
function buildAuditDescription( function buildAuditDescription(
action: string, action: string,
newValue: Record<string, unknown> | null, newValue: Record<string, unknown> | null,
metadata: Record<string, unknown>,
userId: string | null,
): string { ): string {
if (action === 'create') return 'Interest created'; if (action === 'create') return 'Interest created';
if (action === 'archive') return 'Interest archived'; if (action === 'archive') return 'Interest archived';
if (action === 'restore') return 'Interest restored'; if (action === 'restore') return 'Interest restored';
const type = metadata.type;
if (type === 'outcome_set') {
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
const reason = (newValue?.reason as string | undefined) ?? '';
return reason ? `Marked as ${label}${reason}` : `Marked as ${label}`;
}
if (type === 'outcome_cleared') {
const stage = (newValue?.pipelineStage as string | undefined) ?? '';
return stage ? `Reopened to ${stageLabel(stage)}` : 'Reopened';
}
if (type === 'stage_change' && newValue?.pipelineStage) {
const stage = stageLabel(newValue.pipelineStage as string);
const reason = (newValue.reason as string | undefined) ?? '';
const auto = userId === 'system';
if (auto) {
return reason ? `${stage} (auto-advanced — ${reason})` : `Stage advanced to ${stage}`;
}
return reason ? `Stage changed to ${stage}${reason}` : `Stage changed to ${stage}`;
}
if (action === 'update' && newValue?.pipelineStage) { if (action === 'update' && newValue?.pipelineStage) {
return `Stage changed to "${newValue.pipelineStage}"`; return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`;
} }
if (action === 'update') return 'Interest updated'; if (action === 'update') return 'Interest updated';
return action; return action;

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -127,3 +127,45 @@
@apply bg-muted-foreground/30; @apply bg-muted-foreground/30;
} }
} }
/* ─── Form-factor shell visibility ──────────────────────────────────────────
* Two shells (desktop + mobile) render to the DOM on every page; CSS hides
* the inactive one. The data-form-factor body attribute is set server-side
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
* handles desktop browsers resized below lg (1024px), or stripped UAs.
*
* IMPORTANT: only `display: none` rules are emitted — we never set a positive
* display, because the desktop shell uses Tailwind's `flex` class which would
* be overridden by `display: block` (same specificity, later cascade).
*/
[data-shell='mobile'] {
display: none;
}
@media (max-width: 1023.98px) {
[data-shell='desktop'] {
display: none;
}
[data-shell='mobile'] {
display: block;
}
}
body[data-form-factor='mobile'] [data-shell='desktop'] {
display: none;
}
body[data-form-factor='mobile'] [data-shell='mobile'] {
display: block;
}
/*
* React Query Devtools floating button collides with the bottom tab bar's
* "More" tab on mobile. The devtools panel itself remains accessible from
* desktop where the toggle is positioned out of the way of any UI.
*/
@media (max-width: 1023.98px) {
.tsqd-open-btn-container,
.tsqd-parent-container {
display: none !important;
}
}

View File

@@ -1,6 +1,9 @@
import type { Metadata } from 'next'; import type { Metadata, Viewport } from 'next';
import Script from 'next/script';
import { headers } from 'next/headers';
import { Inter, JetBrains_Mono } from 'next/font/google'; import { Inter, JetBrains_Mono } from 'next/font/google';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { classifyFormFactor } from '@/lib/form-factor';
import './globals.css'; import './globals.css';
const inter = Inter({ const inter = Inter({
@@ -15,18 +18,52 @@ const jetbrainsMono = JetBrains_Mono({
display: 'swap', display: 'swap',
}); });
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
themeColor: '#1e2844',
};
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: 'Port Nimara CRM', default: 'Port Nimara CRM',
template: '%s | Port Nimara CRM', template: '%s | Port Nimara CRM',
}, },
description: 'Marina management system for Port Nimara', description: 'Marina management system for Port Nimara',
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Port Nimara',
},
icons: {
icon: [
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
apple: '/apple-touch-icon.png',
},
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers();
const formFactor = classifyFormFactor(headerList.get('user-agent'));
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}> <head>
{process.env.NODE_ENV === 'development' && (
<Script
src="//unpkg.com/react-grab/dist/index.global.js"
crossOrigin="anonymous"
strategy="beforeInteractive"
/>
)}
</head>
<body
data-form-factor={formFactor}
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
>
{children} {children}
<Toaster richColors position="top-right" /> <Toaster richColors position="top-right" />
</body> </body>

View File

@@ -0,0 +1,151 @@
'use client';
import { Activity, Clock, Eye, Pencil, Plus, Trash2, User } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { cn } from '@/lib/utils';
interface AuditEntry {
id: string;
userId: string | null;
action: string;
entityType: string;
entityId: string | null;
fieldChanged: string | null;
oldValue: Record<string, unknown> | null;
newValue: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
ipAddress: string | null;
createdAt: string;
actor: { id: string; email: string; name: string } | null;
}
const ACTION_ACCENT: Record<string, string> = {
create: 'bg-emerald-400',
update: 'bg-blue-400',
delete: 'bg-rose-400',
viewed: 'bg-slate-300',
};
const ACTION_BADGE_COLORS: Record<string, string> = {
create: 'bg-green-600',
update: 'bg-blue-500',
delete: 'bg-red-600',
archive: 'bg-orange-500',
restore: 'bg-teal-500',
login: 'bg-gray-500',
permission_denied: 'bg-red-800',
merge: 'bg-purple-500',
revert: 'bg-amber-500',
};
function ActionIcon({ action }: { action: string }) {
if (action === 'create') return <Plus className="h-5 w-5" />;
if (action === 'update') return <Pencil className="h-5 w-5" />;
if (action === 'delete') return <Trash2 className="h-5 w-5" />;
if (action === 'viewed') return <Eye className="h-5 w-5" />;
return <Activity className="h-5 w-5" />;
}
function actionVerb(action: string): string {
const map: Record<string, string> = {
create: 'Created',
update: 'Updated',
delete: 'Deleted',
archive: 'Archived',
restore: 'Restored',
login: 'Logged in',
permission_denied: 'Permission denied',
merge: 'Merged',
revert: 'Reverted',
viewed: 'Viewed',
};
return map[action] ?? action.charAt(0).toUpperCase() + action.slice(1);
}
interface AuditLogCardProps {
entry: AuditEntry;
}
export function AuditLogCard({ entry }: AuditLogCardProps) {
const accentClass = ACTION_ACCENT[entry.action] ?? 'bg-slate-300';
const badgeColor = ACTION_BADGE_COLORS[entry.action] ?? 'bg-gray-500';
const entityTitle = `${entry.entityType.charAt(0).toUpperCase()}${entry.entityType.slice(1)}${
entry.entityId ? ` ${entry.entityId.slice(0, 8)}` : ''
}`;
const actorName = entry.actor?.name ?? (entry.userId ? `${entry.userId.slice(0, 8)}` : 'system');
// Changed-fields chip line: prefer fieldChanged (single field), then newValue keys
let changedFields: string[] = [];
if (entry.fieldChanged) {
changedFields = [entry.fieldChanged];
} else if (entry.newValue) {
changedFields = Object.keys(entry.newValue);
}
const visibleFields = changedFields.slice(0, 3);
const overflowCount = changedFields.length - visibleFields.length;
return (
<ListCard
href="#"
ariaLabel={`Audit: ${actionVerb(entry.action)} ${entityTitle}`}
accentClassName={accentClass}
>
<div className="flex items-start gap-3">
<ListCardAvatar icon={<ActionIcon action={entry.action} />} />
<div className="min-w-0 flex-1">
{/* Title: entity type + short ID */}
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{entityTitle}
</h3>
{/* Subtitle: action verb + actor */}
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">
{actionVerb(entry.action)} by {actorName}
</span>
</p>
{/* Timestamp meta line */}
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<ListCardMeta icon={<Clock className="h-3 w-3" />}>
{formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true })}
</ListCardMeta>
</div>
{/* Action badge + changed-fields chips */}
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white',
badgeColor,
)}
>
{entry.action}
</span>
{visibleFields.length > 0 ? (
<>
{visibleFields.map((field) => (
<span
key={field}
className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
>
{field}
</span>
))}
{overflowCount > 0 ? (
<span className="text-xs text-muted-foreground">+{overflowCount}</span>
) : null}
</>
) : null}
</div>
</div>
</div>
</ListCard>
);
}

View File

@@ -19,6 +19,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { AuditLogCard } from './audit-log-card';
interface AuditEntry { interface AuditEntry {
id: string; id: string;
@@ -357,6 +358,7 @@ export function AuditLogList() {
data={entries} data={entries}
isLoading={loading} isLoading={loading}
getRowId={(row) => row.id} getRowId={(row) => row.id}
cardRender={(row) => <AuditLogCard entry={row.original} />}
emptyState={ emptyState={
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-muted-foreground">No audit log entries found.</p> <p className="text-muted-foreground">No audit log entries found.</p>

View File

@@ -68,9 +68,11 @@ const KNOWN_SETTINGS: Array<{
open: 0.05, open: 0.05,
details_sent: 0.1, details_sent: 0.1,
in_communication: 0.2, in_communication: 0.2,
signed_eoi_nda: 0.4, eoi_sent: 0.4,
deposit_10pct: 0.6, eoi_signed: 0.6,
contract: 0.8, deposit_10pct: 0.75,
contract_sent: 0.85,
contract_signed: 0.95,
completed: 1.0, completed: 1.0,
}, },
}, },
@@ -105,6 +107,17 @@ const KNOWN_SETTINGS: Array<{
type: 'json', type: 'json',
defaultValue: [], defaultValue: [],
}, },
{
key: 'eoi_signers',
label: 'EOI Signers',
description:
'Internal staff who countersign every EOI. JSON object with `developer` (signs after the client) and `approver` (final approval). Both fields take `{ name, email }`.',
type: 'json',
defaultValue: {
developer: { name: 'David Mizrahi', email: 'dm@portnimara.com' },
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
},
},
]; ];
export function SettingsManager() { export function SettingsManager() {

View File

@@ -7,6 +7,7 @@ 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 { ServiceHealthCard } from './service-health-card'; import { ServiceHealthCard } from './service-health-card';
import { QueueOverview } from './queue-overview'; import { QueueOverview } from './queue-overview';
import { PageHeader } from '@/components/shared/page-header';
import type { import type {
HealthStatus, HealthStatus,
QueueStatus, QueueStatus,
@@ -17,16 +18,14 @@ import type {
export function SystemMonitoringDashboard() { export function SystemMonitoringDashboard() {
const { data: healthData } = useQuery({ const { data: healthData } = useQuery({
queryKey: ['system', 'health'], queryKey: ['system', 'health'],
queryFn: () => queryFn: () => apiFetch<{ data: HealthStatus }>('/api/v1/admin/health').then((r) => r.data),
apiFetch<{ data: HealthStatus }>('/api/v1/admin/health').then((r) => r.data),
staleTime: 30_000, staleTime: 30_000,
refetchInterval: 30_000, refetchInterval: 30_000,
}); });
const { data: queuesData } = useQuery({ const { data: queuesData } = useQuery({
queryKey: ['system', 'queues'], queryKey: ['system', 'queues'],
queryFn: () => queryFn: () => apiFetch<{ data: QueueStatus[] }>('/api/v1/admin/queues').then((r) => r.data),
apiFetch<{ data: QueueStatus[] }>('/api/v1/admin/queues').then((r) => r.data),
staleTime: 10_000, staleTime: 10_000,
refetchInterval: 10_000, refetchInterval: 10_000,
}); });
@@ -47,11 +46,10 @@ export function SystemMonitoringDashboard() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Page header */} <PageHeader
<div> title="System Monitoring"
<h1 className="text-2xl font-bold text-foreground">System Monitoring</h1> description="Real-time health, queue status and connection tracking"
<p className="text-muted-foreground">Real-time health, queue status and connection tracking</p> />
</div>
{/* Service health */} {/* Service health */}
<section className="space-y-3"> <section className="space-y-3">
@@ -79,10 +77,7 @@ export function SystemMonitoringDashboard() {
) : ( ) : (
<div className="flex gap-3"> <div className="flex gap-3">
{[1, 2, 3, 4].map((i) => ( {[1, 2, 3, 4].map((i) => (
<div <div key={i} className="h-[88px] w-[160px] rounded-xl border bg-card animate-pulse" />
key={i}
className="h-[88px] w-[160px] rounded-xl border bg-card animate-pulse"
/>
))} ))}
</div> </div>
)} )}
@@ -124,9 +119,7 @@ export function SystemMonitoringDashboard() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-3xl font-bold"> <p className="text-3xl font-bold">{queues.reduce((sum, q) => sum + q.active, 0)}</p>
{queues.reduce((sum, q) => sum + q.active, 0)}
</p>
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
@@ -141,10 +134,7 @@ export function SystemMonitoringDashboard() {
) : ( ) : (
<div className="grid grid-cols-5 gap-3"> <div className="grid grid-cols-5 gap-3">
{[1, 2, 3, 4, 5].map((i) => ( {[1, 2, 3, 4, 5].map((i) => (
<div <div key={i} className="h-[110px] rounded-xl border bg-card animate-pulse" />
key={i}
className="h-[110px] rounded-xl border bg-card animate-pulse"
/>
))} ))}
</div> </div>
)} )}
@@ -159,8 +149,7 @@ export function SystemMonitoringDashboard() {
function RecentErrorsPanel() { function RecentErrorsPanel() {
const { data: errorsData } = useQuery({ const { data: errorsData } = useQuery({
queryKey: ['system', 'errors'], queryKey: ['system', 'errors'],
queryFn: () => queryFn: () => apiFetch<{ data: RecentError[] }>('/api/v1/admin/errors').then((r) => r.data),
apiFetch<{ data: RecentError[] }>('/api/v1/admin/errors').then((r) => r.data),
staleTime: 30_000, staleTime: 30_000,
refetchInterval: 30_000, refetchInterval: 30_000,
}); });

View File

@@ -0,0 +1,149 @@
'use client';
import { Clock, Mail, MoreHorizontal, Pencil, Shield, Trash2 } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import {
ListCard,
ListCardAvatar,
ListCardMeta,
deriveInitials,
} from '@/components/shared/list-card';
import { cn } from '@/lib/utils';
interface UserRow {
userId: string;
displayName: string;
email: string;
phone: string | null;
isActive: boolean;
isSuperAdmin: boolean;
lastLoginAt: string | null;
role: { id: string; name: string };
assignedAt: string;
}
interface UserCardProps {
user: UserRow;
onEdit: (user: UserRow) => void;
onRemove: (userId: string) => void;
isRemoving: boolean;
}
export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps) {
const initials = deriveInitials(user.displayName || user.email);
const accentClass = user.isSuperAdmin
? 'bg-violet-400'
: !user.isActive
? 'bg-slate-400'
: undefined;
return (
<ListCard
href="#"
ariaLabel={`User: ${user.displayName}`}
accentClassName={accentClass}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${user.displayName}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
onEdit(user);
}}
>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<ConfirmationDialog
trigger={
<DropdownMenuItem className="text-destructive" onSelect={(e) => e.preventDefault()}>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Remove
</DropdownMenuItem>
}
title="Remove User"
description={`Remove "${user.displayName}" from this port? They will lose access but their account remains.`}
confirmLabel="Remove"
onConfirm={() => onRemove(user.userId)}
loading={isRemoving}
/>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-start gap-3">
<ListCardAvatar initials={initials} className={cn(!user.isActive && 'opacity-50')} />
<div className="min-w-0 flex-1">
{/* Title row + spacer for actions button */}
<div className="flex items-start justify-between gap-2">
<h3
className={cn(
'truncate text-base font-semibold tracking-tight',
user.isActive ? 'text-foreground' : 'text-muted-foreground',
)}
>
{user.displayName || user.email}
</h3>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Email subtitle — only when display name is shown as title */}
{user.displayName && user.displayName !== user.email ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">{user.email}</span>
</p>
) : null}
{/* Role + last login meta */}
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<ListCardMeta icon={<Shield className="h-3 w-3" />}>{user.role.name}</ListCardMeta>
{user.lastLoginAt ? (
<ListCardMeta icon={<Clock className="h-3 w-3" />}>
{formatDistanceToNow(new Date(user.lastLoginAt), { addSuffix: true })}
</ListCardMeta>
) : (
<ListCardMeta icon={<Clock className="h-3 w-3" />}>Never logged in</ListCardMeta>
)}
</div>
{/* Status + super-admin pills */}
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
{!user.isActive ? (
<span className="inline-flex items-center rounded-full bg-slate-200 px-2 py-0.5 text-xs font-medium text-slate-700">
Inactive
</span>
) : null}
{user.isSuperAdmin ? (
<span className="inline-flex items-center rounded-full bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
Super Admin
</span>
) : null}
</div>
</div>
</div>
</ListCard>
);
}

View File

@@ -10,6 +10,7 @@ import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
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 { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { UserCard } from './user-card';
import { UserForm } from './user-form'; import { UserForm } from './user-form';
interface UserRow { interface UserRow {
@@ -152,6 +153,14 @@ export function UserList() {
data={users} data={users}
isLoading={loading} isLoading={loading}
getRowId={(row) => row.userId} getRowId={(row) => row.userId}
cardRender={(row) => (
<UserCard
user={row.original}
onEdit={handleEditUser}
onRemove={handleRemoveUser}
isRemoving={deletingId === row.original.userId}
/>
)}
emptyState={ emptyState={
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-muted-foreground">No users assigned to this port.</p> <p className="text-muted-foreground">No users assigned to this port.</p>

View File

@@ -0,0 +1,177 @@
'use client';
import { Activity, Anchor, MapPin, MoreHorizontal, Pencil } from 'lucide-react';
import { useRouter, useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { cn } from '@/lib/utils';
import type { BerthRow } from './berth-columns';
const STATUS_VARIANTS: Record<string, string> = {
available: 'bg-green-100 text-green-800 border-green-200',
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-200',
sold: 'bg-red-100 text-red-800 border-red-200',
};
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
const ACCENT_CLASS: Record<string, string> = {
available: 'bg-emerald-400',
under_offer: 'bg-amber-400',
sold: 'bg-slate-400',
};
function formatPrice(price: string, currency: string): string {
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD',
maximumFractionDigits: 0,
}).format(Number(price));
} catch {
return `${currency} ${price}`;
}
}
interface BerthCardProps {
berth: BerthRow;
}
export function BerthCard({ berth }: BerthCardProps) {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const statusLabel = STATUS_LABELS[berth.status] ?? berth.status;
const statusColor =
STATUS_VARIANTS[berth.status] ?? 'bg-muted text-muted-foreground border-muted';
const accentClass = ACCENT_CLASS[berth.status] ?? 'bg-slate-300';
// Dimensions string
let dimText: string | null = null;
if (berth.lengthM || berth.widthM) {
const l = berth.lengthM ?? '?';
const w = berth.widthM ?? '?';
dimText = `${l}m × ${w}m`;
}
const metaParts: string[] = [];
if (dimText) metaParts.push(dimText);
if (berth.price) metaParts.push(formatPrice(berth.price, berth.priceCurrency));
const tags = berth.tags ?? [];
return (
<ListCard
href={`/${portSlug}/berths/${berth.id}`}
ariaLabel={`Berth ${berth.mooringNumber}`}
accentClassName={accentClass}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for berth ${berth.mooringNumber}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${portSlug}/berths/${berth.id}`);
}}
>
<Activity className="mr-2 h-3.5 w-3.5" />
View details
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${portSlug}/berths/${berth.id}?edit=true`);
}}
>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-start gap-3">
<ListCardAvatar icon={<Anchor className="h-5 w-5" />} />
<div className="min-w-0 flex-1">
{/* Title row + spacer for actions button */}
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{berth.mooringNumber}
</h3>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Area subtitle */}
{berth.area ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">{berth.area}</span>
</p>
) : null}
{/* Dimensions · Price meta line */}
{metaParts.length > 0 ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
{metaParts.map((part, i) => (
<span key={part} className="inline-flex items-center gap-1">
{i > 0 ? <span aria-hidden>·</span> : null}
<ListCardMeta>{part}</ListCardMeta>
</span>
))}
</div>
) : null}
{/* Status pill */}
<div className="mt-1.5">
<span
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium',
statusColor,
)}
>
{statusLabel}
</span>
</div>
{/* Tags */}
{tags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{tags.length > 2 ? (
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
+{tags.length - 2}
</span>
) : null}
</div>
) : null}
</div>
</div>
</ListCard>
);
}

View File

@@ -170,7 +170,9 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold text-foreground">Berth {berth.mooringNumber}</h1> <h1 className="hidden sm:block text-2xl font-bold text-foreground">
Berth {berth.mooringNumber}
</h1>
<span <span
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`} className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
> >
@@ -180,7 +182,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>} {berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 shrink-0">
<PermissionGate resource="berths" action="edit"> <PermissionGate resource="berths" action="edit">
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}> <Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
<RefreshCw className="mr-1.5 h-4 w-4" /> <RefreshCw className="mr-1.5 h-4 w-4" />

View File

@@ -1,11 +1,15 @@
'use client'; 'use client';
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';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
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 {
@@ -26,15 +30,45 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
'berth:statusChanged': [['berth', berthId]], 'berth:statusChanged': [['berth', berthId]],
}); });
const { setChrome } = useMobileChrome();
const titleForChrome: string | null = data?.mooringNumber ?? null;
useEffect(() => {
setChrome({ title: titleForChrome, showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [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 <>
isLoading={isLoading} <DetailLayout
header={berth ? <BerthDetailHeader berth={berth} /> : null} isLoading={isLoading}
tabs={berth ? buildBerthTabs(berth) : []} header={berth ? <BerthDetailHeader berth={berth} /> : null}
defaultTab="overview" tabs={berth ? buildBerthTabs(berth) : []}
/> 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

@@ -19,6 +19,7 @@ import {
import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state'; import { EmptyState } from '@/components/shared/empty-state';
import { Bookmark } from 'lucide-react'; import { Bookmark } from 'lucide-react';
import { PIPELINE_STAGES, stageLabel } from '@/lib/constants';
import type { InterestRow } from '@/components/interests/interest-columns'; import type { InterestRow } from '@/components/interests/interest-columns';
interface BerthInterestsTabProps { interface BerthInterestsTabProps {
@@ -28,27 +29,10 @@ interface BerthInterestsTabProps {
type StageFilter = 'all' | 'active' | 'lost'; type StageFilter = 'all' | 'active' | 'lost';
type SortMode = 'newest' | 'stage' | 'category'; type SortMode = 'newest' | 'stage' | 'category';
const STAGE_LABELS: Record<string, string> = { function stageRank(stage: string): number {
open: 'Open', const idx = PIPELINE_STAGES.indexOf(stage as (typeof PIPELINE_STAGES)[number]);
details_sent: 'Details Sent', return idx === -1 ? 99 : idx;
in_communication: 'In Communication', }
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
const STAGE_ORDER: Record<string, number> = {
open: 0,
details_sent: 1,
in_communication: 2,
visited: 3,
signed_eoi_nda: 4,
deposit_10pct: 5,
contract: 6,
completed: 7,
};
const CATEGORY_RANK: Record<string, number> = { const CATEGORY_RANK: Record<string, number> = {
hot_lead: 0, hot_lead: 0,
@@ -104,8 +88,8 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
}); });
const sorted = [...filtered].sort((a, b) => { const sorted = [...filtered].sort((a, b) => {
if (sortMode === 'stage') { if (sortMode === 'stage') {
const sa = STAGE_ORDER[a.pipelineStage] ?? 99; const sa = stageRank(a.pipelineStage);
const sb = STAGE_ORDER[b.pipelineStage] ?? 99; const sb = stageRank(b.pipelineStage);
if (sa !== sb) return sb - sa; // furthest along first if (sa !== sb) return sb - sa; // furthest along first
} }
if (sortMode === 'category') { if (sortMode === 'category') {
@@ -189,7 +173,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<Badge variant="secondary" className="font-normal"> <Badge variant="secondary" className="font-normal">
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage} {stageLabel(i.pipelineStage)}
</Badge> </Badge>
</td> </td>
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">

View File

@@ -9,6 +9,7 @@ import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { EmptyState } from '@/components/shared/empty-state'; import { EmptyState } from '@/components/shared/empty-state';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { BerthCard } from './berth-card';
import { berthColumns, type BerthRow } from './berth-columns'; import { berthColumns, type BerthRow } from './berth-columns';
import { berthFilterDefinitions } from './berth-filters'; import { berthFilterDefinitions } from './berth-filters';
import { Anchor } from 'lucide-react'; import { Anchor } from 'lucide-react';
@@ -73,6 +74,7 @@ export function BerthList() {
onSortChange={setSort} onSortChange={setSort}
getRowId={(row) => row.id} getRowId={(row) => row.id}
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)} onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
cardRender={(row) => <BerthCard berth={row.original} />}
emptyState={ emptyState={
<EmptyState <EmptyState
icon={Anchor} icon={Anchor}

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,93 +73,99 @@ function OverviewTab({ berth }: { berth: BerthData }) {
: null; : null;
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="space-y-6">
{/* Specifications */} {/* Sales pulse — top-of-page so reps doing berth-level triage can see
<Card> who's interested + how warm without clicking into the Interests tab. */}
<CardHeader className="pb-3"> <BerthInterestPulse berthId={berth.id} />
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
</CardHeader>
<CardContent className="pt-0 divide-y">
<SpecRow label="Length" value={formatDim(berth.lengthFt, berth.lengthM)} />
<SpecRow
label="Width"
value={
formatDim(berth.widthFt, berth.widthM)
? `${formatDim(berth.widthFt, berth.widthM)}${berth.widthIsMinimum ? ' (min)' : ''}`
: null
}
/>
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
<SpecRow
label="Nominal Boat Size"
value={berth.nominalBoatSize || berth.nominalBoatSizeM}
/>
<SpecRow
label="Water Depth"
value={
berth.waterDepth || berth.waterDepthM
? `${formatDim(berth.waterDepth, berth.waterDepthM)}${berth.waterDepthIsMinimum ? ' (min)' : ''}`
: null
}
/>
<SpecRow label="Mooring Type" value={berth.mooringType} />
<SpecRow label="Side Pontoon" value={berth.sidePontoon} />
<SpecRow label="Bow Facing" value={berth.bowFacing} />
<SpecRow label="Access" value={berth.access} />
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
</CardContent>
</Card>
{/* Infrastructure & Pricing */} <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-6"> {/* Specifications */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle> <CardTitle className="text-sm font-medium">Specifications</CardTitle>
</CardHeader>
<CardContent className="pt-0 divide-y">
<SpecRow label="Power Capacity" value={berth.powerCapacity} />
<SpecRow label="Voltage" value={berth.voltage} />
<SpecRow label="Cleat Type" value={berth.cleatType} />
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
<SpecRow label="Bollard Type" value={berth.bollardType} />
<SpecRow label="Bollard Capacity" value={berth.bollardCapacity} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0 divide-y"> <CardContent className="pt-0 divide-y">
<SpecRow label="Length" value={formatDim(berth.lengthFt, berth.lengthM)} />
<SpecRow <SpecRow
label="Tenure Type" label="Width"
value={berth.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'} value={
formatDim(berth.widthFt, berth.widthM)
? `${formatDim(berth.widthFt, berth.widthM)}${berth.widthIsMinimum ? ' (min)' : ''}`
: null
}
/> />
{berth.tenureType === 'fixed_term' && ( <SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
<> <SpecRow
<SpecRow label="Years" value={berth.tenureYears} /> label="Nominal Boat Size"
<SpecRow label="Start Date" value={berth.tenureStartDate} /> value={berth.nominalBoatSize || berth.nominalBoatSizeM}
<SpecRow label="End Date" value={berth.tenureEndDate} /> />
</> <SpecRow
)} label="Water Depth"
<SpecRow label="Price" value={price} /> value={
berth.waterDepth || berth.waterDepthM
? `${formatDim(berth.waterDepth, berth.waterDepthM)}${berth.waterDepthIsMinimum ? ' (min)' : ''}`
: null
}
/>
<SpecRow label="Mooring Type" value={berth.mooringType} />
<SpecRow label="Side Pontoon" value={berth.sidePontoon} />
<SpecRow label="Bow Facing" value={berth.bowFacing} />
<SpecRow label="Access" value={berth.access} />
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
</CardContent> </CardContent>
</Card> </Card>
{berth.tags.length > 0 && ( {/* Infrastructure & Pricing */}
<div className="space-y-6">
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tags</CardTitle> <CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0 divide-y">
<div className="flex flex-wrap gap-1.5"> <SpecRow label="Power Capacity" value={berth.powerCapacity} />
{berth.tags.map((tag) => ( <SpecRow label="Voltage" value={berth.voltage} />
<TagBadge key={tag.id} name={tag.name} color={tag.color} /> <SpecRow label="Cleat Type" value={berth.cleatType} />
))} <SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
</div> <SpecRow label="Bollard Type" value={berth.bollardType} />
<SpecRow label="Bollard Capacity" value={berth.bollardCapacity} />
</CardContent> </CardContent>
</Card> </Card>
)}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
</CardHeader>
<CardContent className="pt-0 divide-y">
<SpecRow
label="Tenure Type"
value={berth.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'}
/>
{berth.tenureType === 'fixed_term' && (
<>
<SpecRow label="Years" value={berth.tenureYears} />
<SpecRow label="Start Date" value={berth.tenureStartDate} />
<SpecRow label="End Date" value={berth.tenureEndDate} />
</>
)}
<SpecRow label="Price" value={price} />
</CardContent>
</Card>
{berth.tags.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tags</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap gap-1.5">
{berth.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
</div>
</CardContent>
</Card>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,142 @@
'use client';
import { Archive, MoreHorizontal, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge';
import {
ListCard,
ListCardAvatar,
ListCardMeta,
deriveInitials,
} from '@/components/shared/list-card';
import { getCountryName } from '@/lib/i18n/countries';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
import type { ClientRow } from './client-columns';
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface ClientCardProps {
client: ClientRow;
portSlug: string;
onEdit: (client: ClientRow) => void;
onArchive: (client: ClientRow) => void;
}
export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardProps) {
const primary = client.contacts?.find((c) => c.isPrimary);
const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
const sourceLabel = client.source ? (SOURCE_LABELS[client.source] ?? client.source) : null;
const tags = client.tags ?? [];
const meta = [nationality, sourceLabel].filter(Boolean) as string[];
const interest = client.latestInterest ?? null;
const interestCount = client.interestCount ?? 0;
const interestBerthLabel = interest
? interest.mooringNumber
? `Berth ${interest.mooringNumber}`
: 'General interest'
: null;
const interestStageLabel = interest ? stageLabel(interest.stage) : null;
const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null;
const extraInterests = interestCount > 1 ? interestCount - 1 : 0;
return (
<ListCard
href={`/${portSlug}/clients/${client.id}`}
ariaLabel={`Client ${client.fullName}`}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${client.fullName}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(client)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(client)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-start gap-3">
<ListCardAvatar initials={deriveInitials(client.fullName)} />
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{client.fullName}
</h3>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{primary ? (
<p className="truncate text-sm text-muted-foreground">{primary.value}</p>
) : null}
{meta.length > 0 ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
{meta.map((m, i) => (
<span key={m} className="inline-flex items-center gap-1">
{i > 0 ? <span aria-hidden>·</span> : null}
<ListCardMeta>{m}</ListCardMeta>
</span>
))}
</div>
) : null}
{interest ? (
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate">{interestBerthLabel}</span>
<span aria-hidden>·</span>
<span
className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${interestStageBadge}`}
>
{interestStageLabel}
</span>
{extraInterests > 0 ? (
<span className="shrink-0 text-muted-foreground/80">+{extraInterests}</span>
) : null}
</div>
) : null}
{tags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{tags.length > 2 ? (
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
+{tags.length - 2}
</span>
) : null}
</div>
) : null}
</div>
</div>
</ListCard>
);
}

View File

@@ -74,7 +74,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
<div className="flex items-start gap-3 flex-wrap"> <div className="flex items-start gap-3 flex-wrap">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold text-foreground truncate">{client.fullName}</h1> <h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
{client.fullName}
</h1>
{isArchived && ( {isArchived && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
Archived Archived
@@ -115,7 +117,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{!isArchived && client.clientPortalEnabled !== false && ( {!isArchived && client.clientPortalEnabled !== false && (
<PortalInviteButton <PortalInviteButton
clientId={client.id} clientId={client.id}

View File

@@ -1,8 +1,10 @@
'use client'; 'use client';
import { useEffect } from 'react';
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';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { ClientDetailHeader } from '@/components/clients/client-detail-header'; import { ClientDetailHeader } from '@/components/clients/client-detail-header';
import { getClientTabs } from '@/components/clients/client-tabs'; import { getClientTabs } from '@/components/clients/client-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
@@ -80,6 +82,13 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data), apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
}); });
const { setChrome } = useMobileChrome();
const titleForChrome: string | null = data?.fullName ?? null;
useEffect(() => {
setChrome({ title: titleForChrome, showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
useRealtimeInvalidation({ useRealtimeInvalidation({
'client:updated': [['clients', clientId]], 'client:updated': [['clients', clientId]],
'client:archived': [['clients', clientId]], 'client:archived': [['clients', clientId]],

View File

@@ -16,6 +16,7 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { ClientForm } from '@/components/clients/client-form'; import { ClientForm } from '@/components/clients/client-form';
import { clientFilterDefinitions } from '@/components/clients/client-filters'; import { clientFilterDefinitions } from '@/components/clients/client-filters';
import { ClientCard } from '@/components/clients/client-card';
import { getClientColumns, type ClientRow } from '@/components/clients/client-columns'; import { getClientColumns, type ClientRow } from '@/components/clients/client-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
@@ -118,6 +119,14 @@ export function ClientList() {
onSortChange={setSort} onSortChange={setSort}
isLoading={isFetching && !isLoading} isLoading={isFetching && !isLoading}
getRowId={(row) => row.id} getRowId={(row) => row.id}
cardRender={(row) => (
<ClientCard
client={row.original}
portSlug={portSlug}
onEdit={setEditClient}
onArchive={setArchiveClient}
/>
)}
emptyState={ emptyState={
<EmptyState <EmptyState
title="No clients found" title="No clients found"

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

@@ -0,0 +1,14 @@
// Re-export from the canonical source so legacy imports keep working.
export {
PIPELINE_STAGES,
STAGE_LABELS,
STAGE_BADGE,
STAGE_DOT,
STAGE_WEIGHTS,
STAGE_TRANSITIONS,
safeStage,
stageLabel,
stageBadgeClass,
stageDotClass,
type PipelineStage,
} from '@/lib/constants';

View File

@@ -0,0 +1,141 @@
'use client';
import { Archive, Building2, Eye, Hash, MapPin, MoreHorizontal, Pencil } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { cn } from '@/lib/utils';
import { getCountryName } from '@/lib/i18n/countries';
import type { CompanyRow } from './company-columns';
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800 border-green-300',
dissolved: 'bg-red-100 text-red-800 border-red-300',
};
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
dissolved: 'Dissolved',
};
interface CompanyCardProps {
company: CompanyRow;
portSlug: string;
onEdit: (company: CompanyRow) => void;
onArchive: (company: CompanyRow) => void;
}
export function CompanyCard({ company, portSlug, onEdit, onArchive }: CompanyCardProps) {
const statusLabel = STATUS_LABELS[company.status] ?? company.status;
const statusColor =
STATUS_COLORS[company.status] ?? 'bg-muted text-muted-foreground border-muted';
const country = company.incorporationCountryIso
? getCountryName(company.incorporationCountryIso, 'en')
: null;
const memberCount = company.memberCount ?? 0;
const yachtCount = company.yachtCount ?? 0;
const countParts: string[] = [];
if (memberCount > 0)
countParts.push(`${memberCount} ${memberCount === 1 ? 'member' : 'members'}`);
if (yachtCount > 0) countParts.push(`${yachtCount} ${yachtCount === 1 ? 'yacht' : 'yachts'}`);
// Skip legalName if it is identical to name or absent
const showLegalName =
company.legalName && company.legalName.toLowerCase() !== company.name.toLowerCase();
return (
<ListCard
href={`/${portSlug}/companies/${company.id}`}
ariaLabel={`Company ${company.name}`}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${company.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/companies/${company.id}`}>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(company)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(company)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-start gap-3">
<ListCardAvatar icon={<Building2 className="h-5 w-5" />} />
<div className="min-w-0 flex-1">
{/* Title row + spacer for actions button */}
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{company.name}
</h3>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Legal name subtitle */}
{showLegalName ? (
<p className="mt-0.5 truncate text-sm text-muted-foreground">{company.legalName}</p>
) : null}
{/* Country + Tax ID meta line */}
{country || company.taxId ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{country ? (
<ListCardMeta icon={<MapPin className="h-3 w-3" />}>{country}</ListCardMeta>
) : null}
{company.taxId ? (
<ListCardMeta icon={<Hash className="h-3 w-3" />}>{company.taxId}</ListCardMeta>
) : null}
</div>
) : null}
{/* Member / yacht counts */}
{countParts.length > 0 ? (
<p className="mt-0.5 text-xs text-muted-foreground">{countParts.join(' · ')}</p>
) : null}
{/* Status pill */}
{company.status ? (
<div className="mt-1.5">
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
statusColor,
)}
>
{statusLabel}
</span>
</div>
) : null}
</div>
</div>
</ListCard>
);
}

View File

@@ -80,7 +80,9 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
<div className="flex items-start gap-3 flex-wrap"> <div className="flex items-start gap-3 flex-wrap">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold text-foreground truncate">{company.name}</h1> <h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
{company.name}
</h1>
<span <span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`} className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
> >
@@ -100,7 +102,7 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<PermissionGate resource="companies" action="edit"> <PermissionGate resource="companies" action="edit">
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}> <Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" /> <Pencil className="mr-1.5 h-3.5 w-3.5" />

View File

@@ -1,9 +1,11 @@
'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 { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { CompanyDetailHeader } from '@/components/companies/company-detail-header'; import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
import { getCompanyTabs } from '@/components/companies/company-tabs'; import { getCompanyTabs } from '@/components/companies/company-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
@@ -45,6 +47,13 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data), apiFetch<{ data: CompanyData }>(`/api/v1/companies/${companyId}`).then((r) => r.data),
}); });
const { setChrome } = useMobileChrome();
const titleForChrome: string | null = data?.name ?? null;
useEffect(() => {
setChrome({ title: titleForChrome, showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
useRealtimeInvalidation({ useRealtimeInvalidation({
'company:updated': [['companies', companyId]], 'company:updated': [['companies', companyId]],
'company:archived': [['companies', companyId]], 'company:archived': [['companies', companyId]],

View File

@@ -14,6 +14,7 @@ import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { CompanyCard } from '@/components/companies/company-card';
import { CompanyForm } from '@/components/companies/company-form'; import { CompanyForm } from '@/components/companies/company-form';
import { companyFilterDefinitions } from '@/components/companies/company-filters'; import { companyFilterDefinitions } from '@/components/companies/company-filters';
import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns'; import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns';
@@ -123,6 +124,14 @@ export function CompanyList() {
onSortChange={setSort} onSortChange={setSort}
isLoading={isFetching && !isLoading} isLoading={isFetching && !isLoading}
getRowId={(row) => row.id} getRowId={(row) => row.id}
cardRender={(row) => (
<CompanyCard
company={row.original}
portSlug={portSlug}
onEdit={setEditCompany}
onArchive={setArchiveCompany}
/>
)}
emptyState={ emptyState={
<EmptyState <EmptyState
title="No companies yet" title="No companies yet"

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

@@ -1,19 +1,13 @@
'use client'; 'use client';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
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 { 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 {
@@ -21,18 +15,8 @@ interface PipelineRow {
count: number; count: number;
} }
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
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'),
@@ -45,7 +29,7 @@ function PipelineChartInner() {
} }
const chartData = (data ?? []).map((row) => ({ const chartData = (data ?? []).map((row) => ({
stage: STAGE_LABELS[row.stage] ?? row.stage, stage: isMobile ? STAGE_SHORT_LABELS[safeStage(row.stage)] : stageLabel(row.stage),
count: row.count, count: row.count,
})); }));

View File

@@ -4,31 +4,24 @@ 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 { 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';
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
interface Props { interface Props {
range: DateRange; range: DateRange;
} }
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: STAGE_LABELS[s.stage] ?? 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,
})); }));
@@ -51,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

@@ -6,6 +6,7 @@ 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 { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { CardSkeleton } from '@/components/shared/loading-skeleton'; import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { stageLabel } from '@/lib/constants';
import { WidgetErrorBoundary } from './widget-error-boundary'; import { WidgetErrorBoundary } from './widget-error-boundary';
interface StageBreakdownRow { interface StageBreakdownRow {
@@ -20,17 +21,6 @@ interface ForecastData {
weightsSource: 'db' | 'default'; weightsSource: 'db' | 'default';
} }
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
function formatCurrency(value: number): string { function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',
@@ -66,9 +56,7 @@ function RevenueForecastInner() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div>
<p className="text-xs text-muted-foreground">Weighted Pipeline Value</p> <p className="text-xs text-muted-foreground">Weighted Pipeline Value</p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">{formatCurrency(data?.totalWeightedValue ?? 0)}</p>
{formatCurrency(data?.totalWeightedValue ?? 0)}
</p>
</div> </div>
{activeStages.length > 0 && ( {activeStages.length > 0 && (
@@ -76,12 +64,10 @@ function RevenueForecastInner() {
{activeStages.map((s) => ( {activeStages.map((s) => (
<div key={s.stage} className="flex items-center justify-between text-sm"> <div key={s.stage} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{STAGE_LABELS[s.stage] ?? s.stage} {stageLabel(s.stage)}
<span className="ml-1 text-xs">({s.count})</span> <span className="ml-1 text-xs">({s.count})</span>
</span> </span>
<span className="font-medium tabular-nums"> <span className="font-medium tabular-nums">{formatCurrency(s.weightedValue)}</span>
{formatCurrency(s.weightedValue)}
</span>
</div> </div>
))} ))}
</div> </div>

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,12 +74,22 @@ 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;
} }
export function DocumentsHub({ portSlug }: DocumentsHubProps) { export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps) {
const [tab, setTab] = useState<DocumentsHubTab>('all'); const [tab, setTab] = useState<DocumentsHubTab>(initialTab);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all'); const [typeFilter, setTypeFilter] = useState<string>('all');
const [signatureOnly, setSignatureOnly] = useState(true); const [signatureOnly, setSignatureOnly] = useState(true);
@@ -186,7 +196,7 @@ export function DocumentsHub({ portSlug }: 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,22 +155,59 @@ 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)
<span </p>
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${ {REQUIRED_LABELS.map(({ key, label }) => (
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700' <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] ? '✓' : '✗'} prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
</span> }`}
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}> >
{label} {prerequisites[key] ? '✓' : '✗'}
</span> </span>
</div> <span
))} className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
>
{label}
</span>
</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> </div>
@@ -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,194 @@
'use client';
import { format } from 'date-fns';
import { Archive, Calendar, Eye, MoreHorizontal, Pencil, Receipt, Tag } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { cn } from '@/lib/utils';
import type { ExpenseRow } from './expense-columns';
const PAYMENT_STATUS_COLORS: Record<string, string> = {
unpaid: 'bg-red-100 text-red-700 border-red-200',
paid: 'bg-green-100 text-green-700 border-green-200',
partial: 'bg-yellow-100 text-yellow-700 border-yellow-200',
reconciled: 'bg-green-100 text-green-700 border-green-200',
flagged: 'bg-rose-100 text-rose-700 border-rose-200',
pending: 'bg-amber-100 text-amber-700 border-amber-200',
};
/**
* Accent bar by payment status:
* paid / reconciled → emerald
* pending → amber
* flagged → rose
* other / null → slate
* If duplicateOf is set, override to amber-500.
*/
function deriveAccent(status: string | null, duplicateOf: string | null): string {
if (duplicateOf) return 'bg-amber-500';
switch (status) {
case 'paid':
case 'reconciled':
return 'bg-emerald-400';
case 'pending':
return 'bg-amber-400';
case 'flagged':
return 'bg-rose-400';
default:
return 'bg-slate-300';
}
}
function formatAmount(amount: string, currency: string): string {
try {
return new Intl.NumberFormat('en', { style: 'currency', currency }).format(Number(amount));
} catch {
return `${currency} ${amount}`;
}
}
interface ExpenseCardProps {
expense: ExpenseRow;
portSlug: string;
onEdit: (expense: ExpenseRow) => void;
onArchive: (expense: ExpenseRow) => void;
}
export function ExpenseCard({ expense, portSlug, onEdit, onArchive }: ExpenseCardProps) {
const accentClass = deriveAccent(expense.paymentStatus, expense.duplicateOf);
const title =
expense.establishmentName ??
(expense.description ? expense.description.slice(0, 40) : null) ??
'Untitled expense';
// Subtitle: category (capitalized) or "{paymentMethod} by {payer}"
let subtitle: string | null = null;
if (expense.category) {
subtitle = expense.category.replace(/_/g, ' ');
// Capitalize first letter
subtitle = subtitle.charAt(0).toUpperCase() + subtitle.slice(1);
} else if (expense.paymentMethod || expense.payer) {
const parts: string[] = [];
if (expense.paymentMethod) parts.push(expense.paymentMethod);
if (expense.payer) parts.push(`by ${expense.payer}`);
subtitle = parts.join(' ');
}
const hasCategory = !!expense.category;
let dateFormatted: string | null = null;
try {
dateFormatted = format(new Date(expense.expenseDate), 'MMM d, yyyy');
} catch {
dateFormatted = expense.expenseDate;
}
const amountFormatted = formatAmount(expense.amount, expense.currency);
const statusColor = expense.paymentStatus
? (PAYMENT_STATUS_COLORS[expense.paymentStatus] ??
'bg-muted text-muted-foreground border-muted')
: null;
return (
<ListCard
href={`/${portSlug}/expenses/${expense.id}`}
ariaLabel={`Expense: ${title}`}
accentClassName={accentClass}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for expense: ${title}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/expenses/${expense.id}`}>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(expense)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(expense)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-start gap-3">
<ListCardAvatar icon={<Receipt className="h-5 w-5" />} />
<div className="min-w-0 flex-1">
{/* Title row + spacer for actions button */}
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{title}
</h3>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Category / payer subtitle */}
{subtitle ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
{hasCategory ? (
<Tag className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
) : null}
<span className="truncate">{subtitle}</span>
</p>
) : null}
{/* Amount — prominent */}
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">
{amountFormatted}
</p>
{/* Date meta */}
{dateFormatted ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<ListCardMeta icon={<Calendar className="h-3 w-3" />}>{dateFormatted}</ListCardMeta>
</div>
) : null}
{/* Status + duplicate pills */}
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{statusColor && expense.paymentStatus ? (
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize',
statusColor,
)}
>
{expense.paymentStatus}
</span>
) : null}
{expense.duplicateOf ? (
<span className="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
Possible duplicate
</span>
) : null}
</div>
</div>
</div>
</ListCard>
);
}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Loader2, Receipt, Edit, Archive } from 'lucide-react'; import { Loader2, Receipt, Edit, Archive } from 'lucide-react';
@@ -10,6 +10,7 @@ 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 { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import type { ExpenseRow } from './expense-columns'; import type { ExpenseRow } from './expense-columns';
import { ExpenseDuplicateBanner } from './expense-duplicate-banner'; import { ExpenseDuplicateBanner } from './expense-duplicate-banner';
@@ -34,6 +35,14 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
queryFn: () => apiFetch(`/api/v1/expenses/${expenseId}`), queryFn: () => apiFetch(`/api/v1/expenses/${expenseId}`),
}); });
const { setChrome } = useMobileChrome();
const titleForChrome: string | null =
data?.data?.establishmentName ?? data?.data?.description?.slice(0, 40) ?? null;
useEffect(() => {
setChrome({ title: titleForChrome ?? 'Expense', showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
const archiveMutation = useMutation({ const archiveMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/expenses/${expenseId}`, { method: 'DELETE' }), mutationFn: () => apiFetch(`/api/v1/expenses/${expenseId}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {

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

@@ -0,0 +1,196 @@
'use client';
import { Anchor, Archive, Compass, MessageSquare, MoreHorizontal, Pencil } from 'lucide-react';
import { formatDistanceToNowStrict } from 'date-fns';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge';
import {
ListCard,
ListCardAvatar,
ListCardMeta,
deriveInitials,
} from '@/components/shared/list-card';
import { cn } from '@/lib/utils';
import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants';
import { computeUrgencyBadges } from '@/components/interests/urgency';
import type { InterestRow } from './interest-columns';
const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General',
specific_qualified: 'Qualified',
hot_lead: 'Hot lead',
};
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface InterestCardProps {
interest: InterestRow;
portSlug: string;
onEdit: (interest: InterestRow) => void;
onArchive: (interest: InterestRow) => void;
}
export function InterestCard({ interest, portSlug, onEdit, onArchive }: InterestCardProps) {
const stageLabel = toStageLabel(interest.pipelineStage);
const stagePill = stageBadgeClass(interest.pipelineStage);
const accentClass = stageDotClass(interest.pipelineStage);
const isHotLead = interest.leadCategory === 'hot_lead';
const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null;
const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null;
const tags = interest.tags ?? [];
const notesCount = interest.notesCount ?? 0;
const urgencyBadges = computeUrgencyBadges(interest);
const clientName = interest.clientName ?? 'Unknown client';
const berthLabel = interest.berthMooringNumber;
const lastIso = interest.dateLastContact ?? interest.updatedAt ?? null;
const lastActivity = lastIso
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
: null;
return (
<ListCard
href={`/${portSlug}/interests/${interest.id}`}
ariaLabel={`Interest for ${clientName}`}
accentClassName={accentClass}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${clientName}'s interest`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(interest)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(interest)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-start gap-3">
<ListCardAvatar initials={deriveInitials(clientName)} />
<div className="min-w-0 flex-1">
{/* Title row: name + comment-icon when notes exist + spacer for actions */}
<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">
{clientName}
</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" />
</div>
{/* Berth or general-interest line */}
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
{berthLabel ? (
<>
<Anchor className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">{berthLabel}</span>
</>
) : (
<>
<Compass className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="italic">General interest</span>
</>
)}
</p>
{/* Stage pill + category + source */}
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
stagePill,
)}
>
{stageLabel}
</span>
{isHotLead ? (
<span className="inline-flex items-center rounded-full bg-rose-100 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-rose-700">
Hot
</span>
) : null}
{(categoryLabel && !isHotLead) || sourceLabel ? (
<div className="flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
{categoryLabel && !isHotLead ? <ListCardMeta>{categoryLabel}</ListCardMeta> : null}
{categoryLabel && !isHotLead && sourceLabel ? <span aria-hidden>·</span> : null}
{sourceLabel ? <ListCardMeta>{sourceLabel}</ListCardMeta> : null}
</div>
) : null}
</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 ? (
<div className="mt-2 flex flex-wrap gap-1">
{tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{tags.length > 2 ? (
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
+{tags.length - 2}
</span>
) : null}
</div>
) : null}
{lastActivity ? (
<p className="mt-1.5 text-[11px] text-muted-foreground tabular-nums">
Last activity {lastActivity}
</p>
) : null}
</div>
</div>
</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';
@@ -14,6 +14,8 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
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 { computeUrgencyBadges, type InterestUrgencyInput } from '@/components/interests/urgency';
export interface InterestRow { export interface InterestRow {
id: string; id: string;
@@ -26,31 +28,18 @@ 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 }>;
} }
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
const STAGE_COLORS: Record<string, string> = {
open: 'bg-slate-100 text-slate-700',
details_sent: 'bg-blue-100 text-blue-700',
in_communication: 'bg-sky-100 text-sky-700',
visited: 'bg-violet-100 text-violet-700',
signed_eoi_nda: 'bg-amber-100 text-amber-700',
deposit_10pct: 'bg-orange-100 text-orange-700',
contract: 'bg-green-100 text-green-700',
completed: 'bg-emerald-100 text-emerald-700',
};
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General Interest', general_interest: 'General Interest',
specific_qualified: 'Specific Qualified', specific_qualified: 'Specific Qualified',
@@ -80,15 +69,29 @@ export function getInterestColumns({
id: 'clientName', id: 'clientName',
accessorKey: 'clientName', accessorKey: 'clientName',
header: 'Client', header: 'Client',
cell: ({ row }) => ( cell: ({ row }) => {
<Link const notesCount = row.original.notesCount ?? 0;
href={`/${portSlug}/clients/${row.original.clientId}`} return (
className="font-medium text-primary hover:underline" <div className="flex items-center gap-1.5 min-w-0">
onClick={(e) => e.stopPropagation()} <Link
> href={`/${portSlug}/clients/${row.original.clientId}`}
{row.original.clientName ?? '—'} className="truncate font-medium text-primary hover:underline"
</Link> onClick={(e) => e.stopPropagation()}
), >
{row.original.clientName ?? '—'}
</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',
@@ -113,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 (
<span <div className="flex flex-col gap-1 items-start">
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STAGE_COLORS[stage] ?? 'bg-gray-100 text-gray-700'}`} <span
> className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`}
{STAGE_LABELS[stage] ?? stage} >
</span> {stageLabel(stage)}
</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>
); );
}, },
}, },
@@ -174,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',
</span> 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>
);
},
}, },
{ {
id: 'actions', id: 'actions',
@@ -205,10 +235,7 @@ export function getInterestColumns({
<Pencil className="mr-2 h-3.5 w-3.5" /> <Pencil className="mr-2 h-3.5 w-3.5" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
className="text-destructive"
onClick={() => onArchive(row.original)}
>
<Archive className="mr-2 h-3.5 w-3.5" /> <Archive className="mr-2 h-3.5 w-3.5" />
Archive Archive
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -2,7 +2,18 @@
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, TrendingUp } 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 { Button } from '@/components/ui/button';
@@ -12,33 +23,35 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip'; import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form'; import { InterestForm } from '@/components/interests/interest-form';
import { InterestStagePicker } from '@/components/interests/interest-stage-picker'; import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
const STAGE_LABELS: Record<string, string> = { const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
open: 'Open', won: { label: 'Won', className: 'bg-emerald-100 text-emerald-700' },
details_sent: 'Details Sent', lost_other_marina: { label: 'Lost — other marina', className: 'bg-rose-100 text-rose-700' },
in_communication: 'In Communication', lost_unqualified: { label: 'Lost — unqualified', className: 'bg-rose-100 text-rose-700' },
visited: 'Visited', lost_no_response: { label: 'Lost — no response', className: 'bg-rose-100 text-rose-700' },
signed_eoi_nda: 'Signed EOI/NDA', cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
}; };
const STAGE_COLORS: Record<string, string> = { // Catch-all so an unknown outcome (e.g. a future `lost_no_berth` enum) still
open: 'bg-slate-100 text-slate-700', // renders as a closed-state badge instead of falling back to the open-state
details_sent: 'bg-blue-100 text-blue-700', // stage picker. Lost-* gets a rose tint; everything else gets neutral slate.
in_communication: 'bg-sky-100 text-sky-700', function resolveOutcomeBadge(outcome: string | null | undefined) {
visited: 'bg-violet-100 text-violet-700', if (!outcome) return null;
signed_eoi_nda: 'bg-amber-100 text-amber-700', const known = OUTCOME_BADGE[outcome];
deposit_10pct: 'bg-orange-100 text-orange-700', if (known) return known;
contract: 'bg-green-100 text-green-700', const isLoss = outcome.startsWith('lost');
completed: 'bg-emerald-100 text-emerald-700', 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 Interest', general_interest: 'General',
specific_qualified: 'Specific Qualified', specific_qualified: 'Specific Qualified',
hot_lead: 'Hot Lead', hot_lead: 'Hot Lead',
}; };
@@ -49,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;
@@ -58,17 +81,50 @@ interface InterestDetailHeaderProps {
reminderEnabled: boolean; reminderEnabled: boolean;
reminderDays: number | null; reminderDays: number | null;
archivedAt: string | null; archivedAt: string | null;
outcome?: 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);
const [archiveOpen, setArchiveOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false);
const [stageOpen, setStageOpen] = useState(false); const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
const isArchived = !!interest.archivedAt; const isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(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({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
},
});
const archiveMutation = useMutation({ const archiveMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }), mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }),
@@ -88,13 +144,50 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
}, },
}); });
const meta: Array<{ key: string; node: React.ReactNode }> = [];
if (interest.berthMooringNumber) {
meta.push({
key: 'berth',
node: (
<Link
href={`/${portSlug}/berths/${interest.berthId}`}
className="text-foreground hover:underline"
>
Berth {interest.berthMooringNumber}
</Link>
),
});
}
if (interest.leadCategory) {
meta.push({
key: 'cat',
node: <span>{CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}</span>,
});
}
if (interest.source) {
meta.push({
key: 'src',
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 (
<> <>
<DetailHeaderStrip> <DetailHeaderStrip>
<div className="flex items-start gap-3 flex-wrap"> <div className="flex items-start gap-2">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold text-foreground"> <h1 className="truncate text-lg font-bold text-foreground sm:text-xl">
{interest.clientName ?? 'Unknown Client'} {interest.clientName ?? 'Unknown Client'}
</h1> </h1>
{isArchived && ( {isArchived && (
@@ -102,95 +195,227 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
Archived Archived
</Badge> </Badge>
)} )}
<span {outcomeBadge ? (
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium ${STAGE_COLORS[interest.pipelineStage] ?? 'bg-gray-100 text-gray-700'}`} <span
> className={cn(
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage} 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
</span> outcomeBadge.className,
)}
title={interest.outcomeReason ?? undefined}
>
{outcomeBadge.label}
</span>
) : (
<PermissionGate
resource="interests"
action="change_stage"
fallback={
<span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700">
{interest.pipelineStage}
</span>
}
>
<InlineStagePicker
interestId={interest.id}
currentStage={interest.pipelineStage}
className="-ml-2.5"
/>
</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>
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground"> {meta.length > 0 ? (
{interest.berthMooringNumber && ( <p className="text-xs text-muted-foreground sm:text-sm">
<span> {meta.map((m, i) => (
Berth:{' '} <span key={m.key}>
<Link {i > 0 ? (
href={`/${portSlug}/berths/${interest.berthId}`} <span aria-hidden className="mx-1.5">
className="text-foreground hover:underline" ·
> </span>
{interest.berthMooringNumber} ) : null}
</Link> {m.node}
</span>
)}
{interest.leadCategory && (
<span>
Category:{' '}
<span className="text-foreground">
{CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}
</span> </span>
</span> ))}
)} </p>
{interest.source && ( ) : null}
<span>
Source: <span className="text-foreground capitalize">{interest.source}</span>
</span>
)}
</div>
{interest.tags && interest.tags.length > 0 && ( {interest.tags && interest.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 pt-0.5">
{interest.tags.map((tag) => ( {interest.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} /> <TagBadge key={tag.id} name={tag.name} color={tag.color} />
))} ))}
</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>
{/* Actions */} {/* Top-right actions. Won/Lost are sales-critical and read as text
<div className="flex items-center gap-2 flex-wrap"> buttons on desktop; Edit/Archive stay icon-only. On mobile,
<PermissionGate resource="interests" action="edit"> Won/Lost shrink to icon buttons to keep the cluster from
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}> wrapping. */}
<Pencil className="mr-1.5 h-3.5 w-3.5" /> <div className="flex shrink-0 items-center gap-1">
Edit
</Button>
</PermissionGate>
<PermissionGate resource="interests" action="change_stage"> <PermissionGate resource="interests" action="change_stage">
<Button variant="outline" size="sm" onClick={() => setStageOpen(true)}> {isClosed ? (
<TrendingUp className="mr-1.5 h-3.5 w-3.5" /> <button
Change Stage type="button"
</Button> onClick={() => reopenMutation.mutate()}
disabled={reopenMutation.isPending}
className={cn(
'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 disabled:opacity-50',
)}
>
<RefreshCcw className="size-3.5" />
Reopen
</button>
) : (
<>
<button
type="button"
onClick={() => setOutcomeDialog('won')}
aria-label="Mark as won"
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
'border border-emerald-200 bg-emerald-50 text-emerald-700',
'hover:bg-emerald-100',
)}
>
<Trophy className="size-3.5" />
<span className="hidden sm:inline">Mark won</span>
</button>
<button
type="button"
onClick={() => setOutcomeDialog('lost')}
aria-label="Close as lost"
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
'border border-rose-200 text-rose-700',
'hover:bg-rose-50',
)}
>
<XCircle className="size-3.5" />
<span className="hidden sm:inline">Close as lost</span>
</button>
</>
)}
</PermissionGate>
<PermissionGate resource="interests" action="edit">
<button
type="button"
onClick={() => setEditOpen(true)}
aria-label="Edit interest"
title="Edit interest"
className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5 hover:text-foreground',
)}
>
<Pencil className="size-4" />
</button>
</PermissionGate> </PermissionGate>
<PermissionGate resource="interests" action="delete"> <PermissionGate resource="interests" action="delete">
<Button variant="outline" size="sm" onClick={() => setArchiveOpen(true)}> <button
{isArchived ? ( type="button"
<> onClick={() => setArchiveOpen(true)}
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> aria-label={isArchived ? 'Restore interest' : 'Archive interest'}
Restore title={isArchived ? 'Restore interest' : 'Archive interest'}
</> className={cn(
) : ( 'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
<> 'hover:bg-foreground/5',
<Archive className="mr-1.5 h-3.5 w-3.5" /> isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
Archive
</>
)} )}
</Button> >
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
</button>
</PermissionGate> </PermissionGate>
</div> </div>
</div> </div>
</DetailHeaderStrip> </DetailHeaderStrip>
{outcomeDialog && (
<InterestOutcomeDialog
interestId={interest.id}
mode={outcomeDialog}
open={outcomeDialog !== null}
onOpenChange={(open) => !open && setOutcomeDialog(null)}
/>
)}
<InterestForm <InterestForm
open={editOpen} open={editOpen}
onOpenChange={setEditOpen} onOpenChange={setEditOpen}
interest={interest as unknown as Parameters<typeof InterestForm>[0]['interest']} interest={interest as unknown as Parameters<typeof InterestForm>[0]['interest']}
/> />
<InterestStagePicker
open={stageOpen}
onOpenChange={setStageOpen}
interestId={interest.id}
currentStage={interest.pipelineStage}
/>
<ArchiveConfirmDialog <ArchiveConfirmDialog
open={archiveOpen} open={archiveOpen}
onOpenChange={setArchiveOpen} onOpenChange={setArchiveOpen}

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

@@ -1,16 +1,5 @@
import type { FilterDefinition } from '@/components/shared/filter-bar'; import type { FilterDefinition } from '@/components/shared/filter-bar';
import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants'; import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES } from '@/lib/constants';
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General Interest', general_interest: 'General Interest',
@@ -30,7 +19,7 @@ export const interestFilterDefinitions: FilterDefinition[] = [
label: 'Stage', label: 'Stage',
type: 'multi-select', type: 'multi-select',
options: PIPELINE_STAGES.map((s) => ({ options: PIPELINE_STAGES.map((s) => ({
label: STAGE_LABELS[s] ?? s, label: STAGE_LABELS[s],
value: s, value: s,
})), })),
}, },

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