This commit is contained in:
2026-08-20 22:21:41 +02:00
parent 686b3da3cf
commit 34627c0b49
6 changed files with 495 additions and 94 deletions
+25
View File
@@ -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.
+13
View File
@@ -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`.
+1 -1
View File
@@ -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'];
+1 -1
View File
@@ -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;
+251 -92
View File
@@ -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 <section className="panel person-flow-panel"><div className="section-head"><div><h2>Personenbezogene Kontoflüsse</h2><p>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.</p></div><strong className={`person-flow-total ${stats.totals.net>=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat</strong></div>{stats.rows.length===0?<Empty text="Noch keine personenbezogenen Werte für diese Auswahl vorhanden."/>:<div className="person-flow-list">{stats.rows.map(row=><div className="person-flow-row" key={row.id}><div className="person-flow-head"><div><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><small>Nettofluss auf diesem Konto</small></div><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div><div className="person-flow-grid"><div><span>Zuflüsse inkl. Transfers</span><strong className="good-text">+{formatMoney(row.displayIn)}</strong></div><div><span>Abflüsse inkl. Transfers</span><strong className="bad-text">{formatMoney(row.displayOut)}</strong></div>{stats.includeTransfers&&<><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(row.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">{formatMoney(row.transferOut)}</strong></div></>}</div></div>)}</div>}</section>;
return <section className="panel person-flow-panel"><div className="section-head"><div><h2>Persönliche Sicht</h2><p>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.</p></div><strong className={`person-flow-total ${stats.totals.liquidAvailable>=0?'good-text':'bad-text'}`}>{stats.totals.liquidAvailable>=0?'+':''}{formatMoney(stats.totals.liquidAvailable)} / Monat verfügbar</strong></div>{stats.rows.length===0?<Empty text="Noch keine personenbezogenen Werte vorhanden."/>:<div className="person-flow-list">{stats.rows.map(row=><div className="person-flow-row" key={row.id}><div className="person-flow-head"><div><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><small>Liquid verfügbar auf persönlichen Zahlungsverkehrskonten</small></div><strong className={row.liquidAvailable>=0?'good-text':'bad-text'}>{row.liquidAvailable>=0?'+':''}{formatMoney(row.liquidAvailable)}</strong></div><div className="person-flow-grid"><div><span>Wirtschaftlich verfügbar</span><strong className={row.economicAvailable>=0?'good-text':'bad-text'}>{row.economicAvailable>=0?'+':''}{formatMoney(row.economicAvailable)}</strong></div><div><span>Netto-Kostenlast</span><strong className="bad-text">{formatMoney(row.netCost)}</strong></div><div><span>Eigene Kontoeingänge</span><strong className="good-text">+{formatMoney(row.personalIncome)}</strong></div><div><span>Gemeinsame Deckung</span><strong className="good-text">+{formatMoney(row.sharedCoverage)}</strong></div>{stats.includeTransfers&&<><div><span>Liquid-Transfers rein</span><strong className="good-text">+{formatMoney(row.liquidTransferIn)}</strong></div><div><span>Liquid-Transfers raus</span><strong className="bad-text">{formatMoney(row.liquidTransferOut)}</strong></div><div><span>Gemeinschaft finanziert</span><strong>{formatMoney(row.sharedFundingActual)}</strong></div><div><span>Vermögensaufbau netto</span><strong className={row.assetBuild>=0?'good-text':'bad-text'}>{row.assetBuild>=0?'+':''}{formatMoney(row.assetBuild)}</strong></div></>}</div></div>)}</div>}</section>;
function StatsHelpModal({title,onClose,children}){
useModalScrollLock();
return <div className="modal-backdrop stats-help-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><div className="modal stats-help-modal" role="dialog" aria-modal="true" aria-label={title}><div className="modal-head"><div><span className="eyebrow">STATISTIK ERKLÄRT</span><h2>{title}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div><div className="stats-help-content">{children}</div><div className="modal-actions"><button type="button" className="primary" onClick={onClose}>Verstanden</button></div></div></div>;
}
function StatsInfoButton({title,children}){
const [open,setOpen]=useState(false);
return <><button type="button" className="stats-info-button" onClick={()=>setOpen(true)} aria-label={`Erklärung: ${title}`} title="Erklärung anzeigen">i</button>{open&&<StatsHelpModal title={title} onClose={()=>setOpen(false)}>{children}</StatsHelpModal>}</>;
}
function StatsSectionTitle({title,children,helpTitle,help}){
return <div className="stats-section-title"><div><h2>{title}</h2>{children}</div>{help&&<StatsInfoButton title={helpTitle||title}>{help}</StatsInfoButton>}</div>;
}
function StatItemList({data,items,empty='Keine beitragenden Positionen.',amountField='statAmount',annualField='statAnnual',amountSuffix='Ø / Monat',showAnnual=true}){
if(!items?.length)return <div className="stat-detail-empty">{empty}</div>;
return <div className="stat-detail-items">{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 <div className="stat-detail-item" key={`${item.id||item.name}-${index}`}><div><strong>{item.name}</strong><span>{meta}</span></div><div><strong>{formatMoney(amount)} {amountSuffix}</strong>{showAnnual&&annual!=null&&<small>{formatMoney(annual)} im Jahr</small>}</div></div>;
})}</div>;
}
function StatFormulaRows({rows}){
return <div className="stat-formula-rows">{rows.map((row,index)=><div key={`${row.label}-${index}`}><span>{row.label}</span><strong className={row.tone==='good'?'good-text':row.tone==='bad'?'bad-text':''}>{typeof row.value==='number'?formatMoney(row.value):row.value}</strong>{row.note&&<small>{row.note}</small>}</div>)}</div>;
}
function StatMetricCard({label,value,displayValue,tone,featured=false,hint,helpTitle,help,children}){
return <div className={`metric stat-explain-metric ${featured?'featured':''}`}><div className="stat-metric-label"><span>{label}</span>{help&&<StatsInfoButton title={helpTitle||label}>{help}</StatsInfoButton>}</div><strong className={tone==='good'?'good-text':tone==='bad'?'bad-text':''}>{displayValue??formatMoney(value)}</strong>{hint&&<small className="stat-metric-hint">{hint}</small>}{children&&<details className="stat-metric-details"><summary>Zusammensetzung <span></span></summary><div>{children}</div></details>}</div>;
}
function StatsMethodPanel({year}){
return <section className="panel stats-method-panel"><div className="section-head"><StatsSectionTitle title="So rechnet diese Statistik" helpTitle="Grundprinzip der Statistik" help={<><p>Die Statistik ist eine Planungs- und Durchschnittssicht für das gewählte Kalenderjahr. Sie ist keine Liste tatsächlich gebuchter Bankumsätze.</p><p><strong>Ø / Monat</strong> bedeutet immer: im Jahr eingeplanter Betrag ÷ 12. Dadurch werden jährliche, vierteljährliche, halbjährliche und einmalige Positionen vergleichbar.</p><p>Transfers bleiben wirtschaftlich getrennt von Einnahmen und Kosten. In einer einzelnen Kontosicht gehören sie trotzdem zum Liquiditäts-Nettofluss.</p></>}><p>Alle Ø-Werte beziehen sich auf <strong>{year}</strong>. Einmalige Positionen zählen genau einmal im Zieljahr. Periodische Positionen mit Fälligkeitsmonat werden in ihren echten Monaten eingeplant.</p></StatsSectionTitle></div><div className="stats-method-grid"><div><strong>Wirtschaftlich</strong><span>Echte Einnahmen minus echte Ausgaben. Transfers zählen nicht als Gewinn oder Kosten.</span></div><div><strong>Personen</strong><span>Ausgaben werden nach den Personen der Bewegung geteilt. Persönliches Einkommen folgt in der Gesamtansicht dem Eigentümer des persönlichen Kontos.</span></div><div><strong>Gemeinschaft</strong><span>Gemeinsame Einnahmen decken zuerst gemeinsame Kosten und werden niemals zu persönlichem Einkommen.</span></div><div><strong>Liquidität</strong><span>Bei einem einzelnen Konto werden Transfers rein und raus im Nettofluss vollständig berücksichtigt.</span></div></div></section>;
}
function SharedFundingOverview({data,personId='all',showTransfers=true}){
const stats=useMemo(()=>sharedFinanceStats(data,personId),[data,personId]);
function PersonFlowOverview({stats,year}){
const help=<><p><strong>Wirtschaftlich verfügbar</strong> beantwortet: Was bleibt nach der persönlichen Kostenlast übrig? <strong>Liquid verfügbar</strong> beantwortet dagegen: Was bleibt auf persönlichen Zahlungsverkehrskonten nach echten Kontobewegungen übrig?</p><p>Diese beiden Werte dürfen unterschiedlich sein. Eine Überweisung ins Depot oder aufs Gemeinschaftskonto ist keine Ausgabe, reduziert aber die liquide Verfügbarkeit.</p></>;
if(!stats.isAll)return <section className="panel person-flow-panel"><div className="section-head"><StatsSectionTitle title="Personenbezogene Kontoflüsse" help={help}><p>Ø-Monatsfluss {year} auf dem ausgewählten Konto. Transfers sind immer im Nettofluss enthalten; der Schalter blendet nur Transferdetails aus.</p></StatsSectionTitle><strong className={`person-flow-total ${stats.totals.net>=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} Ø / Monat</strong></div>{stats.rows.length===0?<Empty text="Noch keine personenbezogenen Werte für diese Auswahl vorhanden."/>:<div className="person-flow-list">{stats.rows.map(row=><div className="person-flow-row" key={row.id}><div className="person-flow-head"><div><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><small>Nettofluss auf diesem Konto</small></div><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div><div className="person-flow-grid"><div><span>Echte Eingänge</span><strong className="good-text">+{formatMoney(row.income)}</strong></div><div><span>Echte Ausgaben</span><strong className="bad-text">{formatMoney(row.expense)}</strong></div>{stats.includeTransfers&&<><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(row.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">{formatMoney(row.transferOut)}</strong></div></>}</div><details className="stat-row-details"><summary>Rechenweg <span></span></summary><StatFormulaRows rows={[{label:'Zuflüsse = echte Eingänge + Transfers rein',value:row.displayIn,tone:'good'},{label:'Abflüsse = echte Ausgaben + Transfers raus',value:row.displayOut,tone:'bad'},{label:'Nettofluss = Zuflüsse Abflüsse',value:row.net,note:'Transfers werden hier liquiditätswirksam gerechnet.'}]}/></details></div>)}</div>}</section>;
return <section className="panel person-flow-panel"><div className="section-head"><StatsSectionTitle title="Persönliche Sicht" help={help}><p>Wirtschaftliche Kosten und persönliche Liquidität als Ø-Monatswerte {year}. Transfers verändern Liquidität, bleiben aber außerhalb der Kostenstatistik.</p></StatsSectionTitle><strong className={`person-flow-total ${stats.totals.liquidAvailable>=0?'good-text':'bad-text'}`}>{stats.totals.liquidAvailable>=0?'+':''}{formatMoney(stats.totals.liquidAvailable)} Ø / Monat liquid</strong></div>{stats.rows.length===0?<Empty text="Noch keine personenbezogenen Werte vorhanden."/>:<div className="person-flow-list">{stats.rows.map(row=><div className="person-flow-row" key={row.id}><div className="person-flow-head"><div><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><small>Liquid verfügbar auf persönlichen Zahlungsverkehrskonten</small></div><strong className={row.liquidAvailable>=0?'good-text':'bad-text'}>{row.liquidAvailable>=0?'+':''}{formatMoney(row.liquidAvailable)}</strong></div><div className="person-flow-grid"><div><span>Wirtschaftlich verfügbar</span><strong className={row.economicAvailable>=0?'good-text':'bad-text'}>{row.economicAvailable>=0?'+':''}{formatMoney(row.economicAvailable)}</strong></div><div><span>Brutto-Kostenanteil</span><strong className="bad-text">{formatMoney(row.expenseShare)}</strong></div><div><span>Gemeinsame Deckung</span><strong className="good-text">+{formatMoney(row.sharedCoverage)}</strong></div><div><span>Netto-Kostenlast</span><strong className="bad-text">{formatMoney(row.netCost)}</strong></div><div><span>Eigene Kontoeingänge</span><strong className="good-text">+{formatMoney(row.personalIncome)}</strong></div>{stats.includeTransfers&&<><div><span>Liquid-Transfers rein</span><strong className="good-text">+{formatMoney(row.liquidTransferIn)}</strong></div><div><span>Liquid-Transfers raus</span><strong className="bad-text">{formatMoney(row.liquidTransferOut)}</strong></div><div><span>Gemeinschaft finanziert</span><strong>{formatMoney(row.sharedFundingActual)}</strong></div><div><span>Vermögensaufbau netto</span><strong className={row.assetBuild>=0?'good-text':'bad-text'}>{row.assetBuild>=0?'+':''}{formatMoney(row.assetBuild)}</strong></div></>}</div><details className="stat-row-details"><summary>Rechenweg <span></span></summary><StatFormulaRows rows={[{label:'Netto-Kostenlast = Brutto-Kostenanteil gemeinsame Deckung',value:row.netCost},{label:'Wirtschaftlich verfügbar = eigenes Einkommen Netto-Kostenlast',value:row.economicAvailable},{label:'Liquid verfügbar = liquide Eingänge Ausgaben + Transfers rein Transfers raus',value:row.liquidAvailable},{label:'Soll Gemeinschaftsfinanzierung',value:row.sharedFundingTarget},{label:'Ist Gemeinschaftsfinanzierung',value:row.sharedFundingActual},{label:'Abweichung Ist Soll',value:row.sharedFundingDifference}]}/></details></div>)}</div>}</section>;
}
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 <section className="panel shared-finance-panel"><div className="section-head"><div><h2>Gemeinschaftsfinanzierung</h2><p>Gemeinsame Einnahmen wie Kindergeld bleiben im gemeinsamen Topf. Sie reduzieren den Finanzierungsbedarf, werden aber keiner Person als persönliche Einnahme zugerechnet.</p></div></div><div className="shared-finance-list">{stats.groups.map(group=><article className="shared-finance-card" key={group.key}><div className="shared-finance-head"><div><strong>{group.accounts.map(a=>a.name).join(' + ')}</strong><span>{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}</span></div><strong className={group.netAssetChange>=0?'good-text':'bad-text'}>{group.netAssetChange>=0?'+':''}{formatMoney(group.netAssetChange)} / Monat</strong></div><div className="shared-finance-summary"><div><span>Gemeinsame Einnahmen</span><strong className="good-text">+{formatMoney(group.income)}</strong></div><div><span>Gemeinsame Kosten</span><strong className="bad-text">{formatMoney(group.expense)}</strong></div><div><span>Kosten-Finanzierungsbedarf</span><strong>{formatMoney(group.costNeed)}</strong></div>{showTransfers&&<div><span>Tatsächliche Finanzierung</span><strong>{formatMoney(group.fundingIn)}</strong></div>}</div>{showTransfers&&group.internalTransfers>0&&<div className="shared-internal-note">Interne Umbuchungen innerhalb dieses Gemeinschaftsbereichs: {formatMoney(group.internalTransfers)} / Monat. Sie sind keine Kosten.</div>}<div className="shared-person-table">{group.personRows.map(row=><div className={`shared-person-row ${showTransfers?'':'compact'}`} key={row.id}><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><div><span>Kostenanteil</span><strong>{formatMoney(row.grossCost)} · {percent(row.costShare)}</strong></div><div><span>Soll-Finanzierung</span><strong>{formatMoney(row.targetFunding)} · {percent(row.targetFundingShare)}</strong></div>{showTransfers&&<><div><span>Ist-Beitrag</span><strong>{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare)}</strong></div><div><span>Anteil am Bedarf</span><strong>{percent(row.needCoverageShare)}</strong></div><div><span>Differenz</span><strong className={row.difference>=0?'good-text':'bad-text'}>{row.difference>=0?'+':''}{formatMoney(row.difference)}</strong></div></>}</div>)}</div>{showTransfers&&<div className="shared-internal-note">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.</div>}{!showTransfers&&<div className="shared-internal-note">Transferdetails sind ausgeblendet. Kostenanteil und Soll-Finanzierung bleiben sichtbar, tatsächliche Beiträge werden nicht angezeigt.</div>}</article>)}</div></section>;
const help=<><p><strong>Kosten-Finanzierungsbedarf</strong> = gemeinsame Kosten gemeinsame Einnahmen, mindestens 0.</p><p><strong>Soll-Finanzierung</strong> verteilt diesen Bedarf im Verhältnis der tatsächlich zugeordneten gemeinsamen Kosten. <strong>Ist-Beitrag</strong> stammt aus Transfers von persönlichen Konten in diesen Gemeinschaftsbereich und wird dem Eigentümer des Quellkontos zugerechnet.</p><p><strong>Nettoveränderung Gemeinschaftsvermögen</strong> = gemeinsame Einnahmen + externe Zuführungen gemeinsame Kosten externe Entnahmen. Interne Umbuchungen zwischen gemeinsamen Konten desselben Eigentümerkreises fallen heraus.</p></>;
return <section className="panel shared-finance-panel"><div className="section-head"><StatsSectionTitle title="Gemeinschaftsfinanzierung" help={help}><p>Ø-Monatswerte {year}. Gemeinsame Einnahmen bleiben im gemeinsamen Topf und reduzieren den Finanzierungsbedarf.</p></StatsSectionTitle></div><div className="shared-finance-list">{stats.groups.map(group=><article className="shared-finance-card" key={group.key}><div className="shared-finance-head"><div><strong>{group.accounts.map(a=>a.name).join(' + ')}</strong><span>{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}</span></div><div className="shared-net-change"><span>Nettoveränderung Gemeinschaftsvermögen</span><strong className={group.netAssetChange>=0?'good-text':'bad-text'}>{group.netAssetChange>=0?'+':''}{formatMoney(group.netAssetChange)} Ø / Monat</strong></div></div><div className="shared-finance-summary"><div><span>Gemeinsame Einnahmen</span><strong className="good-text">+{formatMoney(group.income)}</strong></div><div><span>Gemeinsame Kosten</span><strong className="bad-text">{formatMoney(group.expense)}</strong></div><div><span>Kosten-Finanzierungsbedarf</span><strong>{formatMoney(group.costNeed)}</strong></div>{showTransfers&&<div><span>Tatsächliche externe Zuführung</span><strong>{formatMoney(group.fundingIn)}</strong></div>}</div>{showTransfers&&group.internalTransfers>0&&<div className="shared-internal-note">Interne Umbuchungen innerhalb dieses Gemeinschaftsbereichs: {formatMoney(group.internalTransfers)} Ø / Monat. Sie sind weder Kosten noch externe Finanzierung.</div>}<div className="shared-person-table">{group.personRows.map(row=><div className={`shared-person-row ${showTransfers?'':'compact'}`} key={row.id}><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><div><span>Kostenanteil</span><strong>{formatMoney(row.grossCost)} · {percent(row.costShare)}</strong></div><div><span>Soll-Finanzierung</span><strong>{formatMoney(row.targetFunding)} · {percent(row.targetFundingShare)}</strong></div>{showTransfers&&<><div><span>Ist-Beitrag</span><strong>{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare)}</strong></div><div><span>Anteil am Bedarf</span><strong>{percent(row.needCoverageShare)}</strong></div><div><span>Differenz</span><strong className={row.difference>=0?'good-text':'bad-text'}>{row.difference>=0?'+':''}{formatMoney(row.difference)}</strong></div></>}</div>)}</div><details className="stat-group-details"><summary>Zusammensetzung des Gemeinschaftsbereichs <span></span></summary><div className="stat-group-detail-content"><h4>Gemeinsame Einnahmen</h4><StatItemList data={data} items={group.incomeItems}/><h4>Gemeinsame Kosten</h4><StatItemList data={data} items={group.expenseItems}/>{showTransfers&&<><h4>Externe Zuführungen</h4><StatItemList data={data} items={group.incomingTransfers}/><h4>Interne Umbuchungen</h4><StatItemList data={data} items={group.internalTransferItems}/><h4>Externe Entnahmen</h4><StatItemList data={data} items={group.outgoingTransfers}/></>}</div></details>{showTransfers&&<div className="shared-internal-note">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.</div>}{!showTransfers&&<div className="shared-internal-note">Transferdetails sind ausgeblendet. Kostenanteil und Soll bleiben sichtbar; tatsächliche Beiträge nicht.</div>}</article>)}</div></section>;
}
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 <section className="panel shared-income-burden-panel"><div className="section-head"><div><h2>Gemeinsame Kosten gemessen am persönlichen Einkommen</h2><p>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.</p></div></div><div className="shared-income-burden-list">{stats.groups.map(group=><article className="shared-income-burden-card" key={group.key}><div className="shared-income-burden-head"><div><strong>{group.accounts.map(a=>a.name).join(' + ')}</strong><span>{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}</span></div><div><span>Belastungsquote Haushalt</span><strong>{percent(group.householdBurdenRate)}</strong></div></div><div className="shared-income-burden-summary"><div><span>Gemeinsame Kosten</span><strong className="bad-text">{formatMoney(group.expense)}</strong></div><div><span>Deckung gemeinsame Einnahmen</span><strong className="good-text">+{formatMoney(group.sharedIncomeCoverage)}</strong></div><div><span>Verbleibender Bedarf</span><strong>{formatMoney(group.costNeed)}</strong></div><div><span>Persönliche Einkommen gesamt</span><strong>{formatMoney(group.personalIncomeTotal)}</strong></div></div>{group.sharedIncomeSurplus>0&&<div className="shared-income-burden-note good-text">Gemeinsame Einnahmen übersteigen die gemeinsamen Kosten um {formatMoney(group.sharedIncomeSurplus)} / Monat. Es ist keine persönliche Finanzierung nötig.</div>}{!group.incomeAllocationAvailable?<div className="shared-income-burden-note">Keine persönlichen Einnahmen auf persönlich zugeordneten Konten vorhanden. Eine einkommensbasierte Verteilung kann deshalb nicht berechnet werden.</div>:<div className="shared-income-person-list">{group.incomeBurdenRows.map(row=><div className={`shared-income-person-row ${showTransfers?'':'compact'}`} key={row.id}><span className="person-badge">{row.name}</span><div><span>Persönliches Einkommen</span><strong>{formatMoney(row.personalIncome)}</strong></div><div><span>Einkommensanteil</span><strong>{percent(row.incomeShare==null?null:row.incomeShare*100)}</strong></div><div><span>Soll nach Einkommen</span><strong>{row.incomeBasedFunding==null?'':`${formatMoney(row.incomeBasedFunding)} · ${percent(row.incomeShare==null?null:row.incomeShare*100)}`}</strong></div><div><span>Belastung vom Einkommen</span><strong>{percent(row.burdenRate)}</strong></div>{showTransfers&&<><div><span>Ist-Beitrag</span><strong>{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare==null?null:row.actualFundingShare*100)}</strong></div><div><span>Abweichung Soll / Ist</span><strong className={row.difference==null?'':row.difference>=0?'good-text':'bad-text'}>{row.difference==null?'':`${row.difference>=0?'+':''}${formatMoney(row.difference)} · ${percentPoints(row.shareDifferencePoints)}`}</strong></div></>}</div>)}</div>}<div className="shared-income-burden-note">{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.</div></article>)}</div></section>;
const help=<><p>Diese Sicht ist ein <strong>Fairnessvergleich</strong>, keine automatische Zahlungsregel.</p><p>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.</p><p>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.</p></>;
return <section className="panel shared-income-burden-panel"><div className="section-head"><StatsSectionTitle title="Gemeinsame Kosten gemessen am persönlichen Einkommen" help={help}><p>Ø-Monatswerte {year}. Diese Vergleichssicht zeigt, wie der verbleibende Gemeinschaftsbedarf bei einkommensproportionaler Finanzierung verteilt wäre.</p></StatsSectionTitle></div><div className="shared-income-burden-list">{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 <article className="shared-income-burden-card" key={group.key}><div className="shared-income-burden-head"><div><strong>{group.accounts.map(a=>a.name).join(' + ')}</strong><span>{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}</span></div><div><span>Belastungsquote Haushalt</span><strong>{percent(group.householdBurdenRate)}</strong></div></div><div className="shared-income-burden-summary"><div><span>Gemeinsame Kosten</span><strong className="bad-text">{formatMoney(group.expense)}</strong></div><div><span>Deckung gemeinsame Einnahmen</span><strong className="good-text">+{formatMoney(group.sharedIncomeCoverage)}</strong></div><div><span>Verbleibender Bedarf</span><strong>{formatMoney(group.costNeed)}</strong></div><div><span>Persönliche Einkommen gesamt</span><strong>{formatMoney(group.personalIncomeTotal)}</strong></div></div>{group.sharedIncomeSurplus>0&&<div className="shared-income-burden-note good-text">Gemeinsame Einnahmen übersteigen die gemeinsamen Kosten um {formatMoney(group.sharedIncomeSurplus)} Ø / Monat. Es ist keine persönliche Finanzierung nötig.</div>}{!group.incomeAllocationAvailable?<div className="shared-income-burden-note">Keine persönlichen Einnahmen auf persönlichen Konten vorhanden. Eine einkommensbasierte Verteilung kann deshalb nicht berechnet werden.</div>:<div className="shared-income-person-list">{group.incomeBurdenRows.map(row=><div className={`shared-income-person-row ${showTransfers?'':'compact'}`} key={row.id}><span className="person-badge">{row.name}</span><div><span>Persönliches Einkommen</span><strong>{formatMoney(row.personalIncome)}</strong></div><div><span>Einkommensanteil / Soll-%</span><strong>{percent(row.incomeShare==null?null:row.incomeShare*100)}</strong></div><div><span>Soll nach Einkommen</span><strong>{row.incomeBasedFunding==null?'':formatMoney(row.incomeBasedFunding)}</strong></div><div><span>Belastung vom Einkommen</span><strong>{percent(row.burdenRate)}</strong></div>{showTransfers&&<><div><span>Ist-Beitrag / Ist-%</span><strong>{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare==null?null:row.actualFundingShare*100)}</strong></div><div><span>Abweichung Soll / Ist</span><strong className={row.difference==null?'':row.difference>=0?'good-text':'bad-text'}>{row.difference==null?'':`${row.difference>=0?'+':''}${formatMoney(row.difference)} · ${percentPoints(row.shareDifferencePoints)}`}</strong></div></>}</div>)}</div>}<details className="stat-group-details"><summary>Rechenweg und Einkommensquellen <span></span></summary><div className="stat-group-detail-content"><StatFormulaRows rows={[{label:'Gemeinsame Kosten',value:group.expense},{label:' Deckung durch gemeinsame Einnahmen',value:group.sharedIncomeCoverage},{label:'= verbleibender Finanzierungsbedarf',value:group.costNeed},{label:'Persönliche Einkommen der Eigentümer',value:group.personalIncomeTotal},{label:'Haushalts-Belastungsquote = Bedarf ÷ Einkommen',value:percent(group.householdBurdenRate)}]}/><h4>Persönliche Einkommensquellen</h4><StatItemList data={data} items={personalIncomeItems}/></div></details><div className="shared-income-burden-note">{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.</div></article>})}</div></section>;
}
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'&&<YearPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='planning'&&<PlanningPage data={data} totals={totals}/>}
{tab==='stats'&&<StatisticsPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='transferAnalysis'&&<TransferAnalysisPage data={data}/>}
{tab==='statements'&&<StatementsPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='scenarios'&&<ScenariosPage data={data} persist={persist}/>}
{tab==='data'&&<SettingsDataPage data={data} importFile={importFile}/>}
@@ -821,63 +918,93 @@ function Transfers({data,onEdit,onDuplicate,onDelete}){
</details>})}</div>}</section>;
}
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 <section className="panel transfer-type-panel"><div className="section-head"><div><h2>Transfer- und Finanzierungsarten</h2><p>Diese Beträge sind Kontobewegungen, aber keine Kosten. FixFin leitet die Art automatisch aus Eigentum und Kontofunktion ab.</p></div></div>{stats.rows.length===0?<Empty text="Keine Transfers für diese Auswahl vorhanden."/>:<div className="transfer-type-grid">{stats.rows.map(row=><div className="transfer-type-card" key={row.key}><div><span className={`transfer-class-badge ${row.key}`}>{row.label}</span><small>{row.description}</small></div><strong>{formatMoney(row.amount)} / Monat</strong>{!stats.isAll&&<div className="transfer-type-directions"><span>rein {formatMoney(row.in)}</span><span>raus {formatMoney(row.out)}</span></div>}</div>)}</div>}</section>;
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=<><p>Transfers werden nach Eigentum und Kontofunktion automatisch klassifiziert, z. B. Gemeinschaftsfinanzierung, Rücklagenzuführung oder Transfer zwischen Personen.</p><p>Die Beträge sind <strong>Ø-Monatswerte des gewählten Jahres</strong>. Sie werden nie zu echten Einnahmen oder Kosten addiert. Bei einem einzelnen Konto verändern sie aber den Liquiditäts-Nettofluss.</p></>;
return <section className="panel transfer-type-panel"><div className="section-head"><StatsSectionTitle title="Transfer- und Finanzierungsarten" help={help}><p>Separater Blick auf Umbuchungen {year}. Jede Karte lässt sich bis zu den konkreten Transfers aufklappen.</p></StatsSectionTitle></div>{stats.rows.length===0?<Empty text="Keine Transfers für diese Auswahl vorhanden."/>:<div className="transfer-type-grid">{stats.rows.map(row=><details className="transfer-type-card stat-transfer-type-card" key={row.key}><summary><div><span className={`transfer-class-badge ${row.key}`}>{row.label}</span><small>{row.description} · {row.count} {row.count===1?'Position':'Positionen'}</small></div><div><strong>{formatMoney(row.amount)} Ø / Monat</strong><small>{formatMoney(row.annual)} / Jahr</small>{!stats.isAll&&<div className="transfer-type-directions"><span>rein {formatMoney(row.in)}</span><span>raus {formatMoney(row.out)}</span></div>}</div><span className="accordion-chevron" aria-hidden="true"></span></summary><div className="stat-transfer-type-details"><StatItemList data={data} items={row.items}/></div></details>)}</div>}</section>;
}
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 <section className="panel category-stat-panel"><div className="section-head"><div><h2>{title}</h2><p>{subtitle}</p></div><strong className={`category-total ${tone==='income'?'good-text':tone==='expense'?'bad-text':''}`}>{formatMoney(stats.total)} / Monat</strong></div>
{stats.rows.length===0?<Empty text="Noch keine Werte für diese Auswahl vorhanden."/>:<div className="category-distribution">{stats.rows.map((row,index)=><details className="category-stat-row" key={row.id}><summary><div className="category-stat-head"><div><span className="category-rank">{index+1}</span><strong>{row.name}</strong><small>{row.count} {row.count===1?'Position':'Positionen'}</small></div><div><strong>{formatMoney(row.amount)}</strong><span>{row.percent.toFixed(1).replace('.',',')} %</span></div></div><div className="category-bar"><span className={tone} style={{width:`${Math.max(row.percent,1)}%`}}/></div></summary><div className="category-drilldown">{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=><div className="category-drill-row" key={`${item.id}-${i}`}><div><strong>{item.name}</strong><span>{itemMeta(item)} · {personListLabel(data,item)} · {intervals[item.interval||'monthly'].label}</span></div><strong>{formatMoney(item.statAmount)} / Monat</strong></div>)}</div></details>)}</div>}
const help=<><p>Der Kategorienwert ist die Summe der <strong>Ø-Monatswerte {year}</strong> aller enthaltenen Positionen. Jede Position wird zuerst für das ganze Jahr geplant und anschließend durch 12 geteilt.</p><p>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.</p><p>Der Prozentwert einer Kategorie ist Kategorienwert ÷ Gesamtsumme dieses Blocks.</p></>;
return <section className="panel category-stat-panel"><div className="section-head"><StatsSectionTitle title={title} help={help}><p>{subtitle}</p></StatsSectionTitle><div className={`category-total ${tone==='income'?'good-text':tone==='expense'?'bad-text':''}`}><strong>{formatMoney(stats.total)} Ø / Monat</strong><small>{formatMoney(stats.annual)} / Jahr</small></div></div>
{stats.rows.length===0?<Empty text="Noch keine Werte für diese Auswahl vorhanden."/>:<div className="category-distribution">{stats.rows.map((row,index)=><details className="category-stat-row" key={row.id}><summary><div className="category-stat-head"><div><span className="category-rank">{index+1}</span><strong>{row.name}</strong><small>{row.count} {row.count===1?'Position':'Positionen'}</small></div><div><strong>{formatMoney(row.amount)}</strong><span>{row.percent.toFixed(1).replace('.',',')} %</span></div></div><div className="category-bar"><span className={tone} style={{width:`${Math.max(row.percent,1)}%`}}/></div></summary><div className="category-drilldown"><div className="category-drill-formula"><span>{formatMoney(row.annual)} im Jahr ÷ 12</span><strong>= {formatMoney(row.amount)} Ø / Monat</strong></div>{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=><div className="category-drill-row" key={`${item.id}-${i}`}><div><strong>{item.name}</strong><span>{itemMeta(item)} · {personListLabel(data,item)} · {statAmountMeta(item)}</span></div><div><strong>{formatMoney(item.statAmount)} Ø / Monat</strong><small>{formatMoney(item.statAnnual)} / Jahr</small></div></div>)}</div></details>)}</div>}
</section>;
}
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=<><p>Die Gesamtkennzahlen beziehen sich immer auf <strong>alle Konten und alle Personen</strong> im ausgewählten Jahr. Die Konto- und Personenfilter darunter verändern sie bewusst nicht.</p><p>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.</p></>;
return <div className="content-stack stats-page">
<section className="panel stats-overview-panel"><div className="section-head"><div><h2>Gesamtkennzahlen</h2><p>Wirtschaftliche Kennzahlen verwenden nur echte Einnahmen und echte Ausgaben. Umbuchungen verändern diese Werte nicht.</p></div></div><div className="stats-kpi-grid"><Metric label="Wirtschaftlicher Saldo / Monat" value={totals.balance} positive={totals.balance>=0} negative={totals.balance<0} featured/><TextMetric label="Fixkostenquote" value={`${kpis.fixedCostRatio.toFixed(1).replace('.',',')} %`} hint="Echte Ausgänge ÷ echte Eingänge"/><Metric label="Echte Kosten / Jahr" value={kpis.annualExpense} negative/><Metric label="Rücklage periodisch / Monat" value={kpis.reserveMonthly}/><TextMetric label="Teuerster Monat" value={kpis.expensive?.name||''} hint={kpis.expensive?formatMoney(kpis.expensive.displayExpense):''}/><TextMetric label="Günstigster Monat" value={kpis.cheap?.name||''} hint={kpis.cheap?formatMoney(kpis.cheap.displayExpense):''}/></div></section>
<section className="year-account-filter stats-filter"><div className="year-filter-copy"><span>Statistik für</span><strong>{selectedName} · {selectedPerson}</strong></div><div className="stats-filter-controls"><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section>
<StatsMethodPanel year={year}/>
<section className="panel stats-overview-panel"><div className="section-head"><StatsSectionTitle title={`Gesamtkennzahlen ${year}`} help={globalHelp}><p>Gesamthaushalt, unabhängig von den Konto-/Personenfiltern darunter. Alle Monatswerte sind Jahresdurchschnitte.</p></StatsSectionTitle></div><div className="stats-kpi-grid">
<StatMetricCard label="Wirtschaftlicher Saldo Ø / Monat" value={kpis.freeMonthly} tone={kpis.freeMonthly>=0?'good':'bad'} featured help={<><p>Echte Einnahmen minus echte Ausgaben. Transfers werden vollständig ignoriert, weil sie Geld nur zwischen eigenen Töpfen verschieben.</p><p>Berechnung: ({formatMoney(kpis.annualIncome)} Einnahmen im Jahr {formatMoney(kpis.annualExpense)} Ausgaben im Jahr) ÷ 12.</p></>}><StatFormulaRows rows={[{label:'Echte Eingänge Ø / Monat',value:kpis.monthlyIncome,tone:'good'},{label:'Echte Kosten Ø / Monat',value:kpis.monthlyExpense,tone:'bad'},{label:'Saldo',value:kpis.freeMonthly}]}/><details className="stat-nested-details"><summary>Einzelne Eingänge und Kosten</summary><h4>Eingänge</h4><StatItemList data={data} items={globalIncomeItems}/><h4>Kosten</h4><StatItemList data={data} items={globalExpenseItems}/></details></StatMetricCard>
<StatMetricCard label="Kostenquote Ø" displayValue={`${kpis.fixedCostRatio.toFixed(1).replace('.',',')} %`} help={<><p>Anteil der echten geplanten Kosten an den echten geplanten Einnahmen im Jahr {year}.</p><p>100 % bedeutet: alle Einnahmen werden durch echte Kosten verbraucht. Transfers spielen keine Rolle.</p></>}><StatFormulaRows rows={[{label:'Echte Kosten Ø / Monat',value:kpis.monthlyExpense,tone:'bad'},{label:'Echte Eingänge Ø / Monat',value:kpis.monthlyIncome,tone:'good'},{label:'Kosten ÷ Eingänge',value:`${kpis.fixedCostRatio.toFixed(1).replace('.',',')} %`}]}/></StatMetricCard>
<StatMetricCard label="Echte Kosten / Jahr" value={kpis.annualExpense} tone="bad" help={<><p>Summe aller echten Ausgaben, die im gewählten Jahr geplant sind. Einmalige Ausgaben zählen genau einmal, periodische Ausgaben entsprechend ihrer Fälligkeiten.</p><p>Transfers sind keine Kosten und fehlen deshalb hier.</p></>}><StatItemList data={data} items={globalExpenseItems}/></StatMetricCard>
<StatMetricCard label="Rücklagenbedarf periodische Kosten Ø / Monat" value={kpis.reserveMonthly} help={<><p>Jährliche, halbjährliche und vierteljährliche echte Ausgaben werden auf einen durchschnittlichen Monatsbedarf umgerechnet.</p><p>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.</p></>}><StatItemList data={data} items={periodicExpenseItems}/></StatMetricCard>
<StatMetricCard label="Teuerster Monat" displayValue={kpis.expensive?.name||''} hint={kpis.expensive?formatMoney(kpis.expensive.displayExpense):''} help={<><p>Monat mit der höchsten Summe echter fälliger Ausgaben in {year}. Hier werden konkrete Fälligkeiten und einmalige Positionen berücksichtigt.</p><p>Transfers bleiben außen vor.</p></>}><StatItemList data={data} items={expensiveItems} amountSuffix="im Monat" showAnnual={false}/></StatMetricCard>
<StatMetricCard label="Günstigster Monat" displayValue={kpis.cheap?.name||''} hint={kpis.cheap?formatMoney(kpis.cheap.displayExpense):''} help={<><p>Monat mit der niedrigsten Summe echter fälliger Ausgaben in {year}. Ein Monat ohne geplante Kosten kann deshalb 0 haben.</p></>}><StatItemList data={data} items={cheapItems} amountSuffix="im Monat" showAnnual={false}/></StatMetricCard>
</div></section>
<section className="year-account-filter stats-filter"><div className="year-filter-copy"><span>Auswahl für Detailstatistik</span><strong>{selectedName} · {selectedPerson} · {year}</strong></div><div className="stats-filter-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section>
<TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/>
<section className="summary-grid stats-summary"><Metric label={personalIncomeLabel} value={stats.incomes.total} positive/><Metric label="Echte Kosten Ø / Monat" value={stats.expenses.total} negative/><Metric label={selectionBalanceLabel} value={selectionBalance} positive={selectionBalance>=0} negative={selectionBalance<0}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section>
<PersonFlowOverview stats={personFlows}/>
{accountId==='all'&&<SharedFundingOverview data={data} personId={personId} showTransfers={includeTransfers}/>}
{accountId==='all'&&<SharedIncomeBurdenOverview data={data} personId={personId} showTransfers={includeTransfers}/>}
{includeTransfers&&<TransferTypeOverview data={data} accountId={accountId} personId={personId}/>}
<CategoryDistribution data={data} title="Kosten nach Kategorien" subtitle="Nur echte Ausgaben. Gemeinsam zugeordnete Kosten werden beim Personenfilter gleichmäßig geteilt; Transfers erscheinen hier niemals als Kosten." stats={stats.expenses} tone="expense"/>
<CategoryDistribution data={data} title={personId==='all'?'Eingänge nach Kategorien':'Eigene Eingänge nach Kategorien'} subtitle={personId==='all'?'Nur echte Eingänge. Transfers bleiben getrennt.':'Nur Eingänge auf persönlich zugeordneten Konten dieser Person. Gemeinsame Einnahmen wie Kindergeld werden nicht als persönliche Einnahmen gezählt.'} stats={stats.incomes} tone="income"/>
{includeTransfers&&<CategoryDistribution data={data} title="Transfer-/Finanzierungsströme nach Kategorien" subtitle="Separat von Kosten und Einnahmen. Dazu gehören interne Umbuchungen, Vermögensaufbau und Gemeinschaftsfinanzierung." stats={stats.transfers} tone="transfer"/>}
<section className="stats-selection-section"><div className="stats-selection-heading"><StatsSectionTitle title="Kennzahlen für die aktuelle Auswahl" help={<><p>Diese vier Karten reagieren auf Konto, Person und Jahr.</p><p>Bei <strong>Alle Konten + Person</strong> wird eine wirtschaftliche Personensicht verwendet: persönliches Einkommen folgt Kontoeigentum, Kosten folgen der Bewegungszuordnung und gemeinsame Einnahmen reduzieren die gemeinsame Kostenlast.</p><p>Bei einem <strong>einzelnen Konto</strong> wechselt die dritte Karte bewusst in eine Liquiditätssicht. Transfers sind dann im Nettofluss enthalten, auch wenn Transferdetails ausgeblendet sind.</p></>}/></div><div className="stats-kpi-grid stats-selection-grid">
<StatMetricCard label={personalIncomeLabel} value={stats.incomes.total} tone="good" help={<><p>{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.'}</p><p>Jeder Wert ist Jahresbetrag ÷ 12.</p></>}><StatItemList data={data} items={selectionIncomeItems}/></StatMetricCard>
<StatMetricCard label="Echte Kosten Ø / Monat" value={stats.expenses.total} tone="bad" help={<><p>Nur echte Ausgaben. Bei einer Person wird eine Bewegung mit mehreren zugeordneten Personen gleichmäßig geteilt.</p><p>Transfers bleiben vollständig getrennt.</p></>}><StatItemList data={data} items={selectionExpenseItems}/></StatMetricCard>
<StatMetricCard label={selectionBalanceLabel} value={selectionBalance} tone={selectionBalance>=0?'good':'bad'} help={<><p>{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.'}</p></>}><StatFormulaRows rows={selectionBalanceRows}/></StatMetricCard>
<StatMetricCard label="Ohne Kategorie" displayValue={Number(uncategorized).toLocaleString('de-DE')} help={<><p>Anzahl echter Eingangs- und Ausgangspositionen der aktuellen Auswahl ohne Kategorie.</p><p>Transfers werden hier bewusst nicht mitgezählt, damit der Wert nicht vom Schalter Transferdetails anzeigen abhängt.</p></>}><StatItemList data={data} items={uncategorizedItems}/></StatMetricCard>
</div></section>
<PersonFlowOverview stats={personFlows} year={year}/>
{accountId==='all'&&<SharedFundingOverview data={data} personId={personId} showTransfers={includeTransfers} year={year}/>}
{accountId==='all'&&<SharedIncomeBurdenOverview data={data} personId={personId} showTransfers={includeTransfers} year={year}/>}
{includeTransfers&&<TransferTypeOverview data={data} accountId={accountId} personId={personId} year={year}/>}
<CategoryDistribution data={data} year={year} title="Kosten nach Kategorien" subtitle="Nur echte Ausgaben. Gemeinsam zugeordnete Kosten werden beim Personenfilter gleichmäßig geteilt; Transfers erscheinen hier niemals als Kosten." stats={stats.expenses} tone="expense"/>
<CategoryDistribution data={data} year={year} title={personId==='all'?'Eingänge nach Kategorien':accountId==='all'?'Eigene Eingänge nach Kategorien':'Zugeordnete Eingänge nach Kategorien'} subtitle={personId==='all'?'Nur echte Eingänge. Transfers bleiben getrennt.':accountId==='all'?'Nur Eingänge auf persönlich zugeordneten Konten dieser Person. Gemeinsame Einnahmen wie Kindergeld werden nicht als persönliche Einnahmen gezählt.':'Einzelkonto-Sicht: echte Eingänge werden nach der Personenzuordnung der Bewegung anteilig ausgewiesen.'} stats={stats.incomes} tone="income"/>
{includeTransfers&&<CategoryDistribution data={data} year={year} title="Transfer-/Finanzierungsströme nach Kategorien" subtitle="Separat von Kosten und Einnahmen. Dazu gehören interne Umbuchungen, Vermögensaufbau und Gemeinschaftsfinanzierung." stats={stats.transfers} tone="transfer"/>}
</div>;
}
function YearPage({data,includeTransfers,onTransfersChange}){
const currentYear=new Date().getFullYear();
const [accountId,setAccountId]=useState('all');
@@ -1011,6 +1138,38 @@ function PushoverSettings(){
</section>;
}
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 <section className="transfer-account-selector"><div className="transfer-account-selector-head"><div><strong>{title}</strong><span>{subtitle}</span></div><button type="button" className="secondary compact-button" onClick={()=>onChange(allSelected?[]:accounts.map(a=>a.id))}>{allSelected?'Keine':'Alle'}</button></div><div className="transfer-account-choice-grid">{accounts.map(account=><button type="button" key={account.id} className={`transfer-account-choice ${selected.has(account.id)?'active':''}`} aria-pressed={selected.has(account.id)} onClick={()=>toggle(account.id)}><span className="checkbox-mark">{selected.has(account.id)?'✓':''}</span><span><strong>{account.name}</strong><small>{accountOwnerships[account.ownership]||'Nicht zugeordnet'} · {accountFunctionLabel(account)}</small></span></button>)}</div></section>;
}
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 <div className="content-stack transfer-analysis-page"><section className="panel"><Empty text="Lege zuerst Konten und Transfers an."/></section></div>;
return <div className="content-stack transfer-analysis-page">
<section className="panel transfer-analysis-filter"><div className="section-head"><div><h2>Transferanalyse</h2><p>Wähle eine oder mehrere Quellen und Ziele. Ausgewertet werden nur Transfers, deren Quell- und Zielkonto gleichzeitig in der Auswahl liegen.</p></div><label className="transfer-analysis-year"><span>Planjahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(value=><option key={value} value={value}>{value}</option>)}</select></label></div><div className="transfer-analysis-selectors"><AccountSelection title="Quellkonten" subtitle="Von diesen Konten muss der Transfer ausgehen." accounts={data.accounts} selectedIds={sourceIds} onChange={setSourceIds}/><AccountSelection title="Zielkonten" subtitle="Auf diesen Konten muss der Transfer ankommen." accounts={data.accounts} selectedIds={targetIds} onChange={setTargetIds}/></div></section>
<section className="summary-grid transfer-analysis-summary"><CountMetric label="Gefundene Transfers" value={stats.rows.length}/><Metric label={`Transfervolumen ${year}`} value={stats.annual}/><Metric label="Ø Transfervolumen / Monat" value={stats.monthly}/><CountMetric label="Quellen mit Treffern" value={stats.sources.length}/></section>
{sourceIds.length===0||targetIds.length===0?<section className="panel"><Empty text="Bitte mindestens ein Quell- und ein Zielkonto auswählen."/></section>:stats.rows.length===0?<section className="panel"><Empty text={`Für die gewählte Kombination gibt es ${year} keine geplanten Transfers.`}/></section>:<>
<section className="panel transfer-source-overview"><div className="section-head"><div><h2>Wie viel kommt von welchem Konto?</h2><p>Jede Quelle zeigt ihr gesamtes Transfervolumen und die Verteilung auf die ausgewählten Zielkonten.</p></div></div><div className="transfer-source-list">{stats.sources.map(source=><details className="transfer-source-card" key={source.accountId} open={stats.sources.length===1}><summary><div><strong>{accountName(data,source.accountId)}</strong><span>{source.items.length} {source.items.length===1?'Transfer':'Transfers'} · {percent(source.annual,stats.annual)} des ausgewählten Volumens</span></div><div className="transfer-source-total"><strong>{formatMoney(source.annual)} / Jahr</strong><small>{formatMoney(source.monthly)} Ø / Monat</small></div><span className="accordion-chevron" aria-hidden="true"></span></summary><div className="transfer-source-body"><div className="transfer-target-distribution">{source.targets.map(target=><div className="transfer-target-row" key={target.accountId}><div><strong>{accountName(data,target.accountId)}</strong><span>{target.items.length} {target.items.length===1?'Position':'Positionen'}</span></div><div className="transfer-target-bar"><span style={{width:`${Math.max(target.annual/source.annual*100,1)}%`}}/></div><div><strong>{formatMoney(target.annual)}</strong><span>{percent(target.annual,source.annual)}</span></div></div>)}</div><div className="transfer-analysis-detail-list">{source.items.sort((a,b)=>b.annual-a.annual).map(item=><details className="transfer-analysis-detail" key={item.id}><summary><div><strong>{item.name}</strong><span>{accountName(data,item.fromAccountId)} {accountName(data,item.toAccountId)}</span></div><div><strong>{formatMoney(item.annual)} / Jahr</strong><small>{formatMoney(item.monthly)} Ø / Monat</small></div><span className="accordion-chevron" aria-hidden="true"></span></summary><div className="transfer-analysis-detail-body"><span className={`transfer-class-badge ${item.classification.key}`}>{item.classification.label}</span><div><span>Betrag je Intervall</span><strong>{formatMoney(item.amount)}</strong></div><div><span>Intervall</span><strong>{intervalLabel(item)}</strong></div><div><span>Fälligkeit</span><strong>{movementDueText(item)}</strong></div><div><span>Personen</span><strong>{transferPeopleLabel(data,item)}</strong></div><div><span>Kategorie</span><strong>{categoryName(data,item.categoryId)}</strong></div><div><span>Anteil dieser Quelle</span><strong>{percent(item.annual,source.annual)}</strong></div></div></details>)}</div></div></details>)}</div></section>
<section className="panel transfer-target-overview"><div className="section-head"><div><h2>Verteilung auf die Zielkonten</h2><p>Wie viel des ausgewählten Transfervolumens insgesamt auf jedem Zielkonto landet.</p></div></div><div className="transfer-target-cards">{stats.targets.map(target=><div className="transfer-target-card" key={target.accountId}><div><strong>{accountName(data,target.accountId)}</strong><span>{target.items.length} {target.items.length===1?'Transfer':'Transfers'}</span></div><strong>{formatMoney(target.annual)} / Jahr</strong><div className="transfer-target-card-foot"><span>{formatMoney(target.monthly)} Ø / Monat</span><b>{percent(target.annual,stats.annual)}</b></div></div>)}</div></section>
<section className="panel transfer-matrix-panel"><div className="section-head"><div><h2>Quelle Ziel</h2><p>Matrix der ausgewählten Konten. Die Werte zeigen den geplanten Jahresbetrag {year}.</p></div></div><div className="table-wrap transfer-matrix-wrap"><table className="transfer-matrix"><thead><tr><th>Quelle</th>{stats.targets.map(target=><th key={target.accountId}>{accountName(data,target.accountId)}</th>)}<th>Gesamt</th></tr></thead><tbody>{stats.sources.map(source=><tr key={source.accountId}><th>{accountName(data,source.accountId)}</th>{stats.targets.map(target=>{const match=source.targets.find(x=>x.accountId===target.accountId);return <td key={target.accountId}>{match?formatMoney(match.annual):''}</td>})}<td><strong>{formatMoney(source.annual)}</strong></td></tr>)}</tbody><tfoot><tr><th>Gesamt</th>{stats.targets.map(target=><td key={target.accountId}><strong>{formatMoney(target.annual)}</strong></td>)}<td><strong>{formatMoney(stats.annual)}</strong></td></tr></tfoot></table></div></section>
</>}
</div>;
}
function StatementsPage({data,includeTransfers,onTransfersChange}){
const currentYear=new Date().getFullYear();
const [year,setYear]=useState(currentYear);
+204
View File
@@ -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; }
}