From 34627c0b4984d41f3baad497c284f2e522b07bfb Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Thu, 20 Aug 2026 22:21:41 +0200 Subject: [PATCH] V3.32 --- CHANGELOG_v3.32.txt | 25 ++++ README.md | 13 ++ public/sw.js | 2 +- server/index.js | 2 +- src/main.jsx | 343 ++++++++++++++++++++++++++++++++------------ src/styles.css | 204 ++++++++++++++++++++++++++ 6 files changed, 495 insertions(+), 94 deletions(-) create mode 100644 CHANGELOG_v3.32.txt diff --git a/CHANGELOG_v3.32.txt b/CHANGELOG_v3.32.txt new file mode 100644 index 0000000..93c6748 --- /dev/null +++ b/CHANGELOG_v3.32.txt @@ -0,0 +1,25 @@ +FixFin v3.32 – nachvollziehbare und geprüfte Statistik + +UI +- Statistik erhält eine Jahresauswahl. +- Grundprinzip der Statistik wird direkt auf der Seite erklärt. +- Zentrale Kennzahlen haben Info-Dialoge mit Bedeutung und Formel. +- Kennzahlen besitzen Detail-Akkordeons mit Rechenweg bzw. Einzelpositionen. +- Kategorien zeigen Jahresbetrag, Monatsdurchschnitt und Einzelpositionen. +- Gemeinschaftsfinanzierung, Einkommensvergleich und Transferarten erhalten Erklärungen und Detailansichten. + +Rechenprüfung / Korrekturen +- Statistik-Ø/Monat = geplanter Jahresbetrag / 12. +- Gesamtkennzahlen sind unabhängig vom Dashboard-Schalter für periodische Kosten. +- Einmalige Positionen zählen genau einmal im ausgewählten Jahr. +- Fällige periodische Positionen werden anhand ihrer echten Fälligkeitsmonate berücksichtigt. +- Periodische Positionen ohne Fälligkeitsmonat werden nachvollziehbar auf den Jahresdurchschnitt normalisiert. +- Betragsänderungen und Enddaten werden innerhalb des ausgewählten Jahres berücksichtigt. +- Einzelkonto + Personenfilter: echte Eingänge folgen der Bewegungs-Personenzuordnung. +- Alle Konten + Personenfilter: persönliches Einkommen folgt weiterhin dem Kontoeigentum. +- "Ohne Kategorie" zählt nur echte Ein- und Ausgänge und bleibt vom Transfer-Schalter unabhängig. +- Kontoliquidität bleibt unabhängig vom Transferdetails-Schalter immer inklusive Transfers. + +Datenmodell +- Keine Migration. +- finance.json bleibt version 11. diff --git a/README.md b/README.md index 4310941..b31b39d 100644 --- a/README.md +++ b/README.md @@ -142,3 +142,16 @@ Die Kategorienverwaltung wurde vollständig aus der Statistik entfernt. Kategori ### v3.30 – einmalige geplante Positionen Eingänge, Ausgänge und Transfers unterstützen zusätzlich den Intervalltyp **Einmalig**. Dafür wird ein konkretes Datum (`oneTimeDate`) hinterlegt. Die Position wird nur im betreffenden Monat der Jahresplanung, Statistiken und Kontoauszüge berücksichtigt und nicht auf einen Monatsdurchschnitt verteilt. Einmalige Ausgaben können wie andere Ausgaben per Pushover im Zielmonat erinnert werden. + +### v3.31 – Transferanalyse + +Unter **Auswertung → Transferanalyse** können ein oder mehrere Quellkonten und ein oder mehrere Zielkonten gleichzeitig ausgewählt werden. Die Analyse zeigt das geplante Transfervolumen des gewählten Jahres je Quellkonto, die prozentuale und absolute Verteilung auf die Zielkonten, die einzelnen zugrunde liegenden Transfers sowie eine Quelle-Ziel-Matrix. Einmalige Transfers werden nur in ihrem Zieljahr berücksichtigt; periodische Transfers ohne konkreten Fälligkeitsmonat werden für den Jahreswert über ihren Monatsdurchschnitt normalisiert. + + +### v3.32 – nachvollziehbare und geprüfte Statistik + +Die Statistik ist jetzt jahresbezogen und erklärt ihre Rechenwege direkt in der Oberfläche. **Ø / Monat** bedeutet durchgehend: der im gewählten Kalenderjahr eingeplante Betrag geteilt durch 12. Dadurch werden monatliche, periodische und einmalige Positionen auf einer konsistenten Basis verglichen. Die Statistik ist damit unabhängig vom Dashboard-Schalter, der periodische Kosten nur für den aktuellen Monats-Saldo ein- oder ausblendet. + +Gesamtkennzahlen und Kennzahlen der aktuellen Konto-/Personenauswahl sind visuell getrennt. Jede zentrale Kennzahl besitzt einen aufklappbaren Rechenweg bzw. eine Zusammensetzung aus Einzelpositionen; zusätzliche Info-Buttons öffnen ausführliche Erläuterungen zu Aussage und Formel. Kategorieauswertungen zeigen neben dem Monatsdurchschnitt auch den Jahresbetrag und lassen sich bis zu den einzelnen Positionen aufklappen. Gemeinschaftsfinanzierung, einkommensbasierte Vergleichssicht und Transferarten besitzen ebenfalls Rechenweg-/Detailakkordeons. + +Bei der Rechenprüfung wurden zwei semantische Inkonsistenzen korrigiert: Die Gesamtkennzahlen verwenden nun immer alle für das gewählte Jahr geplanten periodischen und einmaligen Positionen, unabhängig von der Dashboard-Einstellung. Außerdem verwendet ein ausgewähltes Einzelkonto bei Personenfiltern für echte Eingänge die Bewegungs-Personenzuordnung, während die Gesamt-Personensicht persönliches Einkommen weiterhin korrekt aus dem Kontoeigentum ableitet. Der Zähler **Ohne Kategorie** zählt nur echte Ein- und Ausgänge und ändert sich nicht mehr durch das Ein-/Ausblenden von Transferdetails. Das JSON-Datenmodell bleibt bei `version: 11`. diff --git a/public/sw.js b/public/sw.js index b28177e..4c3082a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL_CACHE='fixfin-shell-v3.30'; +const SHELL_CACHE='fixfin-shell-v3.32'; const DATA_CACHE='fixfin-data-v1'; const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; diff --git a/server/index.js b/server/index.js index 2bb81c6..c34dda6 100644 --- a/server/index.js +++ b/server/index.js @@ -116,7 +116,7 @@ async function readPushoverConfig(){const value=await readJsonFile(pushoverFile, async function savePushoverConfig(input){const token=String(input?.token||'').trim();const user=String(input?.user||'').trim();if(!pushoverKeyPattern.test(token))throw new Error('Der Pushover Application API Token muss 30 alphanumerische Zeichen enthalten.');if(!pushoverKeyPattern.test(user))throw new Error('Der Pushover User/Group Key muss 30 alphanumerische Zeichen enthalten.');await writePrivateJson(pushoverFile,{token,user,updatedAt:new Date().toISOString()});return {configured:true,userMasked:maskKey(user)};} async function sendPushover(config,{title,message}){ const body=new URLSearchParams({token:config.token,user:config.user,title,message}); - const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.24'},body}); + const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.32'},body}); let payload={}; try{payload=await response.json();}catch{} if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`); return payload; diff --git a/src/main.jsx b/src/main.jsx index e92171c..5defad5 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -300,14 +300,17 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear() return { rows, unassigned, yearIncome, yearExpense, isAll, accountId, year, includeTransfers }; } -function categoryStats(data, accountId = 'all', personId = 'all', includeTransfers = true) { +function categoryStats(data, accountId = 'all', personId = 'all', includeTransfers = true, year = new Date().getFullYear()) { const categoryMap = new Map((data.categories || []).map(c => [c.id, c.name])); const isAll = accountId === 'all'; const accountById=id=>data.accounts.find(a=>a.id===id); const incomeFactor=item=>{ if(personId==='all')return 1; - const account=accountById(item.accountId); - return account?.ownership==='personal'&&ownerIdsOf(account).includes(personId)?1:0; + if(isAll){ + const account=accountById(item.accountId); + return account?.ownership==='personal'&&ownerIdsOf(account).includes(personId)?1:0; + } + return personFactor(item,personId); }; const defaultFactor=item=>personFactor(item,personId); const aggregate = (items,factorFn=defaultFactor) => { @@ -317,13 +320,16 @@ function categoryStats(data, accountId = 'all', personId = 'all', includeTransfe if(!factor) continue; const key = item.categoryId || '__uncategorized__'; const name = categoryMap.get(item.categoryId) || 'Ohne Kategorie'; - const current = buckets.get(key) || { id:key, name, amount:0, count:0, items:[], uncategorized:key==='__uncategorized__' }; - const amount = monthlyEquivalent(item) * factor; - current.amount += amount; current.count += 1; current.items.push({...item, statAmount:amount}); buckets.set(key,current); + const current = buckets.get(key) || { id:key, name, amount:0, annual:0, count:0, items:[], uncategorized:key==='__uncategorized__' }; + const annual = plannedAmountForYear(item,year) * factor; + if(!annual)continue; + const amount = annual/12; + current.amount += amount; current.annual += annual; current.count += 1; current.items.push({...item, statAmount:amount, statAnnual:annual}); buckets.set(key,current); } const total = [...buckets.values()].reduce((sum,x)=>sum+x.amount,0); + const annual = [...buckets.values()].reduce((sum,x)=>sum+x.annual,0); const rows = [...buckets.values()].sort((a,b)=>b.amount-a.amount).map(x=>({...x,percent:total>0?(x.amount/total)*100:0})); - return { total, rows }; + return { total, annual, rows }; }; const incomeItems=(data.incomes||[]).filter(x=>(isAll||x.accountId===accountId)&&incomeFactor(x)>0).map(x=>({...x,_statType:'income',_statFactor:incomeFactor(x)})); const expenseItems=(data.expenses||[]).filter(x=>(isAll||x.accountId===accountId)&&defaultFactor(x)>0).map(x=>({...x,_statType:'expense',_statFactor:defaultFactor(x)})); @@ -331,7 +337,57 @@ function categoryStats(data, accountId = 'all', personId = 'all', includeTransfe const direction=isAll?'neutral':x.toAccountId===accountId?'in':'out'; return {...x,_statType:direction==='in'?'transfer-in':direction==='out'?'transfer-out':'transfer',_statFactor:transferPersonFactor(data,x,personId,accountId)}; }):[]; - return { incomes:aggregate(incomeItems,x=>x._statFactor), expenses:aggregate(expenseItems,x=>x._statFactor), transfers:aggregate(transferItems,x=>x._statFactor), includeTransfers }; + return { incomes:aggregate(incomeItems,x=>x._statFactor), expenses:aggregate(expenseItems,x=>x._statFactor), transfers:aggregate(transferItems,x=>x._statFactor), includeTransfers, year }; +} + +function plannedAmountForYear(item, year){ + if(isOnce(item)){ + const when=dateMonth(item.oneTimeDate); + if(!when||Number(when.slice(0,4))!==Number(year))return 0; + return effectiveAmount(item,year,Number(when.slice(5,7))-1); + } + const interval=item.interval||'monthly'; + const hasDue=Number.isInteger(Number(item.dueMonth))&&Number(item.dueMonth)>=1&&Number(item.dueMonth)<=12; + let total=0; + for(let m=0;m<12;m++){ + if(interval==='monthly'){ + if(itemActiveInMonth(item,year,m))total+=effectiveAmount(item,year,m); + } else if(hasDue){ + if(occursInMonth(item,m,year))total+=effectiveAmount(item,year,m); + } else if(itemActiveInMonth(item,year,m)){ + total+=effectiveAmount(item,year,m)/(intervals[interval]?.divisor||1); + } + } + return total; +} +function statMonthlyAverage(item,year){return plannedAmountForYear(item,year)/12;} +function statAmountMeta(item){ + if(isOnce(item))return item.oneTimeDate?`einmalig ${formatDate(item.oneTimeDate)}`:'einmalig'; + if(item.interval==='monthly'||!item.interval)return 'monatlich'; + if(item.dueMonth)return `${intervals[item.interval]?.label||item.interval} · ab ${months[Number(item.dueMonth)-1]}`; + return `${intervals[item.interval]?.label||item.interval} · auf Monatsdurchschnitt verteilt`; +} + +function transferAnalysis(data, sourceIds, targetIds, year){ + const sources=new Set(sourceIds||[]),targets=new Set(targetIds||[]); + const rows=(data.transfers||[]).filter(item=>sources.has(item.fromAccountId)&&targets.has(item.toAccountId)).map(item=>{ + const annual=plannedAmountForYear(item,year); + return {...item,annual,monthly:annual/12,classification:transferClassification(data,item)}; + }).filter(item=>item.annual!==0); + const bySource=new Map();const byTarget=new Map(); + for(const item of rows){ + if(!bySource.has(item.fromAccountId))bySource.set(item.fromAccountId,{accountId:item.fromAccountId,annual:0,monthly:0,targets:new Map(),items:[]}); + const source=bySource.get(item.fromAccountId);source.annual+=item.annual;source.monthly+=item.monthly;source.items.push(item); + const target=source.targets.get(item.toAccountId)||{accountId:item.toAccountId,annual:0,monthly:0,items:[]};target.annual+=item.annual;target.monthly+=item.monthly;target.items.push(item);source.targets.set(item.toAccountId,target); + const targetTotal=byTarget.get(item.toAccountId)||{accountId:item.toAccountId,annual:0,monthly:0,items:[]};targetTotal.annual+=item.annual;targetTotal.monthly+=item.monthly;targetTotal.items.push(item);byTarget.set(item.toAccountId,targetTotal); + } + const annual=rows.reduce((sum,item)=>sum+item.annual,0);const monthly=annual/12; + return { + rows:rows.sort((a,b)=>b.annual-a.annual||a.name.localeCompare(b.name,'de')), + sources:[...bySource.values()].map(source=>({...source,targets:[...source.targets.values()].sort((a,b)=>b.annual-a.annual)})).sort((a,b)=>b.annual-a.annual), + targets:[...byTarget.values()].sort((a,b)=>b.annual-a.annual), + annual,monthly, + }; } @@ -345,7 +401,7 @@ function sharedOwnershipGroups(data){ } return [...groups.values()]; } -function personalMonthlyIncomeByPerson(data){ +function personalMonthlyIncomeByPerson(data,year){ const totals=new Map(); for(const person of data.persons||[])totals.set(person.id,0); for(const item of data.incomes||[]){ @@ -354,33 +410,36 @@ function personalMonthlyIncomeByPerson(data){ const owners=ownerIdsOf(account); if(owners.length!==1)continue; const id=owners[0]; - totals.set(id,(totals.get(id)||0)+monthlyEquivalent(item)); + totals.set(id,(totals.get(id)||0)+statMonthlyAverage(item,year)); } return totals; } -function sharedFinanceStats(data, personId='all'){ - const personalIncomeMap=personalMonthlyIncomeByPerson(data); + +function sharedFinanceStats(data, personId='all', year=new Date().getFullYear()){ + const personalIncomeMap=personalMonthlyIncomeByPerson(data,year); const groups=sharedOwnershipGroups(data).map(group=>{ const accountIds=new Set(group.accounts.map(a=>a.id)); - const income=(data.incomes||[]).filter(x=>accountIds.has(x.accountId)).reduce((sum,x)=>sum+monthlyEquivalent(x),0); - const expenses=(data.expenses||[]).filter(x=>accountIds.has(x.accountId)); - const expense=expenses.reduce((sum,x)=>sum+monthlyEquivalent(x),0); - const internalTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.fromAccountId)&&accountIds.has(x.toAccountId)).reduce((sum,x)=>sum+monthlyEquivalent(x),0); - const incomingTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.toAccountId)&&!accountIds.has(x.fromAccountId)); - const outgoingTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.fromAccountId)&&!accountIds.has(x.toAccountId)); - const fundingIn=incomingTransfers.reduce((sum,x)=>sum+monthlyEquivalent(x),0); - const withdrawalOut=outgoingTransfers.reduce((sum,x)=>sum+monthlyEquivalent(x),0); + const incomeItems=(data.incomes||[]).filter(x=>accountIds.has(x.accountId)).map(x=>({...x,statAmount:statMonthlyAverage(x,year),statAnnual:plannedAmountForYear(x,year)})).filter(x=>x.statAnnual); + const expenseItems=(data.expenses||[]).filter(x=>accountIds.has(x.accountId)).map(x=>({...x,statAmount:statMonthlyAverage(x,year),statAnnual:plannedAmountForYear(x,year)})).filter(x=>x.statAnnual); + const income=incomeItems.reduce((sum,x)=>sum+x.statAmount,0); + const expense=expenseItems.reduce((sum,x)=>sum+x.statAmount,0); + const internalTransferItems=(data.transfers||[]).filter(x=>accountIds.has(x.fromAccountId)&&accountIds.has(x.toAccountId)).map(x=>({...x,statAmount:statMonthlyAverage(x,year),statAnnual:plannedAmountForYear(x,year)})).filter(x=>x.statAnnual); + const incomingTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.toAccountId)&&!accountIds.has(x.fromAccountId)).map(x=>({...x,statAmount:statMonthlyAverage(x,year),statAnnual:plannedAmountForYear(x,year)})).filter(x=>x.statAnnual); + const outgoingTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.fromAccountId)&&!accountIds.has(x.toAccountId)).map(x=>({...x,statAmount:statMonthlyAverage(x,year),statAnnual:plannedAmountForYear(x,year)})).filter(x=>x.statAnnual); + const internalTransfers=internalTransferItems.reduce((sum,x)=>sum+x.statAmount,0); + const fundingIn=incomingTransfers.reduce((sum,x)=>sum+x.statAmount,0); + const withdrawalOut=outgoingTransfers.reduce((sum,x)=>sum+x.statAmount,0); const sharedIncomeCoverage=Math.min(income,expense); const sharedIncomeSurplus=Math.max(0,income-expense); const costNeed=Math.max(0,expense-income); const netAssetChange=income+fundingIn-expense-withdrawalOut; const shares=new Map(); - const ensure=id=>{const key=id||'__unassigned__';if(!shares.has(key))shares.set(key,{id:key,name:key==='__unassigned__'?'Ohne Person':personName(data,key),grossCost:0,targetFunding:0,actualFunding:0,difference:0});return shares.get(key);}; + const ensure=id=>{const key=id||'__unassigned__';if(!shares.has(key))shares.set(key,{id:key,name:key==='__unassigned__'?'Ohne Person':personName(data,key),grossCost:0,targetFunding:0,actualFunding:0,difference:0,costItems:[],fundingItems:[]});return shares.get(key);}; for(const id of group.ownerPersonIds)ensure(id); - for(const item of expenses){ - const amount=monthlyEquivalent(item);const ids=personIdsOf(item); - if(!ids.length){ensure('__unassigned__').grossCost+=amount;continue;} - for(const id of ids)ensure(id).grossCost+=amount/ids.length; + for(const item of expenseItems){ + const amount=item.statAmount;const ids=personIdsOf(item); + if(!ids.length){const row=ensure('__unassigned__');row.grossCost+=amount;row.costItems.push({...item,personStatAmount:amount});continue;} + for(const id of ids){const row=ensure(id);const share=amount/ids.length;row.grossCost+=share;row.costItems.push({...item,personStatAmount:share});} } const totalGross=[...shares.values()].reduce((sum,row)=>sum+row.grossCost,0); for(const row of shares.values()){ @@ -390,9 +449,9 @@ function sharedFinanceStats(data, personId='all'){ } for(const item of incomingTransfers){ const from=data.accounts.find(a=>a.id===item.fromAccountId);if(from?.ownership!=='personal')continue; - const amount=monthlyEquivalent(item);const ids=ownerIdsOf(from); - if(!ids.length){ensure('__unassigned__').actualFunding+=amount;continue;} - for(const id of ids)ensure(id).actualFunding+=amount/ids.length; + const amount=item.statAmount;const ids=ownerIdsOf(from); + if(!ids.length){const row=ensure('__unassigned__');row.actualFunding+=amount;row.fundingItems.push({...item,personStatAmount:amount});continue;} + for(const id of ids){const row=ensure(id);const share=amount/ids.length;row.actualFunding+=share;row.fundingItems.push({...item,personStatAmount:share});} } const totalPersonalFunding=[...shares.values()].reduce((sum,row)=>sum+row.actualFunding,0); for(const row of shares.values()){ @@ -403,7 +462,6 @@ function sharedFinanceStats(data, personId='all'){ let personRows=[...shares.values()]; if(personId!=='all')personRows=personRows.filter(x=>x.id===personId); personRows=personRows.filter(x=>x.grossCost||x.targetFunding||x.actualFunding||group.ownerPersonIds.includes(x.id)); - const personalIncomeTotal=group.ownerPersonIds.reduce((sum,id)=>sum+(personalIncomeMap.get(id)||0),0); const incomeAllocationAvailable=personalIncomeTotal>0; let incomeBurdenRows=group.ownerPersonIds.map(id=>{ @@ -419,12 +477,12 @@ function sharedFinanceStats(data, personId='all'){ }); if(personId!=='all')incomeBurdenRows=incomeBurdenRows.filter(x=>x.id===personId); const householdBurdenRate=incomeAllocationAvailable?(costNeed/personalIncomeTotal)*100:null; - return {...group,income,expense,sharedIncomeCoverage,sharedIncomeSurplus,costNeed,fundingIn,withdrawalOut,internalTransfers,netAssetChange,totalPersonalFunding,personRows,personalIncomeTotal,incomeAllocationAvailable,incomeBurdenRows,householdBurdenRate}; + return {...group,income,expense,incomeItems,expenseItems,sharedIncomeCoverage,sharedIncomeSurplus,costNeed,fundingIn,withdrawalOut,incomingTransfers,outgoingTransfers,internalTransferItems,internalTransfers,netAssetChange,totalPersonalFunding,personRows,personalIncomeTotal,incomeAllocationAvailable,incomeBurdenRows,householdBurdenRate,year}; }); - return {groups}; + return {groups,year}; } -function personFlowStats(data, accountId = 'all', personId = 'all', includeTransfers = true) { +function personFlowStats(data, accountId = 'all', personId = 'all', includeTransfers = true, year = new Date().getFullYear()) { const isAll=accountId==='all'; if(!isAll){ const selectedAccount=data.accounts.find(a=>a.id===accountId); @@ -444,10 +502,10 @@ function personFlowStats(data, accountId = 'all', personId = 'all', includeTrans if(personId!=='all')return ids.includes(personId)?[[personId,1/ids.length]]:[]; return ids.map(id=>[id,1/ids.length]); }; - for(const item of data.incomes||[]){if(item.accountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of splitTargets(item))ensure(id).income+=amount*factor;} - for(const item of data.expenses||[]){if(item.accountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of splitTargets(item))ensure(id).expense+=amount*factor;} + for(const item of data.incomes||[]){if(item.accountId!==accountId)continue;const amount=statMonthlyAverage(item,year);for(const [id,factor] of splitTargets(item))ensure(id).income+=amount*factor;} + for(const item of data.expenses||[]){if(item.accountId!==accountId)continue;const amount=statMonthlyAverage(item,year);for(const [id,factor] of splitTargets(item))ensure(id).expense+=amount*factor;} // Transfers gehören immer zur echten Kontoliquidität. Der Schalter steuert nur, ob die Details sichtbar sind. - for(const item of data.transfers||[]){if(item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of transferTargets(item)){const b=ensure(id),share=amount*factor;if(item.toAccountId===accountId)b.transferIn+=share;if(item.fromAccountId===accountId)b.transferOut+=share;}} + for(const item of data.transfers||[]){if(item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue;const amount=statMonthlyAverage(item,year);for(const [id,factor] of transferTargets(item)){const b=ensure(id),share=amount*factor;if(item.toAccountId===accountId)b.transferIn+=share;if(item.fromAccountId===accountId)b.transferOut+=share;}} for(const row of buckets.values()){row.displayIn=row.income+row.transferIn;row.displayOut=row.expense+row.transferOut;row.net=row.displayIn-row.displayOut;} let rows=[...buckets.values()];if(personId!=='all')rows=rows.filter(x=>x.id===personId);else rows=rows.filter(x=>x.displayIn||x.displayOut);rows.sort((a,b)=>b.displayOut-a.displayOut||a.name.localeCompare(b.name,'de')); const totals=rows.reduce((sum,row)=>({displayIn:sum.displayIn+row.displayIn,displayOut:sum.displayOut+row.displayOut,net:sum.net+row.net}),{displayIn:0,displayOut:0,net:0}); @@ -456,9 +514,9 @@ function personFlowStats(data, accountId = 'all', personId = 'all', includeTrans const buckets=new Map();const ensure=id=>{const key=id||'__unassigned__';if(!buckets.has(key))buckets.set(key,{id:key,name:key==='__unassigned__'?'Ohne Person':personName(data,key),personalIncome:0,expenseShare:0,sharedExpenseShare:0,sharedCoverage:0,netCost:0,economicAvailable:0,liquidIncome:0,liquidExpense:0,liquidTransferIn:0,liquidTransferOut:0,liquidAvailable:0,sharedFundingTarget:0,sharedFundingActual:0,sharedFundingDifference:0,assetBuild:0});return buckets.get(key);}; for(const person of data.persons||[])ensure(person.id); - for(const item of data.incomes||[]){const account=data.accounts.find(a=>a.id===item.accountId);if(account?.ownership==='personal'){for(const id of ownerIdsOf(account))ensure(id).personalIncome+=monthlyEquivalent(item);}} - for(const item of data.expenses||[]){const amount=monthlyEquivalent(item),ids=personIdsOf(item);const account=data.accounts.find(a=>a.id===item.accountId);const targets=ids.length?ids:['__unassigned__'];for(const id of targets){const row=ensure(id);const share=amount/targets.length;row.expenseShare+=share;if(account?.ownership==='shared')row.sharedExpenseShare+=share;}} - const shared=sharedFinanceStats(data,'all'); + for(const item of data.incomes||[]){const account=data.accounts.find(a=>a.id===item.accountId);if(account?.ownership==='personal'){for(const id of ownerIdsOf(account))ensure(id).personalIncome+=statMonthlyAverage(item,year);}} + for(const item of data.expenses||[]){const amount=statMonthlyAverage(item,year),ids=personIdsOf(item);const account=data.accounts.find(a=>a.id===item.accountId);const targets=ids.length?ids:['__unassigned__'];for(const id of targets){const row=ensure(id);const share=amount/targets.length;row.expenseShare+=share;if(account?.ownership==='shared')row.sharedExpenseShare+=share;}} + const shared=sharedFinanceStats(data,'all',year); for(const group of shared.groups){const covered=Math.min(group.income,group.expense);const totalCost=group.personRows.reduce((sum,r)=>sum+r.grossCost,0);for(const pr of group.personRows){const row=ensure(pr.id);row.sharedCoverage+=totalCost>0?covered*(pr.grossCost/totalCost):0;row.sharedFundingTarget+=pr.targetFunding;row.sharedFundingActual+=pr.actualFunding;}} // Persönlich verfügbare Liquidität wird ausschließlich aus persönlichen Zahlungsverkehrskonten abgeleitet. @@ -466,57 +524,93 @@ function personFlowStats(data, accountId = 'all', personId = 'all', includeTrans // ohne dadurch zu Konsumausgaben zu werden. const liquidAccountOwner=new Map(); for(const account of data.accounts||[]){if(account.ownership!=='personal'||isAssetAccount(account))continue;const ids=ownerIdsOf(account);if(ids.length===1)liquidAccountOwner.set(account.id,ids[0]);} - for(const item of data.incomes||[]){const id=liquidAccountOwner.get(item.accountId);if(id)ensure(id).liquidIncome+=monthlyEquivalent(item);} - for(const item of data.expenses||[]){const id=liquidAccountOwner.get(item.accountId);if(id)ensure(id).liquidExpense+=monthlyEquivalent(item);} + for(const item of data.incomes||[]){const id=liquidAccountOwner.get(item.accountId);if(id)ensure(id).liquidIncome+=statMonthlyAverage(item,year);} + for(const item of data.expenses||[]){const id=liquidAccountOwner.get(item.accountId);if(id)ensure(id).liquidExpense+=statMonthlyAverage(item,year);} for(const item of data.transfers||[]){ - const amount=monthlyEquivalent(item);const fromId=liquidAccountOwner.get(item.fromAccountId),toId=liquidAccountOwner.get(item.toAccountId); + const amount=statMonthlyAverage(item,year);const fromId=liquidAccountOwner.get(item.fromAccountId),toId=liquidAccountOwner.get(item.toAccountId); if(fromId)ensure(fromId).liquidTransferOut+=amount; if(toId)ensure(toId).liquidTransferIn+=amount; } // Vermögensaufbau bleibt eine separate wirtschaftliche Information und ist unabhängig davon, // ob die Transferdetails gerade eingeblendet werden. - for(const item of data.transfers||[]){const from=data.accounts.find(a=>a.id===item.fromAccountId),to=data.accounts.find(a=>a.id===item.toAccountId);if(from?.ownership!=='personal'||!sameOwnershipDomain(from,to))continue;const fromAsset=isAssetAccount(from),toAsset=isAssetAccount(to);const delta=!fromAsset&&toAsset?monthlyEquivalent(item):fromAsset&&!toAsset?-monthlyEquivalent(item):0;if(!delta)continue;const ids=ownerIdsOf(from);if(!ids.length){ensure('__unassigned__').assetBuild+=delta;continue;}for(const id of ids)ensure(id).assetBuild+=delta/ids.length;} + for(const item of data.transfers||[]){const from=data.accounts.find(a=>a.id===item.fromAccountId),to=data.accounts.find(a=>a.id===item.toAccountId);if(from?.ownership!=='personal'||!sameOwnershipDomain(from,to))continue;const fromAsset=isAssetAccount(from),toAsset=isAssetAccount(to);const delta=!fromAsset&&toAsset?statMonthlyAverage(item,year):fromAsset&&!toAsset?-statMonthlyAverage(item,year):0;if(!delta)continue;const ids=ownerIdsOf(from);if(!ids.length){ensure('__unassigned__').assetBuild+=delta;continue;}for(const id of ids)ensure(id).assetBuild+=delta/ids.length;} for(const row of buckets.values()){row.netCost=Math.max(0,row.expenseShare-row.sharedCoverage);row.economicAvailable=row.personalIncome-row.netCost;row.liquidAvailable=row.liquidIncome-row.liquidExpense+row.liquidTransferIn-row.liquidTransferOut;row.sharedFundingDifference=row.sharedFundingActual-row.sharedFundingTarget;} let rows=[...buckets.values()];if(personId!=='all')rows=rows.filter(x=>x.id===personId);else rows=rows.filter(x=>x.personalIncome||x.expenseShare||x.sharedCoverage||x.sharedFundingActual||x.assetBuild||x.liquidIncome||x.liquidExpense||x.liquidTransferIn||x.liquidTransferOut);rows.sort((a,b)=>b.netCost-a.netCost||a.name.localeCompare(b.name,'de')); const totals=rows.reduce((sum,row)=>({personalIncome:sum.personalIncome+row.personalIncome,expenseShare:sum.expenseShare+row.expenseShare,sharedCoverage:sum.sharedCoverage+row.sharedCoverage,netCost:sum.netCost+row.netCost,economicAvailable:sum.economicAvailable+row.economicAvailable,liquidIncome:sum.liquidIncome+row.liquidIncome,liquidExpense:sum.liquidExpense+row.liquidExpense,liquidTransferIn:sum.liquidTransferIn+row.liquidTransferIn,liquidTransferOut:sum.liquidTransferOut+row.liquidTransferOut,liquidAvailable:sum.liquidAvailable+row.liquidAvailable,sharedFundingTarget:sum.sharedFundingTarget+row.sharedFundingTarget,sharedFundingActual:sum.sharedFundingActual+row.sharedFundingActual,assetBuild:sum.assetBuild+row.assetBuild}),{personalIncome:0,expenseShare:0,sharedCoverage:0,netCost:0,economicAvailable:0,liquidIncome:0,liquidExpense:0,liquidTransferIn:0,liquidTransferOut:0,liquidAvailable:0,sharedFundingTarget:0,sharedFundingActual:0,assetBuild:0}); return {rows,totals,isAll:true,includeTransfers}; } -function PersonFlowOverview({stats}){ - if(!stats.isAll)return

