diff --git a/src/main.jsx b/src/main.jsx index 5ebb0f5..4a138e4 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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() {

{nav.find(x=>x[0]===tab)?.[2]}

Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.

{!['dashboard','year','data'].includes(tab)&&}
{error&&
{error}
} - {tab==='dashboard'&&} {tab==='accounts'&&setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} {tab==='incomes'&&setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&setDialog({type:'expenses',item:x})} onDelete={removeItem}/>} {tab==='transfers'&&setDialog({type:'transfers',item:x})} onDelete={removeItem}/>} {tab==='year'&&} {tab==='data'&&} + {tab==='dashboard'&&} {tab==='accounts'&&setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} {tab==='incomes'&&setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&setDialog({type:'expenses',item:x})} onDelete={removeItem}/>} {tab==='transfers'&&setDialog({type:'transfers',item:x})} onDelete={removeItem}/>} {tab==='year'&&} {tab==='data'&&}
{dialog&&setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>} @@ -138,34 +187,45 @@ function Accounts({data,onEdit,onDelete,onAdd}){return

{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}

Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.

{items.length===0?:
{items.map(x=>)}
BezeichnungKontoIntervallMonatBetragØ / Monat
{x.name}{accountName(x.accountId)}{intervals[x.interval||'monthly'].label}{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}{isIncome?'+':'−'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
}
} function Transfers({data,onEdit,onDelete}){const accountName=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';return

Fixe Transfers

Verschiebungen zwischen eigenen Konten. Global saldoneutral.

{data.transfers.length===0?:
{data.transfers.map(x=>)}
BezeichnungVonNachIntervallMonatBetragØ / Monat
{x.name}{accountName(x.fromAccountId)}{accountName(x.toAccountId)}{intervals[x.interval||'monthly'].label}{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
}
} -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
-
=forecast.yearExpense} negative={forecast.yearIncome
-

Jahresprognose

Monatliche Basiswerte auf einen Blick. Details bei Bedarf ausklappen.

-
{forecast.rows.map(r=>
+
+
Jahresansicht für{selectedName}
+ +
+
=forecast.yearExpense} negative={forecast.yearIncome
+

Jahresprognose

{forecast.isAll?'Gesamt über alle Konten. Transfers bleiben saldoneutral.':`Prognose für ${selectedName}. Transfers werden als Zu- oder Abfluss dieses Kontos berücksichtigt.`}

+
{forecast.rows.map(r=>
{r.name}{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}
-
Eingänge+{formatMoney(r.income)}
-
Ausgänge−{formatMoney(r.expense)}
+
{forecast.isAll?'Eingänge':'Zuflüsse'}+{formatMoney(r.displayIncome)}
+
{forecast.isAll?'Ausgänge':'Abflüsse'}−{formatMoney(r.displayExpense)}
Saldo=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}
- {r.items.length===0?
In diesem Monat sind keine einzelnen Fixpositionen fällig.
:r.items.map((x,i)=>
-
{kindLabel[x.kind]}{x.name}
- {kindSign[x.kind]}{formatMoney(x.amount)} + {r.items.length===0?
In diesem Monat sind keine einzelnen Fixpositionen fällig.
:r.items.map((x,i)=>
+
{kindLabel(x)}{x.name}
+ {kindSign(x)}{formatMoney(x.amount)}
)} - {(r.transferIn>0||r.transferOut>0)&&
Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie verändern den Gesamtsaldo nicht.
} + {forecast.isAll&&(r.transferIn>0||r.transferOut>0)&&
Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie verändern den Gesamtsaldo nicht.
}
)}
- {forecast.unassigned.length>0&&

Periodische Positionen ohne Monat

Diese Werte können in den Monats-Saldo eingerechnet werden, sind aber keiner konkreten Jahresprognose zugeordnet.

{forecast.unassigned.map(x=>{x.name} · {intervals[x.interval].label} · {formatMoney(x.amount)})}
} + {forecast.unassigned.length>0&&

Periodische Positionen ohne Monat

Diese Werte sind keinem konkreten Monat der Jahresprognose zugeordnet.

{forecast.unassigned.map((x,i)=>{chipLabel(x)})}
}
; } + function DataPage({data,importFile}){return

JSON sichern

Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig eine .bak-Datei angelegt.

JSON herunterladen

Aktueller Datenstand

{data.accounts.length} Konten · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers

{JSON.stringify(data,null,2)}
} function Empty({text}){return
{text}
} diff --git a/src/styles.css b/src/styles.css index 4baf5da..2f0b901 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } +}