Files
pn-new-crm/src/components/reports/custom/custom-report-builder.tsx
Matt 79b6ab2ae0 fix(build): split custom-report registry into client-safe metadata + server query module
The custom-report builder (client component) imported the registry which pulls
in @/lib/db (postgres -> tls), breaking the production build. Extract
ENTITY_META/ENTITY_KEYS/column defs into registry-meta.ts (no DB imports);
registry.ts keeps runQuery + composes ENTITY_REGISTRY. Pre-existing blocker
surfaced during pre-merge build validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:28:51 +02:00

409 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { Download, FileText, Loader2, Play, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { ENTITY_KEYS, ENTITY_META, type EntityKey } from '@/lib/reports/custom/registry-meta';
import { formatMoney, formatNumber } from '@/lib/reports/format-currency';
/**
* Map from money-amount column → adjacent currency column. When both
* are selected the on-screen + CSV output formats the amount with the
* row's currency. When only the amount is selected we still pretty-
* print with thousand separators but skip the currency glyph (the
* analyst presumably has context elsewhere).
*/
const MONEY_COLUMN_PAIRS: Record<string, string> = {
price: 'priceCurrency',
depositExpectedAmount: 'depositExpectedCurrency',
};
function isMoneyColumnKey(key: string): boolean {
return key in MONEY_COLUMN_PAIRS;
}
interface RunResponse {
data: Array<Record<string, unknown>>;
meta: {
entity: EntityKey;
columns: Array<{ key: string; label: string }>;
rowCount: number;
};
}
interface CustomTemplateConfig extends Record<string, unknown> {
kind: 'custom';
entity: EntityKey;
columns: string[];
from?: string;
to?: string;
}
function defaultColumnsFor(entity: EntityKey): string[] {
return ENTITY_META[entity].columns.filter((c) => c.defaultSelected).map((c) => c.key);
}
export function CustomReportBuilder({ portSlug: _portSlug }: { portSlug: string }) {
const searchParams = useSearchParams();
const initialTemplateId = searchParams?.get('templateId') ?? null;
const [entity, setEntity] = useState<EntityKey>('clients');
const [columns, setColumns] = useState<string[]>(defaultColumnsFor('clients'));
const [from, setFrom] = useState<string>('');
const [to, setTo] = useState<string>('');
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
const [rows, setRows] = useState<Array<Record<string, unknown>>>([]);
const [columnLabels, setColumnLabels] = useState<Array<{ key: string; label: string }>>([]);
// When the user picks a different entity, reset columns to the
// entity's defaults (carrying forward column keys would be confusing
// since they're entity-specific). Also clear the active template
// badge since the rep is composing a new query.
function handleEntityChange(next: EntityKey) {
setEntity(next);
setColumns(defaultColumnsFor(next));
setRows([]);
setColumnLabels([]);
setActiveTemplateId(null);
}
function toggleColumn(key: string, checked: boolean) {
setColumns((prev) => {
if (checked) return prev.includes(key) ? prev : [...prev, key];
return prev.filter((k) => k !== key);
});
setActiveTemplateId(null);
}
const handleFromChange = useCallback((next: string) => {
setFrom(next);
setActiveTemplateId(null);
}, []);
const handleToChange = useCallback((next: string) => {
setTo(next);
setActiveTemplateId(null);
}, []);
const currentConfig: CustomTemplateConfig = useMemo(
() => ({
kind: 'custom',
entity,
columns,
from: from || undefined,
to: to || undefined,
}),
[entity, columns, from, to],
);
const handleApplyTemplate = useCallback((config: CustomTemplateConfig) => {
// Raw setters: template apply MUST NOT clear the active-template
// badge that the user-facing handlers above clear.
if (config.entity) setEntity(config.entity);
if (config.columns) setColumns(config.columns);
setFrom(config.from ?? '');
setTo(config.to ?? '');
setRows([]);
setColumnLabels([]);
}, []);
const runMutation = useMutation({
mutationFn: async () => {
// Convert the date-only YYYY-MM-DD strings (DatePicker output)
// into ISO-8601 boundaries so the API zod schema accepts them.
const fromIso = from ? new Date(`${from}T00:00:00.000Z`).toISOString() : undefined;
const toIso = to ? new Date(`${to}T23:59:59.999Z`).toISOString() : undefined;
return apiFetch<RunResponse>(`/api/v1/reports/custom/run`, {
method: 'POST',
body: {
entity,
columns,
from: fromIso,
to: toIso,
},
});
},
onSuccess: (res) => {
setRows(res.data);
setColumnLabels(res.meta.columns);
toast.success(`Loaded ${res.meta.rowCount} rows`);
},
onError: (err) => toastError(err),
});
function downloadCsv() {
if (rows.length === 0) {
toast.error('Run the query first');
return;
}
const headerLabels = columnLabels.map((c) => csvCell(c.label));
const lines = [headerLabels.join(',')];
for (const row of rows) {
const cells = columnLabels.map((c) => csvCell(formatCellValue(c.key, row)));
lines.push(cells.join(','));
}
const filenameSlug = `custom-${entity}`;
const dateSuffix = new Date().toISOString().slice(0, 10);
const filename = `${filenameSlug}-${dateSuffix}.csv`;
const bom = '';
const blob = new Blob([bom + lines.join('\r\n') + '\r\n'], {
type: 'text/csv;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast.success(`Downloaded ${filename}`);
}
const def = ENTITY_META[entity];
return (
<div className="space-y-6">
<PageHeader
eyebrow="Reports"
title="Custom report"
description="Pick an entity, choose columns, set an optional date range, download as CSV. Save the configuration as a template to re-run later."
actions={
<div className="flex items-center gap-2">
<ReportTemplatesButton<CustomTemplateConfig>
kind="custom"
currentConfig={currentConfig}
onApply={handleApplyTemplate}
activeTemplateId={activeTemplateId}
onActiveTemplateChange={setActiveTemplateId}
initialTemplateId={initialTemplateId}
/>
</div>
}
/>
<Card>
<CardContent className="space-y-4 pt-6">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[260px_1fr] lg:items-start">
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="custom-entity" className="text-xs">
Entity
</Label>
<Select value={entity} onValueChange={(v) => handleEntityChange(v as EntityKey)}>
<SelectTrigger id="custom-entity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ENTITY_KEYS.map((k) => (
<SelectItem key={k} value={k}>
{ENTITY_META[k].label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">{def.description}</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Date filter ({def.dateAxis})</Label>
<div className="space-y-1.5">
<DatePicker
id="custom-date-from"
value={from}
onChange={handleFromChange}
placeholder="From"
size="sm"
/>
<DatePicker
id="custom-date-to"
value={to}
onChange={handleToChange}
placeholder="To"
size="sm"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Optional. Leave blank for all-time.
</p>
</div>
<div className="flex flex-col gap-2 pt-2">
<Button
onClick={() => runMutation.mutate()}
disabled={runMutation.isPending || columns.length === 0}
size="sm"
>
{runMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<Play className="mr-1.5 h-4 w-4" aria-hidden />
)}
Run query
</Button>
<Button
onClick={downloadCsv}
variant="outline"
size="sm"
disabled={rows.length === 0}
>
<Download className="mr-1.5 h-4 w-4" aria-hidden />
Download CSV
</Button>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Columns</Label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{def.columns.map((c) => {
const checked = columns.includes(c.key);
return (
<label
key={c.key}
className="flex cursor-pointer items-center gap-2 rounded-md border bg-muted/20 px-2 py-1.5 text-sm hover:bg-muted/40"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleColumn(c.key, Boolean(v))}
/>
<span>{c.label}</span>
</label>
);
})}
</div>
{columns.length === 0 ? (
<p className="text-xs text-amber-600">Select at least one column to run.</p>
) : (
<p className="text-[11px] text-muted-foreground">
{columns.length} of {def.columns.length} columns selected.
</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Results table — only shows after Run query. Caps the visible
rows; CSV export gives the full set. */}
{rows.length > 0 ? (
<Card>
<CardContent className="p-0">
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2 text-xs">
<span className="font-medium">{rows.length} rows</span>
<span className="text-muted-foreground">
Showing first {Math.min(rows.length, 50)} · download CSV for full set
</span>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{columnLabels.map((c) => (
<TableHead key={c.key}>{c.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.slice(0, 50).map((row, idx) => (
<TableRow key={idx}>
{columnLabels.map((c) => (
<TableCell key={c.key} className="text-sm">
{formatCellValue(c.key, row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
) : runMutation.isSuccess ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="mb-3 h-8 w-8 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium">No rows match this query</p>
<p className="mt-1 text-xs text-muted-foreground">
Try widening the date range, picking a different entity, or removing filters.
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Sparkles className="mb-3 h-8 w-8 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium">Configure your query above, then Run.</p>
<p className="mt-1 text-xs text-muted-foreground">
Results appear here. Save the configuration as a template to schedule recurring runs
or share it with the team.
</p>
</CardContent>
</Card>
)}
</div>
);
}
/**
* Per-cell value formatter. Falls through to a generic string render
* except for known money columns (per MONEY_COLUMN_PAIRS) where we
* pretty-print with the row's currency when available. Numeric columns
* outside the money set get thousand-separator formatting for
* readability.
*/
function formatCellValue(key: string, row: Record<string, unknown>): string {
const v = row[key];
if (v === null || v === undefined) return '';
if (isMoneyColumnKey(key) && typeof v === 'number') {
const currencyKey = MONEY_COLUMN_PAIRS[key];
if (currencyKey) {
const ccy = row[currencyKey];
if (typeof ccy === 'string' && ccy.length > 0) return formatMoney(v, ccy);
}
// Currency unknown — drop the glyph, keep the readable number.
return formatNumber(v);
}
if (v instanceof Date) return v.toISOString().slice(0, 10);
if (typeof v === 'number') return formatNumber(v);
if (typeof v === 'string') {
if (/^\d{4}-\d{2}-\d{2}T/.test(v)) return v.slice(0, 10);
return v;
}
return String(v);
}
function csvCell(value: string): string {
if (value === '') return '""';
return `"${value.replace(/"/g, '""')}"`;
}