This commit is contained in:
2026-08-20 22:42:07 +02:00
parent 34627c0b49
commit 06c6eb867f
4 changed files with 93 additions and 12 deletions
+5
View File
@@ -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.
+1 -1
View File
@@ -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'];
+24 -11
View File
@@ -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 <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>
<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}/><Metric label={`Echte Einnahmen Quellen ${year}`} value={stats.sourceIncomeAnnual}/><StatMetricCard label="Transferquote zu Einnahmen" displayValue={percentValue(stats.incomeRatio)} featured helpTitle="Transferquote zu Einnahmen" help={<><p>Diese Quote beantwortet: <strong>Wie viel des echten Einkommens der ausgewählten Quellkonten wird über die aktuell ausgewählten Transfers weiterverteilt?</strong></p><p>Formel: ausgewähltes Transfervolumen ÷ echte Einnahmen der ausgewählten Quellkonten × 100.</p><p>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.</p></>}><StatFormulaRows rows={[{label:'Ausgewählte Transfers',value:stats.annual},{label:'Echte Einnahmen der Quellkonten',value:stats.sourceIncomeAnnual},{label:'Transferquote',value:percentValue(stats.incomeRatio)}]}/></StatMetricCard><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-source-overview"><div className="section-head"><div><h2>Wie viel kommt von welchem Konto?</h2><p>Jede Quelle zeigt ihr gesamtes Transfervolumen, die Verteilung auf die ausgewählten Zielkonten und den Anteil dieses Volumens an den echten Einnahmen des Quellkontos.</p></div><StatsInfoButton title="Transferquote je Quellkonto"><p><strong>Transferquote = ausgewählte Transfers dieses Quellkontos ÷ echte Einnahmen dieses Quellkontos × 100.</strong></p><p>Nur echte Eingänge zählen als Einnahmen. Eingehende Transfers sind kein Einkommen. Deshalb kann die Quote auch über 100 % liegen.</p></StatsInfoButton></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 · <b>{percentValue(source.incomeRatio)} der Einnahmen</b></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-income-ratio-strip"><div><span>Echte Einnahmen</span><strong>{formatMoney(source.incomeAnnual)} / Jahr</strong></div><div><span>Ausgewählte Transfers</span><strong>{formatMoney(source.annual)} / Jahr</strong></div><div><span>Transferquote</span><strong>{percentValue(source.incomeRatio)}</strong></div></div><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>{percentValue(item.incomeRatio)} der Einnahmen der Quelle</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><span>Echte Einnahmen Quelle</span><strong>{formatMoney(item.sourceIncomeAnnual)} / Jahr</strong></div><div><span>Transfer / Einnahmen Quelle</span><strong>{percentValue(item.incomeRatio)}</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-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. Zusätzlich siehst du, wie groß dieser Transferzufluss im Verhältnis zu den echten Einnahmen des Zielkontos ist.</p></div><StatsInfoButton title="Transferzufluss im Verhältnis zu Ziel-Einnahmen"><p>Diese Zielquote ist eine Ergänzung zur Quellquote: <strong>eingehende ausgewählte Transfers ÷ echte Einnahmen des Zielkontos × 100.</strong></p><p>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.</p></StatsInfoButton></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 className="transfer-target-income-ratio"><span>Echte Einnahmen: {formatMoney(target.incomeAnnual)}</span><strong>Transferzufluss / Einnahmen: {percentValue(target.incomeRatio)}</strong></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>
</>}
+63
View File
@@ -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; }
}