Personenbezogene Kontoflüsse

Auf einem einzelnen Konto werden tatsächliche Zu- und Abflüsse gezeigt. Transfers sind immer im Nettofluss enthalten; der Schalter blendet nur die Transferdetails ein oder aus.

=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat
{stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}Nettofluss auf diesem Konto
=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
Zuflüsse inkl. Transfers+{formatMoney(row.displayIn)}
Abflüsse inkl. Transfers−{formatMoney(row.displayOut)}
{stats.includeTransfers&&<>
Transfers rein+{formatMoney(row.transferIn)}
Transfers raus−{formatMoney(row.transferOut)}
}
)}
}
; - return

Persönliche Sicht

Wirtschaftliche Kosten und echte persönliche Liquidität werden getrennt gezeigt. Transfers verändern die verfügbare Liquidität, bleiben aber außerhalb der Kostenstatistik.

=0?'good-text':'bad-text'}`}>{stats.totals.liquidAvailable>=0?'+':''}{formatMoney(stats.totals.liquidAvailable)} / Monat verfügbar
{stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}Liquid verfügbar auf persönlichen Zahlungsverkehrskonten
=0?'good-text':'bad-text'}>{row.liquidAvailable>=0?'+':''}{formatMoney(row.liquidAvailable)}
Wirtschaftlich verfügbar=0?'good-text':'bad-text'}>{row.economicAvailable>=0?'+':''}{formatMoney(row.economicAvailable)}
Netto-Kostenlast−{formatMoney(row.netCost)}
Eigene Kontoeingänge+{formatMoney(row.personalIncome)}
Gemeinsame Deckung+{formatMoney(row.sharedCoverage)}
{stats.includeTransfers&&<>
Liquid-Transfers rein+{formatMoney(row.liquidTransferIn)}
Liquid-Transfers raus−{formatMoney(row.liquidTransferOut)}
Gemeinschaft finanziert{formatMoney(row.sharedFundingActual)}
Vermögensaufbau netto=0?'good-text':'bad-text'}>{row.assetBuild>=0?'+':''}{formatMoney(row.assetBuild)}
}
)}
}
; + + +function StatsHelpModal({title,onClose,children}){ + useModalScrollLock(); + return
e.target===e.currentTarget&&onClose()}>
STATISTIK ERKLÄRT

{title}

{children}
; +} +function StatsInfoButton({title,children}){ + const [open,setOpen]=useState(false); + return <>{open&&setOpen(false)}>{children}}; +} +function StatsSectionTitle({title,children,helpTitle,help}){ + return

{title}

{children}
{help&&{help}}
; +} +function StatItemList({data,items,empty='Keine beitragenden Positionen.',amountField='statAmount',annualField='statAnnual',amountSuffix='Ø / Monat',showAnnual=true}){ + if(!items?.length)return
{empty}
; + return
{items.map((item,index)=>{ + const amount=Number(item.personStatAmount??item[amountField]??0);const annual=item[annualField]; + const transfer=Boolean(item.fromAccountId||item.toAccountId); + const meta=transfer?`${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)} · ${transferClassLabel(data,item)} · ${statAmountMeta(item)}`:`${accountName(data,item.accountId)} · ${personListLabel(data,item)} · ${statAmountMeta(item)}`; + return
{item.name}{meta}
{formatMoney(amount)} {amountSuffix}{showAnnual&&annual!=null&&{formatMoney(annual)} im Jahr}
; + })}
; +} +function StatFormulaRows({rows}){ + return
{rows.map((row,index)=>
{row.label}{typeof row.value==='number'?formatMoney(row.value):row.value}{row.note&&{row.note}}
)}
; +} +function StatMetricCard({label,value,displayValue,tone,featured=false,hint,helpTitle,help,children}){ + return
{label}{help&&{help}}
{displayValue??formatMoney(value)}{hint&&{hint}}{children&&
Zusammensetzung
{children}
}
; +} +function StatsMethodPanel({year}){ + return

Die Statistik ist eine Planungs- und Durchschnittssicht für das gewählte Kalenderjahr. Sie ist keine Liste tatsächlich gebuchter Bankumsätze.

Ø / Monat bedeutet immer: im Jahr eingeplanter Betrag ÷ 12. Dadurch werden jährliche, vierteljährliche, halbjährliche und einmalige Positionen vergleichbar.

Transfers bleiben wirtschaftlich getrennt von Einnahmen und Kosten. In einer einzelnen Kontosicht gehören sie trotzdem zum Liquiditäts-Nettofluss.

}>

Alle Ø-Werte beziehen sich auf {year}. Einmalige Positionen zählen genau einmal im Zieljahr. Periodische Positionen mit Fälligkeitsmonat werden in ihren echten Monaten eingeplant.

WirtschaftlichEchte Einnahmen minus echte Ausgaben. Transfers zählen nicht als Gewinn oder Kosten.
PersonenAusgaben werden nach den Personen der Bewegung geteilt. Persönliches Einkommen folgt in der Gesamtansicht dem Eigentümer des persönlichen Kontos.
GemeinschaftGemeinsame Einnahmen decken zuerst gemeinsame Kosten und werden niemals zu persönlichem Einkommen.
LiquiditätBei einem einzelnen Konto werden Transfers rein und raus im Nettofluss vollständig berücksichtigt.
; } -function SharedFundingOverview({data,personId='all',showTransfers=true}){ - const stats=useMemo(()=>sharedFinanceStats(data,personId),[data,personId]); +function PersonFlowOverview({stats,year}){ + const help=<>

Wirtschaftlich verfügbar beantwortet: Was bleibt nach der persönlichen Kostenlast übrig? Liquid verfügbar beantwortet dagegen: Was bleibt auf persönlichen Zahlungsverkehrskonten nach echten Kontobewegungen übrig?

Diese beiden Werte dürfen unterschiedlich sein. Eine Überweisung ins Depot oder aufs Gemeinschaftskonto ist keine Ausgabe, reduziert aber die liquide Verfügbarkeit.

; + if(!stats.isAll)return

Ø-Monatsfluss {year} auf dem ausgewählten Konto. Transfers sind immer im Nettofluss enthalten; der Schalter blendet nur Transferdetails aus.

=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} Ø / Monat
{stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}Nettofluss auf diesem Konto
=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
Echte Eingänge+{formatMoney(row.income)}
Echte Ausgaben−{formatMoney(row.expense)}
{stats.includeTransfers&&<>
Transfers rein+{formatMoney(row.transferIn)}
Transfers raus−{formatMoney(row.transferOut)}
}
Rechenweg
)}
}
; + return

Wirtschaftliche Kosten und persönliche Liquidität als Ø-Monatswerte {year}. Transfers verändern Liquidität, bleiben aber außerhalb der Kostenstatistik.

=0?'good-text':'bad-text'}`}>{stats.totals.liquidAvailable>=0?'+':''}{formatMoney(stats.totals.liquidAvailable)} Ø / Monat liquid
{stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}Liquid verfügbar auf persönlichen Zahlungsverkehrskonten
=0?'good-text':'bad-text'}>{row.liquidAvailable>=0?'+':''}{formatMoney(row.liquidAvailable)}
Wirtschaftlich verfügbar=0?'good-text':'bad-text'}>{row.economicAvailable>=0?'+':''}{formatMoney(row.economicAvailable)}
Brutto-Kostenanteil−{formatMoney(row.expenseShare)}
Gemeinsame Deckung+{formatMoney(row.sharedCoverage)}
Netto-Kostenlast−{formatMoney(row.netCost)}
Eigene Kontoeingänge+{formatMoney(row.personalIncome)}
{stats.includeTransfers&&<>
Liquid-Transfers rein+{formatMoney(row.liquidTransferIn)}
Liquid-Transfers raus−{formatMoney(row.liquidTransferOut)}
Gemeinschaft finanziert{formatMoney(row.sharedFundingActual)}
Vermögensaufbau netto=0?'good-text':'bad-text'}>{row.assetBuild>=0?'+':''}{formatMoney(row.assetBuild)}
}
Rechenweg
)}
}
; +} + +function SharedFundingOverview({data,personId='all',showTransfers=true,year=new Date().getFullYear()}){ + const stats=useMemo(()=>sharedFinanceStats(data,personId,year),[data,personId,year]); if(!stats.groups.length)return null; const percent=value=>value==null?'–':`${value.toFixed(1).replace('.',',')} %`; - return

Gemeinschaftsfinanzierung

Gemeinsame Einnahmen wie Kindergeld bleiben im gemeinsamen Topf. Sie reduzieren den Finanzierungsbedarf, werden aber keiner Person als persönliche Einnahme zugerechnet.

{stats.groups.map(group=>
{group.accounts.map(a=>a.name).join(' + ')}{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}
=0?'good-text':'bad-text'}>{group.netAssetChange>=0?'+':''}{formatMoney(group.netAssetChange)} / Monat
Gemeinsame Einnahmen+{formatMoney(group.income)}
Gemeinsame Kosten−{formatMoney(group.expense)}
Kosten-Finanzierungsbedarf{formatMoney(group.costNeed)}
{showTransfers&&
Tatsächliche Finanzierung{formatMoney(group.fundingIn)}
}
{showTransfers&&group.internalTransfers>0&&
Interne Umbuchungen innerhalb dieses Gemeinschaftsbereichs: {formatMoney(group.internalTransfers)} / Monat. Sie sind keine Kosten.
}
{group.personRows.map(row=>
{row.name}
Kostenanteil{formatMoney(row.grossCost)} · {percent(row.costShare)}
Soll-Finanzierung{formatMoney(row.targetFunding)} · {percent(row.targetFundingShare)}
{showTransfers&&<>
Ist-Beitrag{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare)}
Anteil am Bedarf{percent(row.needCoverageShare)}
Differenz=0?'good-text':'bad-text'}>{row.difference>=0?'+':''}{formatMoney(row.difference)}
}
)}
{showTransfers&&
Ist-Beitrag % = Anteil dieser Person an allen tatsächlichen persönlichen Einzahlungen in den Gemeinschaftsbereich. Anteil am Bedarf = wie viel des verbleibenden Finanzierungsbedarfs diese Person tatsächlich deckt.
}{!showTransfers&&
Transferdetails sind ausgeblendet. Kostenanteil und Soll-Finanzierung bleiben sichtbar, tatsächliche Beiträge werden nicht angezeigt.
}
)}
; + const help=<>

