fix(uat): prod UAT batch — reports, sidebar, search, berths, breakpoint

- financial report: drop Expenses KPI, Net Contribution, cash-flow chart,
  expense donut + ledger (expenses are business-trip costs, not net contribution)
- dashboard report PDF: pagination-safe tables (TableSection + per-row wrap)
  so long doc lists no longer overlap/crush
- clients PDF report: rename "Nationality" -> "Country"
- sidebar: hide a section header when all its items gate off (FINANCIAL orphan)
- topbar: move global search into the 1fr grid track so it can't overlap "New"
- clients card: show all linked berths (not just latest interest's primary)
- berths list: hide table-only toggles (ft/m, density, columns) in card mode
- lists: lower table/card breakpoint lg -> md so narrow desktops get tables
- alert-rules: stale floor created_at -> updated_at (survives created_at backfill)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:41:31 +02:00
parent 93c6554c95
commit 95724c8e3a
12 changed files with 282 additions and 461 deletions

View File

@@ -20,7 +20,7 @@ const DEFAULT_COLUMNS: ReadonlyArray<{ key: string; label: string; widthPct: num
{ key: 'primaryEmail', label: 'Email', widthPct: 25 },
{ key: 'primaryPhone', label: 'Phone', widthPct: 15 },
{ key: 'source', label: 'Source', widthPct: 12 },
{ key: 'nationality', label: 'Nationality', widthPct: 8 },
{ key: 'nationality', label: 'Country', widthPct: 8 },
{ key: 'createdAt', label: 'Created', widthPct: 10 },
];

View File

@@ -579,170 +579,135 @@ export function DashboardReport({
) : null}
{include('recent_activity') && data.recentActivity && data.recentActivity.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Recent activity</Text>
<Text style={styles.sectionSubtitle}>
Last entries from the audit log, compact snapshot.
</Text>
<SimpleTable
styles={styles}
headers={['When', 'Who', 'Summary']}
widths={[18, 22, 60]}
rows={data.recentActivity.map((row) => [
new Date(row.when).toLocaleString('en-GB'),
row.actor ?? 'system',
row.summary,
])}
/>
</View>
<TableSection
styles={styles}
title="Recent activity"
subtitle="Last entries from the audit log, compact snapshot."
headers={['When', 'Who', 'Summary']}
widths={[18, 22, 60]}
rows={data.recentActivity.map((row) => [
new Date(row.when).toLocaleString('en-GB'),
row.actor ?? 'system',
row.summary,
])}
/>
) : null}
{include('new_clients_period') &&
data.newClientsInPeriod &&
data.newClientsInPeriod.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>New clients (in period)</Text>
<Text style={styles.sectionSubtitle}>
Clients added during the report window with their lead source. Capped at 50 rows; full
list lives in the client export.
</Text>
<SimpleTable
styles={styles}
headers={['Client', 'Source', 'Added']}
widths={[55, 25, 20]}
rows={data.newClientsInPeriod.map((r) => [
r.name,
r.source ?? '-',
new Date(r.createdAt).toLocaleDateString('en-GB'),
])}
/>
</View>
<TableSection
styles={styles}
title="New clients (in period)"
subtitle="Clients added during the report window with their lead source. Capped at 50 rows; full list lives in the client export."
headers={['Client', 'Source', 'Added']}
widths={[55, 25, 20]}
rows={data.newClientsInPeriod.map((r) => [
r.name,
r.source ?? '-',
new Date(r.createdAt).toLocaleDateString('en-GB'),
])}
/>
) : null}
{include('new_interests_period') &&
data.newInterestsInPeriod &&
data.newInterestsInPeriod.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>New interests (in period)</Text>
<Text style={styles.sectionSubtitle}>
Interests opened during the report window, with the stage they currently sit at and the
berth(s) attached.
</Text>
<SimpleTable
styles={styles}
headers={['Client', 'Stage', 'Berth', 'Opened']}
widths={[35, 22, 23, 20]}
rows={data.newInterestsInPeriod.map((r) => [
r.clientName,
stageLabel(r.stage),
r.berthLabel ?? '-',
new Date(r.createdAt).toLocaleDateString('en-GB'),
])}
/>
</View>
<TableSection
styles={styles}
title="New interests (in period)"
subtitle="Interests opened during the report window, with the stage they currently sit at and the berth(s) attached."
headers={['Client', 'Stage', 'Berth', 'Opened']}
widths={[35, 22, 23, 20]}
rows={data.newInterestsInPeriod.map((r) => [
r.clientName,
stageLabel(r.stage),
r.berthLabel ?? '-',
new Date(r.createdAt).toLocaleDateString('en-GB'),
])}
/>
) : null}
{include('berths_sold_period') &&
data.berthsSoldInPeriod &&
data.berthsSoldInPeriod.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Berths sold (in period)</Text>
<Text style={styles.sectionSubtitle}>
Berths transitioned to Sold status during the report window, resolved from the audit
log.
</Text>
<SimpleTable
styles={styles}
headers={['Mooring', 'Sold on']}
widths={[50, 50]}
rows={data.berthsSoldInPeriod.map((r) => [
r.mooringNumber,
new Date(r.soldAt).toLocaleDateString('en-GB'),
])}
/>
</View>
<TableSection
styles={styles}
title="Berths sold (in period)"
subtitle="Berths transitioned to Sold status during the report window, resolved from the audit log."
headers={['Mooring', 'Sold on']}
widths={[50, 50]}
rows={data.berthsSoldInPeriod.map((r) => [
r.mooringNumber,
new Date(r.soldAt).toLocaleDateString('en-GB'),
])}
/>
) : null}
{include('signed_documents_period') &&
data.signedDocumentsInPeriod &&
data.signedDocumentsInPeriod.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Documents signed (in period)</Text>
<Text style={styles.sectionSubtitle}>
EOIs, reservations, and contracts marked completed during the report window.
</Text>
<SimpleTable
styles={styles}
headers={['Type', 'Title', 'Signed on']}
widths={[20, 55, 25]}
rows={data.signedDocumentsInPeriod.map((r) => [
r.type,
r.title,
new Date(r.signedAt).toLocaleDateString('en-GB'),
])}
/>
</View>
<TableSection
styles={styles}
title="Documents signed (in period)"
subtitle="EOIs, reservations, and contracts marked completed during the report window."
headers={['Type', 'Title', 'Signed on']}
widths={[20, 55, 25]}
rows={data.signedDocumentsInPeriod.map((r) => [
r.type,
r.title,
new Date(r.signedAt).toLocaleDateString('en-GB'),
])}
/>
) : null}
{include('contracts_signed_period') &&
data.contractsSignedInPeriod &&
data.contractsSignedInPeriod.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Contracts signed (in period)</Text>
<Text style={styles.sectionSubtitle}>
Contract documents that completed signing during the report window.
</Text>
<SimpleTable
styles={styles}
headers={['Title', 'Signed on']}
widths={[75, 25]}
rows={data.contractsSignedInPeriod.map((r) => [
r.title,
new Date(r.signedAt).toLocaleDateString('en-GB'),
])}
/>
</View>
<TableSection
styles={styles}
title="Contracts signed (in period)"
subtitle="Contract documents that completed signing during the report window."
headers={['Title', 'Signed on']}
widths={[75, 25]}
rows={data.contractsSignedInPeriod.map((r) => [
r.title,
new Date(r.signedAt).toLocaleDateString('en-GB'),
])}
/>
) : null}
{include('deposits_received_period') &&
data.depositsReceivedInPeriod &&
data.depositsReceivedInPeriod.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Deposits received (in period)</Text>
<Text style={styles.sectionSubtitle}>
Deposit payments received during the report window, with client + $ amount.
</Text>
<SimpleTable
styles={styles}
headers={['Client', 'Amount', 'Date']}
widths={[55, 25, 20]}
rows={data.depositsReceivedInPeriod.map((r) => [
r.clientName,
formatCurrency(String(r.amount), r.currency, { maxFractionDigits: 0 }),
new Date(r.paidAt).toLocaleDateString('en-GB'),
])}
/>
</View>
<TableSection
styles={styles}
title="Deposits received (in period)"
subtitle="Deposit payments received during the report window, with client + $ amount."
headers={['Client', 'Amount', 'Date']}
widths={[55, 25, 20]}
rows={data.depositsReceivedInPeriod.map((r) => [
r.clientName,
formatCurrency(String(r.amount), r.currency, { maxFractionDigits: 0 }),
new Date(r.paidAt).toLocaleDateString('en-GB'),
])}
/>
) : null}
{include('hot_deals') && data.hotDeals && data.hotDeals.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Hot deals</Text>
<Text style={styles.sectionSubtitle}>
Top active interests, ranked by pipeline stage with most-recent activity as tiebreaker.
</Text>
<SimpleTable
styles={styles}
headers={['Client', 'Mooring', 'Stage', 'Last contact']}
widths={[40, 20, 20, 20]}
rows={data.hotDeals.map((d) => [
d.clientName ?? '-',
d.mooringNumber ?? '-',
stageLabel(d.stage),
d.lastContact ? new Date(d.lastContact).toLocaleDateString('en-GB') : '-',
])}
/>
</View>
<TableSection
styles={styles}
title="Hot deals"
subtitle="Top active interests, ranked by pipeline stage with most-recent activity as tiebreaker."
headers={['Client', 'Mooring', 'Stage', 'Last contact']}
widths={[40, 20, 20, 20]}
rows={data.hotDeals.map((d) => [
d.clientName ?? '-',
d.mooringNumber ?? '-',
stageLabel(d.stage),
d.lastContact ? new Date(d.lastContact).toLocaleDateString('en-GB') : '-',
])}
/>
) : null}
{/* Pending-resolver placeholder. Lets the user see that a
@@ -788,7 +753,11 @@ interface SimpleTableProps {
function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) {
return (
<View style={styles.table}>
<View style={styles.tableHeader}>
{/* `minPresenceAhead` keeps the header attached to at least ~48pt
of body rows: if the header would otherwise land at the very
bottom of a page it moves to the next page WITH its rows rather
than orphaning. */}
<View style={styles.tableHeader} minPresenceAhead={48}>
{headers.map((header, i) => (
<Text key={header + i} style={{ ...styles.tableHeaderCell, width: `${widths[i]}%` }}>
{header}
@@ -796,7 +765,15 @@ function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) {
))}
</View>
{rows.map((row, rowIdx) => (
<View key={rowIdx} style={rowIdx % 2 === 1 ? styles.tableRowZebra : styles.tableRow}>
// `wrap={false}` per row so a cell that wraps to two lines (long
// document filenames) never splits across a page boundary — the
// whole row moves to the next page intact rather than rendering
// half on each, which reads as overlapping text.
<View
key={rowIdx}
style={rowIdx % 2 === 1 ? styles.tableRowZebra : styles.tableRow}
wrap={false}
>
{row.map((cell, i) => (
<Text key={`${rowIdx}-${i}`} style={{ ...styles.tableCell, width: `${widths[i]}%` }}>
{cell}
@@ -807,3 +784,38 @@ function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) {
</View>
);
}
/**
* Section wrapper for list-style widgets whose row count is unbounded
* (the per-period lists, recent activity, hot deals). Unlike the
* fixed-size KPI/chart sections, these MUST be allowed to paginate: a
* `wrap={false}` around an oversized table forces React-PDF to render
* it crushed / overlapping when it can't fit a single page. Here we let
* the table flow across pages and use `minPresenceAhead` on the heading
* so the title isn't orphaned at a page bottom away from its rows.
*/
function TableSection({
styles,
title,
subtitle,
headers,
widths,
rows,
}: {
styles: ReturnType<typeof makeReportStyles>;
title: string;
subtitle: string;
headers: string[];
widths: number[];
rows: string[][];
}) {
return (
<View>
<Text style={styles.sectionTitle} minPresenceAhead={72}>
{title}
</Text>
<Text style={styles.sectionSubtitle}>{subtitle}</Text>
<SimpleTable styles={styles} headers={headers} widths={widths} rows={rows} />
</View>
);
}

View File

@@ -99,12 +99,19 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
eq(interests.portId, portId),
inArray(interests.pipelineStage, STALE_STAGES),
isNull(interests.archivedAt),
// An interest can't be "stale for 14+ days" if it has only existed for
// less than 14 days. Without this floor, a bulk import (which backdates
// dateLastContact to the legacy value) instantly flags every migrated
// interest as stale and floods the alert rail. The 14-day clock starts
// no earlier than when the interest entered THIS system.
lt(interests.createdAt, daysAgo(14)),
// An interest can't be "stale for 14+ days" if it has only existed in
// THIS system for less than 14 days. Without this floor, a bulk import
// (which backdates dateLastContact to the legacy value) instantly flags
// every migrated interest as stale and floods the alert rail.
//
// We floor on updatedAt, NOT createdAt: the legacy→CRM migration
// backfilled created_at to each interest's real origination date (so
// analytics date-ranges work), which would make every migrated row look
// 14+ days old and re-open the flood. updated_at is left at the
// migration timestamp, so it's the reliable "entered/last-touched this
// system" clock — migrated rows stay suppressed for 14 days, then the
// contact-based OR below governs.
lt(interests.updatedAt, daysAgo(14)),
or(
lt(interests.dateLastContact, daysAgo(14)),
and(isNull(interests.dateLastContact), lt(interests.updatedAt, daysAgo(14))),