Files
pn-new-crm/src/components/reports/custom/custom-report-builder.tsx

409 lines
14 KiB
TypeScript
Raw Normal View History

feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1 in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial) remain deferred per the gap audit at the bottom of that doc. Highlights: - Sales performance report: 7 KPI tiles, pipeline funnel + stage velocity + win-rate-over-time + source conversion + rep leaderboard charts, deal-heat section, 5 detail tables, stage / lead-cat / outcome filters. - Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy churn, tenure histogram, signing box plot, occupancy by area, docs in pipeline), 4 tables. Module-OFF banner when tenancies disabled. - Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths, tenancies), column-whitelist composer, date filter, CSV download, save-as-template. Registry-only extension path for the remaining 6 entities documented at src/lib/reports/custom/registry.ts. - Templates: load / modify / save / save-as on Sales / Operational / Custom. ?templateId= URL deep-link hydration via useRef guard. Active-template badge clears when the user drives view-state via wrapped setters; raw setters used on template apply so the badge survives. - Scheduled runs: BullMQ poll fires due schedules, mints report_runs, renders, optionally emails. Recipients optional (zero-recipient schedules archive without sending). PDF-only output for v1. Schedule dialog re-mounts via key prop on schedule.id transitions to avoid setState-in-effect reset patterns. - Server-side PDF endpoint + shared payload renderer (lib/pdf/reports/payload-report.tsx) so client + scheduler share one rendering path. - Shared currency formatter (lib/reports/format-currency.ts) consolidates 5 duplicated formatMoney helpers; fixes hardcoded 'USD' in detail tables; pre-formats money rows so PDF export (which strips column.format callbacks at the JSON boundary) renders consistently with CSV / XLSX. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:41:53 +02:00
'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, '""')}"`;
}