Kosten-Finanzierungsbedarf = gemeinsame Kosten − gemeinsame Einnahmen, mindestens 0.

Soll-Finanzierung verteilt diesen Bedarf im Verhältnis der tatsächlich zugeordneten gemeinsamen Kosten. Ist-Beitrag stammt aus Transfers von persönlichen Konten in diesen Gemeinschaftsbereich und wird dem Eigentümer des Quellkontos zugerechnet.

Nettoveränderung Gemeinschaftsvermögen = gemeinsame Einnahmen + externe Zuführungen − gemeinsame Kosten − externe Entnahmen. Interne Umbuchungen zwischen gemeinsamen Konten desselben Eigentümerkreises fallen heraus.

; + return

Ø-Monatswerte {year}. Gemeinsame Einnahmen bleiben im gemeinsamen Topf und reduzieren den Finanzierungsbedarf.

{stats.groups.map(group=>
{group.accounts.map(a=>a.name).join(' + ')}{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}
Nettoveränderung Gemeinschaftsvermögen=0?'good-text':'bad-text'}>{group.netAssetChange>=0?'+':''}{formatMoney(group.netAssetChange)} Ø / Monat
Gemeinsame Einnahmen+{formatMoney(group.income)}
Gemeinsame Kosten−{formatMoney(group.expense)}
Kosten-Finanzierungsbedarf{formatMoney(group.costNeed)}
{showTransfers&&
Tatsächliche externe Zuführung{formatMoney(group.fundingIn)}
}
{showTransfers&&group.internalTransfers>0&&
Interne Umbuchungen innerhalb dieses Gemeinschaftsbereichs: {formatMoney(group.internalTransfers)} Ø / Monat. Sie sind weder Kosten noch externe Finanzierung.
}
{group.personRows.map(row=>
{row.name}
Kostenanteil{formatMoney(row.grossCost)} · {percent(row.costShare)}
Soll-Finanzierung{formatMoney(row.targetFunding)} · {percent(row.targetFundingShare)}
{showTransfers&&<>
Ist-Beitrag{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare)}
Anteil am Bedarf{percent(row.needCoverageShare)}
Differenz=0?'good-text':'bad-text'}>{row.difference>=0?'+':''}{formatMoney(row.difference)}
}
)}
Zusammensetzung des Gemeinschaftsbereichs

