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