'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 = { price: 'priceCurrency', depositExpectedAmount: 'depositExpectedCurrency', }; function isMoneyColumnKey(key: string): boolean { return key in MONEY_COLUMN_PAIRS; } interface RunResponse { data: Array>; meta: { entity: EntityKey; columns: Array<{ key: string; label: string }>; rowCount: number; }; } interface CustomTemplateConfig extends Record { 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('clients'); const [columns, setColumns] = useState(defaultColumnsFor('clients')); const [from, setFrom] = useState(''); const [to, setTo] = useState(''); const [activeTemplateId, setActiveTemplateId] = useState(initialTemplateId); const [rows, setRows] = useState>>([]); const [columnLabels, setColumnLabels] = useState>([]); // 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(`/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 (
kind="custom" currentConfig={currentConfig} onApply={handleApplyTemplate} activeTemplateId={activeTemplateId} onActiveTemplateChange={setActiveTemplateId} initialTemplateId={initialTemplateId} />
} />

{def.description}

Optional. Leave blank for all-time.

{def.columns.map((c) => { const checked = columns.includes(c.key); return ( ); })}
{columns.length === 0 ? (

Select at least one column to run.

) : (

{columns.length} of {def.columns.length} columns selected.

)}
{/* Results table — only shows after Run query. Caps the visible rows; CSV export gives the full set. */} {rows.length > 0 ? (
{rows.length} rows Showing first {Math.min(rows.length, 50)} · download CSV for full set
{columnLabels.map((c) => ( {c.label} ))} {rows.slice(0, 50).map((row, idx) => ( {columnLabels.map((c) => ( {formatCellValue(c.key, row)} ))} ))}
) : runMutation.isSuccess ? (

No rows match this query

Try widening the date range, picking a different entity, or removing filters.

) : (

Configure your query above, then Run.

Results appear here. Save the configuration as a template to schedule recurring runs or share it with the team.

)} ); } /** * 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 { 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, '""')}"`; }