Gemeinsame Einnahmen

Gemeinsame Kosten

{showTransfers&&<>

Externe Zuführungen

Interne Umbuchungen

Externe Entnahmen

}
{showTransfers&&
Ist-Beitrag % ist der Anteil an allen persönlichen Einzahlungen in den Gemeinschaftsbereich. Anteil am Bedarf zeigt, wie viel des verbleibenden Kostenbedarfs diese Person tatsächlich deckt.
}{!showTransfers&&
Transferdetails sind ausgeblendet. Kostenanteil und Soll bleiben sichtbar; tatsächliche Beiträge nicht.
}
)}
; } - -function SharedIncomeBurdenOverview({data,personId='all',showTransfers=true}){ - const stats=useMemo(()=>sharedFinanceStats(data,personId),[data,personId]); +function SharedIncomeBurdenOverview({data,personId='all',showTransfers=true,year=new Date().getFullYear()}){ + const stats=useMemo(()=>sharedFinanceStats(data,personId,year),[data,personId,year]); if(!stats.groups.length)return null; const percent=value=>value==null?'–':`${value.toFixed(1).replace('.',',')} %`; const percentPoints=value=>value==null?'–':`${value>=0?'+':''}${value.toFixed(1).replace('.',',')} %-Pkt.`; - return

Gemeinsame Kosten gemessen am persönlichen Einkommen

Gemeinsame Einnahmen decken zuerst gemeinsame Kosten. Nur der verbleibende Finanzierungsbedarf wird im Verhältnis der persönlichen Einkommen auf die Eigentümer des Gemeinschaftsbereichs verteilt.

{stats.groups.map(group=>
{group.accounts.map(a=>a.name).join(' + ')}{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}
Belastungsquote Haushalt{percent(group.householdBurdenRate)}
Gemeinsame Kosten−{formatMoney(group.expense)}
Deckung gemeinsame Einnahmen+{formatMoney(group.sharedIncomeCoverage)}
Verbleibender Bedarf{formatMoney(group.costNeed)}
Persönliche Einkommen gesamt{formatMoney(group.personalIncomeTotal)}
{group.sharedIncomeSurplus>0&&
Gemeinsame Einnahmen übersteigen die gemeinsamen Kosten um {formatMoney(group.sharedIncomeSurplus)} / Monat. Es ist keine persönliche Finanzierung nötig.
}{!group.incomeAllocationAvailable?
Keine persönlichen Einnahmen auf persönlich zugeordneten Konten vorhanden. Eine einkommensbasierte Verteilung kann deshalb nicht berechnet werden.
:
{group.incomeBurdenRows.map(row=>
{row.name}
Persönliches Einkommen{formatMoney(row.personalIncome)}
Einkommensanteil{percent(row.incomeShare==null?null:row.incomeShare*100)}
Soll nach Einkommen{row.incomeBasedFunding==null?'–':`${formatMoney(row.incomeBasedFunding)} · ${percent(row.incomeShare==null?null:row.incomeShare*100)}`}
Belastung vom Einkommen{percent(row.burdenRate)}
{showTransfers&&<>
Ist-Beitrag{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare==null?null:row.actualFundingShare*100)}
Abweichung Soll / Ist=0?'good-text':'bad-text'}>{row.difference==null?'–':`${row.difference>=0?'+':''}${formatMoney(row.difference)} · ${percentPoints(row.shareDifferencePoints)}`}
}
)}
}
{showTransfers?'Soll-% entspricht dem Einkommensanteil. Ist-% zeigt den Anteil an allen tatsächlichen persönlichen Einzahlungen in den Gemeinschaftsbereich. Die %-Punkt-Abweichung macht sichtbar, wer relativ zum einkommensbasierten Soll mehr oder weniger beiträgt. ':'Diese Statistik ist eine Vergleichs- und Fairnesssicht. '}Sie verändert weder die hinterlegte Personenzuordnung der Kosten noch die tatsächlichen Kontosalden.
)}
; + const help=<>

