This commit is contained in:
2026-08-18 10:23:16 +02:00
parent ab5c611338
commit 309249b7e7
2 changed files with 100 additions and 25 deletions
+85 -25
View File
@@ -75,18 +75,67 @@ function calc(data) {
return { accounts: [...accountStats.values()], income, expense, transferVolume, periodicExpenseReserve, periodicIncomeAverage, balance: income - expense, includePeriodic };
}
function annualForecast(data) {
function annualForecast(data, accountId = 'all') {
const isAll = !accountId || accountId === 'all';
const rows = months.map((name, index) => ({ name, index, income: 0, expense: 0, transferIn: 0, transferOut: 0, balance: 0, items: [] }));
for (const item of data.incomes) for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].income += item.amount; rows[m].items.push({ ...item, kind:'income' }); }
for (const item of data.expenses) for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].expense += item.amount; rows[m].items.push({ ...item, kind:'expense' }); }
for (const item of data.transfers) for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].transferIn += item.amount; rows[m].transferOut += item.amount; rows[m].items.push({ ...item, kind:'transfer' }); }
rows.forEach(r => { r.balance = r.income - r.expense; });
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 (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 (const item of data.transfers.filter(relevantTransfer)) {
for (let m=0;m<12;m++) if (occursInMonth(item,m)) {
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' });
} 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' });
}
}
}
}
rows.forEach(r => {
r.balance = r.income - r.expense + (isAll ? 0 : r.transferIn - r.transferOut);
r.displayIncome = r.income + (isAll ? 0 : r.transferIn);
r.displayExpense = r.expense + (isAll ? 0 : r.transferOut);
});
const hasMonth = x => Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12;
const unassigned = [
...data.incomes.map(x => ({...x, kind:'income'})),
...data.expenses.map(x => ({...x, kind:'expense'})),
...data.transfers.map(x => ({...x, kind:'transfer'})),
].filter(x => isPeriodic(x) && !(Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12));
return { rows, unassigned, yearIncome: rows.reduce((s,r)=>s+r.income,0), yearExpense: rows.reduce((s,r)=>s+r.expense,0) };
...data.incomes.filter(relevantIncome).map(x => ({...x, kind:'income'})),
...data.expenses.filter(relevantExpense).map(x => ({...x, kind:'expense'})),
...data.transfers.filter(relevantTransfer).flatMap(x => {
if (isAll) return [{...x, kind:'transfer', direction:'neutral'}];
const entries = [];
if (x.toAccountId === accountId) entries.push({...x, kind:'transfer', direction:'in'});
if (x.fromAccountId === accountId) entries.push({...x, kind:'transfer', direction:'out'});
return entries;
}),
].filter(x => isPeriodic(x) && !hasMonth(x));
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 };
}
function Icon({ name }) {
@@ -103,7 +152,7 @@ function Icon({ name }) {
function App() {
const [data,setData]=useState(emptyData), [tab,setTab]=useState('dashboard'), [loading,setLoading]=useState(true), [saving,setSaving]=useState(false), [error,setError]=useState(''), [dialog,setDialog]=useState(null);
const totals=useMemo(()=>calc(data),[data]); const forecast=useMemo(()=>annualForecast(data),[data]);
const totals=useMemo(()=>calc(data),[data]);
useEffect(()=>{ fetch('/api/data').then(async r=>{if(!r.ok)throw new Error((await r.json()).error||'Daten konnten nicht geladen werden.');return r.json();}).then(setData).catch(e=>setError(e.message)).finally(()=>setLoading(false)); },[]);
async function persist(next){setSaving(true);setError('');try{const r=await fetch('/api/data',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(next)});const result=await r.json();if(!r.ok)throw new Error(result.error||'Speichern fehlgeschlagen.');setData(result);}catch(e){setError(e.message);throw e;}finally{setSaving(false);}}
function removeAccount(id){const used=data.incomes.some(x=>x.accountId===id)||data.expenses.some(x=>x.accountId===id)||data.transfers.some(x=>x.fromAccountId===id||x.toAccountId===id);if(used)return setError('Das Konto wird noch verwendet. Einträge oder Transfers bitte zuerst löschen.');if(confirm('Konto wirklich löschen?'))persist({...data,accounts:data.accounts.filter(x=>x.id!==id)});}
@@ -117,7 +166,7 @@ function App() {
<aside className="sidebar"><div className="brand"><div className="brand-mark">F</div><div><strong>FixFin</strong><span>Fixkostenplanung</span></div></div><nav>{nav.map(([id,icon,label])=><button key={id} className={tab===id?'active':''} onClick={()=>setTab(id)}><Icon name={icon}/><span>{label}</span></button>)}</nav><div className="save-state">{saving?'Speichert …':'Automatisch gespeichert'}</div></aside>
<main><header className="topbar"><div><h1>{nav.find(x=>x[0]===tab)?.[2]}</h1><p>Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.</p></div>{!['dashboard','year','data'].includes(tab)&&<button className="primary" onClick={()=>setDialog({type:tab})}>+ Neu</button>}</header>
{error&&<div className="alert"><span>{error}</span><button onClick={()=>setError('')}>×</button></div>}
{tab==='dashboard'&&<Dashboard totals={totals} onToggle={togglePeriodic}/>} {tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} {tab==='incomes'&&<Entries type="incomes" items={data.incomes} accounts={data.accounts} onEdit={x=>setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&<Entries type="expenses" items={data.expenses} accounts={data.accounts} onEdit={x=>setDialog({type:'expenses',item:x})} onDelete={removeItem}/>} {tab==='transfers'&&<Transfers data={data} onEdit={x=>setDialog({type:'transfers',item:x})} onDelete={removeItem}/>} {tab==='year'&&<YearPage forecast={forecast}/>} {tab==='data'&&<DataPage data={data} importFile={importFile}/>}
{tab==='dashboard'&&<Dashboard totals={totals} onToggle={togglePeriodic}/>} {tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} {tab==='incomes'&&<Entries type="incomes" items={data.incomes} accounts={data.accounts} onEdit={x=>setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&<Entries type="expenses" items={data.expenses} accounts={data.accounts} onEdit={x=>setDialog({type:'expenses',item:x})} onDelete={removeItem}/>} {tab==='transfers'&&<Transfers data={data} onEdit={x=>setDialog({type:'transfers',item:x})} onDelete={removeItem}/>} {tab==='year'&&<YearPage data={data}/>} {tab==='data'&&<DataPage data={data} importFile={importFile}/>}
</main>
<nav className="mobile-nav">{nav.map(([id,icon,label])=><button key={id} className={tab===id?'active':''} onClick={()=>setTab(id)}><Icon name={icon}/><span>{label}</span></button>)}</nav>
{dialog&&<EditorDialog dialog={dialog} data={data} onClose={()=>setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>}
@@ -138,34 +187,45 @@ function Accounts({data,onEdit,onDelete,onAdd}){return <section className="panel
function Entries({type,items,accounts,onEdit,onDelete}){const accountName=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt';const isIncome=type==='incomes';return <section className="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"><table><thead><tr><th>Bezeichnung</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></td><td>{accountName(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:''}</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={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
function Transfers({data,onEdit,onDelete}){const accountName=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';return <section className="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"><table><thead><tr><th>Bezeichnung</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>{accountName(x.fromAccountId)}</td><td>{accountName(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:''}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
function YearPage({forecast}){
const kindLabel={income:'Eingang',expense:'Ausgang',transfer:'Transfer'};
const kindSign={income:'+',expense:'',transfer:''};
function YearPage({data}){
const [accountId,setAccountId]=useState('all');
const forecast=useMemo(()=>annualForecast(data,accountId),[data,accountId]);
const selectedAccount=data.accounts.find(a=>a.id===accountId);
const selectedName=selectedAccount?.name||'Alle Konten';
const kindLabel=x=>x.kind==='income'?'Eingang':x.kind==='expense'?'Ausgang':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)}`;
return <div className="content-stack year-page">
<section className="summary-grid year-summary"><Metric label="Einnahmen / Jahr" value={forecast.yearIncome} positive/><Metric label="Ausgaben / Jahr" value={forecast.yearExpense} negative/><Metric label="Saldo / Jahr" value={forecast.yearIncome-forecast.yearExpense} positive={forecast.yearIncome>=forecast.yearExpense} negative={forecast.yearIncome<forecast.yearExpense}/><Metric label="Nicht zugeordnet" value={forecast.unassigned.length}/></section>
<section className="panel year-forecast-panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>Monatliche Basiswerte auf einen Blick. Details bei Bedarf ausklappen.</p></div></div>
<div className="year-month-list">{forecast.rows.map(r=><details className="year-month-card" key={r.name}>
<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="summary-grid year-summary"><Metric label={forecast.isAll?'Einnahmen / Jahr':'Zuflüsse / Jahr'} value={forecast.yearIncome} positive/><Metric label={forecast.isAll?'Ausgaben / Jahr':'Abflüsse / Jahr'} value={forecast.yearExpense} negative/><Metric label="Saldo / Jahr" value={forecast.yearIncome-forecast.yearExpense} positive={forecast.yearIncome>=forecast.yearExpense} negative={forecast.yearIncome<forecast.yearExpense}/><Metric label="Nicht zugeordnet" value={forecast.unassigned.length}/></section>
<section className="panel year-forecast-panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>{forecast.isAll?'Gesamt über alle Konten. Transfers bleiben saldoneutral.':`Prognose für ${selectedName}. Transfers werden als Zu- oder Abfluss dieses Kontos berücksichtigt.`}</p></div></div>
<div className="year-month-list">{forecast.rows.map(r=><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>Eingänge</span><strong className="good-text">+{formatMoney(r.income)}</strong></div>
<div><span>Ausgänge</span><strong className="bad-text">{formatMoney(r.expense)}</strong></div>
<div><span>{forecast.isAll?'Eingänge':'Zuflüsse'}</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div>
<div><span>{forecast.isAll?'Ausgänge':'Abflüsse'}</span><strong className="bad-text">{formatMoney(r.displayExpense)}</strong></div>
<div className="year-month-balance"><span>Saldo</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>:r.items.map((x,i)=><div className="year-detail-row" key={`${x.id}-${i}`}>
<div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel[x.kind]}</span><strong>{x.name}</strong></div>
<span className={`year-detail-amount ${x.kind==='income'?'good-text':x.kind==='expense'?'bad-text':''}`}>{kindSign[x.kind]}{formatMoney(x.amount)}</span>
{r.items.length===0?<div className="year-detail-empty">In diesem Monat sind keine einzelnen Fixpositionen fällig.</div>:r.items.map((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>)}
{(r.transferIn>0||r.transferOut>0)&&<div className="year-transfer-note">Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie verändern den Gesamtsaldo nicht.</div>}
{forecast.isAll&&(r.transferIn>0||r.transferOut>0)&&<div className="year-transfer-note">Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie verändern den Gesamtsaldo nicht.</div>}
</div>
</details>)}</div>
</section>
{forecast.unassigned.length>0&&<section className="panel warning-panel"><div className="section-head"><div><h2>Periodische Positionen ohne Monat</h2><p>Diese Werte können in den Monats-Saldo eingerechnet werden, sind aber keiner konkreten Jahresprognose zugeordnet.</p></div></div><div className="due-items">{forecast.unassigned.map(x=><span className={`due-chip ${x.kind}`} key={`${x.kind}-${x.id}`}>{x.name} · {intervals[x.interval].label} · {formatMoney(x.amount)}</span>)}</div></section>}
{forecast.unassigned.length>0&&<section className="panel warning-panel"><div className="section-head"><div><h2>Periodische Positionen ohne Monat</h2><p>Diese Werte sind keinem konkreten Monat der Jahresprognose zugeordnet.</p></div></div><div className="due-items">{forecast.unassigned.map((x,i)=><span className={`due-chip ${x.kind}`} key={`${x.kind}-${x.id}-${x.direction||'neutral'}-${i}`}>{chipLabel(x)}</span>)}</div></section>}
</div>;
}
function DataPage({data,importFile}){return <div className="content-stack"><section className="panel"><div className="section-head"><div><h2>JSON sichern</h2><p>Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row"><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"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>{data.accounts.length} Konten · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers</p></div></div><pre className="json-preview">{JSON.stringify(data,null,2)}</pre></section></div>}
function Empty({text}){return <div className="empty">{text}</div>}
+15
View File
@@ -138,6 +138,15 @@ code { color:#b8c3ff; }
.info-note { padding:12px 15px; border-radius:12px; border:1px solid #65562a; background:#2b2616; color:#e9d98b; font-size:13px; }
.interval-badge { display:inline-block; padding:4px 8px; border-radius:999px; background:#1c2635; border:1px solid #304057; color:#b8c5d7; font-size:10px; font-weight:800; white-space:nowrap; }
.field-help { color:var(--muted); font-size:11px; margin-top:-2px; }
.year-account-filter { display:flex; align-items:end; justify-content:space-between; gap:18px; background:#0f1621; border:1px solid #29364a; border-radius:16px; padding:14px 16px; }
.year-filter-copy { min-width:0; }
.year-filter-copy span { display:block; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.08em; font-weight:800; margin-bottom:3px; }
.year-filter-copy strong { display:block; font-size:18px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.year-account-filter label { display:grid; gap:5px; min-width:min(320px,45vw); }
.year-account-filter label span { color:var(--muted); font-size:10px; font-weight:700; }
.year-account-filter select { width:100%; background:#0b111a; color:#f5f7fb; border:1px solid #2a374a; border-radius:10px; padding:10px 34px 10px 11px; outline:none; }
.year-account-filter select:focus { border-color:var(--accent); box-shadow:0 0 0 3px #8293ff1d; }
.year-summary { grid-template-columns:repeat(4,minmax(0,1fr)); }
.year-table { min-width:850px; }
.due-items { display:flex; flex-wrap:wrap; gap:5px; }
@@ -297,3 +306,9 @@ code { color:#b8c3ff; }
padding-top:10px;
}
}
@media (max-width:760px) {
.year-account-filter { align-items:stretch; flex-direction:column; gap:10px; padding:12px; }
.year-account-filter label { min-width:0; width:100%; }
.year-filter-copy strong { font-size:16px; }
}