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

@@ -187,36 +187,42 @@ export function BerthList() {
applyView({ filters: savedFilters, sort: savedSort });
}}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')}
aria-label={
density === 'compact'
? 'Switch to comfortable row spacing'
: 'Switch to compact row spacing'
}
title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'}
>
{density === 'compact' ? (
<Rows3 className="h-4 w-4" aria-hidden />
) : (
<Rows4 className="h-4 w-4" aria-hidden />
)}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDimensionUnit(dimensionUnit === 'ft' ? 'm' : 'ft')}
aria-label={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
title={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
className="font-mono text-xs"
>
{dimensionUnit === 'ft' ? 'ft' : 'm'}
</Button>
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
{/* Table-only controls — hidden in card mode (<lg, matching
DataTable's table/card switch). The BerthCard ignores row
density + dimension unit and renders no column set, so these
toggles have no visible effect there and read as broken. */}
<div className="hidden items-center gap-2 md:flex">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')}
aria-label={
density === 'compact'
? 'Switch to comfortable row spacing'
: 'Switch to compact row spacing'
}
title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'}
>
{density === 'compact' ? (
<Rows3 className="h-4 w-4" aria-hidden />
) : (
<Rows4 className="h-4 w-4" aria-hidden />
)}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDimensionUnit(dimensionUnit === 'ft' ? 'm' : 'ft')}
aria-label={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
title={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
className="font-mono text-xs"
>
{dimensionUnit === 'ft' ? 'ft' : 'm'}
</Button>
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
</div>
<ExportListPdfButton kind="berths" />
{canBulkAdd && (
<Button asChild size="sm" variant="default">

View File

@@ -54,11 +54,22 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
const interest = client.latestInterest ?? null;
const interestCount = client.interestCount ?? 0;
const interestBerthLabel = interest
? interest.mooringNumber
? `Berth ${interest.mooringNumber}`
: 'General interest'
: null;
// Show ALL berths the client has interests in (across every interest),
// not just the latest interest's primary mooring — matches the desktop
// table's Berths column + the interest header. Cap the inline list so
// the card stays compact; overflow folds into a "+N" suffix.
const linkedBerths = client.linkedBerths ?? [];
const MAX_BERTHS_SHOWN = 4;
const shownMoorings = linkedBerths.slice(0, MAX_BERTHS_SHOWN).map((b) => b.mooringNumber);
const extraBerths = linkedBerths.length - shownMoorings.length;
const interestBerthLabel =
shownMoorings.length > 0
? `${linkedBerths.length === 1 ? 'Berth' : 'Berths'} ${shownMoorings.join(', ')}${
extraBerths > 0 ? ` +${extraBerths}` : ''
}`
: interest
? 'General interest'
: null;
const interestStageLabel = interest ? stageLabel(interest.stage) : null;
const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null;
const extraInterests = interestCount > 1 ? interestCount - 1 : 0;

View File

@@ -379,7 +379,7 @@ export function InterestList() {
type="button"
onClick={() => setCreateOpen(true)}
aria-label="New interest"
className="fixed bottom-[calc(env(safe-area-inset-bottom)+86px)] right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 lg:hidden"
className="fixed bottom-[calc(env(safe-area-inset-bottom)+86px)] right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 md:hidden"
>
<Plus className="h-6 w-6" aria-hidden />
</button>

View File

@@ -402,6 +402,20 @@ function SidebarContent({
if (section.requiresResidentialModule && !residentialModuleEnabled) return null;
if (section.umamiRequired && !umamiConfigured) return null;
// Resolve the items this section will actually render after
// per-item module/permission gating. If they all gate off
// (e.g. the Financial section once the Expenses module is
// disabled), skip the whole section so its header + separator
// don't linger as an orphaned label.
const visibleItems = section.items.filter((item) => {
const gated = item as NavItemGated;
if (gated.requiresTenanciesModule && !tenanciesModuleEnabled) return false;
if (gated.requiresExpensesModule && !expensesModuleEnabled) return false;
if (gated.umamiRequired && !umamiConfigured) return false;
return true;
});
if (visibleItems.length === 0) return null;
return (
<div key={section.title}>
{!collapsed && (
@@ -425,24 +439,15 @@ function SidebarContent({
)}
{(!section.adminRequired || adminExpanded || collapsed) && (
<ul className="space-y-0.5">
{section.items
.filter((item) => {
const gated = item as NavItemGated;
if (gated.requiresTenanciesModule && !tenanciesModuleEnabled)
return false;
if (gated.requiresExpensesModule && !expensesModuleEnabled) return false;
if (gated.umamiRequired && !umamiConfigured) return false;
return true;
})
.map((item) => (
<li key={item.href}>
<NavItemLink
item={item}
collapsed={collapsed}
active={isActive(item.href, item.exact)}
/>
</li>
))}
{visibleItems.map((item) => (
<li key={item.href}>
<NavItemLink
item={item}
collapsed={collapsed}
active={isActive(item.href, item.exact)}
/>
</li>
))}
</ul>
)}
<Separator className="mt-3 bg-slate-200" aria-hidden />

View File

@@ -62,33 +62,18 @@ export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
<BackButton variant="desktop" />
</div>
{/* CENTER (spacer): the search bar is absolutely positioned below
so it anchors to true viewport center regardless of left/right
column widths. This empty grid track keeps `auto 1fr auto` so
the right column behaves the same as before. */}
<div aria-hidden />
{/* CENTER: global search, anchored to true viewport center.
The topbar element starts AFTER the 256px sidebar at lg+, so
`left: 50%` of the topbar lands sidebar/2 (=128px) right of the
viewport center. We subtract that offset at lg+ so the search
bar sits under the browser address bar; below lg the sidebar
is hidden behind a Sheet and the topbar spans the full
viewport, so plain `left: 50%` is already correct.
Caps scale by viewport tier so the bar doesn't crowd the side
columns. The previous max-w-2xl (672px) at xl ate so much of
the topbar that the back-button column on the left got
visually clipped by the search bar; tightened to max-w-xl so
a "Back to Administration"-class label can render in full:
base: max-w-md (28rem)
lg: max-w-lg (32rem)
xl: max-w-xl (36rem)
The wrapper is pointer-events-none so it doesn't capture
clicks meant for the left/right columns underneath; only the
input itself receives pointer events. */}
<div className="pointer-events-none absolute inset-y-0 left-1/2 lg:left-[calc(50%-var(--width-sidebar)/2)] flex w-full max-w-md -translate-x-1/2 items-center px-4 lg:max-w-lg xl:max-w-xl">
<div className="pointer-events-auto w-full min-w-0">
{/* CENTER: global search lives IN the 1fr grid track so it is
bounded by the left (back-button) and right (actions) columns
and can never overlap them. The previous approach absolutely
positioned the bar at viewport-center, which ignored the side
columns and crowded the "New" button at narrower widths
(UAT 2026-06-03). `mx-auto` keeps it visually centered within
the available middle space; `max-w-xl` stops it sprawling on
wide screens; `min-w-0` lets it shrink rather than push the
side columns. The grid `gap-3` guarantees breathing room from
both neighbours. */}
<div className="min-w-0 px-2">
<div className="mx-auto w-full min-w-0 max-w-xl">
<CommandSearch />
</div>
</div>

View File

@@ -3,20 +3,7 @@
import { useMemo, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -35,6 +22,10 @@ import type { Route } from 'next';
import { Wallet } from 'lucide-react';
// ─── Payload types (mirror the /api/v1/reports/financial response) ───────────
// NOTE: the API still returns expense/cash-flow/net-contribution fields; we
// deliberately omit them here. Expenses are business-trip costs tracked in the
// standalone Expenses section and do NOT factor into the financial picture, so
// the Financial report is purely revenue / deposits / collections.
interface FinancialKpis {
revenueCollected: number;
@@ -43,8 +34,6 @@ interface FinancialKpis {
refundsIssued: number;
pipelineExpected: number;
expectedDepositsOutstanding: number;
expensesTotal: number;
netContribution: number;
currency: string;
}
interface RevenueByMonthRow {
@@ -62,15 +51,6 @@ interface AgingRow {
count: number;
value: number;
}
interface CashFlowRow {
month: string;
inflow: number;
outflow: number;
}
interface ExpenseBreakdownRow {
category: string;
total: number;
}
interface OutstandingDepositRow {
interestId: string;
clientName: string;
@@ -98,16 +78,6 @@ interface RefundRow {
currency: string;
notes: string | null;
}
interface ExpenseLedgerRow {
id: string;
expenseDate: string;
payer: string | null;
category: string | null;
establishmentName: string | null;
amount: number;
currency: string;
paymentStatus: string | null;
}
interface FinancialPayload {
data: {
@@ -115,12 +85,9 @@ interface FinancialPayload {
revenueByMonth: RevenueByMonthRow[];
collectionFunnel: CollectionFunnelRow[];
aging: AgingRow[];
cashFlow: CashFlowRow[];
expenseBreakdown: ExpenseBreakdownRow[];
outstandingDeposits: OutstandingDepositRow[];
recentPayments: RecentPaymentRow[];
refundLog: RefundRow[];
expenseLedger: ExpenseLedgerRow[];
range: { from: string; to: string };
hasData: boolean;
};
@@ -133,15 +100,6 @@ interface FinancialTemplateConfig extends Record<string, unknown> {
type MonthGranularity = 'month' | 'quarter' | 'year';
const DONUT_COLORS = [
'hsl(var(--chart-1))',
'hsl(var(--chart-3))',
'hsl(var(--chart-4))',
'hsl(var(--chart-5))',
'hsl(var(--chart-2))',
'hsl(var(--chart-6))',
];
export function FinancialReportClient({ portSlug }: { portSlug: string }) {
const searchParams = useSearchParams();
const initialTemplateId = searchParams?.get('templateId') ?? null;
@@ -181,11 +139,9 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
const revenueByMonth = d?.revenueByMonth ?? [];
const collectionFunnel = d?.collectionFunnel ?? [];
const aging = d?.aging ?? [];
const expenseBreakdown = d?.expenseBreakdown ?? [];
const outstandingDeposits = d?.outstandingDeposits ?? [];
const recentPayments = d?.recentPayments ?? [];
const refundLog = d?.refundLog ?? [];
const expenseLedger = d?.expenseLedger ?? [];
// Re-bucket the monthly revenue series for the quarter/year toggle.
// Depend on the query-data reference (stable across renders once
@@ -195,17 +151,13 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
() => rebucketRevenue(d?.revenueByMonth ?? [], granularity),
[d?.revenueByMonth, granularity],
);
const cashFlowSeries = useMemo(
() => (d?.cashFlow ?? []).map((r) => ({ ...r, label: formatMonthLabel(r.month) })),
[d?.cashFlow],
);
const fundedCount = collectionFunnel.length > 0 ? collectionFunnel[0]!.count : 0;
function buildExportPayload(): ReportPayload {
if (!kpis) throw new Error('Report still loading');
return {
title: 'Financial',
description: 'Revenue collected, deposits, outstanding, cash flow, and expenses.',
description: 'Revenue collected, deposits, outstanding balances, and collections.',
filenameSlug: 'financial',
range: bounds,
kpis: [
@@ -220,8 +172,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
label: 'Outstanding deposits',
value: formatMoney(kpis.expectedDepositsOutstanding, currency),
},
{ label: 'Expenses', value: formatMoney(kpis.expensesTotal, currency) },
{ label: 'Net contribution', value: formatMoney(kpis.netContribution, currency) },
],
sections: [
{
@@ -252,23 +202,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
daysOutstanding: r.daysOutstanding,
})),
},
{
title: 'Expense ledger',
columns: [
{ key: 'expenseDate', label: 'Date' },
{ key: 'category', label: 'Category' },
{ key: 'payer', label: 'Payer' },
{ key: 'amount', label: 'Amount', align: 'right' },
{ key: 'paymentStatus', label: 'Status' },
],
rows: expenseLedger.map((r) => ({
expenseDate: r.expenseDate ? r.expenseDate.slice(0, 10) : '—',
category: r.category ?? '—',
payer: r.payer ?? '—',
amount: formatMoney(r.amount, r.currency),
paymentStatus: r.paymentStatus ?? '—',
})),
},
],
};
}
@@ -281,14 +214,14 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
<PageHeader
eyebrow="Reports"
title="Financial"
description="Revenue collected, deposits, outstanding balances, cash flow, and expense breakdown."
description="Revenue collected, deposits, and outstanding balances."
/>
<ReportEmptyState
icon={Wallet}
title="No financial activity yet"
body="Record a payment on a deal or log an expense to see revenue, deposits, and cash flow."
actionLabel="Go to expenses"
actionHref={`/${portSlug}/expenses` as Route}
body="Record a payment on a deal to see revenue, deposits, and collections."
actionLabel="View deals"
actionHref={`/${portSlug}/interests` as Route}
/>
</div>
);
@@ -299,7 +232,7 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
<PageHeader
eyebrow="Reports"
title="Financial"
description="Revenue collected, deposits, outstanding balances, cash flow, and expenses. Sourced from recorded payments; the CRM does not invoice."
description="Revenue collected, deposits, and outstanding balances. Sourced from recorded payments; the CRM does not invoice."
actions={
<div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={handleRangeChange} />
@@ -316,13 +249,13 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
}
/>
{/* KPI STRIP — 7 tiles */}
{/* KPI STRIP — 5 tiles (expenses + net-contribution intentionally omitted) */}
<section
aria-label="Financial KPIs"
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
>
{isLoading ? (
Array.from({ length: 7 }).map((_, i) => <KpiSkeleton key={i} />)
Array.from({ length: 5 }).map((_, i) => <KpiSkeleton key={i} />)
) : (
<>
<KpiCard
@@ -348,21 +281,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
value={formatMoney(kpis.expectedDepositsOutstanding, currency)}
hint="Expected but not yet collected"
/>
<KpiCard
label="Expenses"
value={formatMoney(kpis.expensesTotal, currency)}
hint={
kpis.refundsIssued > 0
? `${formatMoney(kpis.refundsIssued, currency)} refunded`
: undefined
}
/>
<KpiCard
label="Net contribution"
value={formatMoney(kpis.netContribution, currency)}
valueTone={kpis.netContribution >= 0 ? 'positive' : 'negative'}
hint="Revenue expenses"
/>
</>
)}
</section>
@@ -526,130 +444,7 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
</Card>
</div>
{/* CHART 4 — Cash flow (inflow vs outflow) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Cash flow</CardTitle>
<p className="text-xs text-muted-foreground">
Money in (payments received) vs money out (expenses booked), per month.
</p>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-[280px] w-full" />
) : (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={cashFlowSeries} margin={{ top: 8, right: 8, left: 4, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
interval="preserveStartEnd"
/>
<YAxis
tickFormatter={(v) => formatMoneyCompact(Number(v), currency)}
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
width={64}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value, name) => [
formatMoney(Number(value), currency),
name === 'inflow' ? 'Inflow' : 'Outflow',
]}
/>
<Line
type="monotone"
dataKey="inflow"
stroke="hsl(var(--chart-1))"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="outflow"
stroke="hsl(var(--chart-4))"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* CHART 5 — Expense breakdown donut + Recent payments table */}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Expense breakdown</CardTitle>
<p className="text-xs text-muted-foreground">By category, for the selected period.</p>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-[260px] w-full" />
) : expenseBreakdown.length === 0 ? (
<EmptyState>No expenses booked in this period.</EmptyState>
) : (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={expenseBreakdown}
dataKey="total"
nameKey="category"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={90}
paddingAngle={2}
>
{expenseBreakdown.map((_, i) => (
<Cell key={i} fill={DONUT_COLORS[i % DONUT_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value, name) => [
formatMoney(Number(value), currency),
String(name),
]}
/>
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Recent payments</CardTitle>
<p className="text-xs text-muted-foreground">Latest money received.</p>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<Skeleton className="mx-6 h-[260px]" />
) : recentPayments.length === 0 ? (
<EmptyState>No payments recorded in this period.</EmptyState>
) : (
<SimpleTable
head={['Date', 'Client', 'Type', 'Amount']}
rows={recentPayments.slice(0, 8).map((p) => [
p.receivedAt ? p.receivedAt.slice(0, 10) : '—',
p.clientName,
<span key="t" className="capitalize">
{p.paymentType}
</span>,
<span key="a" className="tabular-nums">
{formatMoney(p.amount, p.currency)}
</span>,
])}
/>
)}
</CardContent>
</Card>
</div>
{/* TABLES — Outstanding deposits + Expense ledger + Refunds */}
{/* TABLE — Outstanding deposits (the chase list) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Outstanding deposits</CardTitle>
@@ -684,29 +479,29 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
</CardContent>
</Card>
{/* TABLES — Recent payments + Refunds */}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Expense ledger</CardTitle>
<p className="text-xs text-muted-foreground">Expenses booked in the period.</p>
<CardTitle className="text-base">Recent payments</CardTitle>
<p className="text-xs text-muted-foreground">Latest money received.</p>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<Skeleton className="mx-6 h-[200px]" />
) : expenseLedger.length === 0 ? (
<EmptyState>No expenses booked in this period.</EmptyState>
<Skeleton className="mx-6 h-[260px]" />
) : recentPayments.length === 0 ? (
<EmptyState>No payments recorded in this period.</EmptyState>
) : (
<SimpleTable
head={['Date', 'Category', 'Payer', 'Amount', 'Status']}
rows={expenseLedger.slice(0, 10).map((r) => [
r.expenseDate ? r.expenseDate.slice(0, 10) : '—',
r.category ?? '—',
r.payer ?? '—',
<span key="a" className="tabular-nums">
{formatMoney(r.amount, r.currency)}
head={['Date', 'Client', 'Type', 'Amount']}
rows={recentPayments.slice(0, 8).map((p) => [
p.receivedAt ? p.receivedAt.slice(0, 10) : '—',
p.clientName,
<span key="t" className="capitalize">
{p.paymentType}
</span>,
<span key="s" className="capitalize">
{r.paymentStatus ?? '—'}
<span key="a" className="tabular-nums">
{formatMoney(p.amount, p.currency)}
</span>,
])}
/>

View File

@@ -90,7 +90,7 @@ export function ResidentialClientsList() {
{/* Desktop: table layout. Hidden below lg because the 6 columns clip
off the viewport at phone widths. */}
<div className="hidden lg:block rounded-lg border bg-card overflow-hidden">
<div className="hidden md:block rounded-lg border bg-card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
@@ -181,7 +181,7 @@ export function ResidentialClientsList() {
{/* Mobile: card list. Each card mirrors the table row data with
name + status pill on top, then meta line(s) below. */}
<div className="lg:hidden space-y-2">
<div className="md:hidden space-y-2">
{isLoading && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
Loading

View File

@@ -244,7 +244,7 @@ export function DataTable<TData>({
<div
ref={virtualEnabled ? scrollContainerRef : undefined}
style={virtualEnabled ? { height: virtualHeightPx, overflow: 'auto' } : undefined}
className={cn('rounded-md border overflow-x-auto', cardRender && 'hidden lg:block')}
className={cn('rounded-md border overflow-x-auto', cardRender && 'hidden md:block')}
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted/50">
@@ -373,7 +373,7 @@ export function DataTable<TData>({
{/* Mobile card list */}
{cardRender && (
<ul className="lg:hidden flex flex-col gap-2">
<ul className="md:hidden flex flex-col gap-2">
{isLoading ? (
<li className="rounded-md border bg-card p-6 text-center">
<Loader2 className="mx-auto h-5 w-5 animate-spin text-muted-foreground" aria-hidden />

View File

@@ -43,7 +43,7 @@ export function DataView<TData>({
{headerSlot ? <div>{headerSlot}</div> : null}
{/* Desktop: TanStack table */}
<div className="hidden lg:block">
<div className="hidden md:block">
<Table>
<TableHeader>
{table.getHeaderGroups().map((group) => (
@@ -81,7 +81,7 @@ export function DataView<TData>({
</div>
{/* Mobile: card list */}
<ul className="lg:hidden flex flex-col gap-2">
<ul className="md:hidden flex flex-col gap-2">
{isEmpty ? (
<li className="rounded-md border border-border bg-card p-4 text-center text-sm text-muted-foreground">
{emptyState ?? 'No results.'}

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))),