Diese Sicht ist ein Fairnessvergleich, keine automatische Zahlungsregel.

Zuerst werden gemeinsame Einnahmen von gemeinsamen Kosten abgezogen. Der verbleibende Bedarf wird dann proportional zu den persönlichen Einkommen der Eigentümer verteilt. Deshalb entspricht der Soll-% dem Einkommensanteil.

Die Belastungsquote des Haushalts ist verbleibender Bedarf ÷ Summe der persönlichen Einkommen. Bei proportionaler Verteilung ist diese Quote für alle Personen mit Einkommen gleich.

; + return

Ø-Monatswerte {year}. Diese Vergleichssicht zeigt, wie der verbleibende Gemeinschaftsbedarf bei einkommensproportionaler Finanzierung verteilt wäre.

{stats.groups.map(group=>{const personalIncomeItems=(data.incomes||[]).filter(item=>{const account=data.accounts.find(a=>a.id===item.accountId);return account?.ownership==='personal'&&ownerIdsOf(account).some(id=>group.ownerPersonIds.includes(id));}).map(item=>({...item,statAmount:statMonthlyAverage(item,year),statAnnual:plannedAmountForYear(item,year)})).filter(item=>item.statAnnual);return
{group.accounts.map(a=>a.name).join(' + ')}{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}
Belastungsquote Haushalt{percent(group.householdBurdenRate)}
Gemeinsame Kosten−{formatMoney(group.expense)}
Deckung gemeinsame Einnahmen+{formatMoney(group.sharedIncomeCoverage)}
Verbleibender Bedarf{formatMoney(group.costNeed)}
Persönliche Einkommen gesamt{formatMoney(group.personalIncomeTotal)}
{group.sharedIncomeSurplus>0&&
Gemeinsame Einnahmen übersteigen die gemeinsamen Kosten um {formatMoney(group.sharedIncomeSurplus)} Ø / Monat. Es ist keine persönliche Finanzierung nötig.
}{!group.incomeAllocationAvailable?
Keine persönlichen Einnahmen auf persönlichen Konten vorhanden. Eine einkommensbasierte Verteilung kann deshalb nicht berechnet werden.
:
{group.incomeBurdenRows.map(row=>
{row.name}
Persönliches Einkommen{formatMoney(row.personalIncome)}
Einkommensanteil / Soll-%{percent(row.incomeShare==null?null:row.incomeShare*100)}
Soll nach Einkommen{row.incomeBasedFunding==null?'–':formatMoney(row.incomeBasedFunding)}
Belastung vom Einkommen{percent(row.burdenRate)}
{showTransfers&&<>
Ist-Beitrag / Ist-%{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare==null?null:row.actualFundingShare*100)}
Abweichung Soll / Ist=0?'good-text':'bad-text'}>{row.difference==null?'–':`${row.difference>=0?'+':''}${formatMoney(row.difference)} · ${percentPoints(row.shareDifferencePoints)}`}
}
)}
}
Rechenweg und Einkommensquellen

Persönliche Einkommensquellen

{showTransfers?'Soll-% entspricht dem Einkommensanteil. Ist-% zeigt den Anteil an allen tatsächlichen persönlichen Einzahlungen. Die %-Punkt-Abweichung zeigt Über- oder Unterbeitrag relativ zum einkommensbasierten Soll. ':'Transferdetails sind ausgeblendet. '}Diese Sicht verändert keine Bewegungen oder Kontosalden.
})}
; } - -function dashboardKpis(data, totals) { - const forecast = annualForecast(data, 'all'); +function dashboardKpis(data, year = new Date().getFullYear()) { + const annualIncome=(data.incomes||[]).reduce((s,x)=>s+plannedAmountForYear(x,year),0); + const annualExpense=(data.expenses||[]).reduce((s,x)=>s+plannedAmountForYear(x,year),0); + const monthlyIncome=annualIncome/12; + const monthlyExpense=annualExpense/12; + const forecast = annualForecast(data, 'all', year, 'all', true); const expensive = [...forecast.rows].sort((a,b)=>b.displayExpense-a.displayExpense)[0]; const cheap = [...forecast.rows].sort((a,b)=>a.displayExpense-b.displayExpense)[0]; + const reserveAnnual=(data.expenses||[]).filter(isPeriodic).reduce((s,x)=>s+plannedAmountForYear(x,year),0); return { - fixedCostRatio: totals.income > 0 ? totals.expense / totals.income * 100 : 0, - annualExpense: data.expenses.reduce((s,x)=>s+annualEquivalent(x),0), - annualIncome: data.incomes.reduce((s,x)=>s+annualEquivalent(x),0), - freeMonthly: totals.balance, - reserveMonthly: totals.periodicExpenseReserve, - expensive, - cheap, + fixedCostRatio: monthlyIncome > 0 ? monthlyExpense / monthlyIncome * 100 : 0, + annualExpense, annualIncome, monthlyIncome, monthlyExpense, + freeMonthly: monthlyIncome-monthlyExpense, + reserveMonthly: reserveAnnual/12, + reserveAnnual, expensive, cheap, forecast, year, }; } @@ -578,6 +672,7 @@ const PAGE_META={ year:{label:'Jahresplanung',description:'Fälligkeiten und Liquidität über das gesamte Jahr planen.'}, scenarios:{label:'Szenarien',description:'Was-wäre-wenn-Varianten ohne Änderung der echten Planung.'}, stats:{label:'Statistik',description:'Verteilungen, Personenanteile und wirtschaftliche Kennzahlen auswerten.'}, + transferAnalysis:{label:'Transferanalyse',description:'Geldflüsse zwischen frei wählbaren Quell- und Zielkonten auswerten.'}, statements:{label:'Auszüge',description:'Schlanke Kontoauszüge aus den geplanten Kontobewegungen.'}, accounts:{label:'Stammdaten',description:'Konten, Personen und Kategorien zentral verwalten.'}, data:{label:'Einstellungen & Daten',description:'Benachrichtigungen, Backup, Import und Datenstand verwalten.'}, @@ -590,6 +685,7 @@ const NAV_GROUPS={ ]}, analytics:{label:'Auswertung',items:[ {tab:'stats',icon:'chart',label:'Statistik',description:'Kennzahlen & Verteilungen'}, + {tab:'transferAnalysis',icon:'transfer',label:'Transferanalyse',description:'Quellen, Ziele & Verteilung'}, {tab:'statements',icon:'statement',label:'Auszüge',description:'PDF- & Excel-Kontoauszüge'}, ]}, more:{label:'Mehr',items:[ @@ -670,6 +766,7 @@ function App() { {tab==='year'&&} {tab==='planning'&&} {tab==='stats'&&} + {tab==='transferAnalysis'&&} {tab==='statements'&&} {tab==='scenarios'&&} {tab==='data'&&} @@ -821,63 +918,93 @@ function Transfers({data,onEdit,onDuplicate,onDelete}){ })}}; } -function transferTypeStats(data,accountId='all',personId='all'){ +function transferTypeStats(data,accountId='all',personId='all',year=new Date().getFullYear()){ const isAll=accountId==='all';const buckets=new Map(); for(const item of data.transfers||[]){ if(!isAll&&item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue; const factor=transferPersonFactor(data,item,personId,accountId);if(!factor)continue; - const cls=transferClassification(data,item);const amount=monthlyEquivalent(item)*factor; - const row=buckets.get(cls.key)||{key:cls.key,label:cls.label,description:cls.description,amount:0,in:0,out:0,count:0}; - row.amount+=amount;row.count+=1;if(!isAll){if(item.toAccountId===accountId)row.in+=amount;if(item.fromAccountId===accountId)row.out+=amount;}buckets.set(cls.key,row); + const cls=transferClassification(data,item);const annual=plannedAmountForYear(item,year)*factor;if(!annual)continue;const amount=annual/12; + const row=buckets.get(cls.key)||{key:cls.key,label:cls.label,description:cls.description,amount:0,annual:0,in:0,out:0,count:0,items:[]}; + row.amount+=amount;row.annual+=annual;row.count+=1;row.items.push({...item,statAmount:amount,statAnnual:annual});if(!isAll){if(item.toAccountId===accountId)row.in+=amount;if(item.fromAccountId===accountId)row.out+=amount;}buckets.set(cls.key,row); } - return {rows:[...buckets.values()].sort((a,b)=>b.amount-a.amount),isAll}; -} -function TransferTypeOverview({data,accountId='all',personId='all'}){ - const stats=useMemo(()=>transferTypeStats(data,accountId,personId),[data,accountId,personId]); - return

Transfer- und Finanzierungsarten

Diese Beträge sind Kontobewegungen, aber keine Kosten. FixFin leitet die Art automatisch aus Eigentum und Kontofunktion ab.

{stats.rows.length===0?:
{stats.rows.map(row=>
{row.label}{row.description}
{formatMoney(row.amount)} / Monat{!stats.isAll&&
rein {formatMoney(row.in)}raus {formatMoney(row.out)}
}
)}
}
; + return {rows:[...buckets.values()].sort((a,b)=>b.amount-a.amount),isAll,year}; } -function CategoryDistribution({title,subtitle,stats,tone,data}){ +function TransferTypeOverview({data,accountId='all',personId='all',year=new Date().getFullYear()}){ + const stats=useMemo(()=>transferTypeStats(data,accountId,personId,year),[data,accountId,personId,year]); + const help=<>

Transfers werden nach Eigentum und Kontofunktion automatisch klassifiziert, z. B. Gemeinschaftsfinanzierung, Rücklagenzuführung oder Transfer zwischen Personen.

Die Beträge sind Ø-Monatswerte des gewählten Jahres. Sie werden nie zu echten Einnahmen oder Kosten addiert. Bei einem einzelnen Konto verändern sie aber den Liquiditäts-Nettofluss.

; + return

Separater Blick auf Umbuchungen {year}. Jede Karte lässt sich bis zu den konkreten Transfers aufklappen.

{stats.rows.length===0?:
{stats.rows.map(row=>
{row.label}{row.description} · {row.count} {row.count===1?'Position':'Positionen'}
{formatMoney(row.amount)} Ø / Monat{formatMoney(row.annual)} / Jahr{!stats.isAll&&
rein {formatMoney(row.in)}raus {formatMoney(row.out)}
}
)}
}
; +} + +function CategoryDistribution({title,subtitle,stats,tone,data,year}){ function itemMeta(item){ if(item._statType==='transfer-in') return `Transfer rein · ${transferClassLabel(data,item)} · ${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)}`; if(item._statType==='transfer-out') return `Transfer raus · ${transferClassLabel(data,item)} · ${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)}`; if(item._statType==='transfer') return `Transfer · ${transferClassLabel(data,item)} · ${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)}`; return `${item._statType==='income'?'Eingang':'Ausgang'} · ${accountName(data,item.accountId)}`; } - return

{title}

{subtitle}

{formatMoney(stats.total)} / Monat
- {stats.rows.length===0?:
{stats.rows.map((row,index)=>
{index+1}{row.name}{row.count} {row.count===1?'Position':'Positionen'}
{formatMoney(row.amount)}{row.percent.toFixed(1).replace('.',',')} %
{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=>
{item.name}{itemMeta(item)} · {personListLabel(data,item)} · {intervals[item.interval||'monthly'].label}
{formatMoney(item.statAmount)} / Monat
)}
)}
} + const help=<>

Der Kategorienwert ist die Summe der Ø-Monatswerte {year} aller enthaltenen Positionen. Jede Position wird zuerst für das ganze Jahr geplant und anschließend durch 12 geteilt.

Bei einem Personenfilter werden gemeinsam zugeordnete Ausgaben gleichmäßig auf die ausgewählten Personen aufgeteilt. In der Gesamt-Personensicht werden persönliche Einnahmen nach Kontoeigentum bestimmt.

Der Prozentwert einer Kategorie ist Kategorienwert ÷ Gesamtsumme dieses Blocks.

; + return

{subtitle}

{formatMoney(stats.total)} Ø / Monat{formatMoney(stats.annual)} / Jahr
+ {stats.rows.length===0?:
{stats.rows.map((row,index)=>
{index+1}{row.name}{row.count} {row.count===1?'Position':'Positionen'}
{formatMoney(row.amount)}{row.percent.toFixed(1).replace('.',',')} %
{formatMoney(row.annual)} im Jahr ÷ 12= {formatMoney(row.amount)} Ø / Monat
{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=>
{item.name}{itemMeta(item)} · {personListLabel(data,item)} · {statAmountMeta(item)}
{formatMoney(item.statAmount)} Ø / Monat{formatMoney(item.statAnnual)} / Jahr
)}
)}
}
; } function StatisticsPage({data,includeTransfers,onTransfersChange}){ + const currentYear=new Date().getFullYear(); const [accountId,setAccountId]=useState('all'); const [personId,setPersonId]=useState('all'); - const stats=useMemo(()=>categoryStats(data,accountId,personId,includeTransfers),[data,accountId,personId,includeTransfers]); - const personFlows=useMemo(()=>personFlowStats(data,accountId,personId,includeTransfers),[data,accountId,personId,includeTransfers]); - const totals=useMemo(()=>calc(data,true),[data]); - const kpis=useMemo(()=>dashboardKpis(data,totals),[data,totals]); + const [year,setYear]=useState(currentYear); + const stats=useMemo(()=>categoryStats(data,accountId,personId,includeTransfers,year),[data,accountId,personId,includeTransfers,year]); + const personFlows=useMemo(()=>personFlowStats(data,accountId,personId,includeTransfers,year),[data,accountId,personId,includeTransfers,year]); + const kpis=useMemo(()=>dashboardKpis(data,year),[data,year]); const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten'; const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); - const uncategorized=(stats.incomes.rows.find(x=>x.id==='__uncategorized__')?.count||0)+(stats.expenses.rows.find(x=>x.id==='__uncategorized__')?.count||0)+(includeTransfers?(stats.transfers.rows.find(x=>x.id==='__uncategorized__')?.count||0):0); - const personalIncomeLabel=personId==='all'?'Echte Eingänge Ø / Monat':'Eigene Kontoeingänge Ø / Monat'; + const uncategorizedIncome=stats.incomes.rows.find(x=>x.id==='__uncategorized__'); + const uncategorizedExpense=stats.expenses.rows.find(x=>x.id==='__uncategorized__'); + const uncategorized=(uncategorizedIncome?.count||0)+(uncategorizedExpense?.count||0); + const uncategorizedItems=[...(uncategorizedIncome?.items||[]),...(uncategorizedExpense?.items||[])]; + const personalIncomeLabel=accountId==='all'?(personId==='all'?'Echte Eingänge Ø / Monat':'Eigene Kontoeingänge Ø / Monat'):(personId==='all'?'Echte Eingänge Ø / Monat':'Zugeordnete echte Eingänge Ø / Monat'); const selectionBalance=accountId==='all'?(personId==='all'?stats.incomes.total-stats.expenses.total:personFlows.totals.economicAvailable):personFlows.totals.net; - const selectionBalanceLabel=accountId==='all'?(personId==='all'?'Wirtschaftlicher Saldo Auswahl':'Wirtschaftlich verfügbar'):'Nettofluss Auswahl'; + const selectionBalanceLabel=accountId==='all'?(personId==='all'?'Wirtschaftlicher Saldo Auswahl':'Wirtschaftlich verfügbar'):'Liquiditäts-Nettofluss Auswahl'; + const selectionIncomeItems=stats.incomes.rows.flatMap(row=>row.items); + const selectionExpenseItems=stats.expenses.rows.flatMap(row=>row.items); + const globalIncomeItems=(data.incomes||[]).map(item=>({...item,statAnnual:plannedAmountForYear(item,year),statAmount:statMonthlyAverage(item,year)})).filter(item=>item.statAnnual); + const globalExpenseItems=(data.expenses||[]).map(item=>({...item,statAnnual:plannedAmountForYear(item,year),statAmount:statMonthlyAverage(item,year)})).filter(item=>item.statAnnual); + const periodicExpenseItems=(data.expenses||[]).filter(isPeriodic).map(item=>({...item,statAnnual:plannedAmountForYear(item,year),statAmount:statMonthlyAverage(item,year)})).filter(item=>item.statAnnual); + const expensiveItems=(kpis.expensive?.items||[]).filter(item=>item.kind==='expense').map(item=>({...item,statAmount:Number(item.forecastAmount??item.amount??0)})); + const cheapItems=(kpis.cheap?.items||[]).filter(item=>item.kind==='expense').map(item=>({...item,statAmount:Number(item.forecastAmount??item.amount??0)})); + const transferIn=personFlows.rows?.reduce((sum,row)=>sum+(row.transferIn||0),0)||0; + const transferOut=personFlows.rows?.reduce((sum,row)=>sum+(row.transferOut||0),0)||0; + const selectionBalanceRows=accountId==='all'?(personId==='all'?[{label:'Echte Eingänge Ø / Monat',value:stats.incomes.total,tone:'good'},{label:'− echte Kosten Ø / Monat',value:stats.expenses.total,tone:'bad'},{label:'= wirtschaftlicher Saldo',value:selectionBalance}]:[{label:'Eigene Kontoeingänge',value:personFlows.totals.personalIncome,tone:'good'},{label:'Brutto-Kostenanteil',value:personFlows.totals.expenseShare,tone:'bad'},{label:'− gemeinsame Deckung',value:personFlows.totals.sharedCoverage,tone:'good'},{label:'= Netto-Kostenlast',value:personFlows.totals.netCost,tone:'bad'},{label:'= wirtschaftlich verfügbar',value:selectionBalance}]):[{label:'Echte Eingänge',value:stats.incomes.total,tone:'good'},{label:'+ Transfers rein',value:transferIn,tone:'good'},{label:'− echte Ausgaben',value:stats.expenses.total,tone:'bad'},{label:'− Transfers raus',value:transferOut,tone:'bad'},{label:'= Liquiditäts-Nettofluss',value:selectionBalance}]; + const globalHelp=<>

Die Gesamtkennzahlen beziehen sich immer auf alle Konten und alle Personen im ausgewählten Jahr. Die Konto- und Personenfilter darunter verändern sie bewusst nicht.

Anders als früher hängen diese Werte nicht mehr vom Dashboard-Schalter „periodische Kosten im Monats-Saldo“ ab. Für Statistik werden alle geplanten Positionen des Jahres vollständig berücksichtigt.

; return
-

Gesamtkennzahlen

Wirtschaftliche Kennzahlen verwenden nur echte Einnahmen und echte Ausgaben. Umbuchungen verändern diese Werte nicht.

=0} negative={totals.balance<0} featured/>
-
Statistik für{selectedName} · {selectedPerson}
+ +

Gesamthaushalt, unabhängig von den Konto-/Personenfiltern darunter. Alle Monatswerte sind Jahresdurchschnitte.

+ =0?'good':'bad'} featured help={<>

Echte Einnahmen minus echte Ausgaben. Transfers werden vollständig ignoriert, weil sie Geld nur zwischen eigenen Töpfen verschieben.

Berechnung: ({formatMoney(kpis.annualIncome)} Einnahmen im Jahr − {formatMoney(kpis.annualExpense)} Ausgaben im Jahr) ÷ 12.

}>
Einzelne Eingänge und Kosten

