Commit Graph

95 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
a3305a94f3 feat(gdpr): staff-triggered client-data export bundle (Article 15)
Adds a full GDPR Article 15 (right of access) workflow. Staff trigger
an export from the client detail; a BullMQ worker assembles every row
keyed to that client (profile, contacts, addresses, notes, tags,
yachts, company memberships, interests, reservations, invoices,
documents, last 500 audit events) into JSON + a self-contained HTML
report, ZIPs them, uploads to MinIO, and optionally emails the client
a 7-day signed download link.

- New table gdpr_exports tracks lifecycle (pending → building → ready
  → sent / failed) with a 30-day cleanup target
- Bundle builder (gdpr-bundle-builder.ts) — pure read-side, tenant-
  scoped, with HTML escaping to block injection from rogue field values
- Worker hook in export queue dispatches on job name 'gdpr-export'
- New audit actions: 'request_gdpr_export', 'send_gdpr_export'
- API: POST/GET /api/v1/clients/:id/gdpr-export (admin-gated, exports
  rate-limit, Article-15 audit on POST); GET /:exportId returns a
  fresh signed URL
- UI: <GdprExportButton> dialog on client detail header — admin-only,
  shows recent exports, supports email-to-client + override recipient,
  polls every 5s while open
- Validation: refuses email-to-client when no primary email + no
  override (rather than silently dropping the send)

Tests: 778/778 vitest (was 771) — +7 covering builder happy path,
HTML escaping, tenant isolation, empty client, request-flow validation,
and audit / queue interaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:06:31 +02:00
Matt Ciaccio
e7d23b254c feat(ai): per-port token budgets + usage ledger for AI features
Adds a token-denominated guardrail in front of every server-side AI call
so a misconfigured port can't run up an unbounded bill. Soft caps surface
a banner; hard caps refuse new requests until the period rolls over.
Usage flows into a feature-typed ledger so future AI surfaces (summary,
embeddings, reply-draft) can drop in without schema changes.

- New table ai_usage_ledger (port, user, feature, provider, model,
  input/output/total tokens, request id) with two indexes for rollup
- New service ai-budget.service.ts: getAiBudget/setAiBudget,
  checkBudget (pre-flight gate), recordAiUsage, currentPeriodTokens,
  periodBreakdown — all token-based, period boundaries in UTC
- runOcr now returns provider usage so the route can record the actual
  spend instead of estimating
- Scan-receipt route gates on checkBudget before invoking AI; returns
  source: manual / reason: budget-exceeded when blocked, surfaces
  softCapWarning on the success path
- Admin UI: new AiBudgetCard on the OCR settings page — shows current
  spend, per-feature breakdown, soft/hard cap inputs, period selector
- Permission: admin.manage_settings on both routes

Tests: 766/766 vitest (was 756) — +10 budget tests covering enforce/
disabled/cap-exceed/estimate-exceed/soft-warn/period boundaries/
cross-port isolation/silent ledger failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:53:09 +02:00
Matt Ciaccio
2cf1bd9754 feat(ocr): Tesseract.js as default scanner, AI as opt-in per port
The mobile receipt scanner now runs Tesseract.js in-browser by default —
on-device, free, and image bytes never leave the device. AI providers
(OpenAI / Claude) become a per-port opt-in for higher accuracy on
hard-to-read receipts.

- Lazy-load Tesseract WASM in src/lib/ocr/tesseract-client.ts (5 MB
  bundle dynamic-imports on first scan, not in main chunk)
- Heuristic parser src/lib/ocr/parse-receipt-text.ts extracts vendor,
  date, amount, currency, and line items from raw OCR text
- New port-scoped aiEnabled flag on OcrConfig (defaults false). Resolved
  flag never inherits from the global row — each port admin opts in
  independently
- Scan endpoint short-circuits to manual-mode when aiEnabled=false so
  the AI provider is never invoked unless the admin has flipped the
  switch
- Scan UI runs Tesseract first, then asks the server whether AI is
  enabled — uses the AI result only when its confidence beats Tesseract;
  network failures degrade gracefully to the local parse
- Admin OCR-settings form gains the per-port aiEnabled checkbox

