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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? ''));
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
329
src/lib/pdf/reports/charts.tsx
Normal file
329
src/lib/pdf/reports/charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 }))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user