Eingänge

Kosten

+

Anteil der echten geplanten Kosten an den echten geplanten Einnahmen im Jahr {year}.

100 % bedeutet: alle Einnahmen werden durch echte Kosten verbraucht. Transfers spielen keine Rolle.

}>
+

Summe aller echten Ausgaben, die im gewählten Jahr geplant sind. Einmalige Ausgaben zählen genau einmal, periodische Ausgaben entsprechend ihrer Fälligkeiten.

Transfers sind keine Kosten und fehlen deshalb hier.

}>
+

Jährliche, halbjährliche und vierteljährliche echte Ausgaben werden auf einen durchschnittlichen Monatsbedarf umgerechnet.

Monatliche und einmalige Ausgaben gehören nicht in diesen Wert. Er sagt, wie viel rechnerisch pro Monat für periodische Kosten zurückgelegt werden müsste.

}>
+

Monat mit der höchsten Summe echter fälliger Ausgaben in {year}. Hier werden konkrete Fälligkeiten und einmalige Positionen berücksichtigt.

Transfers bleiben außen vor.

}>
+

Monat mit der niedrigsten Summe echter fälliger Ausgaben in {year}. Ein Monat ohne geplante Kosten kann deshalb 0 € haben.

}>
+
+
Auswahl für Detailstatistik{selectedName} · {selectedPerson} · {year}
-
=0} negative={selectionBalance<0}/>
- - {accountId==='all'&&} - {accountId==='all'&&} - {includeTransfers&&} - - - {includeTransfers&&} +

