chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -42,7 +42,7 @@ export default function AiAdminPage() {
</Card>
{/*
Berth-PDF parser AI fallback currently configured via the
Berth-PDF parser AI fallback - currently configured via the
BERTH_PDF_PARSER_* env vars. No per-port override surface today;
when one is added, it lands here so admins don't have to hunt.
*/}
@@ -63,10 +63,10 @@ export default function AiAdminPage() {
{/*
Future AI surfaces. Each gets a section here once it ships:
- Recommender embeddings (currently rule-based, not LLM-based)
- Contact-log action extraction (deferred needs user demand)
- Contact-log action extraction (deferred - needs user demand)
- Inquiry-form auto-classification (deferred)
Listing them inert here closes the "where do I configure AI?"
loop admins land on /admin/ai and see the full landscape.
loop - admins land on /admin/ai and see the full landscape.
*/}
<Card>
<CardHeader>

View File

@@ -0,0 +1,88 @@
import Link from 'next/link';
import type { Route } from 'next';
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
/**
* Berths admin index. Both sub-pages (`bulk-add`, `reconcile`) existed
* pre-2026-05-22 but were only reachable via deep links from inside the
* Berths list. Surfacing them on a dedicated admin landing tile so the
* tools are discoverable without prior knowledge of the URL - part of
* the admin IA regroup (B3 #10 Phase 2).
*/
export default async function BerthsAdminIndex({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
const tools = [
{
href: `/${portSlug}/admin/berths/bulk-add` as Route,
label: 'Bulk add berths',
description:
'Generate many berth rows in one wizard - set pier, prefix, mooring number range, and per-berth defaults; preview before commit.',
icon: Anchor,
},
{
href: `/${portSlug}/admin/berths/reconcile` as Route,
label: 'Reconciliation queue',
description:
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
icon: FileSearch,
},
] as const;
return (
<div className="space-y-6">
<PageHeader
title="Berths admin"
eyebrow="ADMIN"
description="Tools for bulk berth creation and post-import reconciliation. Single-berth edits stay on the Berths list - these surfaces are for batch operations."
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{tools.map((t) => {
const Icon = t.icon;
return (
<Link key={t.href} href={t.href} className="block group">
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<Icon
className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary"
aria-hidden
/>
<CardTitle className="text-base">{t.label}</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>{t.description}</CardDescription>
</CardContent>
</Card>
</Link>
);
})}
</div>
<Card className="border-amber-200 bg-amber-50/50">
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<AlertCircle className="h-5 w-5 mt-0.5 text-amber-600" aria-hidden />
<CardTitle className="text-sm">Not what you&apos;re looking for?</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-xs">
For single-berth edits, browse to the{' '}
<Link
href={`/${portSlug}/berths` as Route}
className="font-medium text-primary hover:underline"
>
Berths list
</Link>{' '}
and click any row. Per-berth PDF uploads + brochure assignment also live there.
</CardDescription>
</CardContent>
</Card>
</div>
);
}

View File

@@ -52,7 +52,7 @@ const FIELDS: SettingFieldDef[] = [
description:
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
type: 'image-upload',
// 16:9 landscape. Without an explicit aspect, the cropper falls
// 16:9 - landscape. Without an explicit aspect, the cropper falls
// back to 1:1 and renders a circular mask (intended for avatars),
// which is the wrong UX for a viewport-cover background.
imageAspect: 16 / 9,

View File

@@ -6,7 +6,7 @@ import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel';
*
* Lists brochures, lets per-port admins upload new versions via direct-to-
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
* body-size limit see §11.1), and toggle the default flag.
* body-size limit - see §11.1), and toggle the default flag.
*/
export default function BrochuresAdminPage() {
return (

View File

@@ -7,7 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
// All field arrays removed every Documenso setting now flows through
// All field arrays removed - every Documenso setting now flows through
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
// source badge on each field. The settings themselves live in
// `src/lib/settings/registry.ts` under sections `documenso.api` /
@@ -17,8 +17,8 @@ export default function DocumensoSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Documenso & EOI"
description="API credentials, signer identities, and document generation behaviour. Use the test-connection button to verify a saved configuration before relying on it."
title="Signing service (Documenso)"
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
/>
<Card>
@@ -200,7 +200,7 @@ export default function DocumensoSettingsPage() {
<RegistryDrivenForm
sections={['documenso.templates']}
title="Templates & signing pathway"
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below - that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
extra={<TemplateSyncButton />}
/>

View File

@@ -5,6 +5,7 @@ import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-fo
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
import { SmtpTestSendCard } from '@/components/admin/email/smtp-test-send-card';
import { TestTemplateCard } from '@/components/admin/email/test-template-card';
export default function EmailSettingsPage() {
return (
@@ -14,7 +15,7 @@ export default function EmailSettingsPage() {
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
/>
{/* Explainer for the "two accounts" model addresses the recurring
{/* Explainer for the "two accounts" model - addresses the recurring
UAT question "why are there separate SMTP credentials for sales
and noreply?". Keeps the answer in front of the admin before
they reach the per-card form below. */}
@@ -39,7 +40,7 @@ export default function EmailSettingsPage() {
</div>
{/* Registry-driven so each field shows the "Using env fallback /
port / global / default" badge inline admins can tell at a
port / global / default" badge inline - admins can tell at a
glance which fields are coming from .env vs. UI overrides. */}
<RegistryDrivenForm
sections={['email.from']}
@@ -52,6 +53,7 @@ export default function EmailSettingsPage() {
description="Optional per-port SMTP credentials for the noreply mailbox. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
/>
<SmtpTestSendCard />
<TestTemplateCard />
<SalesEmailConfigCard />
<EmailRoutingCard />
</div>

View File

@@ -163,11 +163,11 @@ export default function ErrorEventDetailPage() {
<KV label="Method" value={event.method} />
<KV label="Path" value={event.path} mono />
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : ''} />
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '-'} />
<KV label="Port" value={event.portId ?? '(none)'} mono />
<KV label="User" value={event.userId ?? '(none)'} mono />
<KV label="IP" value={event.ipAddress ?? ''} mono />
<KV label="User agent" value={event.userAgent ?? ''} />
<KV label="IP" value={event.ipAddress ?? '-'} mono />
<KV label="User agent" value={event.userAgent ?? '-'} />
</CardContent>
</Card>
@@ -176,11 +176,11 @@ export default function ErrorEventDetailPage() {
<CardTitle className="text-sm font-medium">Error</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<KV label="Name" value={event.errorName ?? ''} mono />
<KV label="Name" value={event.errorName ?? '-'} mono />
<div>
<p className="text-xs text-muted-foreground">Message</p>
<p className="mt-0.5 font-mono whitespace-pre-wrap wrap-break-word">
{event.errorMessage ?? ''}
{event.errorMessage ?? '-'}
</p>
</div>
{event.errorStack && (
@@ -240,7 +240,7 @@ function KV({ label, value, mono }: { label: string; value: string | null; mono?
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? ''}</p>
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '-'}</p>
</div>
);
}

View File

@@ -20,7 +20,7 @@ import { ERROR_CODES } from '@/lib/error-codes';
* plain-language meaning + status code without leaving the app.
*
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
* automatically adding an entry to the registry adds a row here.
* automatically - adding an entry to the registry adds a row here.
*/
export default function ErrorCodeReferencePage() {
const params = useParams<{ portSlug: string }>();
@@ -39,7 +39,7 @@ export default function ErrorCodeReferencePage() {
}, [search]);
// Group by domain prefix (the part before the first underscore) so
// the table reads naturally Expenses, Berths, Storage, etc.
// the table reads naturally - Expenses, Berths, Storage, etc.
const grouped = useMemo(() => {
const groups = new Map<string, typeof entries>();
for (const entry of entries) {

View File

@@ -1,5 +1,19 @@
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
import { redirect } from 'next/navigation';
export default function OcrSettingsPage() {
return <OcrSettingsForm />;
/**
* Legacy route. OCR settings now live on the consolidated AI panel at
* `/admin/ai` (the same `<OcrSettingsForm>` is mounted there alongside
* the master AI switch + provider credentials). Kept as a redirect-only
* page so any bookmarks / docs / deep links land on the right surface.
*
* Slated for full removal once the 2026-05-22 admin IA migration has
* had a quarter to bed in.
*/
export default async function OcrLegacyRedirectPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
redirect(`/${portSlug}/admin/ai`);
}

View File

@@ -85,7 +85,7 @@ export default function PipelineRulesPage() {
});
// Hydrate the local form once the server-side state arrives. We treat
// missing keys as the registered default the page's persisted JSON
// missing keys as the registered default - the page's persisted JSON
// doesn't have to enumerate every trigger, just the overrides.
useEffect(() => {
const persisted = data?.data?.values?.stage_advance_rules?.value;

View File

@@ -1,5 +1,21 @@
import { ReportsDashboard } from '@/components/admin/reports-dashboard';
import { redirect } from 'next/navigation';
export default function AdminReportsPage() {
return <ReportsDashboard />;
/**
* 2026-05-22: `/admin/reports` deleted. The page rendered three cards
* - Pipeline funnel, Berth occupancy, and a KPI grid - all of which
* are already covered by the main Dashboard widgets (`pipeline_funnel`,
* `occupancy_timeline`, `kpi_*`). Redirecting to the dashboard so any
* lingering bookmarks land somewhere coherent.
*
* The `<ReportsDashboard>` component file lives on in the repo for now
* pending a follow-up sweep - once we confirm no other surface mounts
* it, the component + its data hook can be removed too.
*/
export default async function ReportsLegacyRedirectPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
redirect(`/${portSlug}/dashboard`);
}

View File

@@ -12,7 +12,7 @@ export default function ResidentialStagesPage() {
/>
<ResidentialStagesAdmin />
{/* Partner forwarding sits on the same admin page so all
{/* Partner forwarding - sits on the same admin page so all
residential-only port settings live in one place. Reps still
see every inquiry in the CRM; this is an outbound courtesy
notification for the partner who handles residential leads. */}

View File

@@ -1,7 +1,7 @@
import { TemplateEditor } from '@/components/admin/templates/template-editor';
/**
* Phase 7.1 PDF template editor (read + place markers).
* Phase 7.1 - PDF template editor (read + place markers).
*
* Renders the source PDF for the selected template and lets the admin
* drop merge-field markers by clicking on the page. Persists the marker

View File

@@ -4,7 +4,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
/**
* "People with access" surface covers BOTH currently-active CRM users
* "People with access" surface - covers BOTH currently-active CRM users
* and pending invitations. Previously these lived on separate routes
* (/admin/users + /admin/invitations); merged 2026-05-21 so admins land
* on one page and tab between states. The standalone /admin/invitations

View File

@@ -50,15 +50,15 @@ const FIELDS: SettingFieldDef[] = [
},
{
key: 'umami_api_token',
label: 'API key (Umami Cloud only optional)',
label: 'API key (Umami Cloud only - optional)',
description:
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs the username + password above are used instead. Stored AES-256-GCM at rest.',
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs - the username + password above are used instead. Stored AES-256-GCM at rest.',
type: 'password',
defaultValue: '',
},
];
// Tracking-pixel kill switch opt-in per port. When enabled, outbound
// Tracking-pixel kill switch - opt-in per port. When enabled, outbound
// sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that
// records opens to `document_send_opens` and cross-posts to Umami.
const TRACKING_FIELDS: SettingFieldDef[] = [
@@ -66,7 +66,7 @@ const TRACKING_FIELDS: SettingFieldDef[] = [
key: 'email_open_tracking_enabled',
label: 'Track email opens',
description:
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count standard email-tracking caveats apply.',
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count - standard email-tracking caveats apply.',
type: 'boolean',
defaultValue: false,
},

View File

@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation';
// Legacy /alerts route merged into /inbox in 2026-05-11. The hash
// Legacy /alerts route - merged into /inbox in 2026-05-11. The hash
// scrolls + expands the Alerts section on the merged page, so old
// bookmarks land in the right spot.
export default async function AlertsRedirect({

View File

@@ -45,7 +45,7 @@ export default function ScanReceiptPage() {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// After OCR succeeds we also upload the receipt to /api/v1/files/upload
// so the expense links to the actual image. The legacy scanner skipped
// this step and saved expenses without their receipt which silently
// this step and saved expenses without their receipt - which silently
// disqualified them from parent-company reimbursement (the warning the
// PDF export now surfaces).
const [uploadedFile, setUploadedFile] = useState<UploadedFileMeta | null>(null);
@@ -365,7 +365,7 @@ export default function ScanReceiptPage() {
disabled={
saveMutation.isPending ||
!amount ||
// Block save while the receipt upload is still in flight
// Block save while the receipt upload is still in flight -
// otherwise the rep can hit Save before the storage round
// trip finishes and the expense lands without `receiptFileIds`,
// silently re-creating the legacy receipt-loss bug.

View File

@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation';
// Legacy /reminders route merged into /inbox in 2026-05-11. The hash
// Legacy /reminders route - merged into /inbox in 2026-05-11. The hash
// scrolls + expands the Reminders section on the merged page.
export default async function RemindersRedirect({
params,

View File

@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation';
/**
* /<port>/residential is a namespace segment the actual landing is
* /<port>/residential is a namespace segment - the actual landing is
* /residential/clients. Without a page.tsx here, the breadcrumb's
* "Residential" link 404s. Server-redirect to the Clients sub-page so
* the link works as a useful shortcut.

View File

@@ -38,7 +38,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
: portRoles.map((pr) => pr.port);
// Prefer a previously-resolved tier from the client's cookie so the
// server renders the matching shell on first paint eliminates the
// server renders the matching shell on first paint - eliminates the
// mobile↔desktop chrome flicker that happens when UA-based classification
// disagrees with the actual viewport (e.g. macOS Safari with the
// window dragged below 1024). AppShell writes the cookie after the
@@ -58,7 +58,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
// Per-port logo map for the sidebar. Resolved server-side so the
// sidebar can swap brand on port switch without an extra round-trip.
// Falls back to null per port when no logo is configured the
// Falls back to null per port when no logo is configured - the
// sidebar surfaces nothing rather than leaking a generic placeholder.
const portBrandingEntries = await Promise.all(
ports.map(async (p) => {
@@ -85,7 +85,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
flag in prod) so the banner is dev/staging-only. */}
<DevModeBanner />
{/* #26: AppShell mounts ONE responsive tree (desktop OR
* mobile) per render never both so pages don't pay the
* mobile) per render - never both - so pages don't pay the
* double-state, double-fetch, double-Tabs-provider tax. */}
<AppShell
portRoles={portRoles}