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>
This commit is contained in:
408
src/components/reports/custom/custom-report-builder.tsx
Normal file
408
src/components/reports/custom/custom-report-builder.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
'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, '""')}"`;
|
||||
}
|
||||
Reference in New Issue
Block a user