// Finanzen — refined private-banking feel
const { useState, useEffect } = React;
// Empty bar templates (kept so charts still render an axis when there's no data yet)
const EMPTY_WEEK_BARS = [
{ label: 'Mo', inc: 0, exp: 0 }, { label: 'Di', inc: 0, exp: 0 },
{ label: 'Mi', inc: 0, exp: 0 }, { label: 'Do', inc: 0, exp: 0 },
{ label: 'Fr', inc: 0, exp: 0 }, { label: 'Sa', inc: 0, exp: 0 },
{ label: 'So', inc: 0, exp: 0 },
];
const EMPTY_MONTH_BARS = [
{ label: 'W1', inc: 0, exp: 0 }, { label: 'W2', inc: 0, exp: 0 },
{ label: 'W3', inc: 0, exp: 0 }, { label: 'W4', inc: 0, exp: 0 },
];
const EMPTY_YEAR_BARS = [
{ label: 'Jan', inc: 0, exp: 0 }, { label: 'Feb', inc: 0, exp: 0 },
{ label: 'Mär', inc: 0, exp: 0 }, { label: 'Apr', inc: 0, exp: 0 },
{ label: 'Mai', inc: 0, exp: 0 }, { label: 'Jun', inc: 0, exp: 0 },
{ label: 'Jul', inc: 0, exp: 0 }, { label: 'Aug', inc: 0, exp: 0 },
{ label: 'Sep', inc: 0, exp: 0 }, { label: 'Okt', inc: 0, exp: 0 },
{ label: 'Nov', inc: 0, exp: 0 }, { label: 'Dez', inc: 0, exp: 0 },
];
// All historical month detail is now derived from the live `transactions` array
// via fin_buildMonthsRange / fin_txInMonth / fin_sumAgg (see finance-data.jsx).
function pctF(a, b) {if (!b) return null;const p = Math.round((a - b) / b * 100);return { val: Math.abs(p), up: p >= 0 };}
// System colors (matches calendar status bars, equipment utilization, etc.)
const palette = (t) => ({
pos: t.green,
posSft: t.greenSoft,
posBar: t.green + (t.dark ? '85' : '6E'), // slightly more saturated but still soft
neg: t.red,
negSft: t.redSoft,
negBar: t.red + (t.dark ? '85' : '6E'),
ink: t.text,
grid: t.dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'
});
const NUMS = { fontVariantNumeric: 'tabular-nums', fontFeatureSettings: '"tnum"' };
// ─── Chart components ───────────────────────────────────────
function ChartBars({ bars, t, onPick, monthKeys }) {
const p = palette(t);
const rawMax = Math.max(...bars.map((b) => Math.max(b.inc, b.exp)), 1);
// Round max up to a "nice" number so the axis ticks read cleanly
const niceMax = (v) => {
const pow = Math.pow(10, Math.floor(Math.log10(v)));
const n = v / pow;
const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10;
return step * pow;
};
const max = niceMax(rawMax);
const fmtAxis = (v) => v >= 1000 ? (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1).replace('.', ',') + 'k' : String(v);
const AXIS_W = 28;
const H = 110;
const [active, setActive] = React.useState(null); // hovered bar index
const [pinned, setPinned] = React.useState(null); // tap-pinned bar (touch)
// Pointer interaction: on touch we capture and scrub; on mouse we use per-bar enter/leave.
const rowRef = React.useRef(null);
const updateActiveFromEvent = (e) => {
const el = rowRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const x = (e.clientX || (e.touches && e.touches[0] && e.touches[0].clientX) || 0) - rect.left;
const idx = Math.floor(x / (rect.width / bars.length));
if (idx >= 0 && idx < bars.length) setActive(idx);
};
return (
{/* Y-axis labels */}
{[0, 0.25, 0.5, 0.75, 1].map((g) =>
{fmtAxis(Math.round(max * g))}{g === 1 ? ' €' : ''}
)}
{/* Gridlines */}
{[0, 0.25, 0.5, 0.75, 1].map((g) =>
)}
{ if (e.pointerType === 'touch') { updateActiveFromEvent(e); try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} } }}
onPointerMove={(e) => { if (e.pointerType === 'touch' && (e.buttons || e.pressure)) updateActiveFromEvent(e); }}
onPointerUp={(e) => { if (e.pointerType === 'touch') { updateActiveFromEvent(e); setPinned(active); setTimeout(() => { setActive(null); setPinned(null); }, 1600); } }}
style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: H, position: 'relative', touchAction: 'pan-y' }}>
{bars.map((bar, i) => {
const isOn = active === i || pinned === i;
const incBg = isOn ? p.pos : p.posBar;
const expBg = isOn ? p.neg : p.negBar;
const monthKey = monthKeys && monthKeys[i];
const net = bar.inc - bar.exp;
return (
setActive(i)}
onMouseLeave={() => setActive((v) => v === i ? null : v)}
onClick={() => { if (monthKey && onPick) { onPick(monthKey); } else { setPinned(i); setTimeout(() => setPinned(null), 1600); } }}
style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, height: '100%', justifyContent: 'flex-end', cursor: monthKey ? 'pointer' : 'default', position: 'relative' }}>
{isOn &&
{bar.label}
Ein.
+{bar.inc} €
Aus.
−{bar.exp} €
Netto
= 0 ? p.pos : p.neg, fontWeight: 700 }}>{net >= 0 ? '+' : '−'}{Math.abs(net)} €
{monthKey &&
Klick → Details
}
{/* arrow */}
}
);
})}
{bars.map((bar, i) =>
{bar.label}
)}
);
}
function ChartLine({ bars, t }) {
const p = palette(t);
const max = Math.max(...bars.map((b) => Math.max(b.inc, b.exp)), 1);
const W = 280,H = 100,pad = 6;
const xs = (i) => i / (bars.length - 1 || 1) * (W - pad * 2) + pad;
const ys = (v) => H - v / max * (H - pad * 2) - pad;
const linePath = (key) => bars.map((b, i) => `${i === 0 ? 'M' : 'L'}${xs(i)} ${ys(b[key])}`).join(' ');
const areaPath = `M${pad} ${H} ${bars.map((b, i) => `L${xs(i)} ${ys(b.inc)}`).join(' ')} L${W - pad} ${H} Z`;
return (
);
}
function ChartArea({ bars, t }) {
const p = palette(t);
let cum = 0;
const nets = bars.map((b) => (cum += b.inc - b.exp, cum));
const max = Math.max(...nets, 0),min = Math.min(...nets, 0);
const range = max - min || 1;
const W = 280,H = 100,pad = 6;
const xs = (i) => i / (nets.length - 1 || 1) * (W - pad * 2) + pad;
const ys = (v) => H - (v - min) / range * (H - pad * 2) - pad;
const linePath = nets.map((v, i) => `${i === 0 ? 'M' : 'L'}${xs(i)} ${ys(v)}`).join(' ');
const baseY = ys(0);
const areaPath = `M${pad} ${baseY} ${nets.map((v, i) => `L${xs(i)} ${ys(v)}`).join(' ')} L${W - pad} ${baseY} Z`;
return (
);
}
function ChartDonut({ income, expenses, profit, t }) {
const p = palette(t);
const total = income + expenses || 1;
const incPct = income / total;
const C = 80,R = 58,SW = 10,gap = 2;
const circ = 2 * Math.PI * R;
return (
Netto
{profit >= 0 ? '+' : ''}{profit.toLocaleString('de-DE')} €
{Math.round(incPct * 100)}% Ein. · {Math.round((1 - incPct) * 100)}% Aus.
);
}
const CHART_TYPES = [
{ id: 'bars', label: 'Balken', icon: 'M4 20V10M10 20V4M16 20V14M22 20H2' },
{ id: 'line', label: 'Linie', icon: 'M3 17l5-5 4 3 8-9' },
{ id: 'area', label: 'Gewinn', icon: 'M3 17l5-5 4 3 8-9M3 20h18' },
{ id: 'donut', label: 'Anteil', icon: 'M21 12a9 9 0 11-9-9v9h9z' }];
// ─── Add Transaction Sheet ──────────────────────────────────
function AddTxSheet({ open, onClose, onSave }) {
const t = useTheme();
const p = palette(t);
const [kind, setKind] = useState('in');
const [label, setLabel] = useState('');
const [sub, setSub] = useState('');
const [amount, setAmount] = useState('');
const [date, setDate] = useState(() => fin_todayISO());
// Whenever the sheet (re-)opens, default the date back to today
useEffect(() => { if (open) setDate(fin_todayISO()); }, [open]);
const reset = () => {setKind('in');setLabel('');setSub('');setAmount('');setDate(fin_todayISO());};
const close = () => {reset();onClose();};
const save = () => {
const n = parseFloat(String(amount).replace(',', '.'));
if (!label.trim() || !n || n <= 0) return;
onSave({ kind, label: label.trim(), sub: sub.trim() || (kind === 'in' ? 'Vermietung' : 'Ausgabe'), amount: Math.round(n), date: date || fin_todayISO() });
reset();onClose();
};
const fInp = {
width: '100%', padding: '12px 14px', borderRadius: 12,
border: `0.5px solid ${t.inputBorder}`, background: t.card,
fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box'
};
return (
Neue Buchung
Einnahme oder Ausgabe erfassen.
Abbrechen
{[['in', 'Einnahme', p.pos], ['out', 'Ausgabe', p.neg]].map(([k, l, c]) =>
setKind(k)} scale={0.96} style={{ flex: 1 }}>
)}
Datum
{date !== fin_todayISO() &&
setDate(fin_todayISO())} scale={0.96}>
Heute
}
setDate(e.target.value)} type="date" max={fin_todayISO()}
style={{ ...fInp, color: t.text, fontVariantNumeric: 'tabular-nums' }} />
{date === fin_todayISO() ? 'Standard: heute. Antippen, um nachträglich einzutragen.' : `Wird am ${fin_fmtDateLabel(date)} eingetragen.`}
Speichern
);
}
// ─── Edit Transaction Sheet — pre-filled, with delete ───────
function EditTxSheet({ tx, onClose, onSave, onDelete }) {
const t = useTheme();
const p = palette(t);
const [kind, setKind] = useState('in');
const [label, setLabel] = useState('');
const [sub, setSub] = useState('');
const [amount, setAmount] = useState('');
const [date, setDate] = useState('');
const [confirmDel, setConfirmDel] = useState(false);
useEffect(() => {
if (tx) {
setKind(tx.kind);
setLabel(tx.label);
setSub(tx.sub || '');
setAmount(String(tx.amount));
setDate(tx.date && /^\d{4}-\d{2}-\d{2}/.test(tx.date) ? tx.date.slice(0, 10) : fin_todayISO());
}
}, [tx && tx.id]);
if (!tx) return null;
const save = () => {
const n = parseFloat(String(amount).replace(',', '.'));
if (!label.trim() || !n || n <= 0) return;
onSave({ kind, label: label.trim(), sub: sub.trim(), amount: Math.round(n), date: date || fin_todayISO() });
};
const fInp = {
width: '100%', padding: '12px 14px', borderRadius: 12,
border: `0.5px solid ${t.inputBorder}`, background: t.card,
fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box'
};
return (
Buchung bearbeiten
{fin_fmtDateLabel(tx.date)}
Abbrechen
{[['in', 'Einnahme', p.pos], ['out', 'Ausgabe', p.neg]].map(([k, l, c]) =>
setKind(k)} scale={0.96} style={{ flex: 1 }}>
)}
Datum
{date !== fin_todayISO() &&
setDate(fin_todayISO())} scale={0.96}>
Auf heute
}
setDate(e.target.value)} type="date"
style={{ ...fInp, color: t.text, fontVariantNumeric: 'tabular-nums' }} />
setConfirmDel(true)} scale={0.97}>
Speichern
setConfirmDel(false)}
onConfirm={() => { setConfirmDel(false); onDelete(); }} />
);
}
// ─── Month Detail Sheet — drill into a specific month for analysis ─────────
function MonthDetailSheet({ open, monthKey, onClose, t, p, transactions, monthsRange }) {
const [chartType, setChartType] = useState('bars');
if (!monthKey) return null;
// Compute month figures on the fly from the shared transactions array.
const [y, m] = monthKey.split('-').map(Number);
const monthTxs = fin_txInMonth(transactions, monthKey).slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''));
const { income, expenses } = fin_sumAgg(monthTxs);
const profit = income - expenses;
const margin = income > 0 ? Math.round(profit / income * 100) : 0;
const bars = fin_weeklyBars(transactions, y, m - 1);
const label = fin_fmtMonthLabel(monthKey);
// Compare with previous month from range
const keys = (monthsRange || []).map(mo => mo.key);
const idx = keys.indexOf(monthKey);
let prevAgg = null;
if (idx > 0) prevAgg = fin_sumAgg(fin_txInMonth(transactions, keys[idx - 1]));
const incTrend = prevAgg ? pctF(income, prevAgg.income) : null;
const expTrend = prevAgg ? pctF(expenses, prevAgg.expenses) : null;
const profTrend = prevAgg ? pctF(profit, prevAgg.income - prevAgg.expenses) : null;
// Top categories from transactions
const topIn = [...monthTxs].filter((x) => x.kind === 'in').sort((a, b) => b.amount - a.amount).slice(0, 1)[0];
const topOut = [...monthTxs].filter((x) => x.kind === 'out').sort((a, b) => b.amount - a.amount).slice(0, 1)[0];
return (
{/* Big stats */}
Netto-Ergebnis
= 0 ? p.pos : p.neg, letterSpacing: -1.2, ...NUMS }}>
{profit >= 0 ? '+' : ''}{profit.toLocaleString('de-DE')}€
{profTrend &&
{profTrend.up ? '▲' : '▼'} {profTrend.val}%
}
Marge {margin}% · {monthTxs.length} Buchungen
{income.toLocaleString('de-DE')} €
{incTrend &&
{incTrend.up ? '▲' : '▼'} {incTrend.val}% vs. Vormonat
}
{expenses.toLocaleString('de-DE')} €
{expTrend &&
{expTrend.up ? '▲' : '▼'} {expTrend.val}% vs. Vormonat
}
{/* Chart switch */}
{CHART_TYPES.map((c) =>
setChartType(c.id)} scale={0.94} style={{ flex: 1 }}>
{c.label}
)}
{/* Chart */}
{chartType === 'bars' && }
{chartType === 'line' && }
{chartType === 'area' && }
{chartType === 'donut' && }
{/* Highlights */}
{(topIn || topOut) &&
Top-Positionen
{topIn &&
Größte Einnahme
{topIn.label}
+{topIn.amount} €
}
{topOut &&
Größte Ausgabe
{topOut.label}
−{topOut.amount} €
}
}
{/* Transactions */}
Alle Buchungen
{monthTxs.map((tx, i, arr) => {
const isIn = tx.kind === 'in';
return (
{tx.label}
{tx.sub} · {fin_fmtDateLabel(tx.date)}
{isIn ? '+' : '−'}{tx.amount} €
);
})}
);
}
// ─── Main Finanzen ───────────────────────────────────────────
function ScreenFinanzen({ toast, transactions, setTransactions, installedAt }) {
const t = useTheme();
const p = palette(t);
const [period, setPeriod] = useState('month');
const [tab, setTab] = useState('all');
const [addOpen, setAddOpen] = useState(false);
const [editingTx, setEditingTx] = useState(null);
const [monthDetailKey, setMonthDetailKey] = useState(null);
const [defaultChart, setDefaultChart] = useState(() => {
try {
const raw = localStorage.getItem('rf-default-chart');
if (!raw) return 'bars';
// Tolerate legacy raw-string format AND the proper JSON-encoded form.
try { const v = JSON.parse(raw); return typeof v === 'string' ? v : 'bars'; }
catch { return raw; }
} catch {return 'bars';}
});
const [chartType, setChartType] = useState(defaultChart);
const lpRef = React.useRef({ timer: null, fired: false });
const startPinPress = (id) => {
lpRef.current.fired = false;
clearTimeout(lpRef.current.timer);
lpRef.current.timer = setTimeout(() => {
lpRef.current.fired = true;
pinAsDefault(id);
if (navigator.vibrate) try { navigator.vibrate(10); } catch (_) {}
}, 500);
};
const cancelPinPress = () => { clearTimeout(lpRef.current.timer); };
const pinAsDefault = (id) => {
try {localStorage.setItem('rf-default-chart', JSON.stringify(id));} catch {}
setDefaultChart(id);
toast(`„${CHART_TYPES.find((c) => c.id === id).label}“ als Standard gesetzt`);
};
const baseD = { label: period === 'month' ? fin_fmtMonthLabel(fin_monthKeyOf(new Date())) : `Jahr ${new Date().getFullYear()}` };
// ── Live, dynamic data ──
const today = new Date();
const curYear = today.getFullYear();
const curMonth = today.getMonth();
const CURRENT_MONTH_KEY = fin_monthKeyOf(today);
const monthsRange = fin_buildMonthsRange(installedAt, transactions);
const currentMonthLabel = fin_fmtMonthLabel(CURRENT_MONTH_KEY);
const periodTxs = period === 'month'
? fin_txInMonth(transactions, CURRENT_MONTH_KEY)
: fin_txInYear(transactions, curYear);
const periodAgg = fin_sumAgg(periodTxs);
const income = periodAgg.income;
const expenses = periodAgg.expenses;
const profit = periodAgg.profit;
// Bars (weekly for month, monthly-of-active-months for year)
const bars = period === 'month'
? fin_weeklyBars(transactions, curYear, curMonth)
: fin_yearBars(transactions, curYear, installedAt);
// Kontostand = net of ALL transactions (Home & Finanzen share this exact number)
const liveBalance = fin_sumAgg(transactions).profit;
// Hero-card month figures (always current month, independent of period toggle)
const monthTxs = fin_txInMonth(transactions, CURRENT_MONTH_KEY);
const monthAgg = fin_sumAgg(monthTxs);
const monthIncome = monthAgg.income;
const monthExpenses = monthAgg.expenses;
const monthProfit = monthAgg.profit;
// Mini-Bars: income per month across the active range (installation → today,
// or oldest tx → today). Shows one bar per month — e.g. 2 bars for Mai + Juni.
const monthBars = fin_monthlyIncomeBars(transactions, installedAt, 12);
const sparkMax = Math.max(...monthBars.map(b => b.income)) || 1;
// Transaction list (sorted newest-first)
const filtered = periodTxs
.filter((tx) => tab === 'all' || tx.kind === tab)
.slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''));
const saveTxEdit = (id, patch) => {
setTransactions((prev) => prev.map((x) => x.id === id ? { ...x, ...patch } : x));
toast('Buchung aktualisiert');
};
const deleteTx = (id) => {
setTransactions((prev) => prev.filter((x) => x.id !== id));
toast('Buchung gelöscht');
};
const onSaveTx = (tx) => {
setTransactions((prev) => [{ id: Date.now(), ...tx }, ...prev]);
toast(`${tx.kind === 'in' ? 'Einnahme' : 'Ausgabe'} ${tx.amount} € gespeichert`);
};
const fmt = (n) => Math.abs(n) >= 1000 ? (n / 1000).toFixed(1).replace('.', ',') + 'T' : String(n);
return (
{/* Header — title row on top, period segmented control below for breathing room */}
setAddOpen(true)} scale={0.88}>
{[['month', 'Monat'], ['year', 'Jahr']].map(([id, label]) =>
setPeriod(id)} scale={0.96}>
{label}
)}
{/* Hero: balance — matches Home Kontostand card style, with Einnahmen + Ausgaben */}
{/* Row 1 — Kontostand + sparkline */}
Kontostand
{liveBalance.toLocaleString('de-DE')} €
Verfügbares Guthaben
{monthBars.map((b, i) =>
)}
{/* Hairline */}
{currentMonthLabel}
{/* Einnahmen */}
+{monthIncome.toLocaleString('de-DE')} €
{/* Ausgaben */}
−{monthExpenses.toLocaleString('de-DE')} €
{/* Netto */}
= 0 ? p.pos : p.neg }} />
Netto
= 0 ? p.pos : p.neg, marginTop: 5, ...NUMS }}>
{monthProfit >= 0 ? '+' : ''}{monthProfit.toLocaleString('de-DE')} €
{/* Chart selector — always visible. Long-press a tile to make it the default. */}
{CHART_TYPES.map((c) =>
startPinPress(c.id)}
onPointerUp={cancelPinPress}
onPointerLeave={cancelPinPress}
onPointerCancel={cancelPinPress}
onContextMenu={(e) => { e.preventDefault(); pinAsDefault(c.id); }}>
{ if (!lpRef.current.fired) setChartType(c.id); }} scale={0.94} style={{ width: '100%' }}>
{c.label}
{defaultChart === c.id &&
}
)}
Lang gedrückt halten, um als Standard festzulegen
{/* Chart card */}
{chartType === 'donut' ? 'Aufteilung' :
chartType === 'area' ? 'Gewinn kumuliert' :
chartType === 'line' ? 'Verlauf' : 'Einnahmen vs. Ausgaben'}
{chartType !== 'area' &&
<>
{chartType === 'donut' ? 'Ein.' : 'Einnahmen'}
{chartType === 'donut' ? 'Aus.' : 'Ausgaben'}
>
}
{chartType === 'area' &&
}
{chartType === 'bars' &&
setMonthDetailKey(key)}
monthKeys={period === 'year' ? bars.map(b => b.monthKey) : null}/>}
{chartType === 'line' && }
{chartType === 'area' && }
{chartType === 'donut' && }
{/* Transaction list */}
Buchungen
{[['all', 'Alle'], ['in', 'Ein'], ['out', 'Aus']].map(([id, label]) =>
setTab(id)} scale={0.92}>
{label}
)}
{filtered.length === 0 &&
Keine Buchungen.
}
{filtered.map((tx, i, arr) => {
const isIncome = tx.kind === 'in';
const col = isIncome ? p.pos : p.neg;
return (
setEditingTx(tx)}
onEdit={() => setEditingTx(tx)}
onDelete={() => deleteTx(tx.id)}>
{isIncome ? '+' : '−'}{tx.amount} €
{tx.date}
);
})}
{/* Monatsverlauf — drill into past months */}
Monatsverlauf
Tippen für Analyse
{monthsRange.length === 0 &&
Noch keine Monate.
}
{monthsRange.slice().reverse().map((mo, i, arr) => {
const key = mo.key;
const mAgg = fin_sumAgg(fin_txInMonth(transactions, key));
const prof = mAgg.profit;
const isCurrent = key === CURRENT_MONTH_KEY;
return (
setMonthDetailKey(key)} scale={0.99}>
{mo.short}
{key.slice(2, 4)}
{mo.label}
{isCurrent && JETZT}
+{mAgg.income} €
−{mAgg.expenses} €
= 0 ? p.pos : p.neg, ...NUMS }}>
{prof >= 0 ? '+' : ''}{prof.toLocaleString('de-DE')} €
Netto
);
})}
setAddOpen(false)} onSave={onSaveTx} />
setEditingTx(null)}
onSave={(patch) => { saveTxEdit(editingTx.id, patch); setEditingTx(null); }}
onDelete={() => { deleteTx(editingTx.id); setEditingTx(null); }} />
setMonthDetailKey(null)} t={t} p={p}
transactions={transactions} monthsRange={monthsRange} />
);
}
Object.assign(window, { ScreenFinanzen });