feat(CC-078): add Custom Reports page (multi-metric builder with period/grouping + CSV export)

This commit is contained in:
admin-valentin 2026-08-02 12:53:54 +00:00
parent 84dcdd5880
commit 84bd7bb93f

View file

@ -0,0 +1,187 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Task { id: string; status: string; priority: string; dueDate: string | null; createdAt: string; }
interface Goal { id: string; status: string; progress: number | null; createdAt: string; }
interface Observation { id: string; metric: string; value: string; subjectType: string; createdAt: string; observedAt: string | null; }
interface Decision { id: string; status: string; impact: string | null; createdAt: string; }
interface Transaction { id: string; amount: string; type: string; createdAt: string; }
interface Contact { id: string; createdAt: string; }
type Metric = 'tasks' | 'goals' | 'observations' | 'decisions' | 'finance' | 'contacts';
type Period = '7' | '30' | '90' | '180' | '365';
type Grouping = 'day' | 'week' | 'month';
const METRIC_LABELS: Record<Metric, string> = {
tasks: 'Taskuri', goals: 'Obiective', observations: 'Observații',
decisions: 'Decizii', finance: 'Tranzacții', contacts: 'Contacte',
};
function toDate(s: string) { return new Date(s); }
function weekKey(d: Date) {
const start = new Date(d); start.setDate(d.getDate() - d.getDay());
return start.toISOString().slice(0, 10);
}
function monthKey(d: Date) { return d.toISOString().slice(0, 7); }
function groupDates(dates: Date[], grouping: Grouping): Record<string, number> {
const map: Record<string, number> = {};
for (const d of dates) {
const key = grouping === 'day' ? d.toISOString().slice(0, 10)
: grouping === 'week' ? weekKey(d) : monthKey(d);
map[key] = (map[key] ?? 0) + 1;
}
return map;
}
export default function CustomReportsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [metrics, setMetrics] = useState<Metric[]>(['tasks', 'observations']);
const [period, setPeriod] = useState<Period>('30');
const [grouping, setGrouping] = useState<Grouping>('week');
const { data: tasks = [] } = useQuery({ queryKey: ['cr-tasks', tenantId], queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: goals = [] } = useQuery({ queryKey: ['cr-goals', tenantId], queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=200', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: obs = [] } = useQuery({ queryKey: ['cr-obs', tenantId], queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: decisions = [] } = useQuery({ queryKey: ['cr-dec', tenantId], queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=200', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: transactions = [] } = useQuery({ queryKey: ['cr-txn', tenantId], queryFn: () => apiFetch<Transaction[]>('/v1/transactions?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: contacts = [] } = useQuery({ queryKey: ['cr-con', tenantId], queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const cutoff = useMemo(() => new Date(Date.now() - parseInt(period) * 86400_000), [period]);
const DATA_MAP: Record<Metric, { date: string }[]> = {
tasks, goals, observations: obs.map((o) => ({ ...o, date: o.observedAt ?? o.createdAt })),
decisions, finance: transactions, contacts,
};
const charts = useMemo(() => {
return metrics.map((m) => {
const items = DATA_MAP[m]
.filter((item) => {
const d = toDate((item as Record<string, string>).observedAt ?? (item as Record<string, string>).date ?? (item as Record<string, string>).createdAt);
return d >= cutoff;
})
.map((item) => {
const dateStr = (item as Record<string, string>).observedAt ?? (item as Record<string, string>).date ?? (item as Record<string, string>).createdAt;
return toDate(dateStr);
});
const grouped = groupDates(items, grouping);
const sorted = Object.entries(grouped).sort((a, b) => a[0].localeCompare(b[0]));
const max = Math.max(...sorted.map(([, c]) => c), 1);
const total = sorted.reduce((s, [, c]) => s + c, 0);
return { metric: m, sorted, max, total };
});
}, [metrics, cutoff, grouping, tasks, goals, obs, decisions, transactions, contacts]);
function toggleMetric(m: Metric) {
setMetrics((prev) => prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m]);
}
function exportCSV() {
const header = ['Metric', 'Period', 'Count'].join(',');
const rows = charts.flatMap(({ metric, sorted }) =>
sorted.map(([period, count]) => `${metric},${period},${count}`)
);
const csv = [header, ...rows].join('\n');
const el = document.createElement('a');
el.href = `data:text/csv;charset=utf-8,${encodeURIComponent(csv)}`;
el.download = `ceo-os-report-${new Date().toISOString().slice(0, 10)}.csv`;
el.click();
}
return (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Rapoarte Personalizate</h1>
<p className="text-sm text-ink-faint mt-1">Construiește rapoarte combinate din orice modul CEO OS.</p>
</div>
<button onClick={exportCSV} className="rounded-lg border px-4 py-2 text-sm text-ink hover:bg-muted/50">
Exportă CSV
</button>
</div>
{/* Controls */}
<div className="card p-4 space-y-4">
<div>
<p className="text-xs font-semibold text-ink-faint mb-2">Metrici</p>
<div className="flex flex-wrap gap-2">
{(Object.keys(METRIC_LABELS) as Metric[]).map((m) => (
<button key={m} onClick={() => toggleMetric(m)}
className={`rounded-full border px-3 py-1 text-xs ${metrics.includes(m) ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint'}`}>
{METRIC_LABELS[m]}
</button>
))}
</div>
</div>
<div className="flex flex-wrap gap-4">
<div>
<p className="text-xs font-semibold text-ink-faint mb-2">Perioadă</p>
<div className="flex gap-1">
{(['7', '30', '90', '180', '365'] as Period[]).map((p) => (
<button key={p} onClick={() => setPeriod(p)}
className={`rounded border px-2 py-1 text-xs ${period === p ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint'}`}>
{p}z
</button>
))}
</div>
</div>
<div>
<p className="text-xs font-semibold text-ink-faint mb-2">Grupare</p>
<div className="flex gap-1">
{(['day', 'week', 'month'] as Grouping[]).map((g) => (
<button key={g} onClick={() => setGrouping(g)}
className={`rounded border px-2 py-1 text-xs ${grouping === g ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint'}`}>
{g === 'day' ? 'Zi' : g === 'week' ? 'Săpt.' : 'Lună'}
</button>
))}
</div>
</div>
</div>
</div>
{/* Charts */}
{metrics.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">📊</p>
<p className="text-sm text-ink-faint">Selectează cel puțin o metrică de mai sus.</p>
</div>
) : (
<div className="space-y-4">
{charts.map(({ metric, sorted, max, total }) => (
<div key={metric} className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-ink">{METRIC_LABELS[metric]}</p>
<div className="flex gap-3 text-xs text-ink-faint">
<span>Total: <strong className="text-ink">{total}</strong></span>
<span>Perioade: <strong className="text-ink">{sorted.length}</strong></span>
</div>
</div>
{sorted.length === 0 ? (
<p className="text-xs text-ink-faint text-center py-2">Niciun dat în perioadă.</p>
) : (
<div className="space-y-1">
{sorted.map(([key, count]) => (
<div key={key} className="flex items-center gap-2">
<span className="w-24 text-[10px] text-ink-faint shrink-0">{key}</span>
<div className="flex-1 h-4 bg-muted rounded-sm overflow-hidden">
<div className="h-full bg-primary/50 rounded-sm" style={{ width: `${(count / max) * 100}%` }} />
</div>
<span className="w-6 text-right text-[10px] text-ink font-medium shrink-0">{count}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}