This commit is contained in:
2026-08-20 11:54:23 +02:00
parent cd426cce19
commit 1a3dbaa8c6
4 changed files with 105 additions and 21 deletions
+5
View File
@@ -118,3 +118,8 @@ In der Statistik gibt es zusätzlich eine einkommensbasierte Vergleichssicht fü
Die Gemeinschaftsfinanzierung zeigt je Person zusätzlich Prozentwerte. **Kostenanteil** ist der Anteil der Person an den zugeordneten gemeinsamen Kosten. **Soll-Finanzierung %** zeigt den daraus resultierenden Anteil am verbleibenden Finanzierungsbedarf nach Abzug gemeinsamer Einnahmen. **Ist-Beitrag %** zeigt den Anteil der Person an allen tatsächlichen persönlichen Einzahlungen in diesen Gemeinschaftsbereich. **Anteil am Bedarf** zeigt, wie viel des verbleibenden Finanzierungsbedarfs durch die tatsächliche Einzahlung dieser Person gedeckt wird. In der einkommensbasierten Vergleichssicht werden Soll-% (Einkommensanteil) und Ist-% direkt gegenübergestellt; die Abweichung wird zusätzlich in Prozentpunkten angezeigt. Die Gemeinschaftsfinanzierung zeigt je Person zusätzlich Prozentwerte. **Kostenanteil** ist der Anteil der Person an den zugeordneten gemeinsamen Kosten. **Soll-Finanzierung %** zeigt den daraus resultierenden Anteil am verbleibenden Finanzierungsbedarf nach Abzug gemeinsamer Einnahmen. **Ist-Beitrag %** zeigt den Anteil der Person an allen tatsächlichen persönlichen Einzahlungen in diesen Gemeinschaftsbereich. **Anteil am Bedarf** zeigt, wie viel des verbleibenden Finanzierungsbedarfs durch die tatsächliche Einzahlung dieser Person gedeckt wird. In der einkommensbasierten Vergleichssicht werden Soll-% (Einkommensanteil) und Ist-% direkt gegenübergestellt; die Abweichung wird zusätzlich in Prozentpunkten angezeigt.
### v3.27 gerichtete Personentransfers und verfügbare Liquidität
Transfers zwischen persönlichen Konten unterschiedlicher Eigentümer werden in personenbezogenen Auswertungen aus den Kontoeigentümern als Sender → Empfänger abgeleitet. Der globale Schalter blendet Transferdetails nur noch ein oder aus; Kontoliquidität und verfügbare Beträge bleiben immer inklusive Transfers. In der persönlichen Statistik werden wirtschaftlich verfügbar und liquid verfügbar getrennt ausgewiesen.
+1 -1
View File
@@ -1,4 +1,4 @@
const SHELL_CACHE='fixfin-shell-v3.26'; const SHELL_CACHE='fixfin-shell-v3.27';
const DATA_CACHE='fixfin-data-v1'; const DATA_CACHE='fixfin-data-v1';
const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png'];
+19 -3
View File
@@ -25,6 +25,22 @@ function personName(data,id){return data.persons?.find(p=>p.id===id)?.name||'Ohn
function personIdsOf(item){return Array.isArray(item?.personIds)?item.personIds.filter(Boolean):typeof item?.personId==='string'&&item.personId?[item.personId]:[];} function personIdsOf(item){return Array.isArray(item?.personIds)?item.personIds.filter(Boolean):typeof item?.personId==='string'&&item.personId?[item.personId]:[];}
function personLabel(data,item){const ids=personIdsOf(item);return ids.length?ids.map(id=>personName(data,id)).join(', '):'Ohne Person';} function personLabel(data,item){const ids=personIdsOf(item);return ids.length?ids.map(id=>personName(data,id)).join(', '):'Ohne Person';}
function personFactor(item,personId='all'){if(personId==='all')return 1;const ids=personIdsOf(item);return ids.includes(personId)&&ids.length?1/ids.length:0;} function personFactor(item,personId='all'){if(personId==='all')return 1;const ids=personIdsOf(item);return ids.includes(personId)&&ids.length?1/ids.length:0;}
function ownerIdsOf(account){return Array.isArray(account?.ownerPersonIds)?[...new Set(account.ownerPersonIds.filter(Boolean))]:[];}
function transferFactorForAccount(data,item,personId='all',accountId=''){
if(personId==='all')return 1;
const from=data.accounts.find(a=>a.id===item.fromAccountId);const to=data.accounts.find(a=>a.id===item.toAccountId);const selected=data.accounts.find(a=>a.id===accountId);
let ids=[];
if(selected?.ownership==='personal')ids=ownerIdsOf(selected);
else if(item.toAccountId===accountId&&from?.ownership==='personal')ids=ownerIdsOf(from);
else if(item.fromAccountId===accountId&&to?.ownership==='personal')ids=ownerIdsOf(to);
else return personFactor(item,personId);
return ids.includes(personId)?1:0;
}
function transferPersonLabel(data,item){
const from=data.accounts.find(a=>a.id===item.fromAccountId);const to=data.accounts.find(a=>a.id===item.toAccountId);
if(from?.ownership==='personal'&&to?.ownership==='personal'&&ownerIdsOf(from).length&&ownerIdsOf(to).length)return `${ownerIdsOf(from).map(id=>personName(data,id)).join(', ')}${ownerIdsOf(to).map(id=>personName(data,id)).join(', ')}`;
return personLabel(data,item);
}
export function buildAnnualForecast(data,year,accountId,personId='all',includeTransfers=true){ export function buildAnnualForecast(data,year,accountId,personId='all',includeTransfers=true){
const rows=MONTHS.map((name,index)=>({name,index,income:0,expense:0,transferIn:0,transferOut:0,displayIncome:0,displayExpense:0,balance:0,items:[]})); const rows=MONTHS.map((name,index)=>({name,index,income:0,expense:0,transferIn:0,transferOut:0,displayIncome:0,displayExpense:0,balance:0,items:[]}));
@@ -37,7 +53,7 @@ export function buildAnnualForecast(data,year,accountId,personId='all',includeTr
for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m)*factor;rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});} for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m)*factor;rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});}
} }
if(includeTransfers) for(const item of data.transfers||[]){ if(includeTransfers) for(const item of data.transfers||[]){
const factor=personFactor(item,personId); if(!factor||(item.fromAccountId!==accountId&&item.toAccountId!==accountId))continue; const factor=transferFactorForAccount(data,item,personId,accountId); if(!factor||(item.fromAccountId!==accountId&&item.toAccountId!==accountId))continue;
for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
const amount=Number(item.amount||0)*factor; const amount=Number(item.amount||0)*factor;
if(item.toAccountId===accountId){rows[m].transferIn+=amount;rows[m].items.push({...item,kind:'transfer',direction:'in',reportAmount:amount});} if(item.toAccountId===accountId){rows[m].transferIn+=amount;rows[m].items.push({...item,kind:'transfer',direction:'in',reportAmount:amount});}
@@ -52,11 +68,11 @@ function occurrenceRows(data,year,accountId,personId='all',includeTransfers=true
const result=[]; const result=[];
const push=(item,kind,monthIndex,direction='',factor=1)=>{ const push=(item,kind,monthIndex,direction='',factor=1)=>{
const amount=(kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex))*factor; const amount=(kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex))*factor;
result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,person:personLabel(data,item),category:categoryName(data,item.categoryId),amount}); result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,person:kind==='transfer'?transferPersonLabel(data,item):personLabel(data,item),category:categoryName(data,item.categoryId),amount});
}; };
for(const item of data.incomes||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m,'',factor);} for(const item of data.incomes||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m,'',factor);}
for(const item of data.expenses||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m,'',factor);} for(const item of data.expenses||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m,'',factor);}
if(includeTransfers) for(const item of data.transfers||[]){const factor=personFactor(item,personId);if(factor&&(item.fromAccountId===accountId||item.toAccountId===accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ if(includeTransfers) for(const item of data.transfers||[]){const factor=transferFactorForAccount(data,item,personId,accountId);if(factor&&(item.fromAccountId===accountId||item.toAccountId===accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
if(item.toAccountId===accountId)push(item,'transfer',m,'in',factor); if(item.toAccountId===accountId)push(item,'transfer',m,'in',factor);
if(item.fromAccountId===accountId)push(item,'transfer',m,'out',factor); if(item.fromAccountId===accountId)push(item,'transfer',m,'out',factor);
}} }}
+80 -17
View File
@@ -137,6 +137,37 @@ function transferClassification(data,item){
return {key:'other',label:'Sonstiger Transfer',description:'Eigentumsbereich wechselt'}; return {key:'other',label:'Sonstiger Transfer',description:'Eigentumsbereich wechselt'};
} }
function transferClassLabel(data,item){return transferClassification(data,item).label;} function transferClassLabel(data,item){return transferClassification(data,item).label;}
function transferDirectionalOwnerIds(data,item,direction='any'){
const from=data.accounts.find(a=>a.id===item.fromAccountId);
const to=data.accounts.find(a=>a.id===item.toAccountId);
if(direction==='out')return ownerIdsOf(from);
if(direction==='in')return ownerIdsOf(to);
return [...new Set([...ownerIdsOf(from),...ownerIdsOf(to)])];
}
function transferPersonFactor(data,item,personId='all',accountId='all'){
if(personId==='all')return 1;
const cls=transferClassification(data,item);
const from=data.accounts.find(a=>a.id===item.fromAccountId);
const to=data.accounts.find(a=>a.id===item.toAccountId);
let ids=[];
if(cls.key==='shared-funding')ids=ownerIdsOf(from);
else if(cls.key==='shared-withdrawal')ids=ownerIdsOf(to);
else if(cls.key==='person-transfer'){
if(accountId!=='all'&&item.fromAccountId===accountId)ids=ownerIdsOf(from);
else if(accountId!=='all'&&item.toAccountId===accountId)ids=ownerIdsOf(to);
else ids=transferDirectionalOwnerIds(data,item,'any');
} else if(sameOwnershipDomain(from,to)&&from?.ownership==='personal')ids=ownerIdsOf(from);
else return personFactor(item,personId);
return ids.includes(personId)?1:0;
}
function transferPeopleLabel(data,item){
if(transferClassification(data,item).key!=='person-transfer')return personListLabel(data,item);
const fromIds=transferDirectionalOwnerIds(data,item,'out');
const toIds=transferDirectionalOwnerIds(data,item,'in');
const from=fromIds.length?fromIds.map(id=>personName(data,id)).join(', '):'Unbekannt';
const to=toIds.length?toIds.map(id=>personName(data,id)).join(', '):'Unbekannt';
return `${from}${to}`;
}
function calc(data, includeTransfers = true) { function calc(data, includeTransfers = true) {
const includePeriodic = data.settings?.includePeriodicInBalance !== false; const includePeriodic = data.settings?.includePeriodicInBalance !== false;
@@ -199,7 +230,7 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear()
}; };
const relevantIncome = item => incomeFactor(item)>0 && (isAll || item.accountId === accountId); const relevantIncome = item => incomeFactor(item)>0 && (isAll || item.accountId === accountId);
const relevantExpense = item => personMatches(item,personId) && (isAll || item.accountId === accountId); const relevantExpense = item => personMatches(item,personId) && (isAll || item.accountId === accountId);
const relevantTransfer = item => personMatches(item,personId) && (isAll || item.fromAccountId === accountId || item.toAccountId === accountId); const relevantTransfer = item => transferPersonFactor(data,item,personId,accountId)>0 && (isAll || item.fromAccountId === accountId || item.toAccountId === accountId);
for (const item of data.incomes.filter(relevantIncome)) { for (const item of data.incomes.filter(relevantIncome)) {
const factor=incomeFactor(item); const factor=incomeFactor(item);
for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].income += amount; rows[m].items.push({ ...item, kind:'income', forecastAmount:amount }); } for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].income += amount; rows[m].items.push({ ...item, kind:'income', forecastAmount:amount }); }
@@ -209,7 +240,7 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear()
for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].expense += amount; rows[m].items.push({ ...item, kind:'expense', forecastAmount:amount }); } for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].expense += amount; rows[m].items.push({ ...item, kind:'expense', forecastAmount:amount }); }
} }
if (includeTransfers) for (const item of data.transfers.filter(relevantTransfer)) { if (includeTransfers) for (const item of data.transfers.filter(relevantTransfer)) {
const factor=personFactor(item,personId); const factor=transferPersonFactor(data,item,personId,accountId);
for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) {
const amount = Number(item.amount || 0) * factor; const amount = Number(item.amount || 0) * factor;
if (isAll) { if (isAll) {
@@ -272,9 +303,9 @@ function categoryStats(data, accountId = 'all', personId = 'all', includeTransfe
}; };
const incomeItems=(data.incomes||[]).filter(x=>(isAll||x.accountId===accountId)&&incomeFactor(x)>0).map(x=>({...x,_statType:'income',_statFactor:incomeFactor(x)})); 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)})); const expenseItems=(data.expenses||[]).filter(x=>(isAll||x.accountId===accountId)&&defaultFactor(x)>0).map(x=>({...x,_statType:'expense',_statFactor:defaultFactor(x)}));
const transferItems=includeTransfers?(data.transfers||[]).filter(x=>defaultFactor(x)>0&&(isAll||x.fromAccountId===accountId||x.toAccountId===accountId)).map(x=>{ const transferItems=includeTransfers?(data.transfers||[]).filter(x=>transferPersonFactor(data,x,personId,accountId)>0&&(isAll||x.fromAccountId===accountId||x.toAccountId===accountId)).map(x=>{
const direction=isAll?'neutral':x.toAccountId===accountId?'in':'out'; const direction=isAll?'neutral':x.toAccountId===accountId?'in':'out';
return {...x,_statType:direction==='in'?'transfer-in':direction==='out'?'transfer-out':'transfer',_statFactor:defaultFactor(x)}; 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 };
} }
@@ -372,33 +403,65 @@ function sharedFinanceStats(data, personId='all'){
function personFlowStats(data, accountId = 'all', personId = 'all', includeTransfers = true) { function personFlowStats(data, accountId = 'all', personId = 'all', includeTransfers = true) {
const isAll=accountId==='all'; const isAll=accountId==='all';
if(!isAll){ if(!isAll){
const selectedAccount=data.accounts.find(a=>a.id===accountId);
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),income:0,expense:0,transferIn:0,transferOut:0,displayIn:0,displayOut:0,net:0});return buckets.get(key);}; 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),income:0,expense:0,transferIn:0,transferOut:0,displayIn:0,displayOut:0,net:0});return buckets.get(key);};
const splitTargets=item=>{const ids=personIdsOf(item);if(!ids.length)return personId==='all'?[['__unassigned__',1]]:[];if(personId!=='all')return ids.includes(personId)?[[personId,1/ids.length]]:[];return ids.map(id=>[id,1/ids.length]);}; const splitTargets=item=>{const ids=personIdsOf(item);if(!ids.length)return personId==='all'?[['__unassigned__',1]]:[];if(personId!=='all')return ids.includes(personId)?[[personId,1/ids.length]]:[];return ids.map(id=>[id,1/ids.length]);};
const transferTargets=item=>{
const direction=item.fromAccountId===accountId?'out':'in';
const cls=transferClassification(data,item);
let ids=[];
if(selectedAccount?.ownership==='personal')ids=ownerIdsOf(selectedAccount);
else if(cls.key==='shared-funding')ids=transferDirectionalOwnerIds(data,item,'out');
else if(cls.key==='shared-withdrawal')ids=transferDirectionalOwnerIds(data,item,'in');
else if(cls.key==='person-transfer')ids=transferDirectionalOwnerIds(data,item,direction);
else ids=personIdsOf(item);
if(!ids.length)ids=ownerIdsOf(selectedAccount);
if(!ids.length)return personId==='all'?[['__unassigned__',1]]:[];
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.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.expenses||[]){if(item.accountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of splitTargets(item))ensure(id).expense+=amount*factor;}
if(includeTransfers)for(const item of data.transfers||[]){if(item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of splitTargets(item)){const b=ensure(id),share=amount*factor;if(item.toAccountId===accountId)b.transferIn+=share;if(item.fromAccountId===accountId)b.transferOut+=share;}} // Transfers gehören immer zur echten Kontoliquidität. Der Schalter steuert nur, ob die Details sichtbar sind.
for(const row of buckets.values()){row.displayIn=row.income+(includeTransfers?row.transferIn:0);row.displayOut=row.expense+(includeTransfers?row.transferOut:0);row.net=row.displayIn-row.displayOut;} 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 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')); 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}); 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});
return {rows,totals,isAll:false,includeTransfers}; return {rows,totals,isAll:false,includeTransfers};
} }
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,available:0,sharedFundingTarget:0,sharedFundingActual:0,sharedFundingDifference:0,assetBuild:0});return buckets.get(key);}; 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 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.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;}} 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'); const shared=sharedFinanceStats(data,'all');
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;}} 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;}}
if(includeTransfers)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 row of buckets.values()){row.netCost=Math.max(0,row.expenseShare-row.sharedCoverage);row.available=row.personalIncome-row.netCost;row.sharedFundingDifference=row.sharedFundingActual-row.sharedFundingTarget;} // Persönlich verfügbare Liquidität wird ausschließlich aus persönlichen Zahlungsverkehrskonten abgeleitet.
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);rows.sort((a,b)=>b.netCost-a.netCost||a.name.localeCompare(b.name,'de')); // So reduzieren Sparen, Gemeinschaftsfinanzierung und Transfers an andere Personen den verfügbaren Betrag,
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,available:sum.available+row.available,sharedFundingTarget:sum.sharedFundingTarget+row.sharedFundingTarget,sharedFundingActual:sum.sharedFundingActual+row.sharedFundingActual,assetBuild:sum.assetBuild+row.assetBuild}),{personalIncome:0,expenseShare:0,sharedCoverage:0,netCost:0,available:0,sharedFundingTarget:0,sharedFundingActual:0,assetBuild:0}); // 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.transfers||[]){
const amount=monthlyEquivalent(item);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 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}; return {rows,totals,isAll:true,includeTransfers};
} }
function PersonFlowOverview({stats}){ 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 bleiben als Transfers getrennt erkennbar.</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</span><strong className="good-text">+{formatMoney(row.displayIn)}</strong></div><div><span>Abflüsse</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>; 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 wirtschaftliche Sicht</h2><p>Eigene Einnahmen entstehen nur auf persönlich zugeordneten Konten. Gemeinsame Einnahmen erhöhen kein persönliches Einkommen, sondern reduzieren die gemeinsame Kostenlast.</p></div><strong className={`person-flow-total ${stats.totals.available>=0?'good-text':'bad-text'}`}>{stats.totals.available>=0?'+':''}{formatMoney(stats.totals.available)} / Monat</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>Persönliche Einnahmen minus wirtschaftliche Kostenlast</small></div><strong className={row.available>=0?'good-text':'bad-text'}>{row.available>=0?'+':''}{formatMoney(row.available)}</strong></div><div className="person-flow-grid"><div><span>Eigene Kontoeingänge</span><strong className="good-text">+{formatMoney(row.personalIncome)}</strong></div><div><span>Kostenanteil gesamt</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>{stats.includeTransfers&&<><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>; 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 SharedFundingOverview({data,personId='all',showTransfers=true}){ function SharedFundingOverview({data,personId='all',showTransfers=true}){
@@ -517,8 +580,8 @@ const TRANSFER_PREF_KEY='fixfin.include-transfers.v1';
function readTransferPreference(){try{return localStorage.getItem(TRANSFER_PREF_KEY)!=='false';}catch{return true;}} function readTransferPreference(){try{return localStorage.getItem(TRANSFER_PREF_KEY)!=='false';}catch{return true;}}
function TransferToggle({enabled,onChange,compact=false,context='display'}){ function TransferToggle({enabled,onChange,compact=false,context='display'}){
const exportMode=context==='export'; const exportMode=context==='export';
const title=exportMode?'Transfers im Auszug':'Transfer-/Finanzierungsströme anzeigen'; const title=exportMode?'Transfers im Auszug':'Transferdetails anzeigen';
const copy=exportMode?(enabled?'Transfers werden als eigene Zeilen in PDF und Excel ausgegeben.':'PDF und Excel enthalten nur echte Eingänge und Ausgaben.'):(enabled?'Umbuchungen werden separat sichtbar. Kosten- und Einnahmenstatistiken bleiben davon unberührt.':'Transferdetails sind ausgeblendet. Kontosalden bleiben trotzdem echte Liquiditätssalden.'); const copy=exportMode?(enabled?'Transfers werden als eigene Zeilen in PDF und Excel ausgegeben.':'PDF und Excel enthalten nur echte Eingänge und Ausgaben.'):(enabled?'Transfers werden separat aufgeschlüsselt. Salden und verfügbare Liquidität enthalten sie immer.':'Transferdetails sind ausgeblendet. Salden und verfügbare Liquidität bleiben unverändert korrekt.');
return <section className={`toggle-panel transfer-toggle ${compact?'compact-transfer-toggle':''}`}><div><strong>{title}</strong><span>{copy}</span></div><button type="button" className={`switch ${enabled?'on':''}`} onClick={()=>onChange(!enabled)} role="switch" aria-checked={enabled} aria-label={title}><span/></button></section>; return <section className={`toggle-panel transfer-toggle ${compact?'compact-transfer-toggle':''}`}><div><strong>{title}</strong><span>{copy}</span></div><button type="button" className={`switch ${enabled?'on':''}`} onClick={()=>onChange(!enabled)} role="switch" aria-checked={enabled} aria-label={title}><span/></button></section>;
} }
@@ -717,7 +780,7 @@ function Entries({type,items,accounts,categories,persons,onEdit,onDuplicate,onDe
function Transfers({data,onEdit,onDuplicate,onDelete}){ function Transfers({data,onEdit,onDuplicate,onDelete}){
const accountNameLocal=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt'; const accountNameLocal=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';
const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';
const personLabelLocal=item=>personListLabel(data,item); const personLabelLocal=item=>transferPeopleLabel(data,item);
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Transfers werden automatisch anhand von Eigentum und Kontofunktion klassifiziert. Sie beeinflussen Kontoliquidität, sind aber keine Konsumausgaben.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<> return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Transfers werden automatisch anhand von Eigentum und Kontofunktion klassifiziert. Sie beeinflussen Kontoliquidität, sind aber keine Konsumausgaben.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Typ</th><th>Personen</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=>{const cls=transferClassification(data,x);return <tr key={x.id}><td><strong>{x.name}</strong>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`transfer-class-badge ${cls.key}`}>{cls.label}</span></td><td><span className={`person-badge ${personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>})}</tbody></table></div> <div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Typ</th><th>Personen</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=>{const cls=transferClassification(data,x);return <tr key={x.id}><td><strong>{x.name}</strong>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`transfer-class-badge ${cls.key}`}>{cls.label}</span></td><td><span className={`person-badge ${personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>})}</tbody></table></div>
@@ -735,7 +798,7 @@ function transferTypeStats(data,accountId='all',personId='all'){
const isAll=accountId==='all';const buckets=new Map(); const isAll=accountId==='all';const buckets=new Map();
for(const item of data.transfers||[]){ for(const item of data.transfers||[]){
if(!isAll&&item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue; if(!isAll&&item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue;
const factor=personFactor(item,personId);if(!factor)continue; const factor=transferPersonFactor(data,item,personId,accountId);if(!factor)continue;
const cls=transferClassification(data,item);const amount=monthlyEquivalent(item)*factor; 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}; 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); 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);