diff --git a/README.md b/README.md index dcbf0cb..19ceb8f 100644 --- a/README.md +++ b/README.md @@ -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. - 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. diff --git a/public/sw.js b/public/sw.js index 582e141..30c5f45 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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 APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; diff --git a/src/main.jsx b/src/main.jsx index e5225ac..5c3d07d 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -290,7 +290,21 @@ function sharedOwnershipGroups(data){ } 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'){ + const personalIncomeMap=personalMonthlyIncomeByPerson(data); 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); @@ -301,6 +315,8 @@ function sharedFinanceStats(data, personId='all'){ 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 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(); @@ -323,7 +339,20 @@ 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)); - 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}; } @@ -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

Gemeinsame Kosten gemessen am persönlichen Einkommen

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

{stats.groups.map(group=>
{group.accounts.map(a=>a.name).join(' + ')}{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}
Belastungsquote Haushalt{percent(group.householdBurdenRate)}
Gemeinsame Kosten−{formatMoney(group.expense)}
Deckung gemeinsame Einnahmen+{formatMoney(group.sharedIncomeCoverage)}
Verbleibender Bedarf{formatMoney(group.costNeed)}
Persönliche Einkommen gesamt{formatMoney(group.personalIncomeTotal)}
{group.sharedIncomeSurplus>0&&
Gemeinsame Einnahmen übersteigen die gemeinsamen Kosten um {formatMoney(group.sharedIncomeSurplus)} / Monat. Es ist keine persönliche Finanzierung nötig.
}{!group.incomeAllocationAvailable?
Keine persönlichen Einnahmen auf persönlich zugeordneten Konten vorhanden. Eine einkommensbasierte Verteilung kann deshalb nicht berechnet werden.
:
{group.incomeBurdenRows.map(row=>
{row.name}
Persönliches Einkommen{formatMoney(row.personalIncome)}
Einkommensanteil{percent(row.incomeShare==null?null:row.incomeShare*100)}
Soll nach Einkommen{row.incomeBasedFunding==null?'–':formatMoney(row.incomeBasedFunding)}
Belastung vom Einkommen{percent(row.burdenRate)}
{showTransfers&&<>
Ist-Finanzierung{formatMoney(row.actualFunding)}
Abweichung=0?'good-text':'bad-text'}>{row.difference==null?'–':`${row.difference>=0?'+':''}${formatMoney(row.difference)}`}
}
)}
}
Diese Statistik ist eine Vergleichs- und Fairnesssicht. Sie verändert weder die hinterlegte Personenzuordnung der Kosten noch die tatsächlichen Kontosalden.
)}
; +} + + function dashboardKpis(data, totals) { const forecast = annualForecast(data, 'all'); const expensive = [...forecast.rows].sort((a,b)=>b.displayExpense-a.displayExpense)[0]; @@ -727,6 +764,7 @@ function StatisticsPage({data,includeTransfers,onTransfersChange,onAddCategory,o
{accountId==='all'&&} + {accountId==='all'&&} {includeTransfers&&} diff --git a/src/styles.css b/src/styles.css index 7665ec2..2a5dc35 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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.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; } +}