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:
@@ -187,6 +187,11 @@ export function BerthList() {
|
||||
applyView({ filters: savedFilters, sort: savedSort });
|
||||
}}
|
||||
/>
|
||||
{/* 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"
|
||||
@@ -217,6 +222,7 @@ export function BerthList() {
|
||||
{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">
|
||||
|
||||
@@ -54,10 +54,21 @@ 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'
|
||||
// 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,16 +439,7 @@ 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) => (
|
||||
{visibleItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<NavItemLink
|
||||
item={item}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>,
|
||||
])}
|
||||
/>
|
||||
|
||||
@@ -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…
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -579,13 +579,10 @@ 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
|
||||
<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) => [
|
||||
@@ -594,20 +591,15 @@ export function DashboardReport({
|
||||
row.summary,
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : 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
|
||||
<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) => [
|
||||
@@ -616,20 +608,15 @@ export function DashboardReport({
|
||||
new Date(r.createdAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : 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
|
||||
<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) => [
|
||||
@@ -639,20 +626,15 @@ export function DashboardReport({
|
||||
new Date(r.createdAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : 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
|
||||
<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) => [
|
||||
@@ -660,19 +642,15 @@ export function DashboardReport({
|
||||
new Date(r.soldAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : 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
|
||||
<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) => [
|
||||
@@ -681,19 +659,15 @@ export function DashboardReport({
|
||||
new Date(r.signedAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : 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
|
||||
<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) => [
|
||||
@@ -701,19 +675,15 @@ export function DashboardReport({
|
||||
new Date(r.signedAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : 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
|
||||
<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) => [
|
||||
@@ -722,17 +692,13 @@ export function DashboardReport({
|
||||
new Date(r.paidAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : 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
|
||||
<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) => [
|
||||
@@ -742,7 +708,6 @@ export function DashboardReport({
|
||||
d.lastContact ? new Date(d.lastContact).toLocaleDateString('en-GB') : '-',
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))),
|
||||
|
||||
Reference in New Issue
Block a user