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 }); applyView({ filters: savedFilters, sort: savedSort });
}} }}
/> />
<Button {/* Table-only controls — hidden in card mode (<lg, matching
type="button" DataTable's table/card switch). The BerthCard ignores row
size="sm" density + dimension unit and renders no column set, so these
variant="outline" toggles have no visible effect there and read as broken. */}
onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')} <div className="hidden items-center gap-2 md:flex">
aria-label={ <Button
density === 'compact' type="button"
? 'Switch to comfortable row spacing' size="sm"
: 'Switch to compact row spacing' variant="outline"
} onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')}
title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'} aria-label={
> density === 'compact'
{density === 'compact' ? ( ? 'Switch to comfortable row spacing'
<Rows3 className="h-4 w-4" aria-hidden /> : 'Switch to compact row spacing'
) : ( }
<Rows4 className="h-4 w-4" aria-hidden /> title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'}
)} >
</Button> {density === 'compact' ? (
<Button <Rows3 className="h-4 w-4" aria-hidden />
type="button" ) : (
size="sm" <Rows4 className="h-4 w-4" aria-hidden />
variant="outline" )}
onClick={() => setDimensionUnit(dimensionUnit === 'ft' ? 'm' : 'ft')} </Button>
aria-label={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`} <Button
title={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`} type="button"
className="font-mono text-xs" size="sm"
> variant="outline"
{dimensionUnit === 'ft' ? 'ft' : 'm'} onClick={() => setDimensionUnit(dimensionUnit === 'ft' ? 'm' : 'ft')}
</Button> aria-label={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} /> 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" /> <ExportListPdfButton kind="berths" />
{canBulkAdd && ( {canBulkAdd && (
<Button asChild size="sm" variant="default"> <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 interest = client.latestInterest ?? null;
const interestCount = client.interestCount ?? 0; const interestCount = client.interestCount ?? 0;
const interestBerthLabel = interest // Show ALL berths the client has interests in (across every interest),
? interest.mooringNumber // not just the latest interest's primary mooring — matches the desktop
? `Berth ${interest.mooringNumber}` // table's Berths column + the interest header. Cap the inline list so
: 'General interest' // the card stays compact; overflow folds into a "+N" suffix.
: null; 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 interestStageLabel = interest ? stageLabel(interest.stage) : null;
const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null; const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null;
const extraInterests = interestCount > 1 ? interestCount - 1 : 0; const extraInterests = interestCount > 1 ? interestCount - 1 : 0;

View File

@@ -379,7 +379,7 @@ export function InterestList() {
type="button" type="button"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
aria-label="New interest" 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 /> <Plus className="h-6 w-6" aria-hidden />
</button> </button>

View File

@@ -402,6 +402,20 @@ function SidebarContent({
if (section.requiresResidentialModule && !residentialModuleEnabled) return null; if (section.requiresResidentialModule && !residentialModuleEnabled) return null;
if (section.umamiRequired && !umamiConfigured) 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 ( return (
<div key={section.title}> <div key={section.title}>
{!collapsed && ( {!collapsed && (
@@ -425,24 +439,15 @@ function SidebarContent({
)} )}
{(!section.adminRequired || adminExpanded || collapsed) && ( {(!section.adminRequired || adminExpanded || collapsed) && (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{section.items {visibleItems.map((item) => (
.filter((item) => { <li key={item.href}>
const gated = item as NavItemGated; <NavItemLink
if (gated.requiresTenanciesModule && !tenanciesModuleEnabled) item={item}
return false; collapsed={collapsed}
if (gated.requiresExpensesModule && !expensesModuleEnabled) return false; active={isActive(item.href, item.exact)}
if (gated.umamiRequired && !umamiConfigured) return false; />
return true; </li>
}) ))}
.map((item) => (
<li key={item.href}>
<NavItemLink
item={item}
collapsed={collapsed}
active={isActive(item.href, item.exact)}
/>
</li>
))}
</ul> </ul>
)} )}
<Separator className="mt-3 bg-slate-200" aria-hidden /> <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" /> <BackButton variant="desktop" />
</div> </div>
{/* CENTER (spacer): the search bar is absolutely positioned below {/* CENTER: global search lives IN the 1fr grid track so it is
so it anchors to true viewport center regardless of left/right bounded by the left (back-button) and right (actions) columns
column widths. This empty grid track keeps `auto 1fr auto` so and can never overlap them. The previous approach absolutely
the right column behaves the same as before. */} positioned the bar at viewport-center, which ignored the side
<div aria-hidden /> columns and crowded the "New" button at narrower widths
(UAT 2026-06-03). `mx-auto` keeps it visually centered within
{/* CENTER: global search, anchored to true viewport center. the available middle space; `max-w-xl` stops it sprawling on
The topbar element starts AFTER the 256px sidebar at lg+, so wide screens; `min-w-0` lets it shrink rather than push the
`left: 50%` of the topbar lands sidebar/2 (=128px) right of the side columns. The grid `gap-3` guarantees breathing room from
viewport center. We subtract that offset at lg+ so the search both neighbours. */}
bar sits under the browser address bar; below lg the sidebar <div className="min-w-0 px-2">
is hidden behind a Sheet and the topbar spans the full <div className="mx-auto w-full min-w-0 max-w-xl">
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">
<CommandSearch /> <CommandSearch />
</div> </div>
</div> </div>

View File

@@ -3,20 +3,7 @@
import { useMemo, useState, useCallback } from 'react'; import { useMemo, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
Bar,
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -35,6 +22,10 @@ import type { Route } from 'next';
import { Wallet } from 'lucide-react'; import { Wallet } from 'lucide-react';
// ─── Payload types (mirror the /api/v1/reports/financial response) ─────────── // ─── 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 { interface FinancialKpis {
revenueCollected: number; revenueCollected: number;
@@ -43,8 +34,6 @@ interface FinancialKpis {
refundsIssued: number; refundsIssued: number;
pipelineExpected: number; pipelineExpected: number;
expectedDepositsOutstanding: number; expectedDepositsOutstanding: number;
expensesTotal: number;
netContribution: number;
currency: string; currency: string;
} }
interface RevenueByMonthRow { interface RevenueByMonthRow {
@@ -62,15 +51,6 @@ interface AgingRow {
count: number; count: number;
value: number; value: number;
} }
interface CashFlowRow {
month: string;
inflow: number;
outflow: number;
}
interface ExpenseBreakdownRow {
category: string;
total: number;
}
interface OutstandingDepositRow { interface OutstandingDepositRow {
interestId: string; interestId: string;
clientName: string; clientName: string;
@@ -98,16 +78,6 @@ interface RefundRow {
currency: string; currency: string;
notes: string | null; 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 { interface FinancialPayload {
data: { data: {
@@ -115,12 +85,9 @@ interface FinancialPayload {
revenueByMonth: RevenueByMonthRow[]; revenueByMonth: RevenueByMonthRow[];
collectionFunnel: CollectionFunnelRow[]; collectionFunnel: CollectionFunnelRow[];
aging: AgingRow[]; aging: AgingRow[];
cashFlow: CashFlowRow[];
expenseBreakdown: ExpenseBreakdownRow[];
outstandingDeposits: OutstandingDepositRow[]; outstandingDeposits: OutstandingDepositRow[];
recentPayments: RecentPaymentRow[]; recentPayments: RecentPaymentRow[];
refundLog: RefundRow[]; refundLog: RefundRow[];
expenseLedger: ExpenseLedgerRow[];
range: { from: string; to: string }; range: { from: string; to: string };
hasData: boolean; hasData: boolean;
}; };
@@ -133,15 +100,6 @@ interface FinancialTemplateConfig extends Record<string, unknown> {
type MonthGranularity = 'month' | 'quarter' | 'year'; 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 }) { export function FinancialReportClient({ portSlug }: { portSlug: string }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const initialTemplateId = searchParams?.get('templateId') ?? null; const initialTemplateId = searchParams?.get('templateId') ?? null;
@@ -181,11 +139,9 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
const revenueByMonth = d?.revenueByMonth ?? []; const revenueByMonth = d?.revenueByMonth ?? [];
const collectionFunnel = d?.collectionFunnel ?? []; const collectionFunnel = d?.collectionFunnel ?? [];
const aging = d?.aging ?? []; const aging = d?.aging ?? [];
const expenseBreakdown = d?.expenseBreakdown ?? [];
const outstandingDeposits = d?.outstandingDeposits ?? []; const outstandingDeposits = d?.outstandingDeposits ?? [];
const recentPayments = d?.recentPayments ?? []; const recentPayments = d?.recentPayments ?? [];
const refundLog = d?.refundLog ?? []; const refundLog = d?.refundLog ?? [];
const expenseLedger = d?.expenseLedger ?? [];
// Re-bucket the monthly revenue series for the quarter/year toggle. // Re-bucket the monthly revenue series for the quarter/year toggle.
// Depend on the query-data reference (stable across renders once // Depend on the query-data reference (stable across renders once
@@ -195,17 +151,13 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
() => rebucketRevenue(d?.revenueByMonth ?? [], granularity), () => rebucketRevenue(d?.revenueByMonth ?? [], granularity),
[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; const fundedCount = collectionFunnel.length > 0 ? collectionFunnel[0]!.count : 0;
function buildExportPayload(): ReportPayload { function buildExportPayload(): ReportPayload {
if (!kpis) throw new Error('Report still loading'); if (!kpis) throw new Error('Report still loading');
return { return {
title: 'Financial', title: 'Financial',
description: 'Revenue collected, deposits, outstanding, cash flow, and expenses.', description: 'Revenue collected, deposits, outstanding balances, and collections.',
filenameSlug: 'financial', filenameSlug: 'financial',
range: bounds, range: bounds,
kpis: [ kpis: [
@@ -220,8 +172,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
label: 'Outstanding deposits', label: 'Outstanding deposits',
value: formatMoney(kpis.expectedDepositsOutstanding, currency), value: formatMoney(kpis.expectedDepositsOutstanding, currency),
}, },
{ label: 'Expenses', value: formatMoney(kpis.expensesTotal, currency) },
{ label: 'Net contribution', value: formatMoney(kpis.netContribution, currency) },
], ],
sections: [ sections: [
{ {
@@ -252,23 +202,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
daysOutstanding: r.daysOutstanding, 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 <PageHeader
eyebrow="Reports" eyebrow="Reports"
title="Financial" title="Financial"
description="Revenue collected, deposits, outstanding balances, cash flow, and expense breakdown." description="Revenue collected, deposits, and outstanding balances."
/> />
<ReportEmptyState <ReportEmptyState
icon={Wallet} icon={Wallet}
title="No financial activity yet" title="No financial activity yet"
body="Record a payment on a deal or log an expense to see revenue, deposits, and cash flow." body="Record a payment on a deal to see revenue, deposits, and collections."
actionLabel="Go to expenses" actionLabel="View deals"
actionHref={`/${portSlug}/expenses` as Route} actionHref={`/${portSlug}/interests` as Route}
/> />
</div> </div>
); );
@@ -299,7 +232,7 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
<PageHeader <PageHeader
eyebrow="Reports" eyebrow="Reports"
title="Financial" 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={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={handleRangeChange} /> <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 <section
aria-label="Financial KPIs" 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 ? ( {isLoading ? (
Array.from({ length: 7 }).map((_, i) => <KpiSkeleton key={i} />) Array.from({ length: 5 }).map((_, i) => <KpiSkeleton key={i} />)
) : ( ) : (
<> <>
<KpiCard <KpiCard
@@ -348,21 +281,6 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
value={formatMoney(kpis.expectedDepositsOutstanding, currency)} value={formatMoney(kpis.expectedDepositsOutstanding, currency)}
hint="Expected but not yet collected" 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> </section>
@@ -526,130 +444,7 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
</Card> </Card>
</div> </div>
{/* CHART 4 — Cash flow (inflow vs outflow) */} {/* TABLE — Outstanding deposits (the chase list) */}
<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 */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Outstanding deposits</CardTitle> <CardTitle className="text-base">Outstanding deposits</CardTitle>
@@ -684,29 +479,29 @@ export function FinancialReportClient({ portSlug }: { portSlug: string }) {
</CardContent> </CardContent>
</Card> </Card>
{/* TABLES — Recent payments + Refunds */}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Expense ledger</CardTitle> <CardTitle className="text-base">Recent payments</CardTitle>
<p className="text-xs text-muted-foreground">Expenses booked in the period.</p> <p className="text-xs text-muted-foreground">Latest money received.</p>
</CardHeader> </CardHeader>
<CardContent className="px-0"> <CardContent className="px-0">
{isLoading ? ( {isLoading ? (
<Skeleton className="mx-6 h-[200px]" /> <Skeleton className="mx-6 h-[260px]" />
) : expenseLedger.length === 0 ? ( ) : recentPayments.length === 0 ? (
<EmptyState>No expenses booked in this period.</EmptyState> <EmptyState>No payments recorded in this period.</EmptyState>
) : ( ) : (
<SimpleTable <SimpleTable
head={['Date', 'Category', 'Payer', 'Amount', 'Status']} head={['Date', 'Client', 'Type', 'Amount']}
rows={expenseLedger.slice(0, 10).map((r) => [ rows={recentPayments.slice(0, 8).map((p) => [
r.expenseDate ? r.expenseDate.slice(0, 10) : '—', p.receivedAt ? p.receivedAt.slice(0, 10) : '—',
r.category ?? '—', p.clientName,
r.payer ?? '—', <span key="t" className="capitalize">
<span key="a" className="tabular-nums"> {p.paymentType}
{formatMoney(r.amount, r.currency)}
</span>, </span>,
<span key="s" className="capitalize"> <span key="a" className="tabular-nums">
{r.paymentStatus ?? '—'} {formatMoney(p.amount, p.currency)}
</span>, </span>,
])} ])}
/> />

View File

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

View File

@@ -244,7 +244,7 @@ export function DataTable<TData>({
<div <div
ref={virtualEnabled ? scrollContainerRef : undefined} ref={virtualEnabled ? scrollContainerRef : undefined}
style={virtualEnabled ? { height: virtualHeightPx, overflow: 'auto' } : 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> <Table>
<TableHeader className="sticky top-0 z-10 bg-muted/50"> <TableHeader className="sticky top-0 z-10 bg-muted/50">
@@ -373,7 +373,7 @@ export function DataTable<TData>({
{/* Mobile card list */} {/* Mobile card list */}
{cardRender && ( {cardRender && (
<ul className="lg:hidden flex flex-col gap-2"> <ul className="md:hidden flex flex-col gap-2">
{isLoading ? ( {isLoading ? (
<li className="rounded-md border bg-card p-6 text-center"> <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 /> <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} {headerSlot ? <div>{headerSlot}</div> : null}
{/* Desktop: TanStack table */} {/* Desktop: TanStack table */}
<div className="hidden lg:block"> <div className="hidden md:block">
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((group) => ( {table.getHeaderGroups().map((group) => (
@@ -81,7 +81,7 @@ export function DataView<TData>({
</div> </div>
{/* Mobile: card list */} {/* Mobile: card list */}
<ul className="lg:hidden flex flex-col gap-2"> <ul className="md:hidden flex flex-col gap-2">
{isEmpty ? ( {isEmpty ? (
<li className="rounded-md border border-border bg-card p-4 text-center text-sm text-muted-foreground"> <li className="rounded-md border border-border bg-card p-4 text-center text-sm text-muted-foreground">
{emptyState ?? 'No results.'} {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: 'primaryEmail', label: 'Email', widthPct: 25 },
{ key: 'primaryPhone', label: 'Phone', widthPct: 15 }, { key: 'primaryPhone', label: 'Phone', widthPct: 15 },
{ key: 'source', label: 'Source', widthPct: 12 }, { key: 'source', label: 'Source', widthPct: 12 },
{ key: 'nationality', label: 'Nationality', widthPct: 8 }, { key: 'nationality', label: 'Country', widthPct: 8 },
{ key: 'createdAt', label: 'Created', widthPct: 10 }, { key: 'createdAt', label: 'Created', widthPct: 10 },
]; ];

View File

@@ -579,170 +579,135 @@ export function DashboardReport({
) : null} ) : null}
{include('recent_activity') && data.recentActivity && data.recentActivity.length > 0 ? ( {include('recent_activity') && data.recentActivity && data.recentActivity.length > 0 ? (
<View wrap={false}> <TableSection
<Text style={styles.sectionTitle}>Recent activity</Text> styles={styles}
<Text style={styles.sectionSubtitle}> title="Recent activity"
Last entries from the audit log, compact snapshot. subtitle="Last entries from the audit log, compact snapshot."
</Text> headers={['When', 'Who', 'Summary']}
<SimpleTable widths={[18, 22, 60]}
styles={styles} rows={data.recentActivity.map((row) => [
headers={['When', 'Who', 'Summary']} new Date(row.when).toLocaleString('en-GB'),
widths={[18, 22, 60]} row.actor ?? 'system',
rows={data.recentActivity.map((row) => [ row.summary,
new Date(row.when).toLocaleString('en-GB'), ])}
row.actor ?? 'system', />
row.summary,
])}
/>
</View>
) : null} ) : null}
{include('new_clients_period') && {include('new_clients_period') &&
data.newClientsInPeriod && data.newClientsInPeriod &&
data.newClientsInPeriod.length > 0 ? ( data.newClientsInPeriod.length > 0 ? (
<View wrap={false}> <TableSection
<Text style={styles.sectionTitle}>New clients (in period)</Text> styles={styles}
<Text style={styles.sectionSubtitle}> title="New clients (in period)"
Clients added during the report window with their lead source. Capped at 50 rows; full subtitle="Clients added during the report window with their lead source. Capped at 50 rows; full list lives in the client export."
list lives in the client export. headers={['Client', 'Source', 'Added']}
</Text> widths={[55, 25, 20]}
<SimpleTable rows={data.newClientsInPeriod.map((r) => [
styles={styles} r.name,
headers={['Client', 'Source', 'Added']} r.source ?? '-',
widths={[55, 25, 20]} new Date(r.createdAt).toLocaleDateString('en-GB'),
rows={data.newClientsInPeriod.map((r) => [ ])}
r.name, />
r.source ?? '-',
new Date(r.createdAt).toLocaleDateString('en-GB'),
])}
/>
</View>
) : null} ) : null}
{include('new_interests_period') && {include('new_interests_period') &&
data.newInterestsInPeriod && data.newInterestsInPeriod &&
data.newInterestsInPeriod.length > 0 ? ( data.newInterestsInPeriod.length > 0 ? (
<View wrap={false}> <TableSection
<Text style={styles.sectionTitle}>New interests (in period)</Text> styles={styles}
<Text style={styles.sectionSubtitle}> title="New interests (in period)"
Interests opened during the report window, with the stage they currently sit at and the subtitle="Interests opened during the report window, with the stage they currently sit at and the berth(s) attached."
berth(s) attached. headers={['Client', 'Stage', 'Berth', 'Opened']}
</Text> widths={[35, 22, 23, 20]}
<SimpleTable rows={data.newInterestsInPeriod.map((r) => [
styles={styles} r.clientName,
headers={['Client', 'Stage', 'Berth', 'Opened']} stageLabel(r.stage),
widths={[35, 22, 23, 20]} r.berthLabel ?? '-',
rows={data.newInterestsInPeriod.map((r) => [ new Date(r.createdAt).toLocaleDateString('en-GB'),
r.clientName, ])}
stageLabel(r.stage), />
r.berthLabel ?? '-',
new Date(r.createdAt).toLocaleDateString('en-GB'),
])}
/>
</View>
) : null} ) : null}
{include('berths_sold_period') && {include('berths_sold_period') &&
data.berthsSoldInPeriod && data.berthsSoldInPeriod &&
data.berthsSoldInPeriod.length > 0 ? ( data.berthsSoldInPeriod.length > 0 ? (
<View wrap={false}> <TableSection
<Text style={styles.sectionTitle}>Berths sold (in period)</Text> styles={styles}
<Text style={styles.sectionSubtitle}> title="Berths sold (in period)"
Berths transitioned to Sold status during the report window, resolved from the audit subtitle="Berths transitioned to Sold status during the report window, resolved from the audit log."
log. headers={['Mooring', 'Sold on']}
</Text> widths={[50, 50]}
<SimpleTable rows={data.berthsSoldInPeriod.map((r) => [
styles={styles} r.mooringNumber,
headers={['Mooring', 'Sold on']} new Date(r.soldAt).toLocaleDateString('en-GB'),
widths={[50, 50]} ])}
rows={data.berthsSoldInPeriod.map((r) => [ />
r.mooringNumber,
new Date(r.soldAt).toLocaleDateString('en-GB'),
])}
/>
</View>
) : null} ) : null}
{include('signed_documents_period') && {include('signed_documents_period') &&
data.signedDocumentsInPeriod && data.signedDocumentsInPeriod &&
data.signedDocumentsInPeriod.length > 0 ? ( data.signedDocumentsInPeriod.length > 0 ? (
<View wrap={false}> <TableSection
<Text style={styles.sectionTitle}>Documents signed (in period)</Text> styles={styles}
<Text style={styles.sectionSubtitle}> title="Documents signed (in period)"
EOIs, reservations, and contracts marked completed during the report window. subtitle="EOIs, reservations, and contracts marked completed during the report window."
</Text> headers={['Type', 'Title', 'Signed on']}
<SimpleTable widths={[20, 55, 25]}
styles={styles} rows={data.signedDocumentsInPeriod.map((r) => [
headers={['Type', 'Title', 'Signed on']} r.type,
widths={[20, 55, 25]} r.title,
rows={data.signedDocumentsInPeriod.map((r) => [ new Date(r.signedAt).toLocaleDateString('en-GB'),
r.type, ])}
r.title, />
new Date(r.signedAt).toLocaleDateString('en-GB'),
])}
/>
</View>
) : null} ) : null}
{include('contracts_signed_period') && {include('contracts_signed_period') &&
data.contractsSignedInPeriod && data.contractsSignedInPeriod &&
data.contractsSignedInPeriod.length > 0 ? ( data.contractsSignedInPeriod.length > 0 ? (
<View wrap={false}> <TableSection
<Text style={styles.sectionTitle}>Contracts signed (in period)</Text> styles={styles}
<Text style={styles.sectionSubtitle}> title="Contracts signed (in period)"
Contract documents that completed signing during the report window. subtitle="Contract documents that completed signing during the report window."
</Text> headers={['Title', 'Signed on']}
<SimpleTable widths={[75, 25]}
styles={styles} rows={data.contractsSignedInPeriod.map((r) => [
headers={['Title', 'Signed on']} r.title,
widths={[75, 25]} new Date(r.signedAt).toLocaleDateString('en-GB'),
rows={data.contractsSignedInPeriod.map((r) => [ ])}
r.title, />
new Date(r.signedAt).toLocaleDateString('en-GB'),
])}
/>
</View>
) : null} ) : null}
{include('deposits_received_period') && {include('deposits_received_period') &&
data.depositsReceivedInPeriod && data.depositsReceivedInPeriod &&
data.depositsReceivedInPeriod.length > 0 ? ( data.depositsReceivedInPeriod.length > 0 ? (
<View wrap={false}> <TableSection
<Text style={styles.sectionTitle}>Deposits received (in period)</Text> styles={styles}
<Text style={styles.sectionSubtitle}> title="Deposits received (in period)"
Deposit payments received during the report window, with client + $ amount. subtitle="Deposit payments received during the report window, with client + $ amount."
</Text> headers={['Client', 'Amount', 'Date']}
<SimpleTable widths={[55, 25, 20]}
styles={styles} rows={data.depositsReceivedInPeriod.map((r) => [
headers={['Client', 'Amount', 'Date']} r.clientName,
widths={[55, 25, 20]} formatCurrency(String(r.amount), r.currency, { maxFractionDigits: 0 }),
rows={data.depositsReceivedInPeriod.map((r) => [ new Date(r.paidAt).toLocaleDateString('en-GB'),
r.clientName, ])}
formatCurrency(String(r.amount), r.currency, { maxFractionDigits: 0 }), />
new Date(r.paidAt).toLocaleDateString('en-GB'),
])}
/>
</View>
) : null} ) : null}
{include('hot_deals') && data.hotDeals && data.hotDeals.length > 0 ? ( {include('hot_deals') && data.hotDeals && data.hotDeals.length > 0 ? (
<View wrap={false}> <TableSection
<Text style={styles.sectionTitle}>Hot deals</Text> styles={styles}
<Text style={styles.sectionSubtitle}> title="Hot deals"
Top active interests, ranked by pipeline stage with most-recent activity as tiebreaker. subtitle="Top active interests, ranked by pipeline stage with most-recent activity as tiebreaker."
</Text> headers={['Client', 'Mooring', 'Stage', 'Last contact']}
<SimpleTable widths={[40, 20, 20, 20]}
styles={styles} rows={data.hotDeals.map((d) => [
headers={['Client', 'Mooring', 'Stage', 'Last contact']} d.clientName ?? '-',
widths={[40, 20, 20, 20]} d.mooringNumber ?? '-',
rows={data.hotDeals.map((d) => [ stageLabel(d.stage),
d.clientName ?? '-', d.lastContact ? new Date(d.lastContact).toLocaleDateString('en-GB') : '-',
d.mooringNumber ?? '-', ])}
stageLabel(d.stage), />
d.lastContact ? new Date(d.lastContact).toLocaleDateString('en-GB') : '-',
])}
/>
</View>
) : null} ) : null}
{/* Pending-resolver placeholder. Lets the user see that a {/* Pending-resolver placeholder. Lets the user see that a
@@ -788,7 +753,11 @@ interface SimpleTableProps {
function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) { function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) {
return ( return (
<View style={styles.table}> <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) => ( {headers.map((header, i) => (
<Text key={header + i} style={{ ...styles.tableHeaderCell, width: `${widths[i]}%` }}> <Text key={header + i} style={{ ...styles.tableHeaderCell, width: `${widths[i]}%` }}>
{header} {header}
@@ -796,7 +765,15 @@ function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) {
))} ))}
</View> </View>
{rows.map((row, rowIdx) => ( {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) => ( {row.map((cell, i) => (
<Text key={`${rowIdx}-${i}`} style={{ ...styles.tableCell, width: `${widths[i]}%` }}> <Text key={`${rowIdx}-${i}`} style={{ ...styles.tableCell, width: `${widths[i]}%` }}>
{cell} {cell}
@@ -807,3 +784,38 @@ function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) {
</View> </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), eq(interests.portId, portId),
inArray(interests.pipelineStage, STALE_STAGES), inArray(interests.pipelineStage, STALE_STAGES),
isNull(interests.archivedAt), isNull(interests.archivedAt),
// An interest can't be "stale for 14+ days" if it has only existed for // An interest can't be "stale for 14+ days" if it has only existed in
// less than 14 days. Without this floor, a bulk import (which backdates // THIS system for less than 14 days. Without this floor, a bulk import
// dateLastContact to the legacy value) instantly flags every migrated // (which backdates dateLastContact to the legacy value) instantly flags
// interest as stale and floods the alert rail. The 14-day clock starts // every migrated interest as stale and floods the alert rail.
// no earlier than when the interest entered THIS system. //
lt(interests.createdAt, daysAgo(14)), // 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( or(
lt(interests.dateLastContact, daysAgo(14)), lt(interests.dateLastContact, daysAgo(14)),
and(isNull(interests.dateLastContact), lt(interests.updatedAt, daysAgo(14))), and(isNull(interests.dateLastContact), lt(interests.updatedAt, daysAgo(14))),