diff --git a/src/app/dashboard/finance/cashflow/page.tsx b/src/app/dashboard/finance/cashflow/page.tsx new file mode 100644 index 0000000..990bba2 --- /dev/null +++ b/src/app/dashboard/finance/cashflow/page.tsx @@ -0,0 +1,175 @@ +'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 Observation { id: string; metric: string; value: string; unit: string | null; subjectType: string; observedAt: string | null; createdAt: string; } +interface Contract { id: string; value: number | null; status: string; createdAt: string; } + +const INCOME_TYPES = ['venit','income','revenue','incasare','factura']; +const EXPENSE_TYPES = ['cheltuiala','expense','cost','plata']; +const MONTHS_LABELS = ['Ian','Feb','Mar','Apr','Mai','Iun','Iul','Aug','Sep','Oct','Nov','Dec']; + +function parseNum(v: string) { return parseFloat(v.replace(/[^0-9.-]/g, '')) || 0; } +function monthKey(iso: string) { const d = new Date(iso); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; } +function shortLabel(k: string) { const [,m] = k.split('-'); return MONTHS_LABELS[parseInt(m) - 1]; } + +const LAST_12 = Array.from({ length: 12 }, (_, i) => { + const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - (11 - i)); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; +}); + +export default function CashflowPage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + + const { data: observations = [], isLoading: loadObs } = useQuery({ + queryKey: ['cf-obs', tenantId], + queryFn: () => apiFetch('/v1/observations', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + + const { data: contracts = [], isLoading: loadCt } = useQuery({ + queryKey: ['cf-contracts', tenantId], + queryFn: () => apiFetch('/v1/contracts?limit=500', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + + const { incomeByMonth, expenseByMonth } = useMemo(() => { + const inc: Record = {}; + const exp: Record = {}; + for (const o of observations) { + const key = monthKey(o.observedAt ?? o.createdAt); + const metric = o.metric.toLowerCase(); + const val = parseNum(o.value); + if (INCOME_TYPES.some((t) => metric.includes(t)) || o.subjectType === 'income') { + inc[key] = (inc[key] ?? 0) + val; + } else if (EXPENSE_TYPES.some((t) => metric.includes(t)) || o.subjectType === 'expense') { + exp[key] = (exp[key] ?? 0) + val; + } + } + for (const c of contracts.filter((c) => c.status === 'signed' || c.status === 'completed')) { + if (!c.value) continue; + const key = monthKey(c.createdAt); + inc[key] = (inc[key] ?? 0) + c.value; + } + return { incomeByMonth: inc, expenseByMonth: exp }; + }, [observations, contracts]); + + const rows = LAST_12.map((k) => ({ + key: k, + label: shortLabel(k), + income: incomeByMonth[k] ?? 0, + expense: expenseByMonth[k] ?? 0, + net: (incomeByMonth[k] ?? 0) - (expenseByMonth[k] ?? 0), + })); + + const maxVal = Math.max(...rows.map((r) => Math.max(r.income, r.expense)), 1); + const totalNet = rows.reduce((s, r) => s + r.net, 0); + const totalInc = rows.reduce((s, r) => s + r.income, 0); + const totalExp = rows.reduce((s, r) => s + r.expense, 0); + + let running = 0; + const runningBalances = rows.map((r) => { running += r.net; return running; }); + const maxRun = Math.max(...runningBalances.map(Math.abs), 1); + + return ( +
+
+
+

Cashflow

+

Intrări vs ieșiri · ultimele 12 luni

+
+ ← Finance +
+ + {/* KPI row */} +
+ {[ + { label: 'Total intrări', value: totalInc, color: 'text-signal-ok' }, + { label: 'Total ieșiri', value: totalExp, color: 'text-signal-danger' }, + { label: 'Net 12 luni', value: totalNet, color: totalNet >= 0 ? 'text-signal-ok' : 'text-signal-danger' }, + ].map((k) => ( +
+

{k.label}

+

+ {k.value >= 0 ? '+' : ''}{k.value.toLocaleString('ro-RO')} € +

+
+ ))} +
+ + {/* Grouped bar chart */} + {!loadObs && !loadCt && ( +
+
+ Intrări + Ieșiri +
+
+ {rows.map((r) => ( +
+
+
+
+
+

{r.label}

+
+ ))} +
+
+ )} + + {/* Running balance */} +
+

Balanță cumulată

+
+ {runningBalances.map((bal, i) => { + const pct = (Math.abs(bal) / maxRun) * 100; + return ( +
+
= 0 ? 'bg-signal-ok/60' : 'bg-signal-danger/60'}`} + style={{ height: `${Math.max(pct, 2)}%` }} + title={`${LAST_12[i]}: ${bal.toLocaleString()} €`} /> +
+ ); + })} +
+
+ {rows.map((r) => {r.label})} +
+
+ + {/* Monthly table */} +
+ + + + + + + + + + + {[...rows].reverse().map((r) => ( + + + + + + + ))} + +
LunăIntrăriIeșiriNet
{r.key}{r.income > 0 ? `+${r.income.toLocaleString('ro-RO')} €` : '—'}{r.expense > 0 ? `${r.expense.toLocaleString('ro-RO')} €` : '—'}= 0 ? 'text-signal-ok' : 'text-signal-danger'}`}> + {r.net >= 0 ? '+' : ''}{r.net.toLocaleString('ro-RO')} € +
+
+
+ ); +}