feat(CC-090): add Revenue Tracker page (signed contracts + income observations, monthly bar chart)
This commit is contained in:
parent
66a4512d19
commit
724460b3fe
1 changed files with 178 additions and 0 deletions
178
src/app/dashboard/finance/revenue/page.tsx
Normal file
178
src/app/dashboard/finance/revenue/page.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../../lib/api';
|
||||
import { useSession } from '../../../../components/session-provider';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Contract { id: string; title: string; value: number | null; currency: string | null; status: string; counterparty: string | null; tags: string[]; createdAt: string; }
|
||||
interface Observation { id: string; metric: string; value: string; unit: string | null; subjectType: string; observedAt: string | null; createdAt: string; source: string | null; }
|
||||
|
||||
const INCOME_METRICS = ['venit', 'income', 'revenue', 'plata', 'incasare', 'factura', 'invoice'];
|
||||
const MONTHS = ['Ian', 'Feb', 'Mar', 'Apr', 'Mai', 'Iun', 'Iul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
function monthKey(iso: string) { const d = new Date(iso); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; }
|
||||
function parseNum(v: string): number { return parseFloat(v.replace(/[^0-9.-]/g, '')) || 0; }
|
||||
|
||||
export default function RevenuePage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
|
||||
const { data: contracts = [], isLoading: loadCt } = useQuery({
|
||||
queryKey: ['rev-contracts', tenantId],
|
||||
queryFn: () => apiFetch<Contract[]>('/v1/contracts?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: observations = [], isLoading: loadObs } = useQuery({
|
||||
queryKey: ['rev-obs', tenantId],
|
||||
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const incomeObs = useMemo(() =>
|
||||
observations.filter((o) =>
|
||||
INCOME_METRICS.some((m) => o.metric.toLowerCase().includes(m)) ||
|
||||
o.subjectType === 'income' || o.subjectType === 'revenue',
|
||||
),
|
||||
[observations]);
|
||||
|
||||
const signedContracts = useMemo(() =>
|
||||
contracts.filter((c) => c.status === 'signed' || c.status === 'completed' || c.status === 'active'),
|
||||
[contracts]);
|
||||
|
||||
const totalContractValue = signedContracts.reduce((s, c) => s + (c.value ?? 0), 0);
|
||||
const totalObsIncome = incomeObs.reduce((s, o) => s + parseNum(o.value), 0);
|
||||
|
||||
const byMonth = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const o of incomeObs) {
|
||||
const key = monthKey(o.observedAt ?? o.createdAt);
|
||||
map[key] = (map[key] ?? 0) + parseNum(o.value);
|
||||
}
|
||||
for (const c of signedContracts) {
|
||||
if (!c.value) continue;
|
||||
const key = monthKey(c.createdAt);
|
||||
map[key] = (map[key] ?? 0) + c.value;
|
||||
}
|
||||
return map;
|
||||
}, [incomeObs, signedContracts]);
|
||||
|
||||
const sortedMonths = Object.keys(byMonth).sort().slice(-12);
|
||||
const maxVal = Math.max(...sortedMonths.map((k) => byMonth[k]), 1);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const ytd = useMemo(() => {
|
||||
let sum = 0;
|
||||
for (const [k, v] of Object.entries(byMonth)) {
|
||||
if (k.startsWith(String(currentYear))) sum += v;
|
||||
}
|
||||
return sum;
|
||||
}, [byMonth, currentYear]);
|
||||
|
||||
const avgMonthly = sortedMonths.length > 0
|
||||
? Math.round(sortedMonths.reduce((s, k) => s + byMonth[k], 0) / sortedMonths.length)
|
||||
: 0;
|
||||
|
||||
function monthLabel(key: string) {
|
||||
const [y, m] = key.split('-');
|
||||
return `${MONTHS[parseInt(m) - 1]} ${y}`;
|
||||
}
|
||||
|
||||
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">Revenue Tracker</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">Contracte semnate + venituri din observații</p>
|
||||
</div>
|
||||
<Link href="/dashboard/finance" className="rounded-lg border px-3 py-1.5 text-sm text-ink hover:bg-muted/50">
|
||||
← Finance
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: `YTD ${currentYear}`, value: ytd, color: 'text-signal-ok' },
|
||||
{ label: 'Total contracte active', value: totalContractValue, color: 'text-primary' },
|
||||
{ label: 'Medie lunară', value: avgMonthly, color: 'text-ink' },
|
||||
].map((kpi) => (
|
||||
<div key={kpi.label} className="card p-4 space-y-1">
|
||||
<p className="text-xs text-ink-faint">{kpi.label}</p>
|
||||
<p className={`text-2xl font-bold font-display ${kpi.color}`}>
|
||||
{kpi.value.toLocaleString('ro-RO')} €
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Monthly bar chart */}
|
||||
{sortedMonths.length > 0 ? (
|
||||
<div className="card p-5 space-y-4">
|
||||
<p className="text-sm font-semibold text-ink">Ultimele 12 luni</p>
|
||||
<div className="flex items-end gap-1 h-40">
|
||||
{sortedMonths.map((k) => {
|
||||
const v = byMonth[k];
|
||||
const pct = (v / maxVal) * 100;
|
||||
return (
|
||||
<div key={k} className="flex-1 flex flex-col items-center gap-1 min-w-0">
|
||||
<p className="text-[9px] text-ink-faint truncate w-full text-center">{v >= 1000 ? `${Math.round(v / 1000)}k` : v}</p>
|
||||
<div className="w-full rounded-t bg-primary/70 hover:bg-primary transition-colors" style={{ height: `${Math.max(pct, 2)}%` }} title={`${monthLabel(k)}: ${v.toLocaleString()} €`} />
|
||||
<p className="text-[8px] text-ink-faint">{monthLabel(k).slice(0, 3)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
!loadCt && !loadObs && (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-3xl">📊</p>
|
||||
<p className="text-sm text-ink-faint">Niciun venit înregistrat. Adaugă contracte cu valoare sau observații cu metrica „venit".</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Signed contracts list */}
|
||||
{signedContracts.length > 0 && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Contracte active / semnate ({signedContracts.length})</p>
|
||||
<div className="divide-y divide-border/50">
|
||||
{signedContracts.slice(0, 10).map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between py-2.5">
|
||||
<div>
|
||||
<p className="text-sm text-ink">{c.title}</p>
|
||||
{c.counterparty && <p className="text-xs text-ink-faint">{c.counterparty}</p>}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
{c.value ? <p className="text-sm font-semibold text-signal-ok">{c.value.toLocaleString('ro-RO')} {c.currency ?? '€'}</p> : <p className="text-xs text-ink-faint">—</p>}
|
||||
<p className="text-[10px] text-ink-faint capitalize">{c.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Income observations */}
|
||||
{incomeObs.length > 0 && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Venituri logate ({incomeObs.length})</p>
|
||||
<div className="divide-y divide-border/50">
|
||||
{incomeObs.slice(0, 8).map((o) => (
|
||||
<div key={o.id} className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<p className="text-sm text-ink">{o.source ?? o.metric}</p>
|
||||
<p className="text-[10px] text-ink-faint">{new Date(o.observedAt ?? o.createdAt).toLocaleDateString('ro-RO')}</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-signal-ok">{parseNum(o.value).toLocaleString('ro-RO')} {o.unit ?? '€'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue