'use client'; // Finance charts (Recharts). Loaded with next/dynamic from SummaryView so the // chart library only ships when the Finance tab opens. // // Colors: one blue for single-series charts (category/method names sit on the // axis, so identity never relies on color); on the waterfall blue = money in, // red = money out and gray = totals, with signed value labels and a legend // that lists only the kinds on screen; blue = paid / orange = planned with a // legend. Validated with the dataviz palette checks. import { useEffect, useState } from 'react'; import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell, ReferenceLine, LineChart, Line, Legend, LabelList, } from 'recharts'; import type { FinanceSummary, ExpenseCategory } from '@/lib/api'; import { formatNumber, formatPyg, formatPygShort } from '@/lib/money'; import { buildWaterfall, detailChartsAvailable, type WaterfallKind } from './derive'; const C = { blue: '#2a78d6', orange: '#eb6834', red: '#e34948', total: '#52514e', grid: '#eeeeec', axis: '#8a8984', text: '#3f3e3b', }; const KIND_COLOR: Record = { in: C.blue, out: C.red, total: C.total }; const KIND_SYMBOL: Record = { in: '+', out: '−', total: '=' }; type T = (key: string, params?: Record) => string; const axisProps = { stroke: C.axis, fontSize: 12, tickLine: false, axisLine: false } as const; /** Bar labels: exact under 10.000 PYG, abbreviated above, always with the currency. */ const labelPyg = (v: number, locale: string) => (Math.abs(v) < 10000 ? formatPyg(v, locale) : `${formatPygShort(v, locale)} PYG`); export function ChartCard({ title, children, subtitle, legend, className }: { title: string; subtitle?: string; legend?: React.ReactNode; children: React.ReactNode; className?: string; }) { return (

{title}

{legend}
{subtitle &&

{subtitle}

}
{children}
); } function LegendChip({ color, label, symbol }: { color: string; label: string; symbol: string }) { return ( {symbol} {label} ); } function PygTooltip({ active, payload, label, rows, locale }: any) { if (!active || !payload?.length) return null; const items: { name: string; value: number; color?: string }[] = rows ? rows(payload[0].payload) : payload.map((p: any) => ({ name: p.name, value: p.value, color: p.color })); return (
{label !== undefined &&

{label}

} {items.map((it) => (

{it.color && } {it.name} {formatPyg(it.value, locale)}

))}
); } // ==================== Waterfall ==================== /** Phones get a horizontal waterfall: six step names never fit side by side. */ function useNarrow(maxWidth = 640) { const [narrow, setNarrow] = useState(false); useEffect(() => { const mq = window.matchMedia(`(max-width: ${maxWidth - 1}px)`); const update = () => setNarrow(mq.matches); update(); mq.addEventListener('change', update); return () => mq.removeEventListener('change', update); }, [maxWidth]); return narrow; } /** The waterfall bars only; the card around it (with the breakdown) lives in SummaryView. */ export function WaterfallChart({ summary, locale, t, estimated }: { summary: FinanceSummary; locale: string; t: T; estimated: boolean }) { const narrow = useNarrow(); const steps = buildWaterfall(summary); let running = 0; const rows = steps.map((w) => { const isTotal = w.kind === 'total'; const start = isTotal ? 0 : running; const end = isTotal ? w.amount : running + w.amount; running = end; const name = w.key === 'profit' ? t(w.amount < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit') : t(`admin.finance.waterfall.${w.key}`); return { key: w.key, kind: w.kind, name, amount: w.amount, range: [Math.min(start, end), Math.max(start, end)] as [number, number], color: KIND_COLOR[w.kind], signed: isTotal ? labelPyg(w.amount, locale) : `${w.amount >= 0 ? '+' : '−'}${labelPyg(Math.abs(w.amount), locale)}`, }; }); const partnerRows = summary.split.partners.map((p) => ({ name: p.name, value: -p.share, color: C.red })); const tooltipRows = (r: any) => (r.key === 'partners' ? partnerRows : [{ name: r.name, value: r.amount, color: r.color }]); const kinds = (['in', 'out', 'total'] as WaterfallKind[]).filter((k) => rows.some((r) => r.kind === k)); // Value label above each bar's top edge. Bars under zero hang from the zero // line, so their label sits just above it and never runs into the axis labels. const renderLabel = (props: any): React.ReactElement => { const { x, y, width, height, index } = props; const r = rows[index]; if (!r) return ; const top = Math.min(y, y + height); return ( {r.signed} ); }; const short = (v: number) => formatPygShort(v, locale); return (

{t('admin.finance.waterfall.title')}

{kinds.map((k) => ( ))}
{estimated &&

{t('admin.finance.waterfall.estimatedHint')}

}
{narrow ? ( // Step name and signed amount as a two-line axis label; bars run left/right from zero. { const r = rows[props.index]; return ( {r?.name} {r?.signed} ); }} /> } /> {rows.map((r) => )} ) : ( } /> {rows.map((r) => )} )}
); } // ==================== Horizontal single-series bars ==================== function HorizontalBars({ data, locale }: { data: { name: string; value: number; detail?: { name: string; value: number }[] }[]; locale: string }) { return ( formatPygShort(v, locale)} /> r.detail || [{ name: r.name, value: r.value, color: C.blue }]} />} /> labelPyg(v, locale)} style={{ fontSize: 11, fill: C.text, fontWeight: 600 }} /> ); } function categoryName(id: string | null, categories: ExpenseCategory[], locale: string, t: T) { const c = categories.find((x) => x.id === id); if (!c) return t('admin.finance.charts.uncategorized'); return locale === 'es' ? c.nameEs : c.nameEn; } export function ExpensesByCategoryChart({ summary, categories, locale, t }: { summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T }) { const data = summary.expenses.byCategory .filter((c) => c.total > 0) .map((c) => ({ name: categoryName(c.categoryId, categories, locale, t), value: c.total })); return ( ); } export function RevenueByMethodChart({ summary, locale, t }: { summary: FinanceSummary; locale: string; t: T }) { const data = summary.revenue.byMethod .filter((m) => m.gross > 0) .map((m) => ({ name: t(`admin.finance.methods.${m.method}`), value: m.gross, detail: [ { name: t('admin.finance.waterfall.gross'), value: m.gross }, { name: t('admin.finance.waterfall.refunds'), value: -m.refunds }, { name: t('admin.finance.waterfall.fees'), value: -m.fees }, { name: t('admin.finance.waterfall.net'), value: m.net }, ], })); return ( ); } // ==================== Cumulative sales ==================== export function CumulativeSalesChart({ summary, locale, t }: { summary: FinanceSummary; locale: string; t: T }) { const fmtDay = (d: string) => new Date(`${d}T12:00:00Z`).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { day: 'numeric', month: 'short' }); const data = summary.salesTimeline.map((p) => ({ ...p, label: fmtDay(p.date) })); const breakEven = summary.expenses.total > 0 ? summary.breakEven.tickets : null; const maxSold = Math.max(1, ...data.map((d) => d.tickets)); // Only draw the break-even line when it fits a readable scale; otherwise a // far-away target would flatten the sales line to the floor. const showLine = breakEven !== null && breakEven > 0 && breakEven <= maxSold * 3; const top = Math.ceil(Math.max(maxSold, showLine ? breakEven! : 0) * 1.15); return ( 0 && !showLine ? t('admin.finance.charts.breakEvenOffChart', { count: formatNumber(breakEven!, locale) }) : undefined} > formatNumber(v, locale)} label={{ value: t('admin.finance.charts.tickets'), angle: -90, position: 'insideLeft', fontSize: 11, fill: C.axis, dy: 30 }} /> { if (!active || !payload?.length) return null; const p = payload[0].payload; return (

{p.label}

{t('admin.finance.kpi.ticketsSold', { count: formatNumber(p.tickets, locale) })}

{formatPyg(p.revenue, locale)}

); }} /> {showLine && ( )}
); } // ==================== Planned vs paid ==================== export function PlannedVsPaidChart({ summary, categories, locale, t }: { summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T }) { const data = summary.expenses.byCategory .filter((c) => c.total > 0) .map((c) => ({ name: categoryName(c.categoryId, categories, locale, t), paid: c.paid, planned: c.planned })); const paidLabel = t('admin.finance.charts.paid'); const plannedLabel = t('admin.finance.charts.planned'); const hasPaid = summary.expenses.paid > 0; const hasPlanned = summary.expenses.planned > 0; return ( formatPygShort(v, locale)} /> } /> {hasPaid && } {hasPlanned && } ); } export default function FinanceCharts({ summary, categories, locale, t, part, estimated = false }: { summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T; part: 'waterfall' | 'details'; estimated?: boolean; }) { if (part === 'waterfall') return ; const show = detailChartsAvailable(summary); if (!show.costs && !show.methods && !show.timeline) return null; return (
{show.costs && } {show.methods && } {show.timeline && } {show.costs && }
); }