feat(CC-078): add Custom Reports page (multi-metric builder with period/grouping + CSV export)
This commit is contained in:
parent
84dcdd5880
commit
84bd7bb93f
1 changed files with 187 additions and 0 deletions
187
src/app/dashboard/reports/custom/page.tsx
Normal file
187
src/app/dashboard/reports/custom/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue