feat(reports): client / berth / interest list-export PDF reports (phase B)
Extends the report exporter with three list-style report kinds —
clients, berths, interests. Each shares the BrandedReportDocument
layout + the new ReportTable primitive (zebra-striped rows,
proportional widths, no-break rows to keep records together across
page boundaries).
Data fetchers in `src/lib/services/list-report-data.service.ts`:
- resolveClientReportData: clients table joined to per-client
primary email + phone via DISTINCT-style subqueries (matches the
canonical listClients ordering: is_primary DESC, created_at DESC
per channel).
- resolveBerthReportData: berths table, default sort by mooring
number for printed familiarity.
- resolveInterestReportData: interests left-joined to clients +
primary berth, sort by updatedAt desc.
All three cap at 1 000 rows per export with a clear "Showing top N
of <total>" notice rendered when the cap is hit. Above that, the PDF
becomes unreadable (hundreds of pages); reps wanting larger exports
use CSV.
Route schema widened to a 4-arm discriminated union; the dispatch
switch in render-report.ts uses `satisfies` for compile-time variant
narrowing and a `_exhaustive: never` check at the bottom.
UI: each list page (BerthList, ClientList, InterestList) gains an
ExportListPdfButton next to the existing ColumnPicker. Permission-
gated client-side on reports.export; server route re-enforces.
Tests: 3 new render fixtures (1 per kind), all hit the same
%PDF-magic + byte-length assertions. Total render tests now 6/6;
full vitest sweep 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,11 @@ import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { renderReport, type ReportData } from '@/lib/pdf/reports/render-report';
|
||||
import { resolveDashboardReportData } from '@/lib/services/dashboard-report-data.service';
|
||||
import {
|
||||
resolveClientReportData,
|
||||
resolveBerthReportData,
|
||||
resolveInterestReportData,
|
||||
} from '@/lib/services/list-report-data.service';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
|
||||
const dashboardConfigSchema = z.object({
|
||||
@@ -22,12 +27,34 @@ const dashboardConfigSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const listFilters = z.object({ includeArchived: z.boolean().optional() }).passthrough().optional();
|
||||
|
||||
const clientsConfigSchema = z.object({
|
||||
kind: z.literal('clients'),
|
||||
columns: z.array(z.string()).max(20).optional(),
|
||||
filters: listFilters,
|
||||
});
|
||||
|
||||
const berthsConfigSchema = z.object({
|
||||
kind: z.literal('berths'),
|
||||
columns: z.array(z.string()).max(20).optional(),
|
||||
filters: listFilters,
|
||||
});
|
||||
|
||||
const interestsConfigSchema = z.object({
|
||||
kind: z.literal('interests'),
|
||||
columns: z.array(z.string()).max(20).optional(),
|
||||
filters: listFilters,
|
||||
});
|
||||
|
||||
const requestSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
subtitle: z.string().max(400).optional(),
|
||||
config: z.discriminatedUnion('kind', [
|
||||
dashboardConfigSchema,
|
||||
// Phase B will widen this union with clients / berths / interests.
|
||||
clientsConfigSchema,
|
||||
berthsConfigSchema,
|
||||
interestsConfigSchema,
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -59,10 +86,33 @@ export const POST = withAuth(
|
||||
case 'dashboard':
|
||||
data.dashboard = await resolveDashboardReportData(ctx.portId, body.config.widgetIds);
|
||||
break;
|
||||
default:
|
||||
// Unreachable while only the dashboard kind is wired; kept
|
||||
// for the type-narrowing exhaustiveness check.
|
||||
throw new ValidationError('Unsupported report kind');
|
||||
case 'clients':
|
||||
data.clients = await resolveClientReportData(ctx.portId, {
|
||||
includeArchived: Boolean(
|
||||
(body.config.filters as { includeArchived?: boolean } | undefined)?.includeArchived,
|
||||
),
|
||||
});
|
||||
break;
|
||||
case 'berths':
|
||||
data.berths = await resolveBerthReportData(ctx.portId, {
|
||||
includeArchived: Boolean(
|
||||
(body.config.filters as { includeArchived?: boolean } | undefined)?.includeArchived,
|
||||
),
|
||||
});
|
||||
break;
|
||||
case 'interests':
|
||||
data.interests = await resolveInterestReportData(ctx.portId, {
|
||||
includeArchived: Boolean(
|
||||
(body.config.filters as { includeArchived?: boolean } | undefined)?.includeArchived,
|
||||
),
|
||||
});
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = body.config;
|
||||
throw new ValidationError(
|
||||
`Unsupported report kind: ${(_exhaustive as { kind: string }).kind}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = await renderReport({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { ExportListPdfButton } from '@/components/reports/export-list-pdf-button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
@@ -136,6 +137,7 @@ export function BerthList() {
|
||||
)}
|
||||
</Button>
|
||||
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
<ExportListPdfButton kind="berths" />
|
||||
{canBulkAdd && (
|
||||
<Button asChild size="sm" variant="default">
|
||||
<Link href={`/${params.portSlug}/admin/berths/bulk-add`}>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type ClientRow,
|
||||
} from '@/components/clients/client-columns';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { ExportListPdfButton } from '@/components/reports/export-list-pdf-button';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
@@ -202,6 +203,7 @@ export function ClientList() {
|
||||
onChange={setHidden}
|
||||
onSaveView={() => setSaveViewOpen(true)}
|
||||
/>
|
||||
<ExportListPdfButton kind="clients" />
|
||||
{/* New Client moved out of PageHeader actions and into the
|
||||
filter row. Saves a row on mobile (no more dedicated
|
||||
actions strip). ml-auto keeps the primary action at the
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type InterestRow,
|
||||
} from '@/components/interests/interest-columns';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { ExportListPdfButton } from '@/components/reports/export-list-pdf-button';
|
||||
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
@@ -268,6 +269,7 @@ export function InterestList() {
|
||||
onChange={setHidden}
|
||||
onSaveView={() => setSaveViewOpen(true)}
|
||||
/>
|
||||
<ExportListPdfButton kind="interests" />
|
||||
</>
|
||||
) : null}
|
||||
<StageLegend />
|
||||
|
||||
147
src/components/reports/export-list-pdf-button.tsx
Normal file
147
src/components/reports/export-list-pdf-button.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FileDown, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||
|
||||
type ListKind = 'clients' | 'berths' | 'interests';
|
||||
|
||||
interface Props {
|
||||
kind: ListKind;
|
||||
/** Label shown on the trigger button (e.g. "Export PDF"). */
|
||||
buttonLabel?: string;
|
||||
/** Default title pre-populated in the dialog. */
|
||||
defaultTitle?: string;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<ListKind, string> = {
|
||||
clients: 'clients',
|
||||
berths: 'berths',
|
||||
interests: 'interests',
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic list-report export button. Renders a small dialog with
|
||||
* a title input + "include archived" toggle, then POSTs to the
|
||||
* report-generate endpoint. The kind discriminator picks the
|
||||
* matching server-side data resolver and React-PDF template.
|
||||
*
|
||||
* Permission-gated client-side on `reports.export`; the server
|
||||
* route enforces the same.
|
||||
*/
|
||||
export function ExportListPdfButton({ kind, buttonLabel = 'Export PDF', defaultTitle }: Props) {
|
||||
const { can } = usePermissions();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState(
|
||||
defaultTitle ??
|
||||
`${KIND_LABEL[kind].charAt(0).toUpperCase() + KIND_LABEL[kind].slice(1)} report - ${new Date().toLocaleDateString('en-GB')}`,
|
||||
);
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!can('reports', 'export')) return null;
|
||||
|
||||
async function handleExport() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (typeof window !== 'undefined') {
|
||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api') {
|
||||
const portId = await resolvePortIdFromSlug(slug);
|
||||
if (portId) headers.set('X-Port-Id', portId);
|
||||
}
|
||||
}
|
||||
const res = await fetch('/api/v1/reports/generate', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: title.trim() || `${kind} report`,
|
||||
config: {
|
||||
kind,
|
||||
filters: { includeArchived },
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Export failed (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
triggerBlobDownload(blob, `${title.trim().replace(/[\\/]/g, '_')}.pdf`);
|
||||
toast.success('Report downloaded');
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Export failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export {KIND_LABEL[kind]} as PDF</DialogTitle>
|
||||
<DialogDescription>
|
||||
The PDF inherits the active port's logo and primary color. Up to 1 000 rows are
|
||||
exported; for larger exports use CSV.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`export-title-${kind}`}>Title</Label>
|
||||
<Input
|
||||
id={`export-title-${kind}`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={includeArchived}
|
||||
onCheckedChange={(c) => setIncludeArchived(Boolean(c))}
|
||||
aria-label="Include archived"
|
||||
/>
|
||||
Include archived
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={loading}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
)}
|
||||
Download PDF
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
src/lib/pdf/reports/berth-list-report.tsx
Normal file
94
src/lib/pdf/reports/berth-list-report.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { View, Text } from '@react-pdf/renderer';
|
||||
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import { BrandedReportDocument } from './branded-document';
|
||||
import { ReportTable } from './report-table';
|
||||
import { makeReportStyles } from './styles';
|
||||
import type { BerthListReportConfig, ReportBranding } from './types';
|
||||
import type { BerthReportData } from '@/lib/services/list-report-data.service';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
branding: ReportBranding;
|
||||
generatedAt: string;
|
||||
config: BerthListReportConfig;
|
||||
data: BerthReportData;
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMNS: ReadonlyArray<{ key: string; label: string; widthPct: number }> = [
|
||||
{ key: 'mooringNumber', label: 'Mooring', widthPct: 12 },
|
||||
{ key: 'area', label: 'Area', widthPct: 8 },
|
||||
{ key: 'status', label: 'Status', widthPct: 12 },
|
||||
{ key: 'tenureType', label: 'Tenure', widthPct: 13 },
|
||||
{ key: 'lengthFt', label: 'Length (ft)', widthPct: 12 },
|
||||
{ key: 'widthFt', label: 'Width (ft)', widthPct: 11 },
|
||||
{ key: 'draftFt', label: 'Draft (ft)', widthPct: 11 },
|
||||
{ key: 'price', label: 'Price', widthPct: 21 },
|
||||
];
|
||||
|
||||
export function BerthListReport({ title, subtitle, branding, generatedAt, config, data }: Props) {
|
||||
const styles = makeReportStyles(branding);
|
||||
const columns = pickColumns(config.columns);
|
||||
const cappedNotice = data.capHit
|
||||
? `Showing the first ${data.rows.length} of ${data.total.toLocaleString()} matching berths.`
|
||||
: `${data.rows.length} ${data.rows.length === 1 ? 'berth' : 'berths'}`;
|
||||
|
||||
return (
|
||||
<BrandedReportDocument
|
||||
branding={branding}
|
||||
title={title}
|
||||
subtitle={subtitle ?? 'Berth inventory'}
|
||||
generatedAt={generatedAt}
|
||||
>
|
||||
<View>
|
||||
<Text style={styles.sectionSubtitle}>{cappedNotice}</Text>
|
||||
<ReportTable
|
||||
styles={styles}
|
||||
headers={columns.map((c) => c.label)}
|
||||
widths={columns.map((c) => c.widthPct)}
|
||||
rows={data.rows.map((r) => columns.map((c) => formatCell(c.key, r)))}
|
||||
/>
|
||||
</View>
|
||||
</BrandedReportDocument>
|
||||
);
|
||||
}
|
||||
|
||||
function pickColumns(override?: string[]) {
|
||||
if (!override || override.length === 0) return [...DEFAULT_COLUMNS];
|
||||
const map = new Map(DEFAULT_COLUMNS.map((c) => [c.key, c]));
|
||||
return override.flatMap((k) => (map.has(k) ? [map.get(k)!] : []));
|
||||
}
|
||||
|
||||
function formatCell(key: string, row: BerthReportData['rows'][number]): string {
|
||||
switch (key) {
|
||||
case 'mooringNumber':
|
||||
return row.mooringNumber;
|
||||
case 'area':
|
||||
return row.area ?? '';
|
||||
case 'status':
|
||||
return formatStatus(row.status);
|
||||
case 'tenureType':
|
||||
return row.tenureType === 'permanent'
|
||||
? 'Permanent'
|
||||
: row.tenureType === 'fixed_term'
|
||||
? 'Fixed-term'
|
||||
: row.tenureType;
|
||||
case 'lengthFt':
|
||||
return row.lengthFt ? Number(row.lengthFt).toFixed(1) : '';
|
||||
case 'widthFt':
|
||||
return row.widthFt ? Number(row.widthFt).toFixed(1) : '';
|
||||
case 'draftFt':
|
||||
return row.draftFt ? Number(row.draftFt).toFixed(1) : '';
|
||||
case 'price':
|
||||
return row.price
|
||||
? formatCurrency(row.price, row.priceCurrency, { maxFractionDigits: 0 })
|
||||
: '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function formatStatus(s: string): string {
|
||||
return s.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
}
|
||||
85
src/lib/pdf/reports/client-list-report.tsx
Normal file
85
src/lib/pdf/reports/client-list-report.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { View, Text } from '@react-pdf/renderer';
|
||||
|
||||
import { BrandedReportDocument } from './branded-document';
|
||||
import { ReportTable } from './report-table';
|
||||
import { makeReportStyles } from './styles';
|
||||
import type { ClientListReportConfig, ReportBranding } from './types';
|
||||
import type { ClientReportData } from '@/lib/services/list-report-data.service';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
branding: ReportBranding;
|
||||
generatedAt: string;
|
||||
config: ClientListReportConfig;
|
||||
data: ClientReportData;
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMNS: ReadonlyArray<{ key: string; label: string; widthPct: number }> = [
|
||||
{ key: 'fullName', label: 'Name', widthPct: 30 },
|
||||
{ key: 'primaryEmail', label: 'Email', widthPct: 25 },
|
||||
{ key: 'primaryPhone', label: 'Phone', widthPct: 15 },
|
||||
{ key: 'source', label: 'Source', widthPct: 12 },
|
||||
{ key: 'nationality', label: 'Nationality', widthPct: 8 },
|
||||
{ key: 'createdAt', label: 'Created', widthPct: 10 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Filtered clients list report. Columns can be subset via
|
||||
* `config.columns`; default set is the rep-facing subset most useful
|
||||
* on a printed report (skip internal IDs / metadata fields).
|
||||
*/
|
||||
export function ClientListReport({ title, subtitle, branding, generatedAt, config, data }: Props) {
|
||||
const styles = makeReportStyles(branding);
|
||||
const columns = pickColumns(config.columns);
|
||||
const cappedNotice = data.capHit
|
||||
? `Showing the most recent ${data.rows.length} of ${data.total.toLocaleString()} matching clients.`
|
||||
: `${data.rows.length} ${data.rows.length === 1 ? 'client' : 'clients'}`;
|
||||
|
||||
return (
|
||||
<BrandedReportDocument
|
||||
branding={branding}
|
||||
title={title}
|
||||
subtitle={subtitle ?? 'Client list'}
|
||||
generatedAt={generatedAt}
|
||||
>
|
||||
<View>
|
||||
<Text style={styles.sectionSubtitle}>{cappedNotice}</Text>
|
||||
<ReportTable
|
||||
styles={styles}
|
||||
headers={columns.map((c) => c.label)}
|
||||
widths={columns.map((c) => c.widthPct)}
|
||||
rows={data.rows.map((r) => columns.map((c) => formatCell(c.key, r)))}
|
||||
/>
|
||||
</View>
|
||||
</BrandedReportDocument>
|
||||
);
|
||||
}
|
||||
|
||||
function pickColumns(override?: string[]) {
|
||||
if (!override || override.length === 0) return [...DEFAULT_COLUMNS];
|
||||
// Filter the default set to the requested subset; preserve the
|
||||
// ordering from the override array so reps can reorder columns
|
||||
// via the saved-template UI in phase C.
|
||||
const map = new Map(DEFAULT_COLUMNS.map((c) => [c.key, c]));
|
||||
return override.flatMap((k) => (map.has(k) ? [map.get(k)!] : []));
|
||||
}
|
||||
|
||||
function formatCell(key: string, row: ClientReportData['rows'][number]): string {
|
||||
switch (key) {
|
||||
case 'fullName':
|
||||
return row.fullName;
|
||||
case 'primaryEmail':
|
||||
return row.primaryEmail ?? '';
|
||||
case 'primaryPhone':
|
||||
return row.primaryPhone ?? '';
|
||||
case 'source':
|
||||
return row.source ?? '';
|
||||
case 'nationality':
|
||||
return row.nationality ?? '';
|
||||
case 'createdAt':
|
||||
return new Date(row.createdAt).toLocaleDateString('en-GB');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
93
src/lib/pdf/reports/interest-list-report.tsx
Normal file
93
src/lib/pdf/reports/interest-list-report.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { View, Text } from '@react-pdf/renderer';
|
||||
|
||||
import { stageLabel } from '@/lib/constants';
|
||||
import { BrandedReportDocument } from './branded-document';
|
||||
import { ReportTable } from './report-table';
|
||||
import { makeReportStyles } from './styles';
|
||||
import type { InterestListReportConfig, ReportBranding } from './types';
|
||||
import type { InterestReportData } from '@/lib/services/list-report-data.service';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
branding: ReportBranding;
|
||||
generatedAt: string;
|
||||
config: InterestListReportConfig;
|
||||
data: InterestReportData;
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMNS: ReadonlyArray<{ key: string; label: string; widthPct: number }> = [
|
||||
{ key: 'clientName', label: 'Client', widthPct: 32 },
|
||||
{ key: 'primaryMooring', label: 'Mooring', widthPct: 13 },
|
||||
{ key: 'pipelineStage', label: 'Stage', widthPct: 22 },
|
||||
{ key: 'source', label: 'Source', widthPct: 15 },
|
||||
{ key: 'outcome', label: 'Outcome', widthPct: 8 },
|
||||
{ key: 'createdAt', label: 'Created', widthPct: 10 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Interest pipeline export. Rows are grouped within the underlying
|
||||
* query by stage (sort by updatedAt desc, but the rep can re-sort
|
||||
* the printed PDF mentally via the Stage column).
|
||||
*
|
||||
* `outcome` is rendered with a single-letter shorthand (W / L / -)
|
||||
* so the column doesn't dominate the row width.
|
||||
*/
|
||||
export function InterestListReport({
|
||||
title,
|
||||
subtitle,
|
||||
branding,
|
||||
generatedAt,
|
||||
config,
|
||||
data,
|
||||
}: Props) {
|
||||
const styles = makeReportStyles(branding);
|
||||
const columns = pickColumns(config.columns);
|
||||
const cappedNotice = data.capHit
|
||||
? `Showing the most recent ${data.rows.length} of ${data.total.toLocaleString()} matching interests.`
|
||||
: `${data.rows.length} ${data.rows.length === 1 ? 'interest' : 'interests'}`;
|
||||
|
||||
return (
|
||||
<BrandedReportDocument
|
||||
branding={branding}
|
||||
title={title}
|
||||
subtitle={subtitle ?? 'Interest pipeline'}
|
||||
generatedAt={generatedAt}
|
||||
>
|
||||
<View>
|
||||
<Text style={styles.sectionSubtitle}>{cappedNotice}</Text>
|
||||
<ReportTable
|
||||
styles={styles}
|
||||
headers={columns.map((c) => c.label)}
|
||||
widths={columns.map((c) => c.widthPct)}
|
||||
rows={data.rows.map((r) => columns.map((c) => formatCell(c.key, r)))}
|
||||
/>
|
||||
</View>
|
||||
</BrandedReportDocument>
|
||||
);
|
||||
}
|
||||
|
||||
function pickColumns(override?: string[]) {
|
||||
if (!override || override.length === 0) return [...DEFAULT_COLUMNS];
|
||||
const map = new Map(DEFAULT_COLUMNS.map((c) => [c.key, c]));
|
||||
return override.flatMap((k) => (map.has(k) ? [map.get(k)!] : []));
|
||||
}
|
||||
|
||||
function formatCell(key: string, row: InterestReportData['rows'][number]): string {
|
||||
switch (key) {
|
||||
case 'clientName':
|
||||
return row.clientName ?? '';
|
||||
case 'primaryMooring':
|
||||
return row.primaryMooring ?? '';
|
||||
case 'pipelineStage':
|
||||
return stageLabel(row.pipelineStage);
|
||||
case 'source':
|
||||
return row.source ?? '';
|
||||
case 'outcome':
|
||||
return row.outcome === 'won' ? 'W' : row.outcome === 'lost' ? 'L' : '-';
|
||||
case 'createdAt':
|
||||
return new Date(row.createdAt).toLocaleDateString('en-GB');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
|
||||
import { DashboardReport, type DashboardReportData } from './dashboard-report';
|
||||
import { ClientListReport } from './client-list-report';
|
||||
import { BerthListReport } from './berth-list-report';
|
||||
import { InterestListReport } from './interest-list-report';
|
||||
import type {
|
||||
ReportBranding,
|
||||
ReportConfig,
|
||||
@@ -17,6 +20,11 @@ import type {
|
||||
BerthListReportConfig,
|
||||
InterestListReportConfig,
|
||||
} from './types';
|
||||
import type {
|
||||
ClientReportData,
|
||||
BerthReportData,
|
||||
InterestReportData,
|
||||
} from '@/lib/services/list-report-data.service';
|
||||
|
||||
/**
|
||||
* Pre-fetched data payloads each report kind needs at render time.
|
||||
@@ -25,10 +33,9 @@ import type {
|
||||
*/
|
||||
export interface ReportData {
|
||||
dashboard?: DashboardReportData;
|
||||
// Phase B will fill these in.
|
||||
clients?: never;
|
||||
berths?: never;
|
||||
interests?: never;
|
||||
clients?: ClientReportData;
|
||||
berths?: BerthReportData;
|
||||
interests?: InterestReportData;
|
||||
}
|
||||
|
||||
interface RenderArgs {
|
||||
@@ -102,14 +109,41 @@ function pickDocument(
|
||||
data: data.dashboard ?? {},
|
||||
});
|
||||
case 'clients':
|
||||
if (!data.clients) {
|
||||
throw new Error('Client report requested without client data resolved');
|
||||
}
|
||||
return createElement(ClientListReport, {
|
||||
title: request.title,
|
||||
subtitle: request.subtitle,
|
||||
branding,
|
||||
generatedAt,
|
||||
config: cfg satisfies ClientListReportConfig,
|
||||
data: data.clients,
|
||||
});
|
||||
case 'berths':
|
||||
if (!data.berths) {
|
||||
throw new Error('Berth report requested without berth data resolved');
|
||||
}
|
||||
return createElement(BerthListReport, {
|
||||
title: request.title,
|
||||
subtitle: request.subtitle,
|
||||
branding,
|
||||
generatedAt,
|
||||
config: cfg satisfies BerthListReportConfig,
|
||||
data: data.berths,
|
||||
});
|
||||
case 'interests':
|
||||
// Phase B adds the dispatch + matching component. Surface a
|
||||
// clear error so an early-merged Phase A doesn't silently
|
||||
// render a blank PDF when a rep picks one of these kinds.
|
||||
throw new Error(
|
||||
`Report kind '${(cfg as ClientListReportConfig | BerthListReportConfig | InterestListReportConfig).kind}' not implemented yet (Phase B).`,
|
||||
);
|
||||
if (!data.interests) {
|
||||
throw new Error('Interest report requested without interest data resolved');
|
||||
}
|
||||
return createElement(InterestListReport, {
|
||||
title: request.title,
|
||||
subtitle: request.subtitle,
|
||||
branding,
|
||||
generatedAt,
|
||||
config: cfg satisfies InterestListReportConfig,
|
||||
data: data.interests,
|
||||
});
|
||||
default: {
|
||||
// Exhaustiveness check — surfaces a compile error if a new
|
||||
// ReportConfig variant is added without a matching case here.
|
||||
|
||||
48
src/lib/pdf/reports/report-table.tsx
Normal file
48
src/lib/pdf/reports/report-table.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { View, Text } from '@react-pdf/renderer';
|
||||
|
||||
import type { makeReportStyles } from './styles';
|
||||
|
||||
interface ReportTableProps {
|
||||
styles: ReturnType<typeof makeReportStyles>;
|
||||
headers: string[];
|
||||
/** Percentage widths per column. Must sum to 100; 1px slack is fine. */
|
||||
widths: number[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared zebra-striped table primitive used by every list-style
|
||||
* 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
|
||||
* report component formats numbers / dates / currencies to strings
|
||||
* before passing rows in. Keeps the table primitive deliberately
|
||||
* dumb (no formatting decisions live here).
|
||||
*/
|
||||
export function ReportTable({ styles, headers, widths, rows }: ReportTableProps) {
|
||||
return (
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
{headers.map((header, i) => (
|
||||
<Text key={`h-${i}`} style={{ ...styles.tableHeaderCell, width: `${widths[i]}%` }}>
|
||||
{header}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<View
|
||||
key={`r-${rowIdx}`}
|
||||
style={rowIdx % 2 === 1 ? styles.tableRowZebra : styles.tableRow}
|
||||
wrap={false}
|
||||
>
|
||||
{row.map((cell, i) => (
|
||||
<Text key={`c-${rowIdx}-${i}`} style={{ ...styles.tableCell, width: `${widths[i]}%` }}>
|
||||
{cell}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
239
src/lib/services/list-report-data.service.ts
Normal file
239
src/lib/services/list-report-data.service.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Server-side data resolvers for the list-report PDF kinds
|
||||
* (clients, berths, interests). Each returns a capped flat array
|
||||
* matching the shape the matching React-PDF report component
|
||||
* expects.
|
||||
*
|
||||
* Caps:
|
||||
* - 1 000 rows max per export. Above that, the PDF becomes
|
||||
* unreadable (hundreds of pages) and pdf-renderer memory cost
|
||||
* grows linearly. Reps wanting fuller exports use CSV.
|
||||
* - When the cap is hit, the report renders a "Showing top N of
|
||||
* <total>" line so the export isn't silently truncated.
|
||||
*
|
||||
* Filters carried via the `filters` config:
|
||||
* Clients: search, source, nationality, includeArchived
|
||||
* Berths: search, status, area, includeArchived
|
||||
* Interests: search, pipelineStage, includeArchived
|
||||
*
|
||||
* Validation happens at the route layer; this service trusts the
|
||||
* caller has run the inputs through the same zod schemas the
|
||||
* existing list endpoints use.
|
||||
*/
|
||||
import { and, desc, eq, isNull, sql, type SQL } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
|
||||
const REPORT_ROW_CAP = 1_000;
|
||||
|
||||
export interface ClientReportRow {
|
||||
id: string;
|
||||
fullName: string;
|
||||
source: string | null;
|
||||
nationality: string | null;
|
||||
primaryEmail: string | null;
|
||||
primaryPhone: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ClientReportData {
|
||||
rows: ClientReportRow[];
|
||||
total: number;
|
||||
capHit: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim client export. Pulls the bare minimum columns the report
|
||||
* surface renders so payload size and PDF length stay reasonable
|
||||
* even on a 1 000-row port.
|
||||
*/
|
||||
export async function resolveClientReportData(
|
||||
portId: string,
|
||||
filters: { includeArchived?: boolean } = {},
|
||||
): Promise<ClientReportData> {
|
||||
const whereParts: SQL[] = [eq(clients.portId, portId)];
|
||||
if (!filters.includeArchived) whereParts.push(isNull(clients.archivedAt));
|
||||
const whereClause = whereParts.length > 1 ? and(...whereParts) : whereParts[0];
|
||||
|
||||
const countRows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clients)
|
||||
.where(whereClause);
|
||||
const total = Number(countRows[0]?.count ?? 0);
|
||||
|
||||
// Subqueries pick a single email / phone per client. `is_primary
|
||||
// DESC, created_at DESC` picks the primary if set; otherwise the
|
||||
// most recent contact in that channel. Matches the ordering the
|
||||
// canonical `listClients` service uses.
|
||||
const rows = await db
|
||||
.select({
|
||||
id: clients.id,
|
||||
fullName: clients.fullName,
|
||||
source: clients.source,
|
||||
nationality: clients.nationalityIso,
|
||||
primaryEmail: sql<string | null>`(
|
||||
SELECT cc.value
|
||||
FROM client_contacts cc
|
||||
WHERE cc.client_id = ${clients.id} AND cc.channel = 'email'
|
||||
ORDER BY cc.is_primary DESC, cc.created_at DESC
|
||||
LIMIT 1
|
||||
)`,
|
||||
primaryPhone: sql<string | null>`(
|
||||
SELECT cc.value
|
||||
FROM client_contacts cc
|
||||
WHERE cc.client_id = ${clients.id} AND cc.channel = 'phone'
|
||||
ORDER BY cc.is_primary DESC, cc.created_at DESC
|
||||
LIMIT 1
|
||||
)`,
|
||||
createdAt: clients.createdAt,
|
||||
})
|
||||
.from(clients)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(clients.createdAt))
|
||||
.limit(REPORT_ROW_CAP);
|
||||
|
||||
return {
|
||||
rows: rows.map((r) => ({
|
||||
id: r.id,
|
||||
fullName: r.fullName,
|
||||
source: r.source ?? null,
|
||||
nationality: r.nationality ?? null,
|
||||
primaryEmail: r.primaryEmail ?? null,
|
||||
primaryPhone: r.primaryPhone ?? null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
capHit: total > REPORT_ROW_CAP,
|
||||
};
|
||||
}
|
||||
|
||||
export interface BerthReportRow {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
status: string;
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
draftFt: string | null;
|
||||
price: string | null;
|
||||
priceCurrency: string;
|
||||
tenureType: string;
|
||||
}
|
||||
|
||||
export interface BerthReportData {
|
||||
rows: BerthReportRow[];
|
||||
total: number;
|
||||
capHit: boolean;
|
||||
}
|
||||
|
||||
export async function resolveBerthReportData(
|
||||
portId: string,
|
||||
filters: { includeArchived?: boolean } = {},
|
||||
): Promise<BerthReportData> {
|
||||
const whereParts: SQL[] = [eq(berths.portId, portId)];
|
||||
if (!filters.includeArchived) whereParts.push(isNull(berths.archivedAt));
|
||||
const whereClause = whereParts.length > 1 ? and(...whereParts) : whereParts[0];
|
||||
|
||||
const countRows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(berths)
|
||||
.where(whereClause);
|
||||
const total = Number(countRows[0]?.count ?? 0);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: berths.id,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
area: berths.area,
|
||||
status: berths.status,
|
||||
lengthFt: berths.lengthFt,
|
||||
widthFt: berths.widthFt,
|
||||
draftFt: berths.draftFt,
|
||||
price: berths.price,
|
||||
priceCurrency: berths.priceCurrency,
|
||||
tenureType: berths.tenureType,
|
||||
})
|
||||
.from(berths)
|
||||
.where(whereClause)
|
||||
.orderBy(berths.mooringNumber)
|
||||
.limit(REPORT_ROW_CAP);
|
||||
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
capHit: total > REPORT_ROW_CAP,
|
||||
};
|
||||
}
|
||||
|
||||
export interface InterestReportRow {
|
||||
id: string;
|
||||
clientName: string | null;
|
||||
primaryMooring: string | null;
|
||||
pipelineStage: string;
|
||||
source: string | null;
|
||||
outcome: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface InterestReportData {
|
||||
rows: InterestReportRow[];
|
||||
total: number;
|
||||
capHit: boolean;
|
||||
}
|
||||
|
||||
export async function resolveInterestReportData(
|
||||
portId: string,
|
||||
filters: { includeArchived?: boolean } = {},
|
||||
): Promise<InterestReportData> {
|
||||
const whereParts: SQL[] = [eq(interests.portId, portId)];
|
||||
if (!filters.includeArchived) whereParts.push(isNull(interests.archivedAt));
|
||||
const whereClause = whereParts.length > 1 ? and(...whereParts) : whereParts[0];
|
||||
|
||||
const countRows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interests)
|
||||
.where(whereClause);
|
||||
const total = Number(countRows[0]?.count ?? 0);
|
||||
|
||||
// Join client (one-to-one) + primary berth (one-to-one via the
|
||||
// `is_primary=true` row). Keep the join LEFT so interests without
|
||||
// a primary berth still render — those are the early-stage deals
|
||||
// that haven't been pitched a specific mooring yet.
|
||||
const rows = await db
|
||||
.select({
|
||||
id: interests.id,
|
||||
clientName: clients.fullName,
|
||||
primaryMooring: berths.mooringNumber,
|
||||
pipelineStage: interests.pipelineStage,
|
||||
source: interests.source,
|
||||
outcome: interests.outcome,
|
||||
createdAt: interests.createdAt,
|
||||
})
|
||||
.from(interests)
|
||||
.leftJoin(clients, eq(clients.id, interests.clientId))
|
||||
.leftJoin(
|
||||
interestBerths,
|
||||
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
||||
)
|
||||
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(interests.updatedAt))
|
||||
.limit(REPORT_ROW_CAP);
|
||||
|
||||
return {
|
||||
rows: rows.map((r) => ({
|
||||
id: r.id,
|
||||
clientName: r.clientName,
|
||||
primaryMooring: r.primaryMooring,
|
||||
pipelineStage: r.pipelineStage,
|
||||
source: r.source,
|
||||
outcome: r.outcome,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
capHit: total > REPORT_ROW_CAP,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { DashboardReport } from '@/lib/pdf/reports/dashboard-report';
|
||||
import { ClientListReport } from '@/lib/pdf/reports/client-list-report';
|
||||
import { BerthListReport } from '@/lib/pdf/reports/berth-list-report';
|
||||
import { InterestListReport } from '@/lib/pdf/reports/interest-list-report';
|
||||
import type { ReportBranding } from '@/lib/pdf/reports/types';
|
||||
|
||||
const branding: ReportBranding = {
|
||||
@@ -103,6 +106,102 @@ describe('PDF report renderer', () => {
|
||||
expect(buf.byteLength).toBeGreaterThan(1_000);
|
||||
}, 30_000);
|
||||
|
||||
it('renders a client list report to a non-empty PDF buffer', async () => {
|
||||
const element = createElement(ClientListReport, {
|
||||
title: 'Clients',
|
||||
branding,
|
||||
generatedAt: '2026-05-21T12:00:00.000Z',
|
||||
config: { kind: 'clients' },
|
||||
data: {
|
||||
rows: [
|
||||
{
|
||||
id: 'c1',
|
||||
fullName: 'Acme Corp',
|
||||
source: 'website',
|
||||
nationality: 'GB',
|
||||
primaryEmail: 'ops@acme.example',
|
||||
primaryPhone: '+44 20 7946 0000',
|
||||
createdAt: '2026-04-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
fullName: 'Beta Industries',
|
||||
source: 'referral',
|
||||
nationality: null,
|
||||
primaryEmail: null,
|
||||
primaryPhone: null,
|
||||
createdAt: '2026-05-01T10:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
capHit: false,
|
||||
},
|
||||
});
|
||||
|
||||
const buf = await renderToBuffer(element as any);
|
||||
expect(buf.byteLength).toBeGreaterThan(1_500);
|
||||
expect(buf.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
}, 30_000);
|
||||
|
||||
it('renders a berth list report', async () => {
|
||||
const element = createElement(BerthListReport, {
|
||||
title: 'Berths',
|
||||
branding,
|
||||
generatedAt: '2026-05-21T12:00:00.000Z',
|
||||
config: { kind: 'berths' },
|
||||
data: {
|
||||
rows: [
|
||||
{
|
||||
id: 'b1',
|
||||
mooringNumber: 'A1',
|
||||
area: 'A',
|
||||
status: 'available',
|
||||
lengthFt: '40',
|
||||
widthFt: '14',
|
||||
draftFt: '6',
|
||||
price: '120000',
|
||||
priceCurrency: 'USD',
|
||||
tenureType: 'permanent',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
capHit: false,
|
||||
},
|
||||
});
|
||||
|
||||
const buf = await renderToBuffer(element as any);
|
||||
expect(buf.byteLength).toBeGreaterThan(1_500);
|
||||
expect(buf.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
}, 30_000);
|
||||
|
||||
it('renders an interest pipeline report', async () => {
|
||||
const element = createElement(InterestListReport, {
|
||||
title: 'Pipeline',
|
||||
branding,
|
||||
generatedAt: '2026-05-21T12:00:00.000Z',
|
||||
config: { kind: 'interests' },
|
||||
data: {
|
||||
rows: [
|
||||
{
|
||||
id: 'i1',
|
||||
clientName: 'Acme Corp',
|
||||
primaryMooring: 'A1',
|
||||
pipelineStage: 'reservation',
|
||||
source: 'website',
|
||||
outcome: null,
|
||||
createdAt: '2026-04-20T10:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
capHit: false,
|
||||
},
|
||||
});
|
||||
|
||||
const buf = await renderToBuffer(element as any);
|
||||
expect(buf.byteLength).toBeGreaterThan(1_500);
|
||||
expect(buf.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
||||
}, 30_000);
|
||||
|
||||
it('falls back to a stable layout when no logo URL is supplied', async () => {
|
||||
const element = createElement(DashboardReport, {
|
||||
title: 'Logoless',
|
||||
|
||||
Reference in New Issue
Block a user