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_REGISTRY, type EntityKey } from '@/lib/reports/custom/registry';
|
|||
|
|
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_REGISTRY[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_REGISTRY[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_REGISTRY[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, '""')}"`;
|
|||
|
|
}
|