This commit is contained in:
2026-08-19 23:28:40 +02:00
parent 616752abb3
commit a44ffa77ea
4 changed files with 74 additions and 3 deletions
+6 -1
View File
@@ -107,4 +107,9 @@ Die Jahresprognose verwendet kompakte Monats-Akkordeons. Monat, Eingänge, Ausg
- Szenarien: Was-wäre-wenn-Anpassungen für zusätzliche Eingänge, Ausgänge und Transfers, ohne den Basisplan zu verändern. - Szenarien: Was-wäre-wenn-Anpassungen für zusätzliche Eingänge, Ausgänge und Transfers, ohne den Basisplan zu verändern.
- Bewegungen können dupliziert und anschließend angepasst werden. - Bewegungen können dupliziert und anschließend angepasst werden.
Das JSON-Datenmodell verwendet aktuell `version: 10`. Neben Konten und Kategorien gibt es eine zentrale Personenliste (`persons`). Eingänge, Ausgänge, Transfers und Szenario-Anpassungen können über `personIds` einer oder mehreren Personen zugeordnet werden. In personenbezogenen Auswertungen wird der Betrag einer gemeinsam zugeordneten Bewegung gleichmäßig auf die ausgewählten Personen verteilt; in Gesamtansichten wird die Bewegung nur einmal mit ihrem vollen Betrag gerechnet. Bestehende `personId`-Zuordnungen aus Version 9 werden automatisch in ein ein-elementiges `personIds`-Array migriert. Ältere Daten ohne Zuordnung bleiben als „Ohne Person“ erhalten. Vor einer Migration bzw. jedem Speichern wird weiterhin eine `.bak`-Datei angelegt. Das JSON-Datenmodell verwendet aktuell `version: 11`. Neben Konten und Kategorien gibt es eine zentrale Personenliste (`persons`). Eingänge, Ausgänge, Transfers und Szenario-Anpassungen können über `personIds` einer oder mehreren Personen zugeordnet werden. In personenbezogenen Auswertungen wird der Betrag einer gemeinsam zugeordneten Bewegung gleichmäßig auf die ausgewählten Personen verteilt; in Gesamtansichten wird die Bewegung nur einmal mit ihrem vollen Betrag gerechnet. Bestehende `personId`-Zuordnungen aus Version 9 werden automatisch in ein ein-elementiges `personIds`-Array migriert. Ältere Daten ohne Zuordnung bleiben als „Ohne Person“ erhalten. Vor einer Migration bzw. jedem Speichern wird weiterhin eine `.bak`-Datei angelegt.
### Gemeinsame Kosten relativ zum persönlichen Einkommen (v3.25)
In der Statistik gibt es zusätzlich eine einkommensbasierte Vergleichssicht für Gemeinschaftsbereiche. Gemeinsame Einnahmen auf Gemeinschaftskonten werden zuerst zur Deckung gemeinsamer Kosten verwendet und niemals als persönliches Einkommen eines Eigentümers gezählt. Der verbleibende Finanzierungsbedarf wird für diese Vergleichsstatistik proportional zu den echten Einnahmen auf persönlich zugeordneten Konten der Gemeinschaftseigentümer verteilt. Angezeigt werden persönliches Einkommen, Einkommensanteil, Soll-Finanzierung nach Einkommen, Belastungsquote, tatsächliche Gemeinschaftsfinanzierung und deren Abweichung. Diese Vergleichssicht verändert keine Bewegungen, Kosten-Personenzuordnungen oder Kontosalden.
+1 -1
View File
@@ -1,4 +1,4 @@
const SHELL_CACHE='fixfin-shell-v3.24'; const SHELL_CACHE='fixfin-shell-v3.25';
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'];
+39 -1
View File
@@ -290,7 +290,21 @@ function sharedOwnershipGroups(data){
} }
return [...groups.values()]; return [...groups.values()];
} }
function personalMonthlyIncomeByPerson(data){
const totals=new Map();
for(const person of data.persons||[])totals.set(person.id,0);
for(const item of data.incomes||[]){
const account=data.accounts.find(a=>a.id===item.accountId);
if(account?.ownership!=='personal')continue;
const owners=ownerIdsOf(account);
if(owners.length!==1)continue;
const id=owners[0];
totals.set(id,(totals.get(id)||0)+monthlyEquivalent(item));
}
return totals;
}
function sharedFinanceStats(data, personId='all'){ function sharedFinanceStats(data, personId='all'){
const personalIncomeMap=personalMonthlyIncomeByPerson(data);
const groups=sharedOwnershipGroups(data).map(group=>{ const groups=sharedOwnershipGroups(data).map(group=>{
const accountIds=new Set(group.accounts.map(a=>a.id)); 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 income=(data.incomes||[]).filter(x=>accountIds.has(x.accountId)).reduce((sum,x)=>sum+monthlyEquivalent(x),0);
@@ -301,6 +315,8 @@ function sharedFinanceStats(data, personId='all'){
const outgoingTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.fromAccountId)&&!accountIds.has(x.toAccountId)); 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 fundingIn=incomingTransfers.reduce((sum,x)=>sum+monthlyEquivalent(x),0);
const withdrawalOut=outgoingTransfers.reduce((sum,x)=>sum+monthlyEquivalent(x),0); const withdrawalOut=outgoingTransfers.reduce((sum,x)=>sum+monthlyEquivalent(x),0);
const sharedIncomeCoverage=Math.min(income,expense);
const sharedIncomeSurplus=Math.max(0,income-expense);
const costNeed=Math.max(0,expense-income); const costNeed=Math.max(0,expense-income);
const netAssetChange=income+fundingIn-expense-withdrawalOut; const netAssetChange=income+fundingIn-expense-withdrawalOut;
const shares=new Map(); const shares=new Map();
@@ -323,7 +339,20 @@ function sharedFinanceStats(data, personId='all'){
let personRows=[...shares.values()]; let personRows=[...shares.values()];
if(personId!=='all')personRows=personRows.filter(x=>x.id===personId); 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)); personRows=personRows.filter(x=>x.grossCost||x.targetFunding||x.actualFunding||group.ownerPersonIds.includes(x.id));
return {...group,income,expense,costNeed,fundingIn,withdrawalOut,internalTransfers,netAssetChange,personRows};
const personalIncomeTotal=group.ownerPersonIds.reduce((sum,id)=>sum+(personalIncomeMap.get(id)||0),0);
const incomeAllocationAvailable=personalIncomeTotal>0;
let incomeBurdenRows=group.ownerPersonIds.map(id=>{
const personalIncome=personalIncomeMap.get(id)||0;
const incomeShare=incomeAllocationAvailable?personalIncome/personalIncomeTotal:null;
const incomeBasedFunding=incomeAllocationAvailable?costNeed*incomeShare:null;
const burdenRate=personalIncome>0&&incomeBasedFunding!=null?(incomeBasedFunding/personalIncome)*100:null;
const actualFunding=ensure(id).actualFunding;
return {id,name:personName(data,id),personalIncome,incomeShare,incomeBasedFunding,burdenRate,actualFunding,difference:incomeBasedFunding==null?null:actualFunding-incomeBasedFunding};
});
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,personRows,personalIncomeTotal,incomeAllocationAvailable,incomeBurdenRows,householdBurdenRate};
}); });
return {groups}; return {groups};
} }
@@ -367,6 +396,14 @@ function SharedFundingOverview({data,personId='all',showTransfers=true}){
} }
function SharedIncomeBurdenOverview({data,personId='all',showTransfers=true}){
const stats=useMemo(()=>sharedFinanceStats(data,personId),[data,personId]);
if(!stats.groups.length)return null;
const percent=value=>value==null?'':`${value.toFixed(1).replace('.',',')} %`;
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)}</strong></div><div><span>Belastung vom Einkommen</span><strong>{percent(row.burdenRate)}</strong></div>{showTransfers&&<><div><span>Ist-Finanzierung</span><strong>{formatMoney(row.actualFunding)}</strong></div><div><span>Abweichung</span><strong className={row.difference==null?'':row.difference>=0?'good-text':'bad-text'}>{row.difference==null?'':`${row.difference>=0?'+':''}${formatMoney(row.difference)}`}</strong></div></>}</div>)}</div>}<div className="shared-income-burden-note">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>;
}
function dashboardKpis(data, totals) { function dashboardKpis(data, totals) {
const forecast = annualForecast(data, 'all'); const forecast = annualForecast(data, 'all');
const expensive = [...forecast.rows].sort((a,b)=>b.displayExpense-a.displayExpense)[0]; const expensive = [...forecast.rows].sort((a,b)=>b.displayExpense-a.displayExpense)[0];
@@ -727,6 +764,7 @@ function StatisticsPage({data,includeTransfers,onTransfersChange,onAddCategory,o
<section className="summary-grid stats-summary"><Metric label={personalIncomeLabel} value={stats.incomes.total} positive/><Metric label="Echte Kosten Ø / Monat" value={stats.expenses.total} negative/><CountMetric label="Kategorien" value={data.categories.length}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section> <section className="summary-grid stats-summary"><Metric label={personalIncomeLabel} value={stats.incomes.total} positive/><Metric label="Echte Kosten Ø / Monat" value={stats.expenses.total} negative/><CountMetric label="Kategorien" value={data.categories.length}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section>
<PersonFlowOverview stats={personFlows}/> <PersonFlowOverview stats={personFlows}/>
{accountId==='all'&&<SharedFundingOverview data={data} personId={personId} showTransfers={includeTransfers}/>} {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}/>} {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="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"/> <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"/>
+28
View File
@@ -1143,3 +1143,31 @@ html.modal-open, body.modal-open { overflow:hidden !important; overscroll-behavi
} }
.transfer-class-badge.reserve-withdrawal,.transfer-class-badge.investment-withdrawal,.transfer-class-badge.building-withdrawal { border-color:#704f4a;background:#2c1e1c;color:#efc0b8; } .transfer-class-badge.reserve-withdrawal,.transfer-class-badge.investment-withdrawal,.transfer-class-badge.building-withdrawal { border-color:#704f4a;background:#2c1e1c;color:#efc0b8; }
.transfer-class-badge.asset-transfer { border-color:#4d526e;background:#1c2033;color:#c4caea; } .transfer-class-badge.asset-transfer { border-color:#4d526e;background:#1c2033;color:#c4caea; }
/* v3.25: gemeinsame Kosten relativ zum persönlichen Einkommen */
.shared-income-burden-list { display:grid;gap:12px; }
.shared-income-burden-card { border:1px solid #30405a;background:#0d141e;border-radius:14px;padding:13px; }
.shared-income-burden-head { display:flex;justify-content:space-between;gap:14px;align-items:flex-start;padding-bottom:11px;border-bottom:1px solid #202b3b; }
.shared-income-burden-head > div:first-child { min-width:0;display:grid;gap:3px; }
.shared-income-burden-head > div:first-child strong { font-size:13px;line-height:1.3; }
.shared-income-burden-head > div:first-child span { color:var(--muted);font-size:9px; }
.shared-income-burden-head > div:last-child { flex:0 0 auto;display:grid;gap:2px;text-align:right; }
.shared-income-burden-head > div:last-child span,.shared-income-burden-summary span,.shared-income-person-row > div span { color:var(--muted);font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.04em; }
.shared-income-burden-head > div:last-child strong { font-size:15px; }
.shared-income-burden-summary { display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-top:11px; }
.shared-income-burden-summary > div { display:grid;gap:3px;padding:9px;border:1px solid #273447;background:#101925;border-radius:10px; }
.shared-income-burden-summary strong { font-size:11px; }
.shared-income-person-list { display:grid;gap:6px;margin-top:11px; }
.shared-income-person-row { display:grid;grid-template-columns:minmax(115px,1.25fr) repeat(6,minmax(95px,1fr));gap:8px;align-items:center;padding:8px 9px;border:1px solid #253246;border-radius:10px;background:#0b121b; }
.shared-income-person-row.compact { grid-template-columns:minmax(115px,1.25fr) repeat(4,minmax(95px,1fr)); }
.shared-income-person-row > div { min-width:0;display:grid;gap:2px; }
.shared-income-person-row > div strong { overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px; }
.shared-income-burden-note { margin-top:10px;padding:8px 10px;border-radius:9px;background:#151d29;color:#9facbc;font-size:9px;line-height:1.45; }
@media (max-width:760px) {
.shared-income-burden-head { flex-direction:column;gap:7px; }
.shared-income-burden-head > div:last-child { text-align:left; }
.shared-income-burden-summary { grid-template-columns:1fr 1fr; }
.shared-income-person-row,.shared-income-person-row.compact { grid-template-columns:1fr 1fr;gap:7px 10px; }
.shared-income-person-row > .person-badge { grid-column:1/-1; }
}