chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -62,7 +62,7 @@ export type Align = 'left' | 'right' | 'center';
export interface TableColumn<Row> {
header: string;
/** grow weight (default 1) controls column width proportions. */
/** grow weight (default 1) - controls column width proportions. */
flex?: number;
align?: Align;
render: (row: Row, rowIndex: number) => ReactNode;

View File

@@ -44,7 +44,7 @@ export interface KeyValueGridProps {
}
function fmt(v: string | number | null | undefined): string {
if (v === null || v === undefined || v === '') return '';
if (v === null || v === undefined || v === '') return '-';
return typeof v === 'number' ? String(v) : v;
}

View File

@@ -16,7 +16,7 @@ export interface LineChartProps {
yLabel?: string;
/** Render circle markers at each point (default true). */
markers?: boolean;
/** Optional fixed y-axis max used when value-space should not auto-zoom. */
/** Optional fixed y-axis max - used when value-space should not auto-zoom. */
yMax?: number;
}

View File

@@ -57,7 +57,7 @@ export async function loadEoiTemplatePdf(): Promise<Uint8Array> {
if (actual !== EXPECTED_EOI_SHA256) {
logger.warn(
{ expected: EXPECTED_EOI_SHA256, actual },
'EOI source PDF sha256 mismatch template was modified without an EXPECTED_EOI_SHA256 bump. Update assets/README.md + EXPECTED_EOI_SHA256 in lockstep if this was intentional.',
'EOI source PDF sha256 mismatch - template was modified without an EXPECTED_EOI_SHA256 bump. Update assets/README.md + EXPECTED_EOI_SHA256 in lockstep if this was intentional.',
);
}
}
@@ -70,7 +70,7 @@ function formatAddress(address: EoiContext['client']['address']): string {
// EOI's Address field renders as: "street, city, REGION, postal, COUNTRY"
// with REGION as the ISO-3166-2 suffix (e.g. NY) and COUNTRY as the
// alpha-2 code (e.g. US) so the line fits in the PDF box. The separate
// `Nationality` PDF field has been retired the resident's country code
// `Nationality` PDF field has been retired - the resident's country code
// here is the canonical replacement.
return [address.street, address.city, address.subdivision, address.postalCode, address.countryIso]
.filter(Boolean)
@@ -90,7 +90,7 @@ function setText(form: ReturnType<PDFDocument['getForm']>, name: string, value:
if (value && value.trim().length > 0) {
logger.warn(
{ field: name },
`EOI in-app PDF template is missing AcroForm field "${name}" value was dropped. Update the source template.`,
`EOI in-app PDF template is missing AcroForm field "${name}" - value was dropped. Update the source template.`,
);
}
}
@@ -108,7 +108,7 @@ function setCheckbox(
} catch {
logger.warn(
{ field: name, checked },
`EOI in-app PDF template is missing checkbox AcroForm field "${name}" checkbox state was dropped. Update the source template.`,
`EOI in-app PDF template is missing checkbox AcroForm field "${name}" - checkbox state was dropped. Update the source template.`,
);
}
}
@@ -144,7 +144,7 @@ export async function fillEoiFormFields(
const yWid = dimUnit === 'ft' ? context.yacht?.widthFt : context.yacht?.widthM;
const yDra = dimUnit === 'ft' ? context.yacht?.draftFt : context.yacht?.draftM;
// Append the unit suffix so the rendered EOI reads "45 ft" / "13.7 m"
// rather than the bare number matches the Documenso pathway.
// rather than the bare number - matches the Documenso pathway.
const withDimUnit = (v: string | null | undefined): string =>
v && String(v).trim() ? `${String(v).trim()} ${dimUnit}` : '';
setText(form, 'Length', withDimUnit(yLen));
@@ -153,7 +153,7 @@ export async function fillEoiFormFields(
// Berth Number = compact range for multi-berth, primary mooring for
// single-berth (formatBerthRange(['A1']) === 'A1' so single-berth is
// byte-identical to the legacy primary-only path). The dedicated
// `Berth Range` AcroForm field was retired 2026-05-14 the source
// `Berth Range` AcroForm field was retired 2026-05-14 - the source
// PDF only carries `Berth Number`.
setText(form, 'Berth Number', context.eoiBerthRange || (context.berth?.mooringNumber ?? ''));

View File

@@ -3,13 +3,13 @@ import { PDFDocument } from 'pdf-lib';
/**
* Result of inspecting a PDF's AcroForm. Used by the Documenso template
* sync flow to surface what AcroForm fields the operator's uploaded PDF
* actually has so the admin can verify their fillable PDF matches the
* actually has - so the admin can verify their fillable PDF matches the
* CRM's expected field-label set before any EOI is sent in anger.
*/
export interface AcroFormField {
name: string;
/**
* `getType()` from pdf-lib's PDFField subclasses usually one of
* `getType()` from pdf-lib's PDFField subclasses - usually one of
* `PDFTextField`, `PDFCheckBox`, `PDFDropdown`, `PDFRadioGroup`,
* `PDFSignature`, `PDFButton`. Exposed verbatim so the UI can show
* the admin what each field expects at the AcroForm layer.
@@ -20,7 +20,7 @@ export interface AcroFormField {
/**
* Parses the AcroForm in `pdfBytes` and returns one descriptor per form
* field. Returns an empty array when the PDF has no AcroForm at all
* (i.e. a flat / non-fillable PDF). Never throws on a missing form
* (i.e. a flat / non-fillable PDF). Never throws on a missing form -
* the caller treats "empty list" as a signal to nudge the operator
* that their PDF isn't actually fillable.
*/

View File

@@ -42,7 +42,9 @@ export function BerthListReport({ title, subtitle, branding, generatedAt, config
generatedAt={generatedAt}
>
<View>
<Text style={styles.sectionSubtitle}>{cappedNotice}</Text>
<Text style={styles.sectionSubtitle} minPresenceAhead={80}>
{cappedNotice}
</Text>
<ReportTable
styles={styles}
headers={columns.map((c) => c.label)}

View File

@@ -41,7 +41,7 @@ export function BrandedReportDocument({
producer="Port Nimara CRM"
>
<Page size="A4" style={styles.page} wrap>
{/* Header logo + title + subtitle. Re-renders inside each
{/* Header - logo + title + subtitle. Re-renders inside each
page via `fixed` would duplicate the brand bar; instead we
keep it as a non-fixed element so it lives at the very top
of the first content page. Footer is `fixed` (bottom of

View File

@@ -0,0 +1,329 @@
import { Svg, G, Rect, Path, Line, Text as SvgText, Circle } from '@react-pdf/renderer';
/**
* Hand-rolled SVG chart primitives used by the dashboard PDF report.
*
* @react-pdf/renderer ships native `<Svg>` + path primitives but no
* higher-level chart library. Building these by hand (vs. server-side
* rendering recharts to PNG via a headless browser) keeps the report
* generator pure-Node, fast, and free of binary dependencies. Charts
* are intentionally minimal - bars / segments / lines / labels - to
* stay legible at A4 print scale.
*
* Coordinates use the chart's own viewBox so callers don't have to
* think in points. The caller supplies a `width` (in points) and the
* component draws inside that box; height scales proportionally.
*/
const CHART_FONT = 9;
const AXIS_COLOR = '#94a3b8'; // slate-400
const GRID_COLOR = '#e2e8f0'; // slate-200
const LABEL_COLOR = '#334155'; // slate-700
interface BarChartSeriesEntry {
label: string;
value: number;
/** Optional second value rendered as a faint background bar - used
* for "won vs total" style overlays. */
secondaryValue?: number;
}
interface HorizontalBarChartProps {
data: BarChartSeriesEntry[];
/** Box width in points. */
width: number;
/** Box height in points; defaults to a sensible value per row. */
height?: number;
/** Accent color for the primary bars; defaults to a CRM brand teal. */
primaryColor?: string;
/** Color for the secondary (background) bar. */
secondaryColor?: string;
/** Format the trailing per-bar number. Defaults to integer. */
formatValue?: (n: number) => string;
}
/**
* Horizontal bar chart. Each row labels its bar on the left, draws
* the bar in the middle, and prints the value on the right. Used by
* pipeline funnel + source-conversion chart variants.
*/
export function HorizontalBarChart({
data,
width,
height,
primaryColor = '#0d9488',
secondaryColor = '#cbd5e1',
formatValue = (n) => String(Math.round(n)),
}: HorizontalBarChartProps) {
const rowHeight = 22;
const labelWidth = Math.min(140, width * 0.32);
const valueWidth = 50;
const innerLeft = labelWidth + 6;
const innerRight = width - valueWidth - 4;
const innerWidth = innerRight - innerLeft;
const max = Math.max(1, ...data.map((d) => Math.max(d.value, d.secondaryValue ?? 0)));
const h = height ?? data.length * rowHeight + 8;
return (
<Svg width={width} height={h}>
{data.map((row, i) => {
const y = i * rowHeight + 4;
const barH = 14;
const primaryW = (row.value / max) * innerWidth;
const secondaryW = ((row.secondaryValue ?? 0) / max) * innerWidth;
return (
<G key={`${row.label}-${i}`}>
<SvgText
x={labelWidth}
y={y + barH * 0.75}
style={{ fontSize: CHART_FONT, fill: LABEL_COLOR }}
textAnchor="end"
>
{row.label}
</SvgText>
{row.secondaryValue !== undefined ? (
<Rect
x={innerLeft}
y={y}
width={Math.max(1, secondaryW)}
height={barH}
fill={secondaryColor}
/>
) : null}
<Rect
x={innerLeft}
y={y}
width={Math.max(1, primaryW)}
height={barH}
fill={primaryColor}
/>
<SvgText
x={width - 4}
y={y + barH * 0.75}
style={{ fontSize: CHART_FONT, fill: LABEL_COLOR }}
textAnchor="end"
>
{formatValue(row.value)}
</SvgText>
</G>
);
})}
</Svg>
);
}
interface DonutChartProps {
data: Array<{ label: string; value: number; color?: string }>;
width: number;
height?: number;
/** Inner hole radius as a fraction of the outer (0.5 = donut, 0 = pie). */
innerRatio?: number;
/** Centre label override (e.g. total). */
centerLabel?: string;
/** Default palette cycled when entries don't carry their own colors. */
palette?: string[];
}
const DEFAULT_PALETTE = [
'#0d9488', // teal
'#0284c7', // sky
'#7c3aed', // violet
'#f97316', // orange
'#dc2626', // red
'#65a30d', // lime
'#0891b2', // cyan
'#7c2d12', // amber-deep
];
/**
* Donut chart. Renders each slice as an SVG arc path and prints a
* legend below. Used by berth-status + lead-source-mix.
*/
export function DonutChart({
data,
width,
height,
innerRatio = 0.6,
centerLabel,
palette = DEFAULT_PALETTE,
}: DonutChartProps) {
const total = data.reduce((s, d) => s + d.value, 0);
const chartH = height ?? 160;
const legendRowHeight = 14;
const legendH = data.length * legendRowHeight + 8;
const fullH = chartH + legendH;
const cx = width / 2;
const cy = chartH / 2;
const outerR = Math.min(chartH, width) / 2 - 8;
const innerR = outerR * innerRatio;
const START_ANGLE = -Math.PI / 2; // start at 12 o'clock
// Pre-compute cumulative sweep per slice via reduce so we never reassign a
// bound variable during render (react-hooks/immutability).
const cumulativeSweeps = data.reduce<number[]>((acc, d) => {
const sweep = total > 0 ? (d.value / total) * Math.PI * 2 : 0;
const prev = acc.length === 0 ? 0 : (acc[acc.length - 1] ?? 0);
acc.push(prev + sweep);
return acc;
}, []);
const slices = data.map((d, i) => {
const prevCumulative = i === 0 ? 0 : (cumulativeSweeps[i - 1] ?? 0);
const startA = START_ANGLE + prevCumulative;
const endA = START_ANGLE + (cumulativeSweeps[i] ?? prevCumulative);
const sweep = endA - startA;
const x1 = cx + Math.cos(startA) * outerR;
const y1 = cy + Math.sin(startA) * outerR;
const x2 = cx + Math.cos(endA) * outerR;
const y2 = cy + Math.sin(endA) * outerR;
const x3 = cx + Math.cos(endA) * innerR;
const y3 = cy + Math.sin(endA) * innerR;
const x4 = cx + Math.cos(startA) * innerR;
const y4 = cy + Math.sin(startA) * innerR;
const largeArc = sweep > Math.PI ? 1 : 0;
// SVG path: M outer-start → arc → outer-end → L inner-end → arc-back → close
const pathD =
sweep <= 0
? ''
: `M ${x1.toFixed(2)} ${y1.toFixed(2)} ` +
`A ${outerR.toFixed(2)} ${outerR.toFixed(2)} 0 ${largeArc} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} ` +
`L ${x3.toFixed(2)} ${y3.toFixed(2)} ` +
`A ${innerR.toFixed(2)} ${innerR.toFixed(2)} 0 ${largeArc} 0 ${x4.toFixed(2)} ${y4.toFixed(2)} ` +
'Z';
const color = d.color ?? palette[i % palette.length] ?? DEFAULT_PALETTE[0]!;
return { ...d, pathD, color };
});
return (
<Svg width={width} height={fullH}>
{/* Donut slices */}
{slices.map((s, i) =>
s.pathD ? <Path key={`slice-${i}`} d={s.pathD} fill={s.color} /> : null,
)}
{/* Centre label */}
{centerLabel ? (
<SvgText
x={cx}
y={cy + 4}
style={{ fontSize: 14, fill: LABEL_COLOR, fontWeight: 600 }}
textAnchor="middle"
>
{centerLabel}
</SvgText>
) : null}
{/* Legend rows below the donut */}
{slices.map((s, i) => {
const ly = chartH + i * legendRowHeight + 4;
const pct = total > 0 ? ` · ${((s.value / total) * 100).toFixed(1)}%` : '';
return (
<G key={`legend-${i}`}>
<Rect x={8} y={ly} width={9} height={9} fill={s.color} />
<SvgText x={22} y={ly + 8} style={{ fontSize: CHART_FONT, fill: LABEL_COLOR }}>
{`${s.label} · ${s.value}${pct}`}
</SvgText>
</G>
);
})}
</Svg>
);
}
interface LineChartProps {
data: Array<{ label: string; value: number }>;
width: number;
height?: number;
/** Format the y-axis tick labels (defaults to "value%" for 0-100). */
yTickFormat?: (n: number) => string;
/** Color of the line + filled area. */
color?: string;
}
/**
* Simple line chart with a faint filled area below the line and a
* basic x-axis. Used by occupancy-timeline-chart.
*/
export function LineChart({
data,
width,
height = 140,
yTickFormat = (n) => `${n.toFixed(0)}%`,
color = '#0d9488',
}: LineChartProps) {
const padLeft = 32;
const padRight = 10;
const padTop = 8;
const padBottom = 24;
const innerW = width - padLeft - padRight;
const innerH = height - padTop - padBottom;
const values = data.map((d) => d.value);
const max = Math.max(1, ...values);
// Round max up to a "nice" number so the y-axis reads cleanly.
const niceMax = Math.ceil(max / 10) * 10;
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
const yFor = (v: number) => padTop + innerH - (v / niceMax) * innerH;
const xFor = (i: number) => padLeft + i * xStep;
// Polyline path for the line itself + filled area below.
const linePath = data
.map((d, i) => `${i === 0 ? 'M' : 'L'} ${xFor(i).toFixed(2)} ${yFor(d.value).toFixed(2)}`)
.join(' ');
const areaPath = data.length
? linePath +
` L ${xFor(data.length - 1).toFixed(2)} ${(padTop + innerH).toFixed(2)}` +
` L ${padLeft.toFixed(2)} ${(padTop + innerH).toFixed(2)} Z`
: '';
// X-axis labels: thin out to ~6 labels max so they don't overlap.
const labelEvery = Math.max(1, Math.ceil(data.length / 6));
return (
<Svg width={width} height={height}>
{/* Gridlines + y-ticks (0 / 50 / 100 for percent scales) */}
{[0, niceMax / 2, niceMax].map((v, i) => {
const y = yFor(v);
return (
<G key={`grid-${i}`}>
<Line
x1={padLeft}
y1={y}
x2={width - padRight}
y2={y}
stroke={GRID_COLOR}
strokeWidth={0.5}
/>
<SvgText
x={padLeft - 4}
y={y + 3}
style={{ fontSize: CHART_FONT - 1, fill: AXIS_COLOR }}
textAnchor="end"
>
{yTickFormat(v)}
</SvgText>
</G>
);
})}
{/* Area + line */}
{areaPath ? <Path d={areaPath} fill={color} fillOpacity={0.12} /> : null}
{linePath ? <Path d={linePath} stroke={color} strokeWidth={1.4} fill="none" /> : null}
{/* Data points */}
{data.map((d, i) => (
<Circle key={`pt-${i}`} cx={xFor(i)} cy={yFor(d.value)} r={1.4} fill={color} />
))}
{/* X-axis labels */}
{data.map((d, i) =>
i % labelEvery === 0 || i === data.length - 1 ? (
<SvgText
key={`xl-${i}`}
x={xFor(i)}
y={height - 8}
style={{ fontSize: CHART_FONT - 1, fill: AXIS_COLOR }}
textAnchor="middle"
>
{d.label}
</SvgText>
) : null,
)}
</Svg>
);
}

View File

@@ -44,7 +44,12 @@ export function ClientListReport({ title, subtitle, branding, generatedAt, confi
generatedAt={generatedAt}
>
<View>
<Text style={styles.sectionSubtitle}>{cappedNotice}</Text>
{/* The intro line stays attached to the table header via the
table's own `minPresenceAhead` - section heading + capacity
notice + the first few rows always land on the same page. */}
<Text style={styles.sectionSubtitle} minPresenceAhead={80}>
{cappedNotice}
</Text>
<ReportTable
styles={styles}
headers={columns.map((c) => c.label)}

View File

@@ -3,6 +3,7 @@ import { View, Text } from '@react-pdf/renderer';
import { stageLabel } from '@/lib/constants';
import { formatCurrency } from '@/lib/utils/currency';
import { BrandedReportDocument } from './branded-document';
import { HorizontalBarChart, DonutChart, LineChart } from './charts';
import { makeReportStyles } from './styles';
import type { ReportBranding, DashboardReportConfig } from './types';
@@ -10,7 +11,7 @@ import type { ReportBranding, DashboardReportConfig } from './types';
* Data shape consumed by the dashboard report. Caller (the route
* handler) is responsible for fetching the dashboard service's
* outputs and packing them into this struct. Keeps the React-PDF
* tree pure no DB calls inside the document tree.
* tree pure - no DB calls inside the document tree.
*/
export interface DashboardReportData {
kpis?: {
@@ -42,6 +43,113 @@ export interface DashboardReportData {
stage: string;
lastContact: string | null;
}>;
/** Daily occupancy rate over the report window. Drives the
* occupancy-timeline line chart. */
occupancyTimeline?: Array<{ date: string; rate: number }>;
/** Lead-source mix: count of NEW interests grouped by source for
* the donut variant. Distinct from `sourceConversion` which is
* cumulative + win-rate per source. */
leadSourceMix?: Array<{ source: string; count: number }>;
/** Pipeline value broken down by stage with the weighted forecast
* (close-probability * gross) per stage. */
pipelineValueBreakdown?: Array<{
stage: string;
gross: number;
weighted: number;
deals: number;
currency: string;
}>;
/** % of interests that advance from each stage to the next, plus
* the absolute count moved + dropped. */
stageConversionRates?: Array<{
fromStage: string;
toStage: string;
advanced: number;
dropped: number;
rate: number;
}>;
/** Median + mean days from new-enquiry to contract-signed; null
* buckets mean not-enough-data. */
avgSalesCycle?: {
sampleSize: number;
medianDays: number | null;
meanDays: number | null;
};
/** Total weighted forecast snapshot - single dollar figure. */
revenueForecast?: {
grossValue: number;
weightedValue: number;
currency: string;
};
/** Inquiry-inbox triage breakdown over the report window. */
inquiryInboxSummary?: Array<{
kind: string;
triageState: string;
count: number;
}>;
/** Per-assignee reminder activity over the window. */
remindersSummary?: Array<{
assignee: string;
open: number;
completed: number;
}>;
/** Top berths ranked by active-interest count + heat tier. */
berthDemandRanking?: Array<{
mooringNumber: string;
interestCount: number;
tier: 'A' | 'B' | 'C' | 'D';
}>;
/** Pulse-tier histogram across active interests. */
dealPulseDistribution?: Array<{ tier: string; count: number }>;
/** Country-of-origin rollup for the active client book. */
clientCountryDistribution?: Array<{ country: string; count: number }>;
/** Compact recent-activity log for the print snapshot. */
recentActivity?: Array<{
when: string;
actor: string | null;
summary: string;
}>;
/** Clients added during the report window. */
newClientsInPeriod?: Array<{
name: string;
createdAt: string;
source: string | null;
}>;
/** Interests opened during the report window. */
newInterestsInPeriod?: Array<{
clientName: string;
stage: string;
source: string | null;
createdAt: string;
}>;
/** Berths transitioned to Sold status during the report window. */
berthsSoldInPeriod?: Array<{
mooringNumber: string;
soldAt: string;
}>;
/** Deposit payments received during the report window. */
depositsReceivedInPeriod?: Array<{
clientName: string;
amount: number;
currency: string;
paidAt: string;
}>;
/** All signing documents marked completed during the report window. */
signedDocumentsInPeriod?: Array<{
type: string;
title: string;
signedAt: string;
}>;
/** Contracts marked completed during the report window. */
contractsSignedInPeriod?: Array<{
type: string;
title: string;
signedAt: string;
}>;
/** Stub markers for sections whose data resolvers ship later - the
* PDF renders a "data resolver pending" placeholder so the
* surface is discoverable even before the backend lands. */
stubsPending?: string[];
}
interface DashboardReportProps {
@@ -61,7 +169,7 @@ interface DashboardReportProps {
*
* Chart-style widgets render as tables here (counts, percentages,
* cohort breakdowns). The deliberate choice trades a chart's at-a-
* glance shape for the actual numbers a printed report is for
* glance shape for the actual numbers - a printed report is for
* later reference / sharing, not in-the-moment dashboard scanning,
* and the table format is fully accessible to screen readers and
* holds up if the PDF is OCR-scanned downstream.
@@ -88,8 +196,14 @@ export function DashboardReport({
subtitle={subtitle ?? `Dashboard summary${dateRangeLine ? ` · ${dateRangeLine}` : ''}`}
generatedAt={generatedAt}
>
{/* Every section uses `wrap={false}` so the title + description
+ table are guaranteed to land on the same page. If a section
doesn't fit in the remaining space on the current page, the
whole block moves to the next one - avoids the "section
heading orphaned at the bottom of a page with the data on
the next" layout the rep hit on 2026-05-22. */}
{include('kpi_overview') && data.kpis ? (
<View>
<View wrap={false}>
<Text style={styles.sectionTitle}>Key metrics</Text>
<View style={styles.kpiGrid}>
<View style={styles.kpiCard}>
@@ -119,7 +233,7 @@ export function DashboardReport({
) : null}
{include('pipeline_funnel') && data.pipelineCounts ? (
<View>
<View wrap={false}>
<Text style={styles.sectionTitle}>Pipeline funnel</Text>
<Text style={styles.sectionSubtitle}>Active interests grouped by pipeline stage.</Text>
<SimpleTable
@@ -132,7 +246,7 @@ export function DashboardReport({
) : null}
{include('berth_status') && data.berthStatus ? (
<View>
<View wrap={false}>
<Text style={styles.sectionTitle}>Berth status</Text>
<Text style={styles.sectionSubtitle}>Current distribution across the marina.</Text>
<SimpleTable
@@ -166,7 +280,7 @@ export function DashboardReport({
) : null}
{include('source_conversion') && data.sourceConversion ? (
<View>
<View wrap={false}>
<Text style={styles.sectionTitle}>Source conversion</Text>
<Text style={styles.sectionSubtitle}>
Interest counts grouped by lead source, with win rate per source.
@@ -186,8 +300,440 @@ export function DashboardReport({
</View>
) : null}
{include('pipeline_funnel_chart') && data.pipelineCounts ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Pipeline funnel</Text>
<Text style={styles.sectionSubtitle}>
Active interests per pipeline stage. Bar length is proportional to count.
</Text>
<HorizontalBarChart
width={500}
data={data.pipelineCounts.map((row) => ({
label: stageLabel(row.stage),
value: row.count,
}))}
primaryColor={branding.primaryColor}
/>
</View>
) : null}
{include('berth_status_donut') && data.berthStatus ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Berth status</Text>
<Text style={styles.sectionSubtitle}>
Current distribution across the marina, donut variant.
</Text>
<DonutChart
width={420}
centerLabel={`${data.berthStatus.total}`}
data={[
{ label: 'Available', value: data.berthStatus.available, color: '#0d9488' },
{ label: 'Under offer', value: data.berthStatus.underOffer, color: '#f59e0b' },
{ label: 'Sold', value: data.berthStatus.sold, color: '#0284c7' },
{ label: 'Maintenance', value: data.berthStatus.maintenance, color: '#94a3b8' },
]}
/>
</View>
) : null}
{include('source_conversion_chart') && data.sourceConversion ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Source conversion</Text>
<Text style={styles.sectionSubtitle}>
Win rate per lead source. Faint bar shows total volume, primary bar shows won deals.
</Text>
<HorizontalBarChart
width={500}
data={data.sourceConversion.map((row) => ({
label: row.source,
value: row.won,
secondaryValue: row.total,
}))}
primaryColor={branding.primaryColor}
formatValue={(n) => `${n}`}
/>
</View>
) : null}
{include('lead_source_donut') && data.leadSourceMix && data.leadSourceMix.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Lead source mix</Text>
<Text style={styles.sectionSubtitle}>
Share of new interests by lead source over the report window.
</Text>
<DonutChart
width={420}
centerLabel={`${data.leadSourceMix.reduce((s, r) => s + r.count, 0)}`}
data={data.leadSourceMix.map((r) => ({ label: r.source, value: r.count }))}
/>
</View>
) : null}
{include('occupancy_timeline_chart') &&
data.occupancyTimeline &&
data.occupancyTimeline.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Occupancy timeline</Text>
<Text style={styles.sectionSubtitle}>
Daily berth occupancy rate over the report window.
</Text>
<LineChart
width={500}
data={data.occupancyTimeline.map((p) => ({
label: new Date(p.date).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
}),
value: p.rate,
}))}
color={branding.primaryColor}
/>
</View>
) : null}
{include('pipeline_value_breakdown') &&
data.pipelineValueBreakdown &&
data.pipelineValueBreakdown.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Pipeline value breakdown</Text>
<Text style={styles.sectionSubtitle}>
Gross + weighted pipeline value per stage, weighted by close probability.
</Text>
<SimpleTable
styles={styles}
headers={['Stage', 'Deals', 'Gross', 'Weighted']}
widths={[40, 15, 22, 23]}
rows={data.pipelineValueBreakdown.map((row) => [
stageLabel(row.stage),
String(row.deals),
formatCurrency(String(row.gross), row.currency, { maxFractionDigits: 0 }),
formatCurrency(String(row.weighted), row.currency, { maxFractionDigits: 0 }),
])}
/>
</View>
) : null}
{include('stage_conversion_rates') &&
data.stageConversionRates &&
data.stageConversionRates.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Stage conversion rates</Text>
<Text style={styles.sectionSubtitle}>
% of interests that advance from each pipeline stage to the next.
</Text>
<SimpleTable
styles={styles}
headers={['From → To', 'Advanced', 'Dropped', 'Rate']}
widths={[42, 18, 18, 22]}
rows={data.stageConversionRates.map((row) => [
`${stageLabel(row.fromStage)}${stageLabel(row.toStage)}`,
String(row.advanced),
String(row.dropped),
`${(row.rate * 100).toFixed(1)}%`,
])}
/>
</View>
) : null}
{include('reminders_summary') && data.remindersSummary && data.remindersSummary.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Reminders summary</Text>
<Text style={styles.sectionSubtitle}>
Open + completed reminders per assignee over the report window.
</Text>
<SimpleTable
styles={styles}
headers={['Assignee', 'Open', 'Completed']}
widths={[60, 20, 20]}
rows={data.remindersSummary.map((r) => [
r.assignee,
String(r.open),
String(r.completed),
])}
/>
</View>
) : null}
{include('inquiry_inbox_summary') &&
data.inquiryInboxSummary &&
data.inquiryInboxSummary.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Inbound inquiry summary</Text>
<Text style={styles.sectionSubtitle}>
Public-site submissions received during the report window, grouped by triage state.
</Text>
<SimpleTable
styles={styles}
headers={['Kind', 'Triage state', 'Count']}
widths={[40, 40, 20]}
rows={data.inquiryInboxSummary.map((r) => [r.kind, r.triageState, String(r.count)])}
/>
</View>
) : null}
{include('revenue_forecast') && data.revenueForecast ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Revenue forecast snapshot</Text>
<Text style={styles.sectionSubtitle}>
Total pipeline value, weighted by close probability per stage.
</Text>
<View style={styles.kpiGrid}>
<View style={styles.kpiCard}>
<Text style={styles.kpiLabel}>Gross</Text>
<Text style={styles.kpiValue}>
{formatCurrency(
String(data.revenueForecast.grossValue),
data.revenueForecast.currency,
{ maxFractionDigits: 0 },
)}
</Text>
<Text style={styles.kpiSubvalue}>Sum of primary-berth prices, active deals</Text>
</View>
<View style={styles.kpiCard}>
<Text style={styles.kpiLabel}>Weighted forecast</Text>
<Text style={styles.kpiValue}>
{formatCurrency(
String(data.revenueForecast.weightedValue),
data.revenueForecast.currency,
{ maxFractionDigits: 0 },
)}
</Text>
<Text style={styles.kpiSubvalue}>Gross x close-probability per stage</Text>
</View>
</View>
</View>
) : null}
{include('avg_sales_cycle') && data.avgSalesCycle ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Average sales cycle</Text>
<Text style={styles.sectionSubtitle}>
Days from new-enquiry to contract-signed across {data.avgSalesCycle.sampleSize} closed
deals.
</Text>
<View style={styles.kpiGrid}>
<View style={styles.kpiCard}>
<Text style={styles.kpiLabel}>Median</Text>
<Text style={styles.kpiValue}>
{data.avgSalesCycle.medianDays !== null
? `${data.avgSalesCycle.medianDays} d`
: 'n/a'}
</Text>
</View>
<View style={styles.kpiCard}>
<Text style={styles.kpiLabel}>Mean</Text>
<Text style={styles.kpiValue}>
{data.avgSalesCycle.meanDays !== null ? `${data.avgSalesCycle.meanDays} d` : 'n/a'}
</Text>
</View>
</View>
</View>
) : null}
{include('berth_demand_ranking') &&
data.berthDemandRanking &&
data.berthDemandRanking.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Berth demand ranking</Text>
<Text style={styles.sectionSubtitle}>
Top berths by active-interest count + heat tier (A = strongest signal).
</Text>
<SimpleTable
styles={styles}
headers={['Mooring', 'Active interests', 'Tier']}
widths={[40, 40, 20]}
rows={data.berthDemandRanking.map((row) => [
row.mooringNumber,
String(row.interestCount),
row.tier,
])}
/>
</View>
) : null}
{include('deal_pulse_distribution') &&
data.dealPulseDistribution &&
data.dealPulseDistribution.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Deal pulse distribution</Text>
<Text style={styles.sectionSubtitle}>
Counts of active interests in each pulse tier (hot / warm / cool / cold).
</Text>
<SimpleTable
styles={styles}
headers={['Tier', 'Count']}
widths={[70, 30]}
rows={data.dealPulseDistribution.map((row) => [row.tier, String(row.count)])}
/>
</View>
) : null}
{include('client_country_distribution') &&
data.clientCountryDistribution &&
data.clientCountryDistribution.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Client country distribution</Text>
<Text style={styles.sectionSubtitle}>
Active-client counts grouped by nationality (ISO 3166-1 alpha-2).
</Text>
<SimpleTable
styles={styles}
headers={['Country', 'Clients']}
widths={[70, 30]}
rows={data.clientCountryDistribution.map((row) => [row.country, String(row.count)])}
/>
</View>
) : null}
{include('recent_activity') && data.recentActivity && data.recentActivity.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Recent activity</Text>
<Text style={styles.sectionSubtitle}>
Last entries from the audit log, compact snapshot.
</Text>
<SimpleTable
styles={styles}
headers={['When', 'Who', 'Summary']}
widths={[18, 22, 60]}
rows={data.recentActivity.map((row) => [
new Date(row.when).toLocaleString('en-GB'),
row.actor ?? 'system',
row.summary,
])}
/>
</View>
) : null}
{include('new_clients_period') &&
data.newClientsInPeriod &&
data.newClientsInPeriod.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>New clients (in period)</Text>
<Text style={styles.sectionSubtitle}>
Clients added during the report window with their lead source. Capped at 50 rows; full
list lives in the client export.
</Text>
<SimpleTable
styles={styles}
headers={['Client', 'Source', 'Added']}
widths={[55, 25, 20]}
rows={data.newClientsInPeriod.map((r) => [
r.name,
r.source ?? '-',
new Date(r.createdAt).toLocaleDateString('en-GB'),
])}
/>
</View>
) : 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
their lead source.
</Text>
<SimpleTable
styles={styles}
headers={['Client', 'Stage', 'Source', 'Opened']}
widths={[40, 22, 18, 20]}
rows={data.newInterestsInPeriod.map((r) => [
r.clientName,
stageLabel(r.stage),
r.source ?? '-',
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
styles={styles}
headers={['Mooring', 'Sold on']}
widths={[50, 50]}
rows={data.berthsSoldInPeriod.map((r) => [
r.mooringNumber,
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
styles={styles}
headers={['Type', 'Title', 'Signed on']}
widths={[20, 55, 25]}
rows={data.signedDocumentsInPeriod.map((r) => [
r.type,
r.title,
new Date(r.signedAt).toLocaleDateString('en-GB'),
])}
/>
</View>
) : null}
{include('contracts_signed_period') &&
data.contractsSignedInPeriod &&
data.contractsSignedInPeriod.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Contracts signed (in period)</Text>
<Text style={styles.sectionSubtitle}>
Contract documents that completed signing during the report window.
</Text>
<SimpleTable
styles={styles}
headers={['Title', 'Signed on']}
widths={[75, 25]}
rows={data.contractsSignedInPeriod.map((r) => [
r.title,
new Date(r.signedAt).toLocaleDateString('en-GB'),
])}
/>
</View>
) : null}
{include('deposits_received_period') &&
data.depositsReceivedInPeriod &&
data.depositsReceivedInPeriod.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Deposits received (in period)</Text>
<Text style={styles.sectionSubtitle}>
Deposit payments received during the report window, with client + $ amount.
</Text>
<SimpleTable
styles={styles}
headers={['Client', 'Amount', 'Date']}
widths={[55, 25, 20]}
rows={data.depositsReceivedInPeriod.map((r) => [
r.clientName,
formatCurrency(String(r.amount), r.currency, { maxFractionDigits: 0 }),
new Date(r.paidAt).toLocaleDateString('en-GB'),
])}
/>
</View>
) : null}
{include('hot_deals') && data.hotDeals && data.hotDeals.length > 0 ? (
<View>
<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.
@@ -205,12 +751,32 @@ export function DashboardReport({
/>
</View>
) : null}
{/* Pending-resolver placeholder. Lets the user see that a
selected widget IS recognised by the renderer even when its
data resolver hasn't shipped yet - keeps the surface
discoverable instead of silently dropping the request. */}
{data.stubsPending && data.stubsPending.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Coming soon</Text>
<Text style={styles.sectionSubtitle}>
These sections are wired into the export dialog but their data resolvers ship in the
next iteration. The choice persists, so the next report run will include them
automatically:
</Text>
{data.stubsPending.map((id) => (
<Text key={id} style={styles.kpiSubvalue}>
· {id}
</Text>
))}
</View>
) : null}
</BrandedReportDocument>
);
}
function pct(n: number, total: number): string {
return total > 0 ? `${((n / total) * 100).toFixed(1)}%` : '';
return total > 0 ? `${((n / total) * 100).toFixed(1)}%` : '-';
}
interface SimpleTableProps {

View File

@@ -55,7 +55,9 @@ export function InterestListReport({
generatedAt={generatedAt}
>
<View>
<Text style={styles.sectionSubtitle}>{cappedNotice}</Text>
<Text style={styles.sectionSubtitle} minPresenceAhead={80}>
{cappedNotice}
</Text>
<ReportTable
styles={styles}
headers={columns.map((c) => c.label)}

View File

@@ -4,6 +4,7 @@ import { createElement } from 'react';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { eq } from 'drizzle-orm';
import { absolutizeBrandingUrl } from '@/lib/branding/url';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { NotFoundError } from '@/lib/errors';
@@ -84,8 +85,15 @@ async function resolveBranding(portId: string): Promise<ReportBranding> {
});
if (!portRow) throw new NotFoundError('Port');
const cfg = await getPortBrandingConfig(portId);
// `getPortBrandingConfig` returns a path-only URL (e.g. `/uploads/foo.png`)
// when the original was uploaded against a localhost/LAN host - that's
// the right shape for client-side surfaces that resolve against the
// current origin. `@react-pdf/renderer` runs in the Node process and
// needs an absolute URL to fetch the image; otherwise `<Image>` falls
// back to the placeholder and the report ships logo-less. Absolutize
// here against APP_URL the same way email surfaces do.
return {
logoUrl: cfg.logoUrl,
logoUrl: absolutizeBrandingUrl(cfg.logoUrl),
primaryColor: cfg.primaryColor,
portName: portRow.name,
};
@@ -145,7 +153,7 @@ function pickDocument(
data: data.interests,
});
default: {
// Exhaustiveness check surfaces a compile error if a new
// Exhaustiveness check - surfaces a compile error if a new
// ReportConfig variant is added without a matching case here.
const _exhaustive: never = cfg;
throw new Error(`Unsupported report kind: ${(_exhaustive as { kind: string }).kind}`);

View File

@@ -15,7 +15,7 @@ interface ReportTableProps {
* report kind. Header row is gray-100; even rows are white; odd rows
* tinted #fafafa so scanning a 50-row page doesn't lose the eye-line.
*
* The component is intentionally untyped beyond strings every
* The component is intentionally untyped beyond strings - every
* report component formats numbers / dates / currencies to strings
* before passing rows in. Keeps the table primitive deliberately
* dumb (no formatting decisions live here).
@@ -23,7 +23,12 @@ interface ReportTableProps {
export function ReportTable({ styles, headers, widths, rows }: ReportTableProps) {
return (
<View style={styles.table}>
<View style={styles.tableHeader}>
{/* `minPresenceAhead` reserves space for at least ~60pt of rows
below the header before allowing the header itself to render
on a page. Without it, the table header could land at the very
bottom of a page with the first row pushed to the next - same
orphan problem the dashboard sections used to have. */}
<View style={styles.tableHeader} minPresenceAhead={60}>
{headers.map((header, i) => (
<Text key={`h-${i}`} style={{ ...styles.tableHeaderCell, width: `${widths[i]}%` }}>
{header}

View File

@@ -9,7 +9,7 @@ import type { ReportBranding } from './types';
*
* Color contrast is computed against the supplied primary so heading
* text on the accent bar reads at AA. We don't try to drive every
* surface off the primary only the accent stripe, headings, and
* surface off the primary - only the accent stripe, headings, and
* footer separator. Body copy stays slate-700; surfaces stay white +
* a subtle gray. Single primary keeps the report looking intentional
* rather than chromatically chaotic.

View File

@@ -23,7 +23,7 @@ export interface ReportBranding {
export interface DashboardReportConfig {
kind: 'dashboard';
/** Widget ids to include keyed against `widget-registry.ts`. */
/** Widget ids to include - keyed against `widget-registry.ts`. */
widgetIds: string[];
/** Date range applied to dashboard data fetches. */
dateFrom?: string;
@@ -34,7 +34,7 @@ export interface ClientListReportConfig {
kind: 'clients';
/** Optional column override; defaults to a canonical set. */
columns?: string[];
/** Optional filter snapshot same shape as `/api/v1/clients`. */
/** Optional filter snapshot - same shape as `/api/v1/clients`. */
filters?: Record<string, unknown>;
}

View File

@@ -72,7 +72,7 @@ const STATUS_TONE: Record<string, BadgeTone> = {
};
function dim(ft?: string | null, m?: string | null, minimum?: boolean | null): string {
if (!ft && !m) return '';
if (!ft && !m) return '-';
const parts = [ft ? `${ft}ft` : null, m ? `${m}m` : null].filter(Boolean);
return `${parts.join(' / ')}${minimum ? ' (min)' : ''}`;
}
@@ -95,23 +95,23 @@ export function BerthSpecPdf({
maintenance,
}: BerthSpecPdfProps) {
const status = (berth.status ?? 'available').toLowerCase();
const docMeta = `Mooring ${berth.mooringNumber ?? ''}${berth.area ? ` · ${berth.area}` : ''}`;
const docMeta = `Mooring ${berth.mooringNumber ?? '-'}${berth.area ? ` · ${berth.area}` : ''}`;
return (
<DocumentShell
portName={portName}
docTitle={`Berth Spec ${berth.mooringNumber ?? ''}`}
docTitle={`Berth Spec - ${berth.mooringNumber ?? '-'}`}
docMeta={docMeta}
logoBuffer={logoBuffer}
>
<Section title="Overview">
<KeyValueGrid
rows={[
{ label: 'Mooring', value: berth.mooringNumber ?? '' },
{ label: 'Area', value: berth.area ?? '' },
{ label: 'Mooring', value: berth.mooringNumber ?? '-' },
{ label: 'Area', value: berth.area ?? '-' },
{ label: 'Status', value: status.replace('_', ' ') },
{ label: 'Nominal boat size', value: berth.nominalBoatSize ?? '' },
{ label: 'Bow facing', value: berth.bowFacing ?? '' },
{ label: 'Side pontoon', value: berth.sidePontoon ?? '' },
{ label: 'Nominal boat size', value: berth.nominalBoatSize ?? '-' },
{ label: 'Bow facing', value: berth.bowFacing ?? '-' },
{ label: 'Side pontoon', value: berth.sidePontoon ?? '-' },
]}
/>
<Badge
@@ -142,9 +142,9 @@ export function BerthSpecPdf({
rows={[
{ label: 'Price', value: fmtPrice(berth.price, berth.priceCurrency) },
{ label: 'Tenure type', value: berth.tenureType ?? 'permanent' },
{ label: 'Tenure years', value: berth.tenureYears ?? '' },
{ label: 'Tenure start', value: berth.tenureStartDate ?? '' },
{ label: 'Tenure end', value: berth.tenureEndDate ?? '' },
{ label: 'Tenure years', value: berth.tenureYears ?? '-' },
{ label: 'Tenure start', value: berth.tenureStartDate ?? '-' },
{ label: 'Tenure end', value: berth.tenureEndDate ?? '-' },
]}
/>
</Section>
@@ -152,26 +152,26 @@ export function BerthSpecPdf({
<Section title="Infrastructure">
<KeyValueGrid
rows={[
{ label: 'Mooring type', value: berth.mooringType ?? '' },
{ label: 'Mooring type', value: berth.mooringType ?? '-' },
{
label: 'Power',
value: berth.powerCapacity
? `${berth.powerCapacity}${berth.voltage ? ` / ${berth.voltage}V` : ''}`
: '',
: '-',
},
{
label: 'Cleat',
value: berth.cleatType
? `${berth.cleatType}${berth.cleatCapacity ? ` (${berth.cleatCapacity})` : ''}`
: '',
: '-',
},
{
label: 'Bollard',
value: berth.bollardType
? `${berth.bollardType}${berth.bollardCapacity ? ` (${berth.bollardCapacity})` : ''}`
: '',
: '-',
},
{ label: 'Access', value: berth.access ?? '' },
{ label: 'Access', value: berth.access ?? '-' },
]}
/>
</Section>
@@ -182,15 +182,15 @@ export function BerthSpecPdf({
>
<DataTable<BerthSpecWaitingRow>
columns={[
{ header: '#', flex: 0.5, render: (w) => String(w.position ?? '') },
{ header: '#', flex: 0.5, render: (w) => String(w.position ?? '-') },
{ header: 'Client', flex: 3, render: (w) => w.clientName },
{
header: 'Priority',
flex: 1,
render: (w) =>
w.priority === 'high' ? <Badge text="High" tone="warning" /> : (w.priority ?? ''),
w.priority === 'high' ? <Badge text="High" tone="warning" /> : (w.priority ?? '-'),
},
{ header: 'Notes', flex: 3, render: (w) => w.notes ?? '' },
{ header: 'Notes', flex: 3, render: (w) => w.notes ?? '-' },
]}
rows={waitingList}
emptyMessage="No clients on waiting list."
@@ -200,9 +200,9 @@ export function BerthSpecPdf({
<Section title={`Maintenance log (${maintenance.length})`}>
<DataTable<BerthSpecMaintenanceRow>
columns={[
{ header: 'Date', flex: 1.5, render: (m) => m.performedDate ?? '' },
{ header: 'Category', flex: 1.5, render: (m) => m.category ?? '' },
{ header: 'Description', flex: 4, render: (m) => m.description ?? '' },
{ header: 'Date', flex: 1.5, render: (m) => m.performedDate ?? '-' },
{ header: 'Category', flex: 1.5, render: (m) => m.category ?? '-' },
{ header: 'Description', flex: 4, render: (m) => m.description ?? '-' },
{
header: 'Cost',
flex: 1.5,

View File

@@ -48,7 +48,7 @@ export interface ClientSummaryPdfProps {
}
function fmtDate(d: Date | string | null | undefined): string {
if (!d) return '';
if (!d) return '-';
return new Date(d).toISOString().slice(0, 10);
}
@@ -58,7 +58,7 @@ function yachtDims(y: YachtRow): string {
y.widthFt ? `${y.widthFt}ft beam` : null,
y.draftFt ? `${y.draftFt}ft draft` : null,
].filter(Boolean);
return parts.length ? parts.join(' · ') : '';
return parts.length ? parts.join(' · ') : '-';
}
export function ClientSummaryPdf({
@@ -85,9 +85,9 @@ export function ClientSummaryPdf({
<Section title="Client">
<KeyValueGrid
rows={[
{ label: 'Full name', value: client.fullName ?? '' },
{ label: 'Nationality', value: client.nationality ?? '' },
{ label: 'Source', value: client.source ?? '' },
{ label: 'Full name', value: client.fullName ?? '-' },
{ label: 'Nationality', value: client.nationality ?? '-' },
{ label: 'Source', value: client.source ?? '-' },
{ label: 'Added', value: fmtDate(client.createdAt) },
]}
/>
@@ -109,7 +109,7 @@ export function ClientSummaryPdf({
header: 'Primary',
flex: 1,
align: 'center',
render: (c) => (c.isPrimary ? <Badge text="Yes" tone="success" /> : ''),
render: (c) => (c.isPrimary ? <Badge text="Yes" tone="success" /> : '-'),
},
]}
rows={contacts}
@@ -132,8 +132,8 @@ export function ClientSummaryPdf({
<DataTable<InterestRow>
columns={[
{ header: 'Stage', flex: 2, render: (i) => i.pipelineStage ?? 'open' },
{ header: 'Berth', flex: 1, render: (i) => i.berthMooringNumber ?? '' },
{ header: 'Category', flex: 2, render: (i) => i.leadCategory ?? '' },
{ header: 'Berth', flex: 1, render: (i) => i.berthMooringNumber ?? '-' },
{ header: 'Category', flex: 2, render: (i) => i.leadCategory ?? '-' },
{ header: 'Created', flex: 1.5, render: (i) => fmtDate(i.createdAt) },
]}
rows={interests}
@@ -147,7 +147,7 @@ export function ClientSummaryPdf({
{ header: 'When', flex: 1.5, render: (a) => fmtDate(a.createdAt) },
{ header: 'Action', flex: 1.5, render: (a) => a.action },
{ header: 'Entity', flex: 1.5, render: (a) => a.entityType },
{ header: 'Field', flex: 1.5, render: (a) => a.fieldChanged ?? '' },
{ header: 'Field', flex: 1.5, render: (a) => a.fieldChanged ?? '-' },
]}
rows={activity}
emptyMessage="No recent activity."

View File

@@ -61,7 +61,7 @@ const STAGE_TONE: Record<string, BadgeTone> = {
};
function fmt(d: Date | string | null | undefined): string {
if (!d) return '';
if (!d) return '-';
return new Date(d).toISOString().slice(0, 10);
}
@@ -88,33 +88,33 @@ export function InterestSummaryPdf({
<KeyValueGrid
rows={[
{ label: 'Pipeline stage', value: stage.replace('_', ' ') },
{ label: 'Lead category', value: interest.leadCategory ?? '' },
{ label: 'Source', value: interest.source ?? '' },
{ label: 'EOI status', value: interest.eoiStatus ?? '' },
{ label: 'Contract status', value: interest.contractStatus ?? '' },
{ label: 'Deposit status', value: interest.depositStatus ?? '' },
{ label: 'Lead category', value: interest.leadCategory ?? '-' },
{ label: 'Source', value: interest.source ?? '-' },
{ label: 'EOI status', value: interest.eoiStatus ?? '-' },
{ label: 'Contract status', value: interest.contractStatus ?? '-' },
{ label: 'Deposit status', value: interest.depositStatus ?? '-' },
]}
/>
<Badge text={stage.replace('_', ' ').toUpperCase()} tone={STAGE_TONE[stage] ?? 'neutral'} />
</Section>
<Section title="Client">
<KeyValueGrid rows={[{ label: 'Name', value: client.fullName ?? '' }]} />
<KeyValueGrid rows={[{ label: 'Name', value: client.fullName ?? '-' }]} />
</Section>
{yacht ? (
<Section title="Yacht">
<KeyValueGrid
rows={[
{ label: 'Name', value: yacht.name ?? '' },
{ label: 'Name', value: yacht.name ?? '-' },
{
label: 'Length',
value: yacht.lengthFt
? `${yacht.lengthFt}ft${yacht.lengthM ? ` / ${yacht.lengthM}m` : ''}`
: '',
: '-',
},
{ label: 'Beam', value: yacht.widthFt ? `${yacht.widthFt}ft` : '' },
{ label: 'Draft', value: yacht.draftFt ? `${yacht.draftFt}ft` : '' },
{ label: 'Beam', value: yacht.widthFt ? `${yacht.widthFt}ft` : '-' },
{ label: 'Draft', value: yacht.draftFt ? `${yacht.draftFt}ft` : '-' },
]}
/>
</Section>
@@ -124,16 +124,16 @@ export function InterestSummaryPdf({
<Section title="Primary berth">
<KeyValueGrid
rows={[
{ label: 'Mooring', value: berth.mooringNumber ?? '' },
{ label: 'Area', value: berth.area ?? '' },
{ label: 'Length', value: berth.lengthFt ? `${berth.lengthFt}ft` : '' },
{ label: 'Mooring', value: berth.mooringNumber ?? '-' },
{ label: 'Area', value: berth.area ?? '-' },
{ label: 'Length', value: berth.lengthFt ? `${berth.lengthFt}ft` : '-' },
{
label: 'Price',
value: berth.price
? `${berth.priceCurrency ?? 'USD'} ${Number(berth.price).toLocaleString()}`
: '',
: '-',
},
{ label: 'Status', value: berth.status ?? '' },
{ label: 'Status', value: berth.status ?? '-' },
]}
/>
</Section>
@@ -162,9 +162,9 @@ export function InterestSummaryPdf({
}>
columns={[
{ header: 'When', flex: 1.5, render: (e) => fmt(e.createdAt) },
{ header: 'Action', flex: 2, render: (e) => e.action ?? '' },
{ header: 'Entity', flex: 1.5, render: (e) => e.entityType ?? '' },
{ header: 'Field', flex: 1.5, render: (e) => e.fieldChanged ?? '' },
{ header: 'Action', flex: 2, render: (e) => e.action ?? '-' },
{ header: 'Entity', flex: 1.5, render: (e) => e.entityType ?? '-' },
{ header: 'Field', flex: 1.5, render: (e) => e.fieldChanged ?? '-' },
]}
rows={timeline}
emptyMessage="No timeline events."

View File

@@ -38,7 +38,7 @@ export function ParentCompanyExpensePdf({
}: ParentCompanyExpensePdfProps) {
const meta =
dateFrom || dateTo
? `Range: ${dateFrom ?? ''}${dateTo ?? 'today'} · ${rows.length} entries`
? `Range: ${dateFrom ?? '-'}${dateTo ?? 'today'} · ${rows.length} entries`
: `${rows.length} entries`;
return (
@@ -64,7 +64,7 @@ export function ParentCompanyExpensePdf({
{
label: 'Currency conversion',
value:
'EUR exchange rate unavailable at generation time amounts shown at 1:1 USD:EUR fallback.',
'EUR exchange rate unavailable at generation time - amounts shown at 1:1 USD:EUR fallback.',
},
]}
layout="stacked"

View File

@@ -65,7 +65,7 @@ function busiestDay(logs: RowShape[]): string {
bestCount = count;
}
}
return best ? `${best} (${bestCount})` : '';
return best ? `${best} (${bestCount})` : '-';
}
export function ActivityReportPdf({
@@ -76,10 +76,10 @@ export function ActivityReportPdf({
dateTo,
}: ActivityReportPdfProps) {
const topActions = topEntries(data.summary, 5);
const topAction = topActions[0]?.key ?? '';
const topAction = topActions[0]?.key ?? '-';
const meta =
dateFrom || dateTo
? `Range: ${dateFrom ?? ''}${dateTo ?? 'today'} · ${data.logs.length} events`
? `Range: ${dateFrom ?? '-'}${dateTo ?? 'today'} · ${data.logs.length} events`
: `Last 30 days · ${data.logs.length} events`;
const chartData = bucketByDay(data.logs);
@@ -127,7 +127,7 @@ export function ActivityReportPdf({
},
{ header: 'Action', flex: 1.5, render: (r) => r.action },
{ header: 'Entity', flex: 1.5, render: (r) => r.entityType },
{ header: 'User', flex: 1.5, render: (r) => r.userId ?? '' },
{ header: 'User', flex: 1.5, render: (r) => r.userId ?? '-' },
]}
rows={tableRows}
emptyMessage="No activity in the selected period."

View File

@@ -77,7 +77,7 @@ export function OccupancyReportPdf({ portName, logoBuffer, data }: OccupancyRepo
flex: 1,
align: 'right',
render: (r) =>
data.totalBerths > 0 ? `${((r.count / data.totalBerths) * 100).toFixed(1)}%` : '',
data.totalBerths > 0 ? `${((r.count / data.totalBerths) * 100).toFixed(1)}%` : '-',
},
]}
rows={entries.map(([status, count]) => ({ status, count }))}

View File

@@ -54,7 +54,7 @@ export function PipelineReportPdf({ portName, logoBuffer, data }: PipelineReport
{ label: 'Completed', value: completed.toLocaleString() },
{
label: 'Top stage',
value: topStage ? `${stageLabel(topStage[0])} (${topStage[1]})` : '',
value: topStage ? `${stageLabel(topStage[0])} (${topStage[1]})` : '-',
},
{ label: 'Cancelled / Lost', value: cancelled.toLocaleString() },
]}
@@ -74,7 +74,7 @@ export function PipelineReportPdf({ portName, logoBuffer, data }: PipelineReport
header: 'Berth price',
flex: 2,
align: 'right',
render: (r) => (r.berthPrice ? Number(r.berthPrice).toLocaleString() : ''),
render: (r) => (r.berthPrice ? Number(r.berthPrice).toLocaleString() : '-'),
},
]}
rows={data.topInterests}

View File

@@ -52,7 +52,7 @@ export function RevenueReportPdf({
const totalCompleted = Number(data.totalCompleted);
const totalForecast = Number(data.totalForecast);
const subtotal = rows.reduce((s, r) => s + r.amount, 0);
const meta = dateFrom || dateTo ? `Range: ${dateFrom ?? ''}${dateTo ?? 'today'}` : 'All time';
const meta = dateFrom || dateTo ? `Range: ${dateFrom ?? '-'}${dateTo ?? 'today'}` : 'All time';
return (
<DocumentShell