feat(launch-readiness-batch): UAT drains, navigation refactor, launch infra, trackers

Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.

UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
  sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
  aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
  Aggregated helpers in notes.service mirror the listFor*Aggregated
  symmetric-reach joins. yacht-tabs + company-tabs render the
  badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
  `width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
  fixed inset-0 pin so long forms scroll naturally). Form picks up
  port branding (logoUrl + backgroundUrl + appName) via
  loadByToken. Address fields completed (street + city + region +
  postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
  emits toast.success with action link to the destination entity
  or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
  rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
  (5 types visible at once).

Launch infra:
- UTM column wiring (Init 1b step 4): migration
  0089_website_submissions_utm.sql adds utm_source/medium/campaign/
  term/content + composite index (port_id, utm_source, received_at)
  for per-campaign rollups. website-inquiries intake accepts the
  five fields. Residential intake intentionally untouched per audit
  scope.
- Invoicing module gate (Init 1c spike): new
  invoices-module.service + invoices layout guard + registry entry
  invoices_module_enabled (default false). Audit conclusion in
  launch-readiness.md: payments table is canonical money path;
  /invoices flow is parallel infrastructure now hidden by default.

Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
  New route-labels.ts + use-smart-back hook +
  navigation-history-tracker so back falls through to the parent
  route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
  breadcrumb-store kept for back-compat consumers but the
  breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
  upload-receipts, reports kind, tenancies detail, analytics
  metric, client detail) migrated.

Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
  the reports gap audit (cross-cutting filter set, Marketing +
  Financial blockers, custom builder remaining entities, scheduled
  CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
  OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
  (each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 22:42:37 +02:00
parent 3bdf59e917
commit cb8292464c
62 changed files with 2944 additions and 662 deletions

View File

@@ -6,9 +6,14 @@ import { usePathname } from 'next/navigation';
import { type BreadcrumbHint, useBreadcrumbStore } from '@/stores/breadcrumb-store';
/**
* Detail pages call this on mount to register their entity hierarchy
* for the topbar breadcrumb. Pass a stable hint object (or memoise the
* inputs) so the effect doesn't re-fire every render.
* Detail pages call this on mount to register their entity hierarchy.
* The hint is consumed by `useSmartBack` to label the topbar back
* button - the closest parent in `parents` becomes the back target,
* so a rep on an interest page sees "Back to Mary Smith" (the client
* they drilled in from) instead of the URL-derived "Back to Clients".
*
* Pass a stable hint object (or memoise the inputs) so the effect
* doesn't re-fire every render.
*
* Example (interest detail page):
* useBreadcrumbHint({
@@ -18,11 +23,17 @@ import { type BreadcrumbHint, useBreadcrumbStore } from '@/stores/breadcrumb-sto
*
* The hint clears when the page unmounts so a stale hierarchy doesn't
* leak into the next route.
*
* Naming note: the hook + store kept their `breadcrumb` prefix when
* the topbar breadcrumb trail was retired in favor of the contextual
* back button. They are now back-context hints, not breadcrumb chain
* entries - the names stayed to avoid touching every detail page.
*/
export function useBreadcrumbHint(hint: BreadcrumbHint | null | undefined): void {
const pathname = usePathname();
const setHint = useBreadcrumbStore((s) => s.setHint);
const clearHint = useBreadcrumbStore((s) => s.clearHint);
const cacheLabel = useBreadcrumbStore((s) => s.cacheLabel);
// Stringify for stable equality - caller can pass an object literal
// each render without wrecking effect deps. The serialized form is
@@ -32,6 +43,11 @@ export function useBreadcrumbHint(hint: BreadcrumbHint | null | undefined): void
useEffect(() => {
if (!serialized || !hint) return;
setHint(pathname, hint);
// Snapshot the display label into the persistent labelCache so the
// back button can render "Back to Sarah Doe" after the rep has
// drilled away from her detail page (at which point the hint above
// has unmounted, but the label is still load-bearing).
cacheLabel(pathname, hint.current);
return () => {
clearHint(pathname);
};
@@ -39,5 +55,5 @@ export function useBreadcrumbHint(hint: BreadcrumbHint | null | undefined): void
// re-register if the page navigates without unmounting (rare but
// possible on client-side route swaps within the same layout).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serialized, pathname, setHint, clearHint]);
}, [serialized, pathname, setHint, clearHint, cacheLabel]);
}

132
src/hooks/use-smart-back.ts Normal file
View File

@@ -0,0 +1,132 @@
'use client';
import { usePathname } from 'next/navigation';
import { formatSegment, isIdSegment, SEGMENT_LABELS } from '@/lib/route-labels';
import { useBreadcrumbStore } from '@/stores/breadcrumb-store';
export interface SmartBackTarget {
/** Concrete href the back button should navigate to. Always a real
* URL (never router.back()) so deep-link refresh + middle-click open-
* in-new-tab both work. */
href: string;
/** Short label rendered next to the chevron. Example: "Clients",
* "Sarah Doe", "Administration". The button surfaces this as
* "Back to {label}" so the user knows where they're headed. */
label: string;
}
/**
* Derives the contextual back-target for the current route. Replaces the
* topbar breadcrumb trail with a single, prominent "Back to X" button.
*
* Resolution order:
*
* 1. In-app history - if the rep navigated here from another page in
* this same SPA session (and that page belongs to the same port),
* send them back to that exact page. Lets cross-record drills
* (Sarah Doe -> her Yacht -> Back) return to the entity the rep
* was actually on, not the logical Yacht-list parent. The
* `NavigationHistoryTracker` mounted at the app shell feeds this.
* 2. Detail-page hints registered via `useBreadcrumbHint` - when a
* detail page registers a parent (e.g. "Mary Smith" with href
* `/port/clients/abc`), the back button reads "Back to Mary Smith".
* This is the fallback when history is unavailable (refresh, direct
* link, bookmark, first navigation).
* 3. URL-derived parent segment - strip the last path segment (and any
* trailing UUID), look up the human label, route there. So
* /port/admin/branding -> "Back to Administration".
* 4. null - top-level pages like /port/dashboard, /port/clients don't
* get a back button (the sidebar is the way out).
*
* Labels for history-derived back are pulled from `labelCache` (snapshot
* by `useBreadcrumbHint` at the moment each detail page mounted) and
* fall through to URL-derivation when no label was ever cached.
*/
export function useSmartBack(): SmartBackTarget | null {
const pathname = usePathname();
const hint = useBreadcrumbStore((s) => s.hints[pathname]);
const historyStack = useBreadcrumbStore((s) => s.historyStack);
const labelCache = useBreadcrumbStore((s) => s.labelCache);
const segments = pathname.split('/').filter(Boolean);
// The first segment is always the port slug in this app's routing
// scheme. A "top-level" page has 2 segments (port + section) -
// anything shallower has no logical parent within the app.
if (segments.length <= 2) return null;
const portSlug = segments[0];
if (!portSlug) return null;
// 1. In-app history - prefer the actual previous page in this SPA
// session when it's within the same port (cross-port jumps should
// fall back to logical parent so we don't ferry someone across
// tenant boundaries via a stale stack entry).
const previousPath = historyStack[historyStack.length - 1];
if (previousPath && previousPath !== pathname && previousPath.startsWith(`/${portSlug}/`)) {
const cachedLabel = labelCache[previousPath];
if (cachedLabel) {
return { href: previousPath, label: cachedLabel };
}
// No cached label means we never saw a useBreadcrumbHint for that
// path (e.g. list pages, settings pages). Derive from URL so the
// back button still has something readable to show.
const derivedLabel = labelFromPath(previousPath);
if (derivedLabel) return { href: previousPath, label: derivedLabel };
}
// 2. Closest registered hint parent. Detail pages register a chain
// like [{label: "Clients", href: "/port/clients"}, {label: "Mary
// Smith", href: "/port/clients/abc"}] - the last entry is the one
// that semantically WAS the previous page.
if (hint && hint.parents.length > 0) {
const closest = hint.parents[hint.parents.length - 1];
if (closest?.href) {
return { href: closest.href, label: closest.label };
}
}
// URL derivation: walk backwards from the leaf, skipping UUID
// segments, until we find a non-id segment. The parent path is
// everything up to and including that segment.
const significantSegments: Array<{ value: string; index: number }> = [];
for (let i = 1; i < segments.length; i++) {
const seg = segments[i];
if (!seg || isIdSegment(seg)) continue;
significantSegments.push({ value: seg, index: i });
}
// Need at least 2 non-id segments to have a "parent" (one is the
// current page, one is its parent). With only one significant
// segment, we're effectively on a list page - no back affordance.
if (significantSegments.length < 2) return null;
const parent = significantSegments[significantSegments.length - 2];
if (!parent) return null;
const parentPath = '/' + segments.slice(0, parent.index + 1).join('/');
const parentLabel = SEGMENT_LABELS[parent.value] ?? formatSegment(parent.value);
// Defensive: never produce an href that strips the port slug. If the
// walk above produced something that doesn't start with /<portSlug>/
// we're outside the dashboard layout and shouldn't render a back link
// at all.
if (!parentPath.startsWith(`/${portSlug}`)) return null;
return { href: parentPath, label: parentLabel };
}
/**
* Best-effort label for a pathname when no cached label exists. Walks
* the URL backwards, ignoring UUIDs, returning the human-formatted
* deepest non-id segment. Used as the label for history-derived back
* targets when the previous page never registered a useBreadcrumbHint
* (e.g. list pages, settings sub-pages).
*/
function labelFromPath(path: string): string | null {
const segments = path.split('/').filter(Boolean);
for (let i = segments.length - 1; i >= 1; i--) {
const seg = segments[i];
if (!seg || isIdSegment(seg)) continue;
return SEGMENT_LABELS[seg] ?? formatSegment(seg);
}
return null;
}