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>
409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
'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, '""')}"`;
|
||
}
|