diff --git a/public/sw.js b/public/sw.js
index 6360f83..2d5ab0b 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -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 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 2bdbd8b..8ca65c7 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -188,6 +188,73 @@ function categoryStats(data, accountId = 'all', personId = 'all') {
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 Personenbezogene Zahlungsströme {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.'}
=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat
+ {stats.rows.length===0? :{stats.rows.map(row=>
{row.name} {stats.isAll?'wirtschaftlicher Monatssaldo':'Nettofluss auf diesem Konto'}
=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)} {stats.isAll?<>
Einnahmen +{formatMoney(row.income)}
Ausgaben −{formatMoney(row.expense)}
Interne Transfers {formatMoney(row.transferInternal)}
Saldo =0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
>:<>
Zuflüsse +{formatMoney(row.displayIn)}
Abflüsse −{formatMoney(row.displayOut)}
Transfers rein +{formatMoney(row.transferIn)}
Transfers raus −{formatMoney(row.transferOut)}
>}
)}
}
+ ;
+}
+
function dashboardKpis(data, totals) {
const forecast = annualForecast(data, 'all');
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;
return
Konten Konten dienen nur zur Zuordnung. Es gibt keine gespeicherten Kontostände.
+ Konto {data.accounts.length===0? :Konto {data.accounts.map(a=>{a.name} onEdit(a)}>Bearbeiten onDelete(a.id)}>Löschen )}
}
-
Personen Jede Bewegung wird einer Person zugeordnet. Das gilt genauso für Umbuchungen.
+ Person {unassigned>0&&{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person. Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen immer eine Person.
}{(data.persons||[]).length===0? :{data.persons.map(person=>
{person.name} {personUsage(person.id)} {personUsage(person.id)===1?'zugeordnete Bewegung':'zugeordnete Bewegungen'}
onEditPerson(person)}>Bearbeiten onDeletePerson(person.id)}>Löschen
)}
}
+
Personen Du kannst beliebig viele Personen hinterlegen. Jede Bewegung wird genau einer Person zugeordnet, einschließlich Umbuchungen.
+ Person {unassigned>0&&{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person. Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen immer eine Person.
}{(data.persons||[]).length===0? :{data.persons.map(person=>
{person.name} {personUsage(person.id)} {personUsage(person.id)===1?'zugeordnete Bewegung':'zugeordnete Bewegungen'}
onEditPerson(person)}>Bearbeiten onDeletePerson(person.id)}>Löschen
)}
}
;
}
@@ -484,6 +551,7 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
const [accountId,setAccountId]=useState('all');
const [personId,setPersonId]=useState('all');
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 kpis=useMemo(()=>dashboardKpis(data,totals),[data,totals]);
const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten';
@@ -503,6 +571,7 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
Statistik für {selectedName} · {selectedPerson}
Konto auswählen setAccountId(e.target.value)}>Alle Konten {data.accounts.map(a=>{a.name} )} Person auswählen setPersonId(e.target.value)}>Alle Personen {(data.persons||[]).map(p=>{p.name} )}
+
{!stats.transfersMerged&& }
diff --git a/src/styles.css b/src/styles.css
index 488a8e7..bfa73c2 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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 strong { font-size:11px;color:#f2dc8a; }
.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; }
+}