diff --git a/README.md b/README.md index b31b39d..8665160 100644 --- a/README.md +++ b/README.md @@ -155,3 +155,8 @@ Die Statistik ist jetzt jahresbezogen und erklärt ihre Rechenwege direkt in der 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`. + + +### v3.33 – Transferquote zu echten Einnahmen + +Die Transferanalyse startet ohne vorausgewählte Quell- oder Zielkonten. Ausgewählte Konten werden mit einem deutlich sichtbaren Häkchen markiert. Zusätzlich zeigt die Analyse das Verhältnis des ausgewählten Transfervolumens zu den echten Einnahmen der Quellkonten. Die Transferquote wird insgesamt, je Quellkonto und je einzelner Transferposition ausgewiesen. Zielkonten zeigen ergänzend den eingehenden Transferanteil im Verhältnis zu ihren echten Einnahmen. Eingehende Transfers selbst zählen dabei nie als echte Einnahmen; bei 0 EUR echten Einnahmen wird keine Prozentquote berechnet. diff --git a/public/sw.js b/public/sw.js index 4c3082a..57c6dde 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL_CACHE='fixfin-shell-v3.32'; +const SHELL_CACHE='fixfin-shell-v3.33'; 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 5defad5..ca29dc9 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -370,23 +370,35 @@ function statAmountMeta(item){ function transferAnalysis(data, sourceIds, targetIds, year){ const sources=new Set(sourceIds||[]),targets=new Set(targetIds||[]); + const incomeByAccount=new Map((data.accounts||[]).map(account=>[account.id,0])); + for(const income of data.incomes||[]){ + incomeByAccount.set(income.accountId,(incomeByAccount.get(income.accountId)||0)+plannedAmountForYear(income,year)); + } + const ratio=(transferValue,incomeValue)=>incomeValue>0?(transferValue/incomeValue)*100:null; 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)}; + const sourceIncomeAnnual=incomeByAccount.get(item.fromAccountId)||0; + const targetIncomeAnnual=incomeByAccount.get(item.toAccountId)||0; + return {...item,annual,monthly:annual/12,sourceIncomeAnnual,targetIncomeAnnual,incomeRatio:ratio(annual,sourceIncomeAnnual),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:[]}); + if(!bySource.has(item.fromAccountId))bySource.set(item.fromAccountId,{accountId:item.fromAccountId,annual:0,monthly:0,incomeAnnual:incomeByAccount.get(item.fromAccountId)||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 target=source.targets.get(item.toAccountId)||{accountId:item.toAccountId,annual:0,monthly:0,incomeAnnual:incomeByAccount.get(item.toAccountId)||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,incomeAnnual:incomeByAccount.get(item.toAccountId)||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; + const selectedSourceIncomeAnnual=[...sources].reduce((sum,id)=>sum+(incomeByAccount.get(id)||0),0); + const sourceRows=[...bySource.values()].map(source=>({...source,incomeRatio:ratio(source.annual,source.incomeAnnual),targets:[...source.targets.values()].map(target=>({...target,incomeRatio:ratio(target.annual,target.incomeAnnual)})).sort((a,b)=>b.annual-a.annual)})).sort((a,b)=>b.annual-a.annual); + const targetRows=[...byTarget.values()].map(target=>({...target,incomeRatio:ratio(target.annual,target.incomeAnnual)})).sort((a,b)=>b.annual-a.annual); 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), + sources:sourceRows, + targets:targetRows, annual,monthly, + sourceIncomeAnnual:selectedSourceIncomeAnnual, + incomeRatio:ratio(annual,selectedSourceIncomeAnnual), }; } @@ -1148,22 +1160,23 @@ 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)); + const [sourceIds,setSourceIds]=useState([]); + const [targetIds,setTargetIds]=useState([]); 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 percentValue=value=>value==null?'–':`${Number(value).toFixed(1).replace('.',',')} %`; const intervalLabel=item=>intervals[item.interval||'monthly']?.label||item.interval; if(!data.accounts.length)return
; return

Transferanalyse

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

-
+

Diese Quote beantwortet: Wie viel des echten Einkommens der ausgewählten Quellkonten wird über die aktuell ausgewählten Transfers weiterverteilt?

Formel: ausgewähltes Transfervolumen ÷ echte Einnahmen der ausgewählten Quellkonten × 100.

Transfers, die auf einem Quellkonto eingehen, zählen nicht als Einnahmen. Die Quote kann über 100 % liegen, wenn zusätzlich vorhandenes Guthaben oder eingehende Transfers weiterverteilt werden.

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

Wie viel kommt von welchem Konto?

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

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

Wie viel kommt von welchem Konto?

Jede Quelle zeigt ihr gesamtes Transfervolumen, die Verteilung auf die ausgewählten Zielkonten und den Anteil dieses Volumens an den echten Einnahmen des Quellkontos.

Transferquote = ausgewählte Transfers dieses Quellkontos ÷ echte Einnahmen dieses Quellkontos × 100.

Nur echte Eingänge zählen als Einnahmen. Eingehende Transfers sind kein Einkommen. Deshalb kann die Quote auch über 100 % liegen.

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

Verteilung auf die Zielkonten

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

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

Verteilung auf die Zielkonten

Wie viel des ausgewählten Transfervolumens insgesamt auf jedem Zielkonto landet. Zusätzlich siehst du, wie groß dieser Transferzufluss im Verhältnis zu den echten Einnahmen des Zielkontos ist.

Diese Zielquote ist eine Ergänzung zur Quellquote: eingehende ausgewählte Transfers ÷ echte Einnahmen des Zielkontos × 100.

Hat das Zielkonto keine echten Einnahmen, wird keine Prozentquote berechnet. Das ist kein Fehler, sondern bedeutet, dass das Konto ausschließlich oder zusätzlich durch Transfers gespeist wird.

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

Quelle → Ziel

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

{stats.targets.map(target=>)}{stats.sources.map(source=>{stats.targets.map(target=>{const match=source.targets.find(x=>x.accountId===target.accountId);return })})}{stats.targets.map(target=>)}
Quelle{accountName(data,target.accountId)}Gesamt
{accountName(data,source.accountId)}{match?formatMoney(match.annual):'–'}{formatMoney(source.annual)}
Gesamt{formatMoney(target.annual)}{formatMoney(stats.annual)}
} diff --git a/src/styles.css b/src/styles.css index 2b007c8..7bbf036 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1468,3 +1468,66 @@ html.modal-open, body.modal-open { overflow:hidden !important; overscroll-behavi .stat-detail-item > div:last-child { max-width:none; text-align:left; } .category-drill-formula { display:grid; gap:3px; } } + +/* v3.33 Transferanalyse: Auswahlstatus + Einnahmen/Transfer-Verhältnis */ +.transfer-account-choice .checkbox-mark { + width:20px; + height:20px; + flex:0 0 20px; + border:2px solid #65748b; + border-radius:6px; + background:#0a111b; + color:transparent; + font-size:12px; + font-weight:950; + line-height:1; + box-shadow:inset 0 0 0 1px #0a111b; +} +.transfer-account-choice.active { + border-color:#8293ff; + background:#1a2640; + box-shadow:inset 0 0 0 1px #6577e84d; +} +.transfer-account-choice.active .checkbox-mark { + background:var(--accent); + border-color:#b4beff; + color:#0b1020; + box-shadow:0 0 0 2px #8b9cff26; +} +.transfer-analysis-summary { grid-template-columns:repeat(3,minmax(0,1fr)); } +.transfer-income-ratio-strip { + display:grid; + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:8px; +} +.transfer-income-ratio-strip > div { + display:grid; + gap:4px; + padding:10px; + border:1px solid #253246; + border-radius:10px; + background:#0b121c; +} +.transfer-income-ratio-strip span, +.transfer-target-income-ratio span { + color:var(--muted); + font-size:8px; + font-weight:800; + text-transform:uppercase; + letter-spacing:.04em; +} +.transfer-income-ratio-strip strong { font-size:10px; } +.transfer-target-income-ratio { + display:grid; + gap:4px; + margin-top:9px; + padding-top:9px; + border-top:1px solid #273448; +} +.transfer-target-income-ratio strong { font-size:9px; color:#dbe3ff; } +.transfer-source-card > summary > div:first-child span b { color:#dbe3ff; font-weight:850; } + +@media (max-width:760px) { + .transfer-analysis-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } + .transfer-income-ratio-strip { grid-template-columns:1fr; } +}