Tests: 756/756 vitest (was 747) — +7 parser unit tests, +2 aiEnabled
config tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:46:29 +02:00
Matt Ciaccio
46937bbcb9 feat(addresses): full CRUD UI for client + company multi-address
Client and company detail pages each gain an Addresses tab with click-to-edit
fields wired to the existing CountryCombobox/SubdivisionCombobox primitives.
Adds a primary toggle that demotes the previous primary inside one transaction
so the partial unique index never trips.

- New service helpers: list/add/update/remove ClientAddress + CompanyAddress
- New routes: /api/v1/clients/[id]/addresses[/addressId], same under companies/
- New shared component: <AddressesEditor> reused by both detail surfaces
- Integration tests cover happy path, primary demotion, and tenant scoping

Tests: 747/747 vitest (was 741, +6 address tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:38:43 +02:00
Matt Ciaccio
27cdbcc695 chore(i18n): drop legacy free-text country/nationality columns
Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).

Migration 0016 drops:
  - clients.nationality
  - companies.incorporation_country
  - client_addresses.{state_province, country}
  - company_addresses.{state_province, country}

Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.

Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.

Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.

Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').

Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:57 +02:00
Matt Ciaccio
16d98d630e feat(i18n): country/phone/timezone/subdivision primitives + form wiring
Cross-cutting i18n polish for forms across the marina + residential + company
domains. Introduces a single source of truth for country/phone/timezone/
subdivision data and replaces every nationality-as-free-text and timezone-
as-string Input with a dedicated combobox.

PR1  Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames
     for localized labels, detectDefaultCountry() with navigator-region
     fallback to US, CountryCombobox with regional-indicator flag glyphs +
     compact mode for inline use.
PR2  Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType /
     callingCodeFor), PhoneInput with flag dropdown + national-format
     AsYouType + paste-detect that flips the country dropdown for pasted
     international strings.
PR3  Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/
     ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"),
     TimezoneCombobox with Suggested/All grouping driven by countryHint.
PR4  Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2
     codes for every country), per-country cache, SubdivisionCombobox with
     "Pick a country first" / "No regions available" empty states.
