V3.21
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const SHELL_CACHE='fixfin-shell-v3.20';
|
const SHELL_CACHE='fixfin-shell-v3.21';
|
||||||
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'];
|
||||||
|
|
||||||
|
|||||||
+70
-1
@@ -188,6 +188,73 @@ function categoryStats(data, accountId = 'all', personId = 'all') {
|
|||||||
return { incomes: aggregate(incomeItems), expenses: aggregate(expenseItems), transfers:{total:0,rows:[]}, transfersMerged:true };
|
return { incomes: aggregate(incomeItems), expenses: aggregate(expenseItems), transfers:{total:0,rows:[]}, transfersMerged:true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function personFlowStats(data, accountId = 'all', personId = 'all') {
|
||||||
|
const isAll = accountId === 'all';
|
||||||
|
const unassignedId = '__unassigned__';
|
||||||
|
const buckets = new Map();
|
||||||
|
const ensure = id => {
|
||||||
|
const key = id || unassignedId;
|
||||||
|
if (!buckets.has(key)) buckets.set(key, {
|
||||||
|
id:key,
|
||||||
|
name:key===unassignedId?'Ohne Person':personName(data,key),
|
||||||
|
income:0,
|
||||||
|
expense:0,
|
||||||
|
transferIn:0,
|
||||||
|
transferOut:0,
|
||||||
|
transferInternal:0,
|
||||||
|
displayIn:0,
|
||||||
|
displayOut:0,
|
||||||
|
net:0,
|
||||||
|
});
|
||||||
|
return buckets.get(key);
|
||||||
|
};
|
||||||
|
for (const person of data.persons || []) ensure(person.id);
|
||||||
|
const personMatches = item => personId === 'all' || item.personId === personId;
|
||||||
|
for (const item of data.incomes || []) {
|
||||||
|
if (!personMatches(item) || (!isAll && item.accountId !== accountId)) continue;
|
||||||
|
ensure(item.personId).income += monthlyEquivalent(item);
|
||||||
|
}
|
||||||
|
for (const item of data.expenses || []) {
|
||||||
|
if (!personMatches(item) || (!isAll && item.accountId !== accountId)) continue;
|
||||||
|
ensure(item.personId).expense += monthlyEquivalent(item);
|
||||||
|
}
|
||||||
|
for (const item of data.transfers || []) {
|
||||||
|
if (!personMatches(item)) continue;
|
||||||
|
const amount = monthlyEquivalent(item);
|
||||||
|
const bucket = ensure(item.personId);
|
||||||
|
if (isAll) bucket.transferInternal += amount;
|
||||||
|
else {
|
||||||
|
if (item.toAccountId === accountId) bucket.transferIn += amount;
|
||||||
|
if (item.fromAccountId === accountId) bucket.transferOut += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const row of buckets.values()) {
|
||||||
|
row.displayIn = isAll ? row.income : row.income + row.transferIn;
|
||||||
|
row.displayOut = isAll ? row.expense : 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.income || x.expense || x.transferIn || x.transferOut || x.transferInternal);
|
||||||
|
rows.sort((a,b) => b.displayOut - a.displayOut || b.displayIn - a.displayIn || a.name.localeCompare(b.name,'de'));
|
||||||
|
const totals = rows.reduce((sum,row) => ({
|
||||||
|
displayIn:sum.displayIn + row.displayIn,
|
||||||
|
displayOut:sum.displayOut + row.displayOut,
|
||||||
|
transferIn:sum.transferIn + row.transferIn,
|
||||||
|
transferOut:sum.transferOut + row.transferOut,
|
||||||
|
transferInternal:sum.transferInternal + row.transferInternal,
|
||||||
|
net:sum.net + row.net,
|
||||||
|
}), {displayIn:0,displayOut:0,transferIn:0,transferOut:0,transferInternal:0,net:0});
|
||||||
|
return { rows, totals, isAll, accountId, personId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonFlowOverview({stats}){
|
||||||
|
return <section className="panel person-flow-panel"><div className="section-head"><div><h2>Personenbezogene Zahlungsströme</h2><p>{stats.isAll?'Wirtschaftliche Sicht pro Person. Interne Transfers werden separat gezeigt und verändern den Saldo nicht.':'Liquiditätssicht pro Person auf dem gewählten Konto. Transfers werden als echte Zu- und Abflüsse berücksichtigt.'}</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>{stats.isAll?'wirtschaftlicher Monatssaldo':'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">{stats.isAll?<><div><span>Einnahmen</span><strong className="good-text">+{formatMoney(row.income)}</strong></div><div><span>Ausgaben</span><strong className="bad-text">−{formatMoney(row.expense)}</strong></div><div><span>Interne Transfers</span><strong>{formatMoney(row.transferInternal)}</strong></div><div><span>Saldo</span><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div></>:<><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><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>;
|
||||||
|
}
|
||||||
|
|
||||||
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];
|
||||||
@@ -410,7 +477,7 @@ function Accounts({data,onEdit,onDelete,onAdd,onAddPerson,onEditPerson,onDeleteP
|
|||||||
const unassigned=[...data.incomes,...data.expenses,...data.transfers].filter(x=>!x.personId).length;
|
const unassigned=[...data.incomes,...data.expenses,...data.transfers].filter(x=>!x.personId).length;
|
||||||
return <div className="content-stack accounts-people-page">
|
return <div className="content-stack accounts-people-page">
|
||||||
<section className="panel"><div className="section-head"><div><h2>Konten</h2><p>Konten dienen nur zur Zuordnung. Es gibt keine gespeicherten Kontostände.</p></div><button className="secondary" onClick={onAdd}>+ Konto</button></div>{data.accounts.length===0?<Empty text="Noch keine Konten vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Konto</th><th></th></tr></thead><tbody>{data.accounts.map(a=><tr key={a.id}><td><strong>{a.name}</strong></td><td className="actions"><button onClick={()=>onEdit(a)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>
|
<section className="panel"><div className="section-head"><div><h2>Konten</h2><p>Konten dienen nur zur Zuordnung. Es gibt keine gespeicherten Kontostände.</p></div><button className="secondary" onClick={onAdd}>+ Konto</button></div>{data.accounts.length===0?<Empty text="Noch keine Konten vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Konto</th><th></th></tr></thead><tbody>{data.accounts.map(a=><tr key={a.id}><td><strong>{a.name}</strong></td><td className="actions"><button onClick={()=>onEdit(a)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>
|
||||||
<section className="panel"><div className="section-head"><div><h2>Personen</h2><p>Jede Bewegung wird einer Person zugeordnet. Das gilt genauso für Umbuchungen.</p></div><button className="secondary" onClick={onAddPerson}>+ Person</button></div>{unassigned>0&&<div className="person-unassigned-note"><strong>{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person.</strong><span>Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen immer eine Person.</span></div>}{(data.persons||[]).length===0?<Empty text="Noch keine Personen angelegt."/>:<div className="category-manager-list">{data.persons.map(person=><div className="category-manager-row" key={person.id}><div><strong>{person.name}</strong><span>{personUsage(person.id)} {personUsage(person.id)===1?'zugeordnete Bewegung':'zugeordnete Bewegungen'}</span></div><div className="actions"><button onClick={()=>onEditPerson(person)}>Bearbeiten</button><button className="danger" onClick={()=>onDeletePerson(person.id)}>Löschen</button></div></div>)}</div>}</section>
|
<section className="panel"><div className="section-head"><div><h2>Personen</h2><p>Du kannst beliebig viele Personen hinterlegen. Jede Bewegung wird genau einer Person zugeordnet, einschließlich Umbuchungen.</p></div><button className="secondary" onClick={onAddPerson}>+ Person</button></div>{unassigned>0&&<div className="person-unassigned-note"><strong>{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person.</strong><span>Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen immer eine Person.</span></div>}{(data.persons||[]).length===0?<Empty text="Noch keine Personen angelegt."/>:<div className="category-manager-list">{data.persons.map(person=><div className="category-manager-row" key={person.id}><div><strong>{person.name}</strong><span>{personUsage(person.id)} {personUsage(person.id)===1?'zugeordnete Bewegung':'zugeordnete Bewegungen'}</span></div><div className="actions"><button onClick={()=>onEditPerson(person)}>Bearbeiten</button><button className="danger" onClick={()=>onDeletePerson(person.id)}>Löschen</button></div></div>)}</div>}</section>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +551,7 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
|
|||||||
const [accountId,setAccountId]=useState('all');
|
const [accountId,setAccountId]=useState('all');
|
||||||
const [personId,setPersonId]=useState('all');
|
const [personId,setPersonId]=useState('all');
|
||||||
const stats=useMemo(()=>categoryStats(data,accountId,personId),[data,accountId,personId]);
|
const stats=useMemo(()=>categoryStats(data,accountId,personId),[data,accountId,personId]);
|
||||||
|
const personFlows=useMemo(()=>personFlowStats(data,accountId,personId),[data,accountId,personId]);
|
||||||
const totals=useMemo(()=>calc(data),[data]);
|
const totals=useMemo(()=>calc(data),[data]);
|
||||||
const kpis=useMemo(()=>dashboardKpis(data,totals),[data,totals]);
|
const kpis=useMemo(()=>dashboardKpis(data,totals),[data,totals]);
|
||||||
const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten';
|
const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten';
|
||||||
@@ -503,6 +571,7 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
|
|||||||
</div></section>
|
</div></section>
|
||||||
<section className="year-account-filter stats-filter"><div className="year-filter-copy"><span>Statistik für</span><strong>{selectedName} · {selectedPerson}</strong></div><div className="stats-filter-controls"><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section>
|
<section className="year-account-filter stats-filter"><div className="year-filter-copy"><span>Statistik für</span><strong>{selectedName} · {selectedPerson}</strong></div><div className="stats-filter-controls"><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section>
|
||||||
<section className="summary-grid stats-summary"><Metric label="Eingänge Ø / Monat" value={stats.incomes.total} positive/><Metric label="Ausgänge Ø / 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="Eingänge Ø / Monat" value={stats.incomes.total} positive/><Metric label="Ausgänge Ø / Monat" value={stats.expenses.total} negative/><CountMetric label="Kategorien" value={data.categories.length}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section>
|
||||||
|
<PersonFlowOverview stats={personFlows}/>
|
||||||
<CategoryDistribution data={data} title="Ausgaben nach Kategorien" subtitle={stats.transfersMerged?'Ausgaben dieses Kontos inklusive ausgehender Transfers. Kategorie anklicken für die Einzelpositionen.':'Verteilung auf Basis des durchschnittlichen Monatswerts. Kategorie anklicken für die Einzelpositionen.'} stats={stats.expenses} tone="expense"/>
|
<CategoryDistribution data={data} title="Ausgaben nach Kategorien" subtitle={stats.transfersMerged?'Ausgaben dieses Kontos inklusive ausgehender Transfers. Kategorie anklicken für die Einzelpositionen.':'Verteilung auf Basis des durchschnittlichen Monatswerts. Kategorie anklicken für die Einzelpositionen.'} stats={stats.expenses} tone="expense"/>
|
||||||
<CategoryDistribution data={data} title="Eingänge nach Kategorien" subtitle={stats.transfersMerged?'Eingänge dieses Kontos inklusive eingehender Transfers. Kategorie anklicken für die Einzelpositionen.':'Verteilung der regelmäßigen Eingänge. Kategorie anklicken für die Einzelpositionen.'} stats={stats.incomes} tone="income"/>
|
<CategoryDistribution data={data} title="Eingänge nach Kategorien" subtitle={stats.transfersMerged?'Eingänge dieses Kontos inklusive eingehender Transfers. Kategorie anklicken für die Einzelpositionen.':'Verteilung der regelmäßigen Eingänge. Kategorie anklicken für die Einzelpositionen.'} stats={stats.incomes} tone="income"/>
|
||||||
{!stats.transfersMerged&&<CategoryDistribution data={data} title="Transfers nach Kategorien" subtitle="Nur bei Alle Konten separat. Bei Einzelkonten fließen Transfers je nach Richtung in Ein- oder Ausgänge ein." stats={stats.transfers} tone="transfer"/>}
|
{!stats.transfersMerged&&<CategoryDistribution data={data} title="Transfers nach Kategorien" subtitle="Nur bei Alle Konten separat. Bei Einzelkonten fließen Transfers je nach Richtung in Ein- oder Ausgänge ein." stats={stats.transfers} tone="transfer"/>}
|
||||||
|
|||||||
@@ -1042,3 +1042,25 @@ html.modal-open, body.modal-open { overflow:hidden !important; overscroll-behavi
|
|||||||
.person-unassigned-note { display:grid;gap:3px;padding:10px 12px;margin:0 0 12px;border:1px solid #5a4d24;background:#211b0d;border-radius:11px; }
|
.person-unassigned-note { display:grid;gap:3px;padding:10px 12px;margin:0 0 12px;border:1px solid #5a4d24;background:#211b0d;border-radius:11px; }
|
||||||
.person-unassigned-note strong { font-size:11px;color:#f2dc8a; }
|
.person-unassigned-note strong { font-size:11px;color:#f2dc8a; }
|
||||||
.person-unassigned-note span { font-size:9px;line-height:1.45;color:#a99b73; }
|
.person-unassigned-note span { font-size:9px;line-height:1.45;color:#a99b73; }
|
||||||
|
|
||||||
|
|
||||||
|
/* v3.21: personenbezogene Zahlungsstrom-Statistik */
|
||||||
|
.person-flow-panel .section-head { align-items:flex-end; }
|
||||||
|
.person-flow-total { flex:0 0 auto; font-size:18px; white-space:nowrap; }
|
||||||
|
.person-flow-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
||||||
|
.person-flow-row { min-width:0; border:1px solid #29364a; background:#0d131c; border-radius:14px; padding:12px; }
|
||||||
|
.person-flow-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; padding-bottom:10px; border-bottom:1px solid #202a39; }
|
||||||
|
.person-flow-head > div { min-width:0; display:grid; gap:4px; }
|
||||||
|
.person-flow-head small { color:var(--muted); font-size:8px; line-height:1.35; }
|
||||||
|
.person-flow-head > strong { flex:0 0 auto; font-size:15px; white-space:nowrap; }
|
||||||
|
.person-flow-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px 12px; padding-top:10px; }
|
||||||
|
.person-flow-grid > div { min-width:0; display:grid; gap:2px; }
|
||||||
|
.person-flow-grid span { color:var(--muted); font-size:8px; font-weight:800; text-transform:uppercase; letter-spacing:.045em; }
|
||||||
|
.person-flow-grid strong { font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
@media (max-width:760px) {
|
||||||
|
.person-flow-panel .section-head { align-items:flex-start; flex-direction:column; gap:7px; }
|
||||||
|
.person-flow-total { font-size:15px; }
|
||||||
|
.person-flow-list { grid-template-columns:1fr; gap:8px; }
|
||||||
|
.person-flow-row { padding:10px; }
|
||||||
|
.person-flow-head > strong { font-size:14px; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user