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

@@ -4,17 +4,18 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { ArrowLeft, Copy, Wrench } from 'lucide-react';
import { Copy, Wrench } from 'lucide-react';
import { toast } from 'sonner';
import type { Route } from 'next';
import { Badge } from '@/components/ui/badge';
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
import { Button } from '@/components/ui/button';
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import type { ErrorEvent } from '@/lib/db/schema/system';
import type { LikelyCulprit } from '@/lib/error-classifier';
@@ -36,6 +37,17 @@ export default function ErrorEventDetailPage() {
const portSlug = params?.portSlug ?? '';
const requestId = params?.requestId ?? '';
// Smart-back target: send the user back to the error list, not the
// generic Administration page that URL-derivation would land on.
useBreadcrumbHint(
portSlug
? {
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
current: `Error ${requestId.slice(0, 8)}`,
}
: null,
);
const query = useQuery<DetailResponse>({
queryKey: ['admin', 'error-events', requestId],
queryFn: () => apiFetch<DetailResponse>(`/api/v1/admin/error-events/${requestId}`),
@@ -71,15 +83,6 @@ export default function ErrorEventDetailPage() {
return (
<div className="space-y-4">
<div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/${portSlug}/admin/errors` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to error list
</Link>
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold">Error {requestId.slice(0, 8)}</h1>
<Badge

View File

@@ -1,16 +1,13 @@
'use client';
import { useState, useMemo } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { ArrowLeft, BookOpen, Search } from 'lucide-react';
import type { Route } from 'next';
import { BookOpen, Search } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { ERROR_CODES } from '@/lib/error-codes';
/**
@@ -27,6 +24,17 @@ export default function ErrorCodeReferencePage() {
const portSlug = params?.portSlug ?? '';
const [search, setSearch] = useState('');
// Smart-back target: send the user back to the error inspector, not
// the generic Administration page URL-derivation would land on.
useBreadcrumbHint(
portSlug
? {
parents: [{ label: 'Error inspector', href: `/${portSlug}/admin/errors` }],
current: 'Error code reference',
}
: null,
);
const entries = useMemo(() => {
const all = Object.entries(ERROR_CODES) as Array<
[string, (typeof ERROR_CODES)[keyof typeof ERROR_CODES]]
@@ -53,15 +61,6 @@ export default function ErrorCodeReferencePage() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href={`/${portSlug}/admin/errors` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to error inspector
</Link>
</Button>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">

View File

@@ -0,0 +1,43 @@
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
interface ExpensesLayoutProps {
children: React.ReactNode;
params: Promise<{ portSlug: string }>;
}
/**
* Layout-level gate for the entire /expenses subtree (list, scan,
* detail). When the port has expenses_module_enabled = false, every
* route under /expenses renders the ModuleDisabledPage instead of the
* real content. This is the route-level half of the "hybrid hide+block"
* model (the sidebar entries are independently hidden via
* expensesModuleByPort on the SSR-resolved sidebar prop).
*
* Using a layout rather than per-page guards means: (a) one place to
* change the gate logic, (b) nested routes (scan, [id]) are covered
* automatically, (c) the children subtree never mounts when disabled,
* so its data-fetching effects don't fire.
*/
export default async function ExpensesLayout({ children, params }: ExpensesLayoutProps) {
const { portSlug } = await params;
const port = await db.query.ports.findFirst({
where: eq(portsTable.slug, portSlug),
columns: { id: true },
});
if (!port) return children;
const enabled = await isExpensesModuleEnabled(port.id);
if (enabled) return children;
return (
<ModuleDisabledPage
moduleName="Expenses"
description="Expense tracking and receipt upload are turned off for this port. Previously-recorded expense rows are preserved and will reappear when the module is re-enabled."
settingsHref={`/${portSlug}/admin/settings`}
fallbackHref={`/${portSlug}/dashboard`}
/>
);
}

View File

@@ -0,0 +1,42 @@
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { isInvoicesModuleEnabled } from '@/lib/services/invoices-module.service';
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
interface InvoicesLayoutProps {
children: React.ReactNode;
params: Promise<{ portSlug: string }>;
}
/**
* Layout-level gate for the standalone `/invoices` flow (list, new,
* detail, upload-receipts). Default is OFF: the canonical "money
* received" path in this CRM is the per-interest Payments tab; the
* standalone invoicing surface only matters when an operator wants to
* generate client-facing invoices from the CRM itself (rare). Admins
* can opt in from Admin → Operations.
*
* Existing invoice rows are preserved when the module is disabled —
* the API endpoints still respond so historical PDF links / webhook
* callbacks keep resolving. Only the UI is hidden.
*/
export default async function InvoicesLayout({ children, params }: InvoicesLayoutProps) {
const { portSlug } = await params;
const port = await db.query.ports.findFirst({
where: eq(portsTable.slug, portSlug),
columns: { id: true },
});
if (!port) return children;
const enabled = await isInvoicesModuleEnabled(port.id);
if (enabled) return children;
return (
<ModuleDisabledPage
moduleName="Standalone invoicing"
description="The standalone /invoices flow is turned off for this port. The canonical money-received path here is the per-interest Payments tab — that's where to record deposits and balance payments. Existing invoice rows are preserved and will reappear when the module is re-enabled."
settingsHref={`/${portSlug}/admin/settings`}
fallbackHref={`/${portSlug}/dashboard`}
/>
);
}

View File

@@ -1,6 +1,11 @@
import type { Metadata } from 'next';
import { eq } from 'drizzle-orm';
import { UploadReceiptsGuide } from '@/components/invoices/upload-receipts-guide';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
import { ModuleDisabledPage } from '@/components/shared/module-disabled-page';
export const metadata: Metadata = {
title: 'How to upload receipts',
@@ -12,5 +17,23 @@ export default async function UploadReceiptsPage({
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
// Mirrors the /expenses layout gate: the receipt-upload explainer is
// part of the expenses surface, so the same port-scoped toggle
// blocks it. /invoices/upload-receipts isn't under /expenses, so a
// separate gate is needed here.
const port = await db.query.ports.findFirst({
where: eq(portsTable.slug, portSlug),
columns: { id: true },
});
if (port && !(await isExpensesModuleEnabled(port.id))) {
return (
<ModuleDisabledPage
moduleName="Expenses"
description="Receipt upload is part of the Expenses module, which is turned off for this port."
settingsHref={`/${portSlug}/admin/settings`}
fallbackHref={`/${portSlug}/dashboard`}
/>
);
}
return <UploadReceiptsGuide portSlug={portSlug} />;
}

View File

@@ -18,6 +18,7 @@ import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
import { classifyFormFactor } from '@/lib/form-factor';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers();
@@ -89,6 +90,24 @@ export default async function DashboardLayout({ children }: { children: React.Re
);
const tenanciesModuleByPort: Record<string, boolean> = Object.fromEntries(tenanciesModuleEntries);
// Per-port expenses-module gate. Defaults to enabled (the registry's
// default) so existing ports keep the feature on deploy. Resolved
// server-side so the sidebar SSRs without flicker when an admin has
// turned the feature off for a tenant.
const expensesModuleEntries = await Promise.all(
ports.map(async (p) => {
try {
return [p.id, await isExpensesModuleEnabled(p.id)] as const;
} catch {
// Conservative default on lookup failure: keep the feature
// visible so a transient DB hiccup doesn't hide the module
// for a port that actually has it enabled.
return [p.id, true] as const;
}
}),
);
const expensesModuleByPort: Record<string, boolean> = Object.fromEntries(expensesModuleEntries);
return (
<QueryProvider>
<PortProvider
@@ -116,6 +135,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
ports={ports}
portLogoUrls={portLogoUrls}
tenanciesModuleByPort={tenanciesModuleByPort}
expensesModuleByPort={expensesModuleByPort}
initialFormFactor={initialFormFactor}
>
{children}