PR5  Schema deltas (migration 0015) — clients.nationality_iso, clientContacts
     {value_e164, value_country}, clientAddresses {country_iso, subdivision_iso},
     residentialClients {phone_e164, phone_country, nationality_iso, timezone,
     place_of_residence_country_iso, subdivision_iso}, companies {incorporation_
     country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso,
     subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used
     by every entity validator + route handler.
PR6  ClientForm + ClientDetail — CountryCombobox replaces nationality Input,
     TimezoneCombobox replaces timezone Input (driven by nationalityIso hint),
     PhoneInput conditionally rendered for phone/whatsapp contacts. Inline
     editors (InlineCountryField / InlineTimezoneField / InlinePhoneField)
     for the detail-page overview rows + ContactsEditor.
PR7  Residential client form + detail — phone -> PhoneInput, nationality/
     timezone/place-of-residence-country/subdivision rows in both create
     sheet and inline-editable detail view. Subdivision wipes when country
     flips since codes are country-scoped.
PR8  Company form + detail — incorporation country -> CountryCombobox,
     incorporation region -> SubdivisionCombobox in both modes.
PR9  Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry
     and i18n fields from newer website builds, server-side parsePhone()
     fallback for legacy raw-international submissions. Old Nuxt builds
     keep working unchanged.

Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for
the public phone-normalization path (3 tests), 1 smoke spec asserting the
combobox triggers render in all three create sheets.

Test totals: vitest 713 -> 741 (+28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:13:08 +02:00
Matt Ciaccio
f52d21df83 feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.

PR4  Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
     date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5  Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
     right-rail, three-tab page (active/dismissed/resolved), socket-driven
     invalidation. Bell lazy-loads list on popover open to keep cold pages
     fast in non-dashboard routes.
PR6  EOI queue tab on documents hub — filters to in-flight EOIs, count
     surfaces in tab label.
PR7  Interests-by-berth tab on berth detail — replaces the stub.
PR8  Expense duplicate detection — BullMQ job runs scan on create, yellow
     banner on detail w/ Merge / Not-a-duplicate, transactional merge
     consolidates receipts and archives the source.
PR9  Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
     its own (scanner) group with no dashboard chrome, dynamic per-port
     manifest, OpenAI + Claude provider abstraction, admin OCR settings
     page (port-level + super-admin global default w/ opt-in fallback),
     test-connection endpoint, manual-entry fallback when no key is
     configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
     existing GIN index, cursor pagination, filters for entity/action/user
     /date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
     real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
     socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
     cleanly without their gate envs so CI stays green.

Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
Matt Ciaccio
5f9bbb97bd fix(sidebar): replace floating circular collapse button with blended row
User feedback: the circular toggle floating off the sidebar's right
edge looked tacked-on. Replaced with a flush full-width row above the
user footer (right-aligned 'Collapse <' chip when expanded; centered
chevron when collapsed). Same nav-item hover treatment so it merges
visually with the sidebar palette. The <aside> no longer needs to
host an overhanging button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:00:20 +02:00
Matt Ciaccio
4911083d0f fix(visual): KPITile data-testid + restore residential interest casing
Post-PR10c follow-ups discovered during smoke triage:
- KPITile gets data-testid="kpi-tile" so the dashboard smoke spec's
  '[data-testid*="kpi"]' selector matches (test 10-dashboard:27 expected
  >=4 kpi cards; the old Card-based render was matched by the
  '[class*="card"]' branch and didn't need a testid).
- Residential interest detail eyebrow text reverted from "Residential
  Interest" to "Residential interest" (lowercase i). The visual is
  identical because the wrapper has the `uppercase` class; the smoke
  spec at 26-residential:140 looks for the literal lowercase string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:56:32 +02:00
Matt Ciaccio
3a7fef59b0 fix(visual): dark-mode-safe borders + sidebar relative + ring-background
Code-review follow-up to PR10b-e:
- DetailHeaderStrip + KPITile: border-slate-200 → border-border so dark
  mode doesn't paint a bright halo around the gradient strip.
- Topbar avatar: ring-white → ring-background so the 2px ring tracks
  the surface (matches the sidebar footer pattern).
- KpiTileSkeleton stripe: bg-slate-100 → bg-muted for parity with
  shadcn skeleton tokens in dark mode.
- Sidebar <aside>: add `relative` so the absolute-positioned
  collapse-toggle button anchors to the sidebar itself rather than
  the nearest positioned ancestor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:24:14 +02:00
Matt Ciaccio
2d1b50745a style(mobile): responsive tabs + table overflow + hub flex-wrap (Phase A)
Adds <ResponsiveTabs> primitive that swaps the TabsList for a native Select on
phone-sized viewports (<640px). DetailLayout now routes its tab strip through it,
so every tabbed detail page gets the collapse for free. DataTable wraps the
Table in overflow-x-auto so wide column sets scroll horizontally instead of
breaking the layout under 768px. Documents-hub row swaps the fixed
grid-cols-[auto_1fr_auto_auto_auto_auto] for flex-wrap below sm: so signers /
status / dates stack instead of clipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:21 +02:00
Matt Ciaccio
c7ca7c1f96 style(layout): sidebar stripe + topbar gradient + bell spring + search ring
Sidebar active items: 4px brand left-edge stripe (rounded-r-full) replacing the
border-l-2 + bg shift; section header smaller-caps + brand-200 colour; user-footer
avatar gets shadow-sm + ring-2 ring-white/30.

Topbar '+ New' uses bg-gradient-brand with shadow-sm + scale-1.02 hover. User
avatar trigger gets shadow-sm + ring-2 ring-white. Notification badge gets
gradient-brand fill + ring-2 ring-background + animate-badge-pop spring keyframe
(retriggers on count change via key={unreadCount}). Command search gets shadow-xs
inset + brand focus ring (ring-4 ring-brand/15).

Adds badge-pop keyframes to tailwind config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:11 +02:00
Matt Ciaccio
a3424b80d5 style(dashboard): KPITile primitive + gradient PageHeader + tile skeletons
Replaces flat Card-based KPI rendering with KPITile (gradient-brand-soft + accent
stripe). Adds polished gradient PageHeader to DashboardShell with eyebrow, KPI
sub-line, description. Tile-shaped skeletons replace the four CardSkeletons during
KPI load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:09:59 +02:00