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.

{data.accounts.length===0?:
{data.accounts.map(a=>)}
Konto
{a.name}
}
-

Personen

Jede Bewegung wird einer Person zugeordnet. Das gilt genauso für Umbuchungen.

{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'}
)}
}
+

Personen

Du kannst beliebig viele Personen hinterlegen. Jede Bewegung wird genau einer Person zugeordnet, einschließlich Umbuchungen.

{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'}
)}
}
; } @@ -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}
+ {!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; } +}