diff --git a/src/app/api/v1/reports/generate/route.ts b/src/app/api/v1/reports/generate/route.ts index e9ff2dd5..f2e67b96 100644 --- a/src/app/api/v1/reports/generate/route.ts +++ b/src/app/api/v1/reports/generate/route.ts @@ -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({ diff --git a/src/components/berths/berth-list.tsx b/src/components/berths/berth-list.tsx index fea78537..c465ef48 100644 --- a/src/components/berths/berth-list.tsx +++ b/src/components/berths/berth-list.tsx @@ -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() { )} + {canBulkAdd && ( diff --git a/src/components/clients/client-list.tsx b/src/components/clients/client-list.tsx index e1af3f0d..a888f290 100644 --- a/src/components/clients/client-list.tsx +++ b/src/components/clients/client-list.tsx @@ -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)} /> + {/* 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 diff --git a/src/components/interests/interest-list.tsx b/src/components/interests/interest-list.tsx index c6009427..1f7df7bf 100644 --- a/src/components/interests/interest-list.tsx +++ b/src/components/interests/interest-list.tsx @@ -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)} /> + > ) : null} diff --git a/src/components/reports/export-list-pdf-button.tsx b/src/components/reports/export-list-pdf-button.tsx new file mode 100644 index 00000000..ff9a37ed --- /dev/null +++ b/src/components/reports/export-list-pdf-button.tsx @@ -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 = { + 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 ( + <> + setOpen(true)}> + + {buttonLabel} + + + + + Export {KIND_LABEL[kind]} as PDF + + The PDF inherits the active port's logo and primary color. Up to 1 000 rows are + exported; for larger exports use CSV. + + + + + Title + setTitle(e.target.value)} + /> + + + setIncludeArchived(Boolean(c))} + aria-label="Include archived" + /> + Include archived + + + + setOpen(false)} disabled={loading}> + Cancel + + + {loading ? ( + + ) : ( + + )} + Download PDF + + + + + > + ); +} diff --git a/src/lib/pdf/reports/berth-list-report.tsx b/src/lib/pdf/reports/berth-list-report.tsx new file mode 100644 index 00000000..6fbd8b1b --- /dev/null +++ b/src/lib/pdf/reports/berth-list-report.tsx @@ -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 ( + + + {cappedNotice} + c.label)} + widths={columns.map((c) => c.widthPct)} + rows={data.rows.map((r) => columns.map((c) => formatCell(c.key, r)))} + /> + + + ); +} + +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()); +} diff --git a/src/lib/pdf/reports/client-list-report.tsx b/src/lib/pdf/reports/client-list-report.tsx new file mode 100644 index 00000000..3342ce87 --- /dev/null +++ b/src/lib/pdf/reports/client-list-report.tsx @@ -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 ( + + + {cappedNotice} + c.label)} + widths={columns.map((c) => c.widthPct)} + rows={data.rows.map((r) => columns.map((c) => formatCell(c.key, r)))} + /> + + + ); +} + +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 ''; + } +} diff --git a/src/lib/pdf/reports/interest-list-report.tsx b/src/lib/pdf/reports/interest-list-report.tsx new file mode 100644 index 00000000..16b265b4 --- /dev/null +++ b/src/lib/pdf/reports/interest-list-report.tsx @@ -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 ( + + + {cappedNotice} + c.label)} + widths={columns.map((c) => c.widthPct)} + rows={data.rows.map((r) => columns.map((c) => formatCell(c.key, r)))} + /> + + + ); +} + +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 ''; + } +} diff --git a/src/lib/pdf/reports/render-report.ts b/src/lib/pdf/reports/render-report.ts index 5125b137..e1b88228 100644 --- a/src/lib/pdf/reports/render-report.ts +++ b/src/lib/pdf/reports/render-report.ts @@ -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. diff --git a/src/lib/pdf/reports/report-table.tsx b/src/lib/pdf/reports/report-table.tsx new file mode 100644 index 00000000..e184ea9e --- /dev/null +++ b/src/lib/pdf/reports/report-table.tsx @@ -0,0 +1,48 @@ +import { View, Text } from '@react-pdf/renderer'; + +import type { makeReportStyles } from './styles'; + +interface ReportTableProps { + styles: ReturnType; + 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 ( + + + {headers.map((header, i) => ( + + {header} + + ))} + + {rows.map((row, rowIdx) => ( + + {row.map((cell, i) => ( + + {cell} + + ))} + + ))} + + ); +} diff --git a/src/lib/services/list-report-data.service.ts b/src/lib/services/list-report-data.service.ts new file mode 100644 index 00000000..8c21a2d0 --- /dev/null +++ b/src/lib/services/list-report-data.service.ts @@ -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 + * " 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 { + 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`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`( + 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`( + 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 { + 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`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 { + 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`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, + }; +} diff --git a/tests/unit/pdf-report-renderer.test.ts b/tests/unit/pdf-report-renderer.test.ts index 463ec620..fe8662d5 100644 --- a/tests/unit/pdf-report-renderer.test.ts +++ b/tests/unit/pdf-report-renderer.test.ts @@ -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',