This commit is contained in:
2026-08-19 11:24:28 +02:00
parent 4f5e300b4a
commit 38b6b8b1fa
6 changed files with 334 additions and 43 deletions
+65 -27
View File
@@ -10,7 +10,7 @@ const intervals = {
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
yearly: { label: 'Jährlich', divisor: 12, step: 12 },
};
const emptyData = { version: 7, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [], scenarios: [] };
const emptyData = { version: 8, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [], scenarios: [] };
function formatMoney(value) { return euro.format(Number(value || 0)); }
function parseAmount(value) {
@@ -31,14 +31,24 @@ function uid() {
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
}
function monthlyEquivalent(item) { return Number(item.amount || 0) / (intervals[item.interval]?.divisor || 1); }
function monthKey(year, monthIndex) { return `${year}-${String(monthIndex + 1).padStart(2, '0')}`; }
function dateMonth(value) { return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value.slice(0, 7) : null; }
function itemActiveInMonth(item, year, monthIndex) { const end = dateMonth(item.endDate); return !end || monthKey(year, monthIndex) <= end; }
function effectiveAmount(item, year = new Date().getFullYear(), monthIndex = new Date().getMonth()) {
const key = monthKey(year, monthIndex); let amount = Number(item.amount || 0); let best = '';
for (const change of item.amountChanges || []) { const mk = dateMonth(change.effectiveFrom); if (mk && mk <= key && mk >= best) { best = mk; amount = Number(change.amount || 0); } }
return amount;
}
function monthlyEquivalent(item, year = new Date().getFullYear(), monthIndex = new Date().getMonth()) { return itemActiveInMonth(item, year, monthIndex) ? effectiveAmount(item, year, monthIndex) / (intervals[item.interval]?.divisor || 1) : 0; }
function annualEquivalent(item) { return monthlyEquivalent(item) * 12; }
function isPeriodic(item) { return (item.interval || 'monthly') !== 'monthly'; }
function balanceAmount(item, includePeriodic) {
if (!isPeriodic(item)) return Number(item.amount || 0);
return includePeriodic ? monthlyEquivalent(item) : 0;
const now = new Date(); if (!itemActiveInMonth(item, now.getFullYear(), now.getMonth())) return 0;
if (!isPeriodic(item)) return effectiveAmount(item, now.getFullYear(), now.getMonth());
return includePeriodic ? monthlyEquivalent(item, now.getFullYear(), now.getMonth()) : 0;
}
function occursInMonth(item, monthIndex) {
function occursInMonth(item, monthIndex, year = new Date().getFullYear()) {
if (!itemActiveInMonth(item, year, monthIndex)) return false;
const interval = item.interval || 'monthly';
if (interval === 'monthly') return true;
const dueMonth = Number(item.dueMonth);
@@ -83,26 +93,26 @@ function calc(data) {
return { accounts: [...accountStats.values()], income, expense, transferVolume, periodicExpenseReserve, periodicIncomeAverage, balance: income - expense, includePeriodic };
}
function annualForecast(data, accountId = 'all') {
function annualForecast(data, accountId = 'all', year = new Date().getFullYear()) {
const isAll = !accountId || accountId === 'all';
const rows = months.map((name, index) => ({ name, index, income: 0, expense: 0, transferIn: 0, transferOut: 0, balance: 0, items: [] }));
const relevantIncome = item => isAll || item.accountId === accountId;
const relevantExpense = item => isAll || item.accountId === accountId;
const relevantTransfer = item => isAll || item.fromAccountId === accountId || item.toAccountId === accountId;
for (const item of data.incomes.filter(relevantIncome)) {
for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].income += Number(item.amount || 0); rows[m].items.push({ ...item, kind:'income' }); }
for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m); rows[m].income += amount; rows[m].items.push({ ...item, kind:'income', forecastAmount:amount }); }
}
for (const item of data.expenses.filter(relevantExpense)) {
for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].expense += Number(item.amount || 0); rows[m].items.push({ ...item, kind:'expense' }); }
for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m); rows[m].expense += amount; rows[m].items.push({ ...item, kind:'expense', forecastAmount:amount }); }
}
for (const item of data.transfers.filter(relevantTransfer)) {
for (let m=0;m<12;m++) if (occursInMonth(item,m)) {
for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) {
const amount = Number(item.amount || 0);
if (isAll) {
rows[m].transferIn += amount; rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'neutral' });
rows[m].transferIn += amount; rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'neutral', forecastAmount:amount });
} else {
if (item.toAccountId === accountId) { rows[m].transferIn += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'in' }); }
if (item.fromAccountId === accountId) { rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'out' }); }
if (item.toAccountId === accountId) { rows[m].transferIn += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'in', forecastAmount:amount }); }
if (item.fromAccountId === accountId) { rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'out', forecastAmount:amount }); }
}
}
}
@@ -125,10 +135,10 @@ function annualForecast(data, accountId = 'all') {
if (x.fromAccountId === accountId) entries.push({...x, kind:'transfer', direction:'out'});
return entries;
}),
].filter(x => isPeriodic(x) && !hasMonth(x));
].filter(x => isPeriodic(x) && !hasMonth(x) && months.some((_,m)=>itemActiveInMonth(x,year,m)));
const yearIncome = rows.reduce((s,r)=>s+r.displayIncome,0);
const yearExpense = rows.reduce((s,r)=>s+r.displayExpense,0);
return { rows, unassigned, yearIncome, yearExpense, isAll, accountId };
return { rows, unassigned, yearIncome, yearExpense, isAll, accountId, year };
}
function categoryStats(data, accountId = 'all') {
@@ -179,6 +189,15 @@ function reserveOverview(data) {
return { items, monthly:items.reduce((s,x)=>s+x.monthlyReserve,0), annual:items.reduce((s,x)=>s+x.annualNeed,0) };
}
function formatDate(value){if(!value)return '';const d=new Date(`${value}T12:00:00`);return Number.isNaN(d.getTime())?value:new Intl.DateTimeFormat('de-DE',{dateStyle:'medium'}).format(d);}
function buildWarnings(data){
const now=new Date();const year=now.getFullYear();const currentMonth=now.getMonth();const horizon=new Date(now);horizon.setDate(horizon.getDate()+90);const warnings=[];
for(const account of data.accounts){const f=annualForecast(data,account.id,year);const negatives=f.rows.slice(currentMonth).filter(r=>r.balance<0);if(negatives.length){const worst=negatives.reduce((a,b)=>b.balance<a.balance?b:a);warnings.push({kind:'danger',title:`${account.name}: Unterdeckung im ${worst.name}`,text:`Geplante Abflüsse übersteigen die Zuflüsse um ${formatMoney(Math.abs(worst.balance))}.`,sort:0});}}
for(let m=currentMonth;m<12;m++){const due=(data.expenses||[]).filter(x=>isPeriodic(x)&&occursInMonth(x,m,year));if(due.length>=3){const total=due.reduce((sum,x)=>sum+effectiveAmount(x,year,m),0);warnings.push({kind:'warning',title:`${due.length} periodische Ausgaben im ${months[m]}`,text:`Zusammen ${formatMoney(total)}. Dieser Monat bündelt mehrere größere Fälligkeiten.`,sort:1});}}
for(const [type,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]])for(const item of items){for(const change of item.amountChanges||[]){const d=new Date(`${change.effectiveFrom}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name}: Betrag ändert sich`,text:`${type} auf ${accountName(data,item.accountId)} ab ${formatDate(change.effectiveFrom)}: ${formatMoney(change.amount)}.`,sort:2,date:d});}if(item.endDate){const d=new Date(`${item.endDate}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name} läuft aus`,text:`${type} auf ${accountName(data,item.accountId)} endet am ${formatDate(item.endDate)}.`,sort:3,date:d});}}
return warnings.sort((a,b)=>a.sort-b.sort||(a.date?.getTime()||0)-(b.date?.getTime()||0)).slice(0,10);
}
function scenarioData(data, scenario) {
if (!scenario) return data;
const next = { ...data, incomes:[...data.incomes], expenses:[...data.expenses], transfers:[...data.transfers] };
@@ -256,6 +275,7 @@ function Dashboard({data,totals,onToggle}){
const visibleAccounts=useMemo(()=>totals.accounts.filter(s=>selectedSet.has(s.account.id)),[totals.accounts,selectedSet]);
const selectedBalance=visibleAccounts.reduce((sum,s)=>sum+s.balance,0);
const allSelected=filter.mode==='all'||validSelected.length===accountIds.length;
const warnings=useMemo(()=>buildWarnings(data),[data]);
useEffect(()=>{
if(filter.mode==='some'&&accountIds.length>0&&validSelected.length===0) setFilter({mode:'all',ids:[]});
@@ -292,6 +312,8 @@ function Dashboard({data,totals,onToggle}){
{visibleAccounts.length===0?<Empty text="Keine Konten ausgewählt."/>:<div className="account-balance-list">{visibleAccounts.map(s=><details className="account-balance-card" key={s.account.id}><summary><div className="account-balance-title"><h3>{s.account.name}</h3><span>monatlicher Fix-Saldo</span></div><div className="account-balance-right"><strong className={`account-balance-value ${s.balance>=0?'good-text':'bad-text'}`}>{s.balance>=0?'+':''}{formatMoney(s.balance)}</strong><span className="accordion-chevron" aria-hidden="true"></span></div></summary><div className="account-balance-details"><div className="mini-grid"><div><span>Eingänge</span><strong className="good-text">+{formatMoney(s.income)}</strong></div><div><span>Ausgänge</span><strong className="bad-text">{formatMoney(s.expense)}</strong></div><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(s.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">{formatMoney(s.transferOut)}</strong></div></div></div></details>)}</div>}
</section>
<section className="panel fixfin-warnings"><div className="section-head"><div><h2>Hinweise</h2><p>FixFin prüft deinen aktuellen Plan auf Liquiditätslücken, gebündelte Fälligkeiten, anstehende Änderungen und auslaufende Positionen.</p></div><span className={`warning-count ${warnings.some(w=>w.kind==='danger')?'danger':''}`}>{warnings.length}</span></div>{warnings.length===0?<div className="warning-clear"><strong>Keine Auffälligkeiten</strong><span>Für die nächsten Monate sieht der Plan unauffällig aus.</span></div>:<div className="warning-list">{warnings.map((w,i)=><div className={`warning-item ${w.kind}`} key={`${w.title}-${i}`}><span className="warning-dot"/><div><strong>{w.title}</strong><span>{w.text}</span></div></div>)}</div>}</section>
{!totals.includePeriodic&&totals.periodicExpenseReserve>0&&<div className="info-note">Periodische Ausgaben von rechnerisch <strong>{formatMoney(totals.periodicExpenseReserve)} / Monat</strong> sind derzeit nicht im Saldo enthalten.</div>}
<section className="toggle-panel dashboard-toggle"><div><strong>Periodische Kosten in Monats-Saldo einrechnen</strong><span>Jährliche, halbjährliche und vierteljährliche Beträge werden auf einen Monatswert heruntergerechnet.</span></div><button className={`switch ${totals.includePeriodic?'on':''}`} onClick={onToggle} role="switch" aria-checked={totals.includePeriodic}><span/></button></section>
</div>;
@@ -334,10 +356,11 @@ function Entries({type,items,accounts,categories,onEdit,onDuplicate,onDelete}){
const categoryNameLocal=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie';
const isIncome=type==='incomes';
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
const currentAmount=x=>effectiveAmount(x);
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}</td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} Änderung{x.amountChanges.length===1?'':'en'}</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
<div className="movement-mobile-list">{items.map(x=><article className="movement-card" key={x.id}>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder am 1.</span>}</div><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(x.amount)}</strong></div>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder am 1.</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} geplant</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</strong></div>
<div className="movement-card-grid">
<div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div>
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div>
@@ -354,9 +377,9 @@ function Transfers({data,onEdit,onDuplicate,onDelete}){
const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
<div className="movement-mobile-list">{data.transfers.map(x=><article className="movement-card transfer-card" key={x.id}>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></div><strong>{formatMoney(x.amount)}</strong></div>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div><strong>{formatMoney(x.amount)}</strong></div>
<div className="transfer-route"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><span className="route-arrow"></span><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div></div>
<div className="movement-card-grid">
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div>
@@ -408,8 +431,10 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
}
function YearPage({data}){
const currentYear=new Date().getFullYear();
const [accountId,setAccountId]=useState('all');
const forecast=useMemo(()=>annualForecast(data,accountId),[data,accountId]);
const [year,setYear]=useState(currentYear);
const forecast=useMemo(()=>annualForecast(data,accountId,year),[data,accountId,year]);
const selectedAccount=data.accounts.find(a=>a.id===accountId);
const selectedName=selectedAccount?.name||'Alle Konten';
const labels=forecast.isAll
@@ -418,14 +443,14 @@ function YearPage({data}){
const kindLabel=x=>x.kind==='income'?(forecast.isAll?'Eingang':'Echter Eingang'):x.kind==='expense'?(forecast.isAll?'Ausgang':'Echte Ausgabe'):x.direction==='in'?'Transfer rein':x.direction==='out'?'Transfer raus':'Transfer';
const kindSign=x=>x.kind==='income'||x.direction==='in'?'+':x.kind==='expense'||x.direction==='out'?'':'';
const kindTone=x=>x.kind==='income'||x.direction==='in'?'good-text':x.kind==='expense'||x.direction==='out'?'bad-text':'';
const chipLabel=x=>`${x.name} · ${intervals[x.interval||'monthly'].label} · ${kindLabel(x)} · ${formatMoney(x.amount)}`;
const chipLabel=x=>`${x.name} · ${intervals[x.interval||'monthly'].label} · ${kindLabel(x)} · ${formatMoney(x.forecastAmount??x.amount)}`;
const maxExpense=Math.max(1,...forecast.rows.map(r=>r.displayExpense));
const maxRow=forecast.rows.reduce((a,b)=>b.displayExpense>a.displayExpense?b:a,forecast.rows[0]);
const yearBalance=forecast.yearIncome-forecast.yearExpense;
const detailRow=(x,i)=><div className="year-detail-row" key={`${x.id}-${x.direction||x.kind}-${i}`}><div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel(x)}</span><strong>{x.name}</strong></div><span className={`year-detail-amount ${kindTone(x)}`}>{kindSign(x)}{formatMoney(x.amount)}</span></div>;
const detailRow=(x,i)=><div className="year-detail-row" key={`${x.id}-${x.direction||x.kind}-${i}`}><div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel(x)}</span><strong>{x.name}</strong></div><span className={`year-detail-amount ${kindTone(x)}`}>{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}</span></div>;
return <div className="content-stack year-page">
<section className="year-account-filter"><div className="year-filter-copy"><span>Jahresansicht für</span><strong>{selectedName}</strong></div><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></section>
<section className="year-account-filter"><div className="year-filter-copy"><span>Jahresansicht für</span><strong>{selectedName} · {year}</strong></div><div className="year-filter-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></div></section>
<section className="summary-grid year-summary">
<Metric label={labels.yearIncome} value={forecast.yearIncome} positive/>
@@ -440,7 +465,7 @@ function YearPage({data}){
<div className="year-month-list">{forecast.rows.map(r=>{
const inflows=r.items.filter(x=>x.kind==='income'||x.direction==='in');
const outflows=r.items.filter(x=>x.kind==='expense'||x.direction==='out');
return <details className="year-month-card" key={`${accountId}-${r.name}`}><summary><div className="year-month-heading"><strong>{r.name}</strong><span>{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}</span></div><div className="year-month-basics"><div><span>{labels.income}</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div><div><span>{labels.expense}</span><strong className="bad-text">{formatMoney(r.displayExpense)}</strong></div><div className="year-month-balance"><span>{labels.balance}</span><strong className={r.balance>=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}</strong></div></div><span className="accordion-chevron" aria-hidden="true"></span></summary>
return <details className="year-month-card" key={`${accountId}-${year}-${r.name}`}><summary><div className="year-month-heading"><strong>{r.name}</strong><span>{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}</span></div><div className="year-month-basics"><div><span>{labels.income}</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div><div><span>{labels.expense}</span><strong className="bad-text">{formatMoney(r.displayExpense)}</strong></div><div className="year-month-balance"><span>{labels.balance}</span><strong className={r.balance>=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}</strong></div></div><span className="accordion-chevron" aria-hidden="true"></span></summary>
<div className="year-month-details">
{r.items.length===0?<div className="year-detail-empty">In diesem Monat sind keine einzelnen Fixpositionen fällig.</div>:forecast.isAll?<>{r.items.map(detailRow)}{(r.transferIn>0||r.transferOut>0)&&<div className="year-transfer-note">Interne Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie bleiben vollständig außerhalb von Einnahmen, Ausgaben und Überschuss.</div>}</>:<>
<div className="year-flow-breakdown">
@@ -509,6 +534,15 @@ function PushoverSettings(){
</section>;
}
function ReportExports({data}){
const currentYear=new Date().getFullYear();
const [year,setYear]=useState(currentYear);
const [accountId,setAccountId]=useState('all');
const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}`;
const scope=accountId==='all'?'Alle Konten':accountName(data,accountId);
return <section className="panel report-export-panel"><div className="section-head"><div><h2>Auszüge & Berichte</h2><p>Aufbereitete Planungsdaten als PDF oder Excel. Die Kontosicht enthält echte Ein-/Ausgänge und Transfers als getrennte Zahlungsströme.</p></div><span className="report-badge">PDF · XLSX</span></div><div className="report-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Umfang</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></div><div className="report-preview"><div><span>Auszug</span><strong>{scope} · {year}</strong></div><p>PDF: druckfertiger Planungs-Auszug im Kontoauszug-Stil. Excel: Übersicht, Planungshinweise, Jahresplan, Bewegungen, Änderungen, Stammdaten, Konten/Kategorien, Szenarien und Konto-Blätter.</p></div><div className="button-row report-actions"><a className="primary button-link" href={`/api/report.pdf?${query}`}>PDF herunterladen</a><a className="secondary button-link" href={`/api/report.xlsx?${query}`}>Excel herunterladen</a></div><p className="report-disclaimer">Planungs-Auszug aus FixFin, kein von einer Bank ausgestellter Kontoauszug.</p></section>;
}
function DataPage({data,importFile}){
const summary=[
['Konten',data.accounts.length],
@@ -520,6 +554,7 @@ function DataPage({data,importFile}){
];
const json=JSON.stringify(data,null,2);
return <div className="content-stack data-page">
<ReportExports data={data}/>
<PushoverSettings/>
<section className="panel data-backup-panel"><div className="section-head"><div><h2>Daten sichern</h2><p>Alle Daten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row data-actions"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section>
<section className="panel data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div>
@@ -533,8 +568,9 @@ function Empty({text}){return <div className="empty">{text}</div>}
function EditorDialog({dialog,data,onClose,onSave}){
const type=dialog.type,item=dialog.item; const editing=Boolean(item?.id);
const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[pushoverReminder,setPushoverReminder]=useState(item?.pushoverReminder===true),[localError,setLocalError]=useState('');
const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[pushoverReminder,setPushoverReminder]=useState(item?.pushoverReminder===true),[endDate,setEndDate]=useState(item?.endDate||''),[amountChanges,setAmountChanges]=useState(Array.isArray(item?.amountChanges)?item.amountChanges:[]),[changeDate,setChangeDate]=useState(''),[changeAmount,setChangeAmount]=useState(''),[localError,setLocalError]=useState('');
const labels={accounts:'Konto',categories:'Kategorie',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'};
function addAmountChange(){const numeric=parseAmount(changeAmount);if(!changeDate)return setLocalError('Bitte ein Datum für die Betragsänderung auswählen.');if(!Number.isFinite(numeric)||numeric<0)return setLocalError('Bitte einen gültigen neuen Betrag eingeben.');if(endDate&&changeDate.slice(0,7)>endDate.slice(0,7))return setLocalError('Die Betragsänderung liegt nach dem Enddatum.');if(amountChanges.some(c=>c.effectiveFrom===changeDate))return setLocalError('Für dieses Datum existiert bereits eine Betragsänderung.');setAmountChanges([...amountChanges,{id:uid(),effectiveFrom:changeDate,amount:numeric}].sort((a,b)=>a.effectiveFrom.localeCompare(b.effectiveFrom)));setChangeDate('');setChangeAmount('');setLocalError('');}
async function submit(e){
e.preventDefault();setLocalError(''); const numeric=parseAmount(amount);
if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.');
@@ -542,23 +578,25 @@ function EditorDialog({dialog,data,onClose,onSave}){
if(!['accounts','categories'].includes(type)&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.');
if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.');
if(type==='expenses'&&pushoverReminder&&interval!=='monthly'&&!dueMonth)return setLocalError('Für den Reminder bitte einen Fälligkeitsmonat festlegen.');
if(endDate&&amountChanges.some(c=>c.effectiveFrom.slice(0,7)>endDate.slice(0,7)))return setLocalError('Eine geplante Betragsänderung liegt nach dem Enddatum.');
let next=structuredClone(data); const id=editing?item.id:uid(); let value;
if(type==='accounts') value={id,name:name.trim()};
else if(type==='categories') { const duplicate=next.categories.some(c=>c.id!==item?.id&&c.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Kategorie existiert bereits.'); value={id,name:name.trim()}; }
else {
const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null)};
const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null),endDate:endDate||null};
let selectedCategoryId=categoryId||null;
if(categoryId==='__new__'){const categoryNameValue=newCategoryName.trim();if(!categoryNameValue)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedCategoryId=existing.id;else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryNameValue}); }}
if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,pushoverReminder}; else value={...common,accountId,categoryId:selectedCategoryId};
if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,amountChanges,pushoverReminder}; else value={...common,accountId,categoryId:selectedCategoryId,amountChanges};
}
next[type]=editing?next[type].map(x=>x.id===item.id?value:x):[...next[type],value];
try{await onSave(next);}catch(e){setLocalError(e.message);}
}
const simpleType=type==='accounts'||type==='categories';
return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{localError&&<div className="alert">{localError}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={type==='accounts'?'z. B. Girokonto':type==='categories'?'z. B. Wohnen':'z. B. Kfz-Versicherung'}/></label>
{!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}</>}
{!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}<label><span>Enddatum / Laufzeit (optional)</span><input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)}/><small className="field-help">Der Monat des Enddatums wird noch vollständig eingeplant.</small></label></>}
{(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Kategorie</span><select value={categoryId} onChange={e=>setCategoryId(e.target.value)}><option value="">Ohne Kategorie</option>{data.categories.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}<option value="__new__">+ Neue Kategorie </option></select></label>{categoryId==='__new__'&&<label className="new-category-field"><span>Neue Kategorie</span><input value={newCategoryName} onChange={e=>setNewCategoryName(e.target.value)} placeholder="z. B. Sparen"/></label>}</>}
{(type==='incomes'||type==='expenses')&&<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>}
{(type==='incomes'||type==='expenses')&&<section className="amount-change-panel"><div className="amount-change-head"><div><strong>Geplante Betragsänderungen</strong><span>Ein neuer Betrag gilt ab dem Monat des gewählten Datums. Der Basisbetrag oben bleibt für frühere Monate erhalten.</span></div><span className="change-count">{amountChanges.length}</span></div>{amountChanges.length>0&&<div className="amount-change-list">{amountChanges.map(c=><div className="amount-change-row" key={c.id}><div><span>ab {formatDate(c.effectiveFrom)}</span><strong>{formatMoney(c.amount)}</strong></div><button type="button" className="danger" onClick={()=>setAmountChanges(amountChanges.filter(x=>x.id!==c.id))}>Entfernen</button></div>)}</div>}<div className="amount-change-add"><label><span>Wirksam ab</span><input type="date" value={changeDate} onChange={e=>setChangeDate(e.target.value)}/></label><label><span>Neuer Betrag</span><div className="money-input"><input inputMode="decimal" value={changeAmount} onChange={e=>setChangeAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><button type="button" className="secondary" onClick={addAmountChange}>+ Änderung</button></div></section>}
{type==='expenses'&&<button type="button" className={`reminder-toggle ${pushoverReminder?'active':''}`} aria-pressed={pushoverReminder} onClick={()=>setPushoverReminder(x=>!x)}><span className={`checkbox-mark ${pushoverReminder?'checked':''}`}>{pushoverReminder?'✓':''}</span><span><strong>Pushover-Erinnerung</strong><small>Am 1. des Fälligkeitsmonats mit Betrag und Konto erinnern.</small></span></button>}
{type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>;
}
+65
View File
@@ -898,3 +898,68 @@ code { color:#b8c3ff; }
.pushover-actions button { width:100%; min-height:42px; }
.reminder-toggle { padding:10px; }
}
/* v3.16: Terminierungen, Warnungen und Reportcenter */
.fixfin-warnings .section-head { align-items:center; }
.warning-count { min-width:34px;height:34px;border-radius:999px;display:grid;place-items:center;background:#1b2635;border:1px solid #31415a;color:#cbd5e1;font-weight:850; }
.warning-count.danger { background:#321a20;border-color:#67333b;color:#ff9aa2; }
.warning-list { display:grid;gap:8px; }
.warning-item { display:grid;grid-template-columns:10px 1fr;gap:11px;align-items:start;padding:11px 12px;border-radius:11px;border:1px solid #29364a;background:#0d141e; }
.warning-item strong { display:block;font-size:13px; }
.warning-item span:not(.warning-dot) { display:block;color:var(--muted);font-size:11px;margin-top:3px;line-height:1.45; }
.warning-dot { width:8px;height:8px;border-radius:50%;margin-top:5px;background:#8293ff; }
.warning-item.danger { border-color:#5f3038;background:#26171c; }
.warning-item.danger .warning-dot { background:#ff7f8a; }
.warning-item.warning { border-color:#5a4e2b;background:#242016; }
.warning-item.warning .warning-dot { background:#e4c45f; }
.warning-clear { padding:14px;border-radius:11px;background:#10241f;border:1px solid #245947; }
.warning-clear strong { display:block;color:#7de0b5; }
.warning-clear span { display:block;color:#8eb8a8;font-size:11px;margin-top:3px; }
.change-badge,.end-badge { display:inline-flex;align-items:center;margin-left:6px;padding:3px 6px;border-radius:999px;font-size:8px;font-weight:800;white-space:nowrap;vertical-align:middle; }
.change-badge { background:#1d2848;border:1px solid #41558b;color:#b4c0ff; }
.end-badge { background:#2d2515;border:1px solid #65562a;color:#e9d98b; }
.amount-change-panel { margin-top:4px;padding:13px;border-radius:12px;border:1px solid #2a374a;background:#0d141e; }
.amount-change-head { display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:10px; }
.amount-change-head strong { display:block;font-size:13px; }
.amount-change-head span:not(.change-count) { display:block;color:var(--muted);font-size:10px;line-height:1.4;margin-top:3px; }
.change-count { min-width:26px;height:26px;border-radius:999px;display:grid;place-items:center;background:#1d2848;color:#b4c0ff;font-size:10px;font-weight:850; }
.amount-change-list { display:grid;gap:6px;margin-bottom:10px; }
.amount-change-row { display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 9px;border-radius:9px;background:#121b28;border:1px solid #243247; }
.amount-change-row div span { display:block;color:var(--muted);font-size:9px; }
.amount-change-row div strong { display:block;font-size:12px;margin-top:2px; }
.amount-change-row button { padding:6px 8px;font-size:9px; }
.amount-change-add { display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:end; }
.amount-change-add label { margin:0; }
.amount-change-add button { min-height:41px; }
.year-filter-controls { display:flex;gap:8px;align-items:end; }
.year-filter-controls label:first-child { min-width:110px; }
.report-export-panel { border-color:#35466a; }
.report-badge { padding:6px 9px;border-radius:999px;background:#1d2848;border:1px solid #41558b;color:#b4c0ff;font-size:10px;font-weight:850; }
.report-controls { display:grid;grid-template-columns:160px minmax(220px,1fr);gap:10px;max-width:560px;margin-bottom:12px; }
.report-controls label { display:grid;gap:5px; }
.report-controls label span { color:var(--muted);font-size:10px;font-weight:700; }
.report-controls select { width:100%;background:#0b111a;color:#f5f7fb;border:1px solid #2a374a;border-radius:10px;padding:10px 34px 10px 11px;outline:none; }
.report-preview { padding:13px 14px;border-radius:12px;border:1px solid #29364a;background:#0d141e;margin-bottom:12px; }
.report-preview div span { display:block;color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.07em;font-weight:800; }
.report-preview div strong { display:block;font-size:16px;margin-top:2px; }
.report-preview p { color:var(--muted);font-size:11px;line-height:1.5;margin:8px 0 0; }
.report-disclaimer { color:#718096;font-size:9px;margin:9px 0 0; }
@media (max-width:760px) {
.year-account-filter { align-items:stretch;flex-direction:column; }
.year-filter-controls { width:100%;display:grid;grid-template-columns:100px 1fr; }
.year-filter-controls label { min-width:0 !important; }
.report-controls { grid-template-columns:1fr 1.5fr;max-width:none; }
.report-actions { display:grid;grid-template-columns:1fr 1fr; }
.report-actions .button-link { text-align:center;justify-content:center; }
.amount-change-add { grid-template-columns:1fr 1fr; }
.amount-change-add button { grid-column:1 / -1; }
}
@media (max-width:430px) {
.report-controls,.report-actions { grid-template-columns:1fr; }
.amount-change-add { grid-template-columns:1fr; }
.amount-change-add button { grid-column:auto; }
}