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,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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>,
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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…
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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.'}
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))),
|
||||||
|
|||||||
Reference in New Issue
Block a user