Diese vier Karten reagieren auf Konto, Person und Jahr.

Bei Alle Konten + Person wird eine wirtschaftliche Personensicht verwendet: persönliches Einkommen folgt Kontoeigentum, Kosten folgen der Bewegungszuordnung und gemeinsame Einnahmen reduzieren die gemeinsame Kostenlast.

Bei einem einzelnen Konto wechselt die dritte Karte bewusst in eine Liquiditätssicht. Transfers sind dann im Nettofluss enthalten, auch wenn Transferdetails ausgeblendet sind.

}/>
+

{accountId==='all'&&personId!=='all'?'Nur echte Eingänge auf persönlichen Konten, deren Eigentümer die ausgewählte Person ist. Gemeinsame Einnahmen werden nicht als persönliches Einkommen gezählt.':accountId!=='all'&&personId!=='all'?'Echte Eingänge auf dem ausgewählten Konto, anteilig gemäß Personenzuordnung der Bewegung.':'Echte Eingänge der aktuellen Auswahl.'}

Jeder Wert ist Jahresbetrag ÷ 12.

}>
+

Nur echte Ausgaben. Bei einer Person wird eine Bewegung mit mehreren zugeordneten Personen gleichmäßig geteilt.

Transfers bleiben vollständig getrennt.

}>
+ =0?'good':'bad'} help={<>

{accountId==='all'?(personId==='all'?'Wirtschaftlicher Saldo: echte Einnahmen minus echte Kosten.':'Wirtschaftlich verfügbar: persönliches Einkommen minus persönliche Netto-Kostenlast nach gemeinsamer Deckung.'):'Liquiditäts-Nettofluss des Kontos: echte Eingänge + Transfers rein − echte Ausgaben − Transfers raus.'}

}>
+

Anzahl echter Eingangs- und Ausgangspositionen der aktuellen Auswahl ohne Kategorie.

