From 724460b3fe91209ab165c07c3459264a76bb1fd3 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 18:00:42 +0000 Subject: [PATCH] feat(CC-090): add Revenue Tracker page (signed contracts + income observations, monthly bar chart) --- src/app/dashboard/finance/revenue/page.tsx | 178 +++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src/app/dashboard/finance/revenue/page.tsx diff --git a/src/app/dashboard/finance/revenue/page.tsx b/src/app/dashboard/finance/revenue/page.tsx new file mode 100644 index 0000000..0a75f53 --- /dev/null +++ b/src/app/dashboard/finance/revenue/page.tsx @@ -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('/v1/contracts?limit=500', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + + const { data: observations = [], isLoading: loadObs } = useQuery({ + queryKey: ['rev-obs', tenantId], + queryFn: () => apiFetch('/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 = {}; + 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 ( +
+
+
+

Revenue Tracker

+

Contracte semnate + venituri din observații

+
+ + ← Finance + +
+ + {/* KPI Cards */} +
+ {[ + { 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) => ( +
+

{kpi.label}

+

+ {kpi.value.toLocaleString('ro-RO')} € +

+
+ ))} +
+ + {/* Monthly bar chart */} + {sortedMonths.length > 0 ? ( +
+

Ultimele 12 luni

+
+ {sortedMonths.map((k) => { + const v = byMonth[k]; + const pct = (v / maxVal) * 100; + return ( +
+

{v >= 1000 ? `${Math.round(v / 1000)}k` : v}

+
+

{monthLabel(k).slice(0, 3)}

+
+ ); + })} +
+
+ ) : ( + !loadCt && !loadObs && ( +
+

📊

+

Niciun venit înregistrat. Adaugă contracte cu valoare sau observații cu metrica „venit".

+
+ ) + )} + + {/* Signed contracts list */} + {signedContracts.length > 0 && ( +
+

Contracte active / semnate ({signedContracts.length})

+
+ {signedContracts.slice(0, 10).map((c) => ( +
+
+

{c.title}

+ {c.counterparty &&

{c.counterparty}

} +
+
+ {c.value ?

{c.value.toLocaleString('ro-RO')} {c.currency ?? '€'}

:

} +

{c.status}

+
+
+ ))} +
+
+ )} + + {/* Income observations */} + {incomeObs.length > 0 && ( +
+

Venituri logate ({incomeObs.length})

+
+ {incomeObs.slice(0, 8).map((o) => ( +
+
+

{o.source ?? o.metric}

+

{new Date(o.observedAt ?? o.createdAt).toLocaleDateString('ro-RO')}

+
+

{parseNum(o.value).toLocaleString('ro-RO')} {o.unit ?? '€'}

+
+ ))} +
+
+ )} +
+ ); +}