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

@@ -19,7 +19,7 @@ interface KpiResponse {
}
/**
* Compact rail-sized KPI tile single number, label, and a click-
* Compact rail-sized KPI tile - single number, label, and a click-
* through to the interests pipeline. Reuses the existing dashboard KPIs
* endpoint so we don't pay an extra round-trip.
*/
@@ -36,7 +36,7 @@ export function ActiveDealsTile() {
return (
<Card>
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` (it assumes a
CardHeader sits above). The `sm:` variants are required without
CardHeader sits above). The `sm:` variants are required - without
them `sm:pt-0` wins at the sm breakpoint and the content snaps to
the top edge. */}
<CardContent className="flex items-center gap-3 pt-5 pb-5 sm:pt-5 sm:pb-5">
@@ -57,7 +57,7 @@ export function ActiveDealsTile() {
</div>
<Link
// Next typedRoutes can't infer dynamic-segment routes from a template
// literal cast through unknown rather than `any` so the lint rule
// literal - cast through unknown rather than `any` so the lint rule
// is satisfied while the runtime href is still correct.
href={`/${portSlug}/interests` as unknown as Route}
className="text-xs font-medium text-primary hover:underline"

View File

@@ -29,7 +29,7 @@ interface ActivityItem {
label: string | null;
userId: string | null;
/** Server-resolved actor display name (from user_profiles). When null,
* the actor row no longer exists render falls back to a "Unknown
* the actor row no longer exists - render falls back to a "Unknown
* user" sentinel rather than the raw UUID prefix. */
actorName: string | null;
fieldChanged: string | null;
@@ -52,6 +52,55 @@ function humanizeFieldName(name: string): string {
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Entity type alias map for the feed labels. Most types humanize fine
* via `humanizeFieldName`, but a few read awkwardly ("Residential
* Client" is clearer than the raw enum, notes flatten to their parent). */
const ENTITY_TYPE_LABELS: Record<string, string> = {
residential_client: 'Residential client',
residential_interest: 'Residential interest',
berth_reservation: 'Berth reservation',
berth_maintenance_log: 'Berth maintenance',
berth_recommendation: 'Berth recommendation',
client_note: 'Client note',
yacht_note: 'Yacht note',
company_note: 'Company note',
interest_note: 'Interest note',
interest_qualification: 'Interest qualification',
document_send: 'Document send',
document_folder: 'Document folder',
document_template: 'Document template',
documentTemplate: 'Document template',
form_template: 'Form template',
report_template: 'Report template',
email_account: 'Email account',
email_message: 'Email message',
user_email_change: 'Email change',
custom_field_definition: 'Custom field',
custom_field_values: 'Custom field',
expense_export: 'Expense export',
gdpr_export: 'GDPR export',
qualification_criterion: 'Qualification criterion',
website_submission: 'Website submission',
webhook_inbound: 'Inbound webhook',
webhook_delivery: 'Webhook delivery',
audit_log: 'Audit log',
portal_user: 'Portal user',
portal_session: 'Portal session',
portal_auth_token: 'Portal token',
client_contact: 'Client contact',
clientContact: 'Client contact',
clientAddress: 'Client address',
companyAddress: 'Company address',
clientRelationship: 'Client relationship',
company_membership: 'Company membership',
crm_invite: 'CRM invite',
queue_job: 'Queue job',
super_admin: 'Super admin',
};
function humanizeEntityType(type: string): string {
return ENTITY_TYPE_LABELS[type] ?? humanizeFieldName(type);
}
/** Map enum-typed field values to their canonical human labels. The audit
* log stores raw enum strings (`deposit_10pct`, `lost_other_marina`); the
* feed should read like `10% Deposit`, not the wire value. */
@@ -85,13 +134,13 @@ function normalizeEnumValue(field: string, value: unknown): unknown {
* count; nulls / empty render as em-dash. */
function shortValue(value: unknown, fieldContext?: string): string {
if (fieldContext) value = normalizeEnumValue(fieldContext, value);
if (value === null || value === undefined || value === '') return '';
if (value === null || value === undefined || value === '') return '-';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`;
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return '';
if (entries.length === 0) return '-';
return entries
.slice(0, 3)
.map(
@@ -199,7 +248,7 @@ function ActivityFeedInner() {
// A1: permission_denied rows on the activity feed render as a bare
// action badge with no entity name (they target `admin.X` with empty
// entityId). They're noise for the rep keep them in the audit log
// entityId). They're noise for the rep - keep them in the audit log
// page but hide them from the dashboard feed.
const items = (data ?? []).filter((i) => i.action !== 'permission_denied');
@@ -245,18 +294,23 @@ function ActivityFeedInner() {
space between them. */}
<span className="text-muted-foreground/60 mx-1.5">·</span>
<span className="text-muted-foreground text-xs capitalize">
{item.entityType}
{humanizeEntityType(item.entityType)}
</span>
</>
) : (
<>
<span className="font-medium capitalize">{item.entityType}</span>
{item.entityId && (
<span className="ml-1 text-muted-foreground font-mono text-xs">
{item.entityId.slice(0, 8)}
// No resolvable label - either the entity was
// deleted or the type isn't in the server-side
// resolver yet. Either way we never expose a
// UUID fragment: it reads as noise to the rep
// and leaks an internal identifier.
<span className="font-medium capitalize">
{humanizeEntityType(item.entityType)}
{item.entityId ? (
<span className="ml-1 text-muted-foreground text-xs font-normal">
(removed)
</span>
)}
</>
) : null}
</span>
)}
</p>
{diffLine ? (

View File

@@ -1,7 +1,7 @@
'use client';
/**
* Berth-demand widget ranks berths by active interest count, with a
* Berth-demand widget - ranks berths by active interest count, with a
* horizontal bar per row encoding magnitude relative to the leader.
* Matches the standard CardHeader / CardContent layout of its dashboard
* siblings; the bars (not chrome) do the visual work.

View File

@@ -9,6 +9,7 @@ import { Globe } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { CountryFlag } from '@/components/shared/country-flag';
import { getCountryName } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
@@ -32,7 +33,7 @@ interface ClientsByCountryResponse {
* into a specific market. Country names render via the existing
* locale-aware helper; unknown ISO codes fall back to the raw code.
*
* Variant (b) of the master-doc design a true choropleth would need
* Variant (b) of the master-doc design - a true choropleth would need
* a heavier viz lib (react-simple-maps + topojson) and pushes us to
* the chart-library migration agenda. Variant (a) ships now; the
* world-map variant can land alongside the recharts→ECharts pass.
@@ -100,13 +101,11 @@ export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) {
className="group flex items-center justify-between gap-3 rounded-md px-2 py-1.5 -mx-2 hover:bg-foreground/5"
title={`${row.count} client${row.count === 1 ? '' : 's'} in ${name}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="w-8 shrink-0 text-xs font-mono uppercase text-muted-foreground">
{row.country}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<CountryFlag code={row.country} className="h-3.5 w-5" decorative />
<span className="truncate text-sm">{name}</span>
</div>
{/* Mini bar same `BerthHeatWidget` idiom: a thin
{/* Mini bar - same `BerthHeatWidget` idiom: a thin
background track with a coloured fill. The count
sits on the right so the eye can read both the
bar shape and the precise number. */}

View File

@@ -33,23 +33,35 @@ import {
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
import type { DashboardWidget } from './widget-registry';
import type { DashboardWidget, WidgetGroup } from './widget-registry';
// The dashboard renders widgets in three independent visual regions at
// xl (1280+): charts (main column), rails (right aside), feed (full-
// width). Below xl, all three regions stack into one visual column -
// from the rep's eye it reads as a single ordered list, so the modal
// flattens its sortable in that tier. At xl it splits into three
// region-scoped sortables to match the actual side-by-side layout.
const GROUP_LABELS: Record<WidgetGroup, string> = {
chart: 'Charts',
rail: 'Side rail',
feed: 'Activity',
};
const GROUP_ORDER: readonly WidgetGroup[] = ['chart', 'rail', 'feed'];
/**
* Combined visibility + reorder picker for the dashboard header. Two
* sections in one modal:
* Combined visibility + reorder picker for the dashboard header.
*
* 1. "On dashboard" — visible widgets, each row with a drag handle
* (reorder via dnd-kit single SortableContext, no buckets); flipping
* a switch off moves the row to section 2.
* 2. "Hidden" — widgets currently off; flipping a switch on appends to
* the bottom of section 1.
* The dashboard renders widgets in three independent visual regions -
* Charts (main column), Side rail (right aside), Activity (full-width
* feed). A drag across regions can't change the visual outcome, so the
* modal exposes one sortable list per region instead of a single flat
* list that silently fails on cross-region moves. Toggling a widget off
* moves it to the "Hidden" section; toggling on appends it to the
* bottom of its native region.
*
* Both visibility toggles and order changes commit optimistically via
* `useDashboardWidgets` so the dashboard reflows in the background and
* the rep can keep editing. The "Rearrange" button on the header is
* gone — order lives here too now, keeping all dashboard layout
* controls in one place.
* the rep can keep editing.
*/
export function CustomizeWidgetsMenu() {
const [open, setOpen] = useState(false);
@@ -57,6 +69,7 @@ export function CustomizeWidgetsMenu() {
allWidgets,
visibleWidgets,
visibility,
isXlLayout,
setVisible,
setAll,
setOrder,
@@ -79,7 +92,53 @@ export function CustomizeWidgetsMenu() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function onDragEnd(event: DragEndEvent) {
// Visible widgets split per region. Empty regions render nothing so
// we don't show an "On dashboard / Side rail (0)" tease.
const visibleByGroup: Record<WidgetGroup, DashboardWidget[]> = {
chart: visibleWidgets.filter((w) => w.group === 'chart'),
rail: visibleWidgets.filter((w) => w.group === 'rail'),
feed: visibleWidgets.filter((w) => w.group === 'feed'),
};
// A drag inside group X only moves widgets within that group. Rebuild
// the flat order by walking `visibleWidgets` in its current sequence
// and replacing each group-X slot with the next id from the reordered
// group list. This preserves the relative position of every other
// widget - only the dragged group's internal order changes.
function reorderGroup(group: WidgetGroup, oldIndex: number, newIndex: number) {
const groupIds = visibleByGroup[group].map((w) => w.id);
if (
oldIndex < 0 ||
newIndex < 0 ||
oldIndex >= groupIds.length ||
newIndex >= groupIds.length
) {
return;
}
const reordered = arrayMove(groupIds, oldIndex, newIndex);
let cursor = 0;
const nextOrder = visibleWidgets.map((w) =>
w.group === group ? (reordered[cursor++] ?? w.id) : w.id,
);
setOrder(nextOrder);
}
function makeDragEndHandler(group: WidgetGroup) {
return (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = visibleByGroup[group].map((w) => w.id);
const oldIndex = ids.indexOf(String(active.id));
const newIndex = ids.indexOf(String(over.id));
if (oldIndex === -1 || newIndex === -1) return;
reorderGroup(group, oldIndex, newIndex);
};
}
// Flat reorder used by the stacked layout (< xl). One SortableContext
// over every visible widget; drops persist via setOrder, which the
// hook routes to the mobile order field.
function onFlatDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = visibleWidgets.map((w) => w.id);
@@ -97,24 +156,64 @@ export function CustomizeWidgetsMenu() {
Customize
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Customize dashboard</DialogTitle>
<DialogDescription>
Drag a visible widget to change its position. Toggle the switch to show or hide. Hidden
widgets leave no empty space - the layout reflows to fill the available width.
{isXlLayout
? 'Editing the desktop layout - drag a widget to reorder it within its region.'
: 'Editing the stacked layout for this device - drag a widget to reorder. Your desktop arrangement is saved separately.'}{' '}
Toggle the switch to show or hide. Hidden widgets leave no empty space - the layout
reflows to fill the available width.
</DialogDescription>
</DialogHeader>
{/* Toggle + reorder list. Capped at ~60vh with internal scroll so
the modal doesn't push the action footer off-screen. */}
the modal doesn't push the action footer off-screen. The
layout matches what the rep is actually seeing: at xl the
dashboard renders charts | rails | feed as three independent
slots, so the picker exposes three region-scoped sortables.
Below xl everything stacks into one column visually, so the
picker collapses to a single flat sortable that reorders
across the whole list. */}
<div className="max-h-[60vh] -mx-2 overflow-y-auto px-2">
{visibleWidgets.length > 0 ? (
{isXlLayout ? (
GROUP_ORDER.map((group) => {
const widgets = visibleByGroup[group];
if (widgets.length === 0) return null;
return (
<Section key={group} title={`${GROUP_LABELS[group]} (${widgets.length})`}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={makeDragEndHandler(group)}
>
<SortableContext
items={widgets.map((w) => w.id)}
strategy={verticalListSortingStrategy}
>
<ul className="space-y-1">
{widgets.map((w, idx) => (
<SortableVisibleRow
key={w.id}
widget={w}
position={idx + 1}
disabled={isSaving}
onToggle={(checked) => setVisible(w.id, checked)}
/>
))}
</ul>
</SortableContext>
</DndContext>
</Section>
);
})
) : visibleWidgets.length > 0 ? (
<Section title={`On dashboard (${visibleWidgets.length})`}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
onDragEnd={onFlatDragEnd}
>
<SortableContext
items={visibleWidgets.map((w) => w.id)}

View File

@@ -90,7 +90,7 @@ export function DashboardShell({
const feed = visibleWidgets.filter((w) => w.group === 'feed');
// Reuses the existing ['me'] cache (5-minute staleTime) populated by
// useTablePreferences elsewhere usually a cache hit, so no extra
// useTablePreferences elsewhere - usually a cache hit, so no extra
// request. When the page server-prefetches the first name we seed it
// here via `initialData` so the cache is warm before the post-mount
// fetch resolves, eliminating the "Welcome back → Hello, Matt" flash.
@@ -107,12 +107,12 @@ export function DashboardShell({
// Greeting word is computed in a useEffect so the rendered HTML can't lock
// to the server's clock during hydration. Until the effect fires, the
// header reads "Welcome" a neutral phrase that's correct at every hour
// header reads "Welcome" - a neutral phrase that's correct at every hour
// and never produces a hydration warning. `clientGreeting` flips to the
// local-time-aware phrasing once the component has mounted.
const [clientGreeting, setClientGreeting] = useState<string | null>(null);
useEffect(() => {
// setState here is intentional we delay the time-aware greeting
// setState here is intentional - we delay the time-aware greeting
// until after hydration to avoid SSR/client clock mismatch.
// eslint-disable-next-line react-hooks/set-state-in-effect
setClientGreeting(timeOfDayGreeting());
@@ -149,7 +149,7 @@ export function DashboardShell({
<div className="space-y-6">
{/* Mobile-only greeting strip. The shared PageHeader is hidden
below `sm` (its title is normally duplicated by the topbar),
so we render the welcome message inline here for mobile
so we render the welcome message inline here for mobile -
keeps the personalized touch from desktop without polluting
the topbar (which stays "Dashboard" for wayfinding). */}
<div className="sm:hidden">
@@ -170,8 +170,8 @@ export function DashboardShell({
actions={
<div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={setRange} />
<ExportDashboardPdfButton />
<CustomizeWidgetsMenu />
<ExportDashboardPdfButton />
</div>
}
/>
@@ -232,7 +232,7 @@ export function DashboardShell({
/**
* Placeholder shown when the rep has hidden every widget. Without this,
* the dashboard collapses to just the gradient header strip and looks
* like a broken page this hints at the "Customize" button to bring
* like a broken page - this hints at the "Customize" button to bring
* widgets back.
*/
function EmptyDashboardHint() {

View File

@@ -25,7 +25,7 @@ interface HotDealsResponse {
// Local label map intentionally narrowed to the stages this widget
// surfaces. Keys MUST match the canonical DB values for the 7-stage
// pipeline (post-2026-05 refactor) the reporting audit caught typos
// pipeline (post-2026-05 refactor) - the reporting audit caught typos
// that broke the rank ladder server-side AND rendered raw enum to the user.
const STAGE_LABELS: Record<string, string> = {
contract: 'Contract',

View File

@@ -59,7 +59,7 @@ const STAGE_BAR_CLASS: Record<string, string> = {
export function PipelineValueTile({ range }: { range?: DateRange } = {}) {
// Range query-string is keyed on the slug ('7d' / 'custom-2026-01-01...').
// When range is undefined, the tile falls back to the "all active deals"
// snapshot preserves the old behaviour for callers that don't yet
// snapshot - preserves the old behaviour for callers that don't yet
// thread range through.
const slug = range ? rangeToSlug(range) : null;
const qs = slug ? `?range=${encodeURIComponent(slug)}` : '';

View File

@@ -70,7 +70,7 @@ export function SourceConversionChart() {
</span>
</span>
</div>
{/* Inline bar keeps the widget compact and lets eight
{/* Inline bar - keeps the widget compact and lets eight
rows share the same vertical space a Recharts plot
would use for two. */}
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">

View File

@@ -86,7 +86,7 @@ export function TimezoneDriftBanner() {
try {
window.localStorage.setItem(DISMISS_STORAGE_KEY, 'true');
} catch {
// Non-fatal we just don't persist the dismissal.
// Non-fatal - we just don't persist the dismissal.
}
}

View File

@@ -4,11 +4,11 @@
* Compact "Website at a glance" tile for the main sales dashboard. Shows
* pageviews for the dashboard's current range + active visitors right
* now + a deep-link to the full /website-analytics page. Soft-fails
* (renders nothing) when Umami isn't configured for this port the
* (renders nothing) when Umami isn't configured for this port - the
* configure-prompt lives on the dedicated page, not the dashboard.
*
* When an Umami call fails (auth, network, shape) the tile renders a
* dash "" instead of "0" so the rep can tell error from no-data.
* dash "-" instead of "0" so the rep can tell error from no-data.
*/
import Link from 'next/link';
@@ -49,7 +49,7 @@ export function WebsiteGlanceTile({ range = '30d' }: Props) {
return null;
}
// Umami v3 returns flat numbers `data?.data?.pageviews` is a number,
// Umami v3 returns flat numbers - `data?.data?.pageviews` is a number,
// not `{value, prev}`. The previous nested shape was Umami v1; v3 moved
// comparison values into a sibling `comparison` block.
const pageviews = stats.data?.data?.pageviews;

View File

@@ -1,5 +1,5 @@
/**
* Dashboard widget registry the single source of truth for which
* Dashboard widget registry - the single source of truth for which
* widgets exist, what they're called, where they live, and what they
* default to. The DashboardShell loops over this; the settings UI also
* loops over this. Adding a new widget = adding one entry here.
@@ -76,7 +76,7 @@ export type WidgetGroup = 'chart' | 'rail' | 'feed';
export type WidgetIntegration = 'umami' | 'documenso';
export interface DashboardWidget {
/** Stable persistence key. Don't rename old preferences would break. */
/** Stable persistence key. Don't rename - old preferences would break. */
id: string;
label: string;
description: string;
@@ -92,7 +92,7 @@ export interface DashboardWidget {
/**
* Some widgets self-gate (e.g. WebsiteGlanceTile renders null when
* Umami isn't configured). When `true`, the settings UI still shows
* the toggle so admins can enable it once the integration is wired
* the toggle so admins can enable it once the integration is wired -
* but the widget itself decides whether to render content.
*/
selfGates?: boolean;
@@ -106,7 +106,7 @@ export interface DashboardWidget {
export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
// ── KPI tiles (rail) ────────────────────────────────────────────────
// Off by default keep the existing dashboard layout unchanged for
// Off by default - keep the existing dashboard layout unchanged for
// users on first paint after the upgrade; reps can flip them on from
// the Customize menu.
{
@@ -166,10 +166,10 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
{
id: 'source_conversion',
label: 'Source Conversion',
description: 'Win rate per lead source which channels deliver buyers, not just leads.',
description: 'Win rate per lead source - which channels deliver buyers, not just leads.',
render: () => <SourceConversionChart />,
group: 'chart',
// Flipped on 2026-05-14 investor-facing conversion-funnel-by-source
// Flipped on 2026-05-14 - investor-facing conversion-funnel-by-source
// surface (PRE-DEPLOY-PLAN § 1.6.23). Reads inquiry → client linkage
// (clients.source_inquiry_id) added in migration 0065.
defaultVisible: true,
@@ -189,7 +189,7 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
description:
'Per-country distribution of the active client book. Click a row to filter the clients list by country.',
render: () => <ClientsByCountryWidget />,
// Same rail-tile idiom as BerthHeatWidget + HotDealsCard compact
// Same rail-tile idiom as BerthHeatWidget + HotDealsCard - compact
// ranked list with mini-bars. Variant (a) per the master-doc design;
// the world-map variant lands alongside the recharts→ECharts pass.
group: 'rail',