Transfers werden hier bewusst nicht mitgezählt, damit der Wert nicht vom Schalter „Transferdetails anzeigen“ abhängt.

}>
+
+ + {accountId==='all'&&} + {accountId==='all'&&} + {includeTransfers&&} + + + {includeTransfers&&}
; } - function YearPage({data,includeTransfers,onTransfersChange}){ const currentYear=new Date().getFullYear(); const [accountId,setAccountId]=useState('all'); @@ -1011,6 +1138,38 @@ function PushoverSettings(){
; } + +function AccountSelection({title,subtitle,accounts,selectedIds,onChange}){ + const selected=new Set(selectedIds||[]);const allSelected=accounts.length>0&&selected.size===accounts.length; + function toggle(id){const next=new Set(selected);if(next.has(id))next.delete(id);else next.add(id);onChange([...next]);} + return
{title}{subtitle}
{accounts.map(account=>)}
; +} +function TransferAnalysisPage({data}){ + const currentYear=new Date().getFullYear(); + const accountIds=useMemo(()=>data.accounts.map(a=>a.id),[data.accounts]); + const [year,setYear]=useState(currentYear); + const [sourceIds,setSourceIds]=useState(()=>data.accounts.map(a=>a.id)); + const [targetIds,setTargetIds]=useState(()=>data.accounts.map(a=>a.id)); + useEffect(()=>{setSourceIds(ids=>ids.filter(id=>accountIds.includes(id)));setTargetIds(ids=>ids.filter(id=>accountIds.includes(id)));},[accountIds.join('|')]); + const stats=useMemo(()=>transferAnalysis(data,sourceIds,targetIds,year),[data,sourceIds,targetIds,year]); + const percent=(value,total)=>total>0?`${(value/total*100).toFixed(1).replace('.',',')} %`:'–'; + const intervalLabel=item=>intervals[item.interval||'monthly']?.label||item.interval; + if(!data.accounts.length)return
; + return
+

Transferanalyse

Wähle eine oder mehrere Quellen und Ziele. Ausgewertet werden nur Transfers, deren Quell- und Zielkonto gleichzeitig in der Auswahl liegen.

+ +
+ + {sourceIds.length===0||targetIds.length===0?
:stats.rows.length===0?
:<> +

Wie viel kommt von welchem Konto?

Jede Quelle zeigt ihr gesamtes Transfervolumen und die Verteilung auf die ausgewählten Zielkonten.

{stats.sources.map(source=>
{accountName(data,source.accountId)}{source.items.length} {source.items.length===1?'Transfer':'Transfers'} · {percent(source.annual,stats.annual)} des ausgewählten Volumens
{formatMoney(source.annual)} / Jahr{formatMoney(source.monthly)} Ø / Monat
{source.targets.map(target=>
{accountName(data,target.accountId)}{target.items.length} {target.items.length===1?'Position':'Positionen'}
{formatMoney(target.annual)}{percent(target.annual,source.annual)}
)}
{source.items.sort((a,b)=>b.annual-a.annual).map(item=>
{item.name}{accountName(data,item.fromAccountId)} → {accountName(data,item.toAccountId)}
{formatMoney(item.annual)} / Jahr{formatMoney(item.monthly)} Ø / Monat
{item.classification.label}
Betrag je Intervall{formatMoney(item.amount)}
Intervall{intervalLabel(item)}
Fälligkeit{movementDueText(item)}
Personen{transferPeopleLabel(data,item)}
Kategorie{categoryName(data,item.categoryId)}
Anteil dieser Quelle{percent(item.annual,source.annual)}
)}
)}
+ +

Verteilung auf die Zielkonten

Wie viel des ausgewählten Transfervolumens insgesamt auf jedem Zielkonto landet.

{stats.targets.map(target=>
{accountName(data,target.accountId)}{target.items.length} {target.items.length===1?'Transfer':'Transfers'}
{formatMoney(target.annual)} / Jahr
{formatMoney(target.monthly)} Ø / Monat{percent(target.annual,stats.annual)}
)}
+ +

Quelle → Ziel

Matrix der ausgewählten Konten. Die Werte zeigen den geplanten Jahresbetrag {year}.

{stats.targets.map(target=>)}{stats.sources.map(source=>{stats.targets.map(target=>{const match=source.targets.find(x=>x.accountId===target.accountId);return })})}{stats.targets.map(target=>)}
Quelle{accountName(data,target.accountId)}Gesamt
{accountName(data,source.accountId)}{match?formatMoney(match.annual):'–'}{formatMoney(source.annual)}
Gesamt{formatMoney(target.annual)}{formatMoney(stats.annual)}
+ } +
; +} + function StatementsPage({data,includeTransfers,onTransfersChange}){ const currentYear=new Date().getFullYear(); const [year,setYear]=useState(currentYear); diff --git a/src/styles.css b/src/styles.css index a51651c..2b007c8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1264,3 +1264,207 @@ html.modal-open, body.modal-open { overflow:hidden !important; overscroll-behavi .master-data-header .section-head { align-items:flex-start; } .master-data-counts { justify-content:flex-start; } } + +/* v3.31 Transferanalyse */ +.transfer-analysis-filter .section-head { align-items:flex-end; } +.transfer-analysis-year { min-width:130px; } +.transfer-analysis-year span { display:block; margin-bottom:5px; color:var(--muted); font-size:9px; font-weight:800; text-transform:uppercase; letter-spacing:.05em; } +.transfer-analysis-selectors { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } +.transfer-account-selector { border:1px solid #28364a; background:#0d141e; border-radius:14px; padding:12px; } +.transfer-account-selector-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; margin-bottom:10px; } +.transfer-account-selector-head > div { display:grid; gap:3px; } +.transfer-account-selector-head strong { font-size:12px; } +.transfer-account-selector-head span { color:var(--muted); font-size:9px; line-height:1.4; } +.compact-button { min-height:30px; padding:5px 9px; font-size:9px; } +.transfer-account-choice-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } +.transfer-account-choice { display:flex; gap:8px; align-items:flex-start; min-width:0; padding:9px; border:1px solid #253246; border-radius:10px; background:#101925; color:var(--text); text-align:left; } +.transfer-account-choice:hover { border-color:#3b4d68; background:#141f2d; } +.transfer-account-choice.active { border-color:#566f9a; background:#18243a; } +.transfer-account-choice > span:last-child { display:grid; min-width:0; gap:2px; } +.transfer-account-choice strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10px; } +.transfer-account-choice small { color:var(--muted); font-size:8px; line-height:1.35; } +.transfer-analysis-summary { grid-template-columns:repeat(4,minmax(0,1fr)); } +.transfer-source-list { display:grid; gap:10px; } +.transfer-source-card { border:1px solid #2a384c; border-radius:14px; background:#0d141e; overflow:hidden; } +.transfer-source-card > summary { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:12px; align-items:center; padding:13px; cursor:pointer; list-style:none; } +.transfer-source-card > summary::-webkit-details-marker { display:none; } +.transfer-source-card > summary > div:first-child { display:grid; gap:3px; min-width:0; } +.transfer-source-card > summary > div:first-child strong { font-size:12px; } +.transfer-source-card > summary > div:first-child span { color:var(--muted); font-size:9px; } +.transfer-source-total { display:grid; gap:2px; text-align:right; white-space:nowrap; } +.transfer-source-total strong { font-size:12px; } +.transfer-source-total small { color:var(--muted); font-size:8px; } +.transfer-source-card[open] > summary { background:#111a27; border-bottom:1px solid #243248; } +.transfer-source-card[open] > summary .accordion-chevron { transform:rotate(180deg); } +.transfer-source-body { display:grid; gap:14px; padding:13px; } +.transfer-target-distribution { display:grid; gap:8px; } +.transfer-target-row { display:grid; grid-template-columns:minmax(130px,1fr) minmax(90px,2fr) auto; align-items:center; gap:10px; } +.transfer-target-row > div:first-child { display:grid; gap:2px; } +.transfer-target-row > div:first-child strong { font-size:10px; } +.transfer-target-row > div:first-child span,.transfer-target-row > div:last-child span { color:var(--muted); font-size:8px; } +.transfer-target-row > div:last-child { display:grid; gap:2px; min-width:90px; text-align:right; } +.transfer-target-row > div:last-child strong { font-size:10px; } +.transfer-target-bar { height:7px; overflow:hidden; border-radius:999px; background:#1b2636; } +.transfer-target-bar > span { display:block; height:100%; border-radius:999px; background:#6179a5; } +.transfer-analysis-detail-list { display:grid; gap:7px; padding-top:3px; border-top:1px solid #202c3d; } +.transfer-analysis-detail { border:1px solid #253246; border-radius:11px; background:#101925; overflow:hidden; } +.transfer-analysis-detail > summary { display:grid; grid-template-columns:minmax(0,1fr) auto auto; align-items:center; gap:10px; padding:10px; cursor:pointer; list-style:none; } +.transfer-analysis-detail > summary::-webkit-details-marker { display:none; } +.transfer-analysis-detail > summary > div:first-child { display:grid; gap:2px; min-width:0; } +.transfer-analysis-detail > summary > div:first-child strong { font-size:10px; } +.transfer-analysis-detail > summary > div:first-child span { color:var(--muted); font-size:8px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.transfer-analysis-detail > summary > div:nth-child(2) { display:grid; gap:2px; text-align:right; white-space:nowrap; } +.transfer-analysis-detail > summary > div:nth-child(2) strong { font-size:10px; } +.transfer-analysis-detail > summary > div:nth-child(2) small { color:var(--muted); font-size:8px; } +.transfer-analysis-detail[open] > summary { border-bottom:1px solid #253246; } +.transfer-analysis-detail[open] > summary .accordion-chevron { transform:rotate(180deg); } +.transfer-analysis-detail-body { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; padding:10px; } +.transfer-analysis-detail-body > div { display:grid; gap:3px; padding:8px; border-radius:9px; background:#0c131d; } +.transfer-analysis-detail-body > div span { color:var(--muted); font-size:8px; font-weight:800; text-transform:uppercase; letter-spacing:.04em; } +.transfer-analysis-detail-body > div strong { font-size:9px; } +.transfer-analysis-detail-body > .transfer-class-badge { align-self:start; justify-self:start; } +.transfer-target-cards { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:9px; } +.transfer-target-card { display:grid; gap:8px; padding:11px; border:1px solid #28364a; border-radius:12px; background:#0d141e; } +.transfer-target-card > div:first-child { display:grid; gap:2px; } +.transfer-target-card > div:first-child strong { font-size:11px; } +.transfer-target-card > div:first-child span { color:var(--muted); font-size:8px; } +.transfer-target-card > strong { font-size:12px; } +.transfer-target-card-foot { display:flex; justify-content:space-between; gap:8px; color:var(--muted); font-size:8px; } +.transfer-target-card-foot b { color:var(--text); } +.transfer-matrix th,.transfer-matrix td { white-space:nowrap; } +.transfer-matrix thead th { vertical-align:bottom; } +.transfer-matrix tbody th,.transfer-matrix tfoot th { text-align:left; } +.transfer-matrix td { text-align:right; } +.transfer-matrix tfoot th,.transfer-matrix tfoot td { border-top:2px solid #3a4a62; } + +@media (max-width: 900px) { + .transfer-analysis-selectors { grid-template-columns:1fr; } + .transfer-analysis-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } + .transfer-target-cards { grid-template-columns:repeat(2,minmax(0,1fr)); } +} +@media (max-width: 620px) { + .transfer-analysis-filter .section-head { align-items:stretch; } + .transfer-analysis-year { width:100%; } + .transfer-account-choice-grid { grid-template-columns:1fr; } + .transfer-source-card > summary { grid-template-columns:minmax(0,1fr) auto; } + .transfer-source-card > summary .accordion-chevron { grid-column:2; grid-row:1 / span 2; } + .transfer-source-total { grid-column:1; text-align:left; } + .transfer-target-row { grid-template-columns:minmax(0,1fr) auto; } + .transfer-target-bar { grid-column:1 / -1; grid-row:2; } + .transfer-analysis-detail > summary { grid-template-columns:minmax(0,1fr) auto; } + .transfer-analysis-detail > summary > div:nth-child(2) { grid-column:1; text-align:left; } + .transfer-analysis-detail > summary .accordion-chevron { grid-column:2; grid-row:1 / span 2; } + .transfer-analysis-detail-body { grid-template-columns:1fr 1fr; } + .transfer-target-cards { grid-template-columns:1fr; } +} + +/* v3.32: nachvollziehbare Statistik, Erklärungen und Rechenwege */ +.stats-section-title { min-width:0; display:flex; align-items:flex-start; gap:9px; } +.stats-section-title > div { min-width:0; } +.stats-section-title h2 { margin:0; } +.stats-section-title p { margin-top:4px; } +.stats-info-button { + flex:0 0 24px; width:24px; height:24px; display:grid; place-items:center; + border:1px solid #465776; border-radius:999px; background:#172235; color:#cbd6eb; + font-size:12px; font-weight:900; line-height:1; cursor:pointer; padding:0; +} +.stats-info-button:hover { border-color:#7388ba; background:#202f48; color:#fff; } +.stats-help-modal { max-width:620px; } +.stats-help-content { display:grid; gap:10px; color:#b7c2d1; font-size:11px; line-height:1.6; } +.stats-help-content p { margin:0; } +.stats-help-content strong { color:#eef2f8; } +.stats-method-panel { padding:14px; } +.stats-method-panel .section-head { margin-bottom:11px; } +.stats-method-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; } +.stats-method-grid > div { display:grid; gap:4px; padding:10px 11px; border:1px solid #2a394f; border-radius:11px; background:#0d151f; } +.stats-method-grid strong { font-size:10px; color:#dfe6f2; } +.stats-method-grid span { color:var(--muted); font-size:9px; line-height:1.45; } + +.stat-explain-metric { position:relative; align-content:start; gap:5px; } +.stat-metric-label { display:flex; align-items:flex-start; justify-content:space-between; gap:7px; } +.stat-metric-label > span { min-width:0; } +.stat-metric-label .stats-info-button { flex-basis:20px; width:20px; height:20px; font-size:10px; } +.stat-metric-hint { color:var(--muted); font-size:9px; line-height:1.35; } +.stat-metric-details { margin-top:7px; border-top:1px solid #273447; padding-top:7px; } +.stat-metric-details > summary,.stat-row-details > summary,.stat-group-details > summary,.stat-nested-details > summary { + list-style:none; cursor:pointer; display:flex; align-items:center; justify-content:space-between; gap:8px; + color:#aebbd0; font-size:9px; font-weight:850; letter-spacing:.02em; +} +.stat-metric-details > summary::-webkit-details-marker,.stat-row-details > summary::-webkit-details-marker,.stat-group-details > summary::-webkit-details-marker,.stat-nested-details > summary::-webkit-details-marker { display:none; } +.stat-metric-details[open] > summary span,.stat-row-details[open] > summary span,.stat-group-details[open] > summary span { transform:rotate(180deg); } +.stat-metric-details > div { padding-top:8px; } +.stat-nested-details { margin-top:8px; padding-top:8px; border-top:1px dashed #2a374a; } +.stat-nested-details > div,.stat-nested-details h4 { margin-top:8px; } +.stat-nested-details h4,.stat-group-detail-content h4 { margin:10px 0 6px; color:#c9d3e2; font-size:9px; text-transform:uppercase; letter-spacing:.055em; } + +.stat-formula-rows { display:grid; gap:5px; } +.stat-formula-rows > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:3px 10px; align-items:center; padding:6px 0; border-bottom:1px solid #202c3c; } +.stat-formula-rows > div:last-child { border-bottom:0; } +.stat-formula-rows span { color:#9cabbf; font-size:9px; line-height:1.35; } +.stat-formula-rows strong { font-size:10px; white-space:nowrap; } +.stat-formula-rows small { grid-column:1/-1; color:#738297; font-size:8px; line-height:1.35; } +.stat-detail-items { display:grid; gap:5px; } +.stat-detail-item { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:7px 0; border-bottom:1px solid #202c3c; } +.stat-detail-item:last-child { border-bottom:0; } +.stat-detail-item > div:first-child { min-width:0; display:grid; gap:2px; } +.stat-detail-item > div:first-child strong { font-size:9px; color:#d8e0eb; } +.stat-detail-item > div:first-child span { color:#75859a; font-size:8px; line-height:1.35; } +.stat-detail-item > div:last-child { flex:0 0 auto; display:grid; gap:1px; text-align:right; } +.stat-detail-item > div:last-child strong { font-size:9px; white-space:nowrap; } +.stat-detail-item > div:last-child small { color:#75859a; font-size:8px; white-space:nowrap; } +.stat-detail-empty { color:#738297; font-size:9px; padding:5px 0; } + +.stats-selection-section { display:grid; gap:9px; } +.stats-selection-heading { padding:0 2px; } +.stats-selection-grid { grid-template-columns:repeat(4,minmax(0,1fr)); } +.stat-row-details { margin-top:10px; padding-top:9px; border-top:1px dashed #2a374a; } +.stat-row-details > div { padding-top:8px; } +.stat-group-details { margin-top:11px; padding:9px 10px; border:1px solid #26364b; background:#0b121b; border-radius:10px; } +.stat-group-detail-content { padding-top:8px; } +.shared-net-change { flex:0 0 auto; display:grid; gap:2px; text-align:right; } +.shared-net-change span { color:var(--muted); font-size:7px; font-weight:800; text-transform:uppercase; letter-spacing:.045em; } +.shared-net-change strong { white-space:nowrap; font-size:13px; } + +.stat-transfer-type-card { padding:0; overflow:hidden; } +.stat-transfer-type-card > summary { list-style:none; cursor:pointer; display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:10px; align-items:center; padding:11px; } +.stat-transfer-type-card > summary::-webkit-details-marker { display:none; } +.stat-transfer-type-card > summary > div:first-child { min-width:0; display:grid; gap:5px; } +.stat-transfer-type-card > summary > div:first-child small { color:var(--muted); font-size:8px; line-height:1.35; } +.stat-transfer-type-card > summary > div:nth-child(2) { display:grid; gap:2px; text-align:right; } +.stat-transfer-type-card > summary > div:nth-child(2) > strong { font-size:11px; white-space:nowrap; } +.stat-transfer-type-card > summary > div:nth-child(2) > small { color:var(--muted); font-size:8px; } +.stat-transfer-type-card[open] .accordion-chevron { transform:rotate(180deg); } +.stat-transfer-type-details { border-top:1px solid #253246; padding:8px 11px 10px; } + +.category-total { display:grid; gap:2px; text-align:right; white-space:nowrap; } +.category-total strong { font-size:13px; } +.category-total small { color:var(--muted); font-size:8px; font-weight:700; } +.category-drill-formula { display:flex; justify-content:space-between; gap:12px; padding:7px 0 9px; margin-bottom:2px; border-bottom:1px dashed #2c3849; color:#8594a8; font-size:8px; } +.category-drill-formula strong { color:#cbd5e3; } +.category-drill-row > div:last-child { flex:0 0 auto; display:grid; gap:1px; text-align:right; } +.category-drill-row > div:last-child strong { white-space:nowrap; } +.category-drill-row > div:last-child small { color:var(--muted); font-size:8px; } + +@media (max-width:900px) { + .stats-method-grid { grid-template-columns:1fr 1fr; } + .stats-selection-grid { grid-template-columns:1fr 1fr; } +} +@media (max-width:760px) { + .stats-section-title { gap:6px; } + .stats-method-grid { grid-template-columns:1fr 1fr; gap:6px; } + .stats-method-grid > div { padding:9px; } + .stats-selection-grid { grid-template-columns:1fr 1fr; } + .stat-detail-item { gap:8px; } + .stat-detail-item > div:last-child { max-width:45%; } + .shared-net-change { text-align:left; } + .stat-transfer-type-card > summary { grid-template-columns:minmax(0,1fr) auto; } + .stat-transfer-type-card > summary > div:nth-child(2) { grid-column:1/2; text-align:left; } + .stat-transfer-type-card > summary > .accordion-chevron { grid-column:2; grid-row:1/3; } + .category-total { text-align:left; } +} +@media (max-width:430px) { + .stats-method-grid { grid-template-columns:1fr; } + .stat-detail-item { display:grid; } + .stat-detail-item > div:last-child { max-width:none; text-align:left; } + .category-drill-formula { display:grid; gap:3px; } +}