This commit is contained in:
2026-08-20 12:14:47 +02:00
parent 1a3dbaa8c6
commit 647cbbbdd0
4 changed files with 167 additions and 60 deletions
+15
View File
@@ -123,3 +123,18 @@ Die Gemeinschaftsfinanzierung zeigt je Person zusätzlich Prozentwerte. **Kosten
### v3.27 gerichtete Personentransfers und verfügbare Liquidität ### v3.27 gerichtete Personentransfers und verfügbare Liquidität
Transfers zwischen persönlichen Konten unterschiedlicher Eigentümer werden in personenbezogenen Auswertungen aus den Kontoeigentümern als Sender → Empfänger abgeleitet. Der globale Schalter blendet Transferdetails nur noch ein oder aus; Kontoliquidität und verfügbare Beträge bleiben immer inklusive Transfers. In der persönlichen Statistik werden wirtschaftlich verfügbar und liquid verfügbar getrennt ausgewiesen. Transfers zwischen persönlichen Konten unterschiedlicher Eigentümer werden in personenbezogenen Auswertungen aus den Kontoeigentümern als Sender → Empfänger abgeleitet. Der globale Schalter blendet Transferdetails nur noch ein oder aus; Kontoliquidität und verfügbare Beträge bleiben immer inklusive Transfers. In der persönlichen Statistik werden wirtschaftlich verfügbar und liquid verfügbar getrennt ausgewiesen.
### v3.28 kompakte Akkordeons in Planung und Bewegungen
**Benötigt pro Konto** zeigt geschlossen nur Konto, Eigentum/Funktion und den resultierenden Fehlbetrag bzw. Überschuss. Die Zusammensetzung aus echten Ein-/Ausgängen und Transfers wird erst beim Aufklappen sichtbar. **Bewegungen** werden ebenfalls als einzelne Akkordeons dargestellt: Bezeichnung, Betrag sowie kompakte Badges bleiben sichtbar; Personen, Fälligkeit, Monatswert, Klassifikation, geplante Betragsänderungen und Bearbeitungsaktionen liegen im Detailbereich.
### v3.29 gebündelte Navigation und Stammdaten
Die Hauptnavigation bleibt bei fünf Punkten, die fachlichen Unterseiten sind aber neu gruppiert:
- **Planung**: Kontenbedarf, Jahresplanung und Szenarien.
- **Auswertung**: Statistik und Auszüge.
- **Mehr → Stammdaten**: Konten, Personen und Kategorien auf einer gemeinsamen Verwaltungsseite mit Tabs.
- **Mehr → Einstellungen & Daten**: Benachrichtigungen sowie Backup/Import und Datenstand auf getrennten Tabs.
Die Kategorienverwaltung wurde vollständig aus der Statistik entfernt. Kategorieauswertungen bleiben dort erhalten. Der bisherige reine Kategorie-Zähler in der Statistik wurde durch einen zur aktuellen Konto-/Personenauswahl passenden Saldo- bzw. Nettoflusswert ersetzt. Die Änderung betrifft nur Navigation und UI-Struktur; das JSON-Datenmodell bleibt bei `version: 11`.
+1 -1
View File
@@ -1,4 +1,4 @@
const SHELL_CACHE='fixfin-shell-v3.27'; const SHELL_CACHE='fixfin-shell-v3.29';
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'];
+53 -54
View File
@@ -541,30 +541,33 @@ function Icon({ name }) {
const PAGE_META={ const PAGE_META={
dashboard:{label:'Übersicht',description:'Monatliche Salden und die wichtigsten Hinweise auf einen Blick.'}, dashboard:{label:'Übersicht',description:'Monatliche Salden und die wichtigsten Hinweise auf einen Blick.'},
movements:{label:'Bewegungen',description:'Eingänge, Ausgänge und interne Transfers verwalten.'}, movements:{label:'Bewegungen',description:'Eingänge, Ausgänge und interne Transfers verwalten.'},
planning:{label:'Planung',description:'Kontobedarf und Rücklagen für die laufende Planung.'}, planning:{label:'Kontenbedarf',description:'Liquiditätsbedarf und Rücklagen für die laufende Planung.'},
year:{label:'Jahr',description:'Jahresplanung zwischen wirtschaftlicher Sicht und Kontoliquidität.'}, year:{label:'Jahresplanung',description:'Fälligkeiten und Liquidität über das gesamte Jahr planen.'},
stats:{label:'Statistik',description:'Kategorien, Verteilungen und Kennzahlen aus der Planung.'},
statements:{label:'Auszüge',description:'Schlanke Kontoauszüge aus den geplanten Kontobewegungen.'},
accounts:{label:'Konten & Personen',description:'Konten und Personen für deine Bewegungen verwalten.'},
scenarios:{label:'Szenarien',description:'Was-wäre-wenn-Varianten ohne Änderung der echten Planung.'}, scenarios:{label:'Szenarien',description:'Was-wäre-wenn-Varianten ohne Änderung der echten Planung.'},
data:{label:'Daten',description:'Backup, Import und Pushover-Einstellungen.'}, stats:{label:'Statistik',description:'Verteilungen, Personenanteile und wirtschaftliche Kennzahlen auswerten.'},
statements:{label:'Auszüge',description:'Schlanke Kontoauszüge aus den geplanten Kontobewegungen.'},
accounts:{label:'Stammdaten',description:'Konten, Personen und Kategorien zentral verwalten.'},
data:{label:'Einstellungen & Daten',description:'Benachrichtigungen, Backup, Import und Datenstand verwalten.'},
}; };
const NAV_GROUPS={ const NAV_GROUPS={
planning:{label:'Planung',items:[
{tab:'planning',icon:'planning',label:'Kontenbedarf',description:'Liquiditätsbedarf & Rücklagen'},
{tab:'year',icon:'calendar',label:'Jahresplanung',description:'Fälligkeiten & Jahresliquidität'},
{tab:'scenarios',icon:'flask',label:'Szenarien',description:'Was-wäre-wenn-Planungen'},
]},
analytics:{label:'Auswertung',items:[ analytics:{label:'Auswertung',items:[
{tab:'year',icon:'calendar',label:'Jahr',description:'Jahresplan & Liquidität'}, {tab:'stats',icon:'chart',label:'Statistik',description:'Kennzahlen & Verteilungen'},
{tab:'stats',icon:'chart',label:'Statistik',description:'Kategorien & Kennzahlen'},
{tab:'statements',icon:'statement',label:'Auszüge',description:'PDF- & Excel-Kontoauszüge'}, {tab:'statements',icon:'statement',label:'Auszüge',description:'PDF- & Excel-Kontoauszüge'},
]}, ]},
more:{label:'Mehr',items:[ more:{label:'Mehr',items:[
{tab:'accounts',icon:'wallet',label:'Konten & Personen',description:'Zuordnungen verwalten'}, {tab:'accounts',icon:'wallet',label:'Stammdaten',description:'Konten, Personen & Kategorien'},
{tab:'scenarios',icon:'flask',label:'Szenarien',description:'Was-wäre-wenn-Planungen'}, {tab:'data',icon:'data',label:'Einstellungen & Daten',description:'Benachrichtigungen, Backup & Import'},
{tab:'data',icon:'data',label:'Daten',description:'Backup, Import & Pushover'},
]}, ]},
}; };
const MAIN_NAV=[ const MAIN_NAV=[
{tab:'dashboard',icon:'dashboard',label:'Übersicht'}, {tab:'dashboard',icon:'dashboard',label:'Übersicht'},
{tab:'movements',icon:'transfer',label:'Bewegungen'}, {tab:'movements',icon:'transfer',label:'Bewegungen'},
{tab:'planning',icon:'planning',label:'Planung'}, {menu:'planning',icon:'planning',label:'Planung'},
{menu:'analytics',icon:'chart',label:'Auswertung'}, {menu:'analytics',icon:'chart',label:'Auswertung'},
{menu:'more',icon:'more',label:'Mehr'}, {menu:'more',icon:'more',label:'Mehr'},
]; ];
@@ -629,14 +632,14 @@ function App() {
<main><header className="topbar"><div><h1>{meta.label}</h1><p>{meta.description}</p></div></header> <main><header className="topbar"><div><h1>{meta.label}</h1><p>{meta.description}</p></div></header>
{error&&<div className="alert"><span>{error}</span><button onClick={()=>setError('')}>×</button></div>} {error&&<div className="alert"><span>{error}</span><button onClick={()=>setError('')}>×</button></div>}
{tab==='dashboard'&&<Dashboard data={data} totals={totals} onToggle={togglePeriodic} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>} {tab==='dashboard'&&<Dashboard data={data} totals={totals} onToggle={togglePeriodic} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})} onAddPerson={()=>setDialog({type:'persons'})} onEditPerson={x=>setDialog({type:'persons',item:x})} onDeletePerson={removePerson}/>} {tab==='accounts'&&<MasterDataPage data={data} onEditAccount={x=>setDialog({type:'accounts',item:x})} onDeleteAccount={removeAccount} onAddAccount={()=>setDialog({type:'accounts'})} onAddPerson={()=>setDialog({type:'persons'})} onEditPerson={x=>setDialog({type:'persons',item:x})} onDeletePerson={removePerson} onAddCategory={()=>setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>}
{tab==='movements'&&<MovementsPage data={data} onEdit={(type,x)=>setDialog({type,item:x})} onDuplicate={(type,x)=>setDialog({type,item:{...x,id:null},duplicate:true})} onDelete={removeItem} onAdd={type=>setDialog({type})}/>} {tab==='movements'&&<MovementsPage data={data} onEdit={(type,x)=>setDialog({type,item:x})} onDuplicate={(type,x)=>setDialog({type,item:{...x,id:null},duplicate:true})} onDelete={removeItem} onAdd={type=>setDialog({type})}/>}
{tab==='year'&&<YearPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>} {tab==='year'&&<YearPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='planning'&&<PlanningPage data={data} totals={totals}/>} {tab==='planning'&&<PlanningPage data={data} totals={totals}/>}
{tab==='stats'&&<StatisticsPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers} onAddCategory={()=>setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>} {tab==='stats'&&<StatisticsPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='statements'&&<StatementsPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>} {tab==='statements'&&<StatementsPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='scenarios'&&<ScenariosPage data={data} persist={persist}/>} {tab==='scenarios'&&<ScenariosPage data={data} persist={persist}/>}
{tab==='data'&&<DataPage data={data} importFile={importFile}/>} {tab==='data'&&<SettingsDataPage data={data} importFile={importFile}/>}
</main> </main>
<nav className="mobile-nav">{MAIN_NAV.map(item=><button key={item.tab||item.menu} className={mainActive(item)?'active':''} onClick={()=>activateMain(item)}><Icon name={item.icon}/><span>{item.label}</span></button>)}</nav> <nav className="mobile-nav">{MAIN_NAV.map(item=><button key={item.tab||item.menu} className={mainActive(item)?'active':''} onClick={()=>activateMain(item)}><Icon name={item.icon}/><span>{item.label}</span></button>)}</nav>
{navMenu&&<NavGroupMenu group={navMenu} tab={tab} onClose={()=>setNavMenu(null)} onSelect={selectTab}/>} {navMenu&&<NavGroupMenu group={navMenu} tab={tab} onClose={()=>setNavMenu(null)} onSelect={selectTab}/>}
@@ -711,13 +714,12 @@ function PlanningPage({data,totals}){
return <div className="content-stack planning-page"> return <div className="content-stack planning-page">
<div className="info-note"><strong>Liquiditätsplanung:</strong> Transfers zählen hier immer als echte Kontobewegungen. Eine Überweisung zu Depot, Bausparer oder Gemeinschaftskonto reduziert deshalb den Monatssaldo des Quellkontos, ohne zur Kostenstatistik zu werden.</div> <div className="info-note"><strong>Liquiditätsplanung:</strong> Transfers zählen hier immer als echte Kontobewegungen. Eine Überweisung zu Depot, Bausparer oder Gemeinschaftskonto reduziert deshalb den Monatssaldo des Quellkontos, ohne zur Kostenstatistik zu werden.</div>
<section className="summary-grid planning-summary"><Metric label="Echte Kosten / Monat" value={totalRequired} negative/><Metric label="Zusätzliche Zuführung" value={totalFundingNeed} negative={totalFundingNeed>0} positive={totalFundingNeed===0}/><Metric label="Rücklage / Monat" value={reserves.monthly}/><Metric label="Periodischer Bedarf / Jahr" value={reserves.annual}/></section> <section className="summary-grid planning-summary"><Metric label="Echte Kosten / Monat" value={totalRequired} negative/><Metric label="Zusätzliche Zuführung" value={totalFundingNeed} negative={totalFundingNeed>0} positive={totalFundingNeed===0}/><Metric label="Rücklage / Monat" value={reserves.monthly}/><Metric label="Periodischer Bedarf / Jahr" value={reserves.annual}/></section>
<section className="panel"><div className="section-head"><div><h2>Benötigt pro Konto</h2><p>Liquiditätsbedarf je Konto: echte Ausgaben plus Transfers raus, gedeckt durch echte Eingänge plus Transfers rein.</p></div></div>{totals.accounts.length===0?<Empty text="Lege zuerst ein Konto an."/>:<div className="funding-grid">{totals.accounts.map(s=><div className="funding-card" key={s.account.id}><div className="funding-head"><strong>{s.account.name}</strong><span className={s.fundingNeed>0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}</span></div><div className="funding-numbers"><div><span>Echte Ausgaben</span><strong>{formatMoney(s.expense)}</strong></div><div><span>Transfers raus</span><strong>{formatMoney(s.transferOut)}</strong></div><div><span>Echte Eingänge</span><strong>{formatMoney(s.income)}</strong></div><div><span>Transfers rein</span><strong>{formatMoney(s.transferIn)}</strong></div><div><span>Zusätzliche Zuführung</span><strong className={s.fundingNeed>0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}</strong></div></div></div>)}</div>}</section> <section className="panel"><div className="section-head"><div><h2>Benötigt pro Konto</h2><p>Die geschlossene Zeile zeigt nur Konto und Ergebnis. Aufklappen zeigt, wie sich der Liquiditätsbedarf zusammensetzt.</p></div></div>{totals.accounts.length===0?<Empty text="Lege zuerst ein Konto an."/>:<div className="funding-grid funding-accordion-list">{totals.accounts.map(s=><details className="funding-card funding-accordion" key={s.account.id}><summary><div className="funding-summary-copy"><strong>{s.account.name}</strong><div className="compact-badges"><span className={`ownership-badge ${s.account.ownership||'unassigned'}`}>{accountOwnerships[s.account.ownership]||accountOwnerships.unassigned}</span><span className="account-function-badge">{accountFunctionLabel(s.account)}</span></div></div><div className="funding-summary-right"><span className={s.fundingNeed>0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}</span><span className="accordion-chevron" aria-hidden="true"></span></div></summary><div className="funding-detail"><div className="funding-numbers"><div><span>Echte Ausgaben</span><strong>{formatMoney(s.expense)}</strong></div><div><span>Transfers raus</span><strong>{formatMoney(s.transferOut)}</strong></div><div><span>Echte Eingänge</span><strong>{formatMoney(s.income)}</strong></div><div><span>Transfers rein</span><strong>{formatMoney(s.transferIn)}</strong></div><div className="funding-result"><span>Zusätzliche Zuführung</span><strong className={s.fundingNeed>0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}</strong></div></div><p className="funding-formula">Ausgaben + Transfers raus Eingänge Transfers rein. Nur ein positiver Rest ist zusätzliche Zuführung.</p></div></details>)}</div>}</section>
<SharedFundingOverview data={data}/> <SharedFundingOverview data={data}/>
<section className="panel reserve-panel"><div className="section-head"><div><h2>Rücklagenübersicht</h2><p>Periodische echte Ausgaben auf einen gleichmäßigen Monatsbetrag heruntergerechnet.</p></div><div className="reserve-total"><span>{formatMoney(reserves.annual)} / Jahr</span><strong>{formatMoney(reserves.monthly)} / Monat</strong></div></div>{reserves.items.length===0?<Empty text="Keine periodischen Ausgaben vorhanden."/>:<div className="reserve-list">{reserves.items.map(x=><div className="reserve-row" key={x.id}><div><strong>{x.name}</strong><span>{accountName(data,x.accountId)} · {categoryName(data,x.categoryId)} · {intervals[x.interval].label}</span></div><div><span>{formatMoney(x.annualNeed)} / Jahr</span><strong>{formatMoney(x.monthlyReserve)} / Monat</strong></div></div>)}</div>}</section> <section className="panel reserve-panel"><div className="section-head"><div><h2>Rücklagenübersicht</h2><p>Periodische echte Ausgaben auf einen gleichmäßigen Monatsbetrag heruntergerechnet.</p></div><div className="reserve-total"><span>{formatMoney(reserves.annual)} / Jahr</span><strong>{formatMoney(reserves.monthly)} / Monat</strong></div></div>{reserves.items.length===0?<Empty text="Keine periodischen Ausgaben vorhanden."/>:<div className="reserve-list">{reserves.items.map(x=><div className="reserve-row" key={x.id}><div><strong>{x.name}</strong><span>{accountName(data,x.accountId)} · {categoryName(data,x.categoryId)} · {intervals[x.interval].label}</span></div><div><span>{formatMoney(x.annualNeed)} / Jahr</span><strong>{formatMoney(x.monthlyReserve)} / Monat</strong></div></div>)}</div>}</section>
</div>; </div>;
} }
function Metric({label,value,positive,negative,featured}){return <div className={`metric ${featured?'featured':''}`}><span>{label}</span><strong className={positive?'good-text':negative?'bad-text':''}>{formatMoney(value)}</strong></div>} function Metric({label,value,positive,negative,featured}){return <div className={`metric ${featured?'featured':''}`}><span>{label}</span><strong className={positive?'good-text':negative?'bad-text':''}>{formatMoney(value)}</strong></div>}
function TextMetric({label,value,hint}){return <div className="metric text-metric"><span>{label}</span><strong>{value}</strong>{hint&&<small>{hint}</small>}</div>} function TextMetric({label,value,hint}){return <div className="metric text-metric"><span>{label}</span><strong>{value}</strong>{hint&&<small>{hint}</small>}</div>}
function CountMetric({label,value}){return <div className="metric"><span>{label}</span><strong>{Number(value||0).toLocaleString('de-DE')}</strong></div>} function CountMetric({label,value}){return <div className="metric"><span>{label}</span><strong>{Number(value||0).toLocaleString('de-DE')}</strong></div>}
@@ -732,18 +734,26 @@ function PersonMultiSelect({persons,selectedIds,onChange,label='Personen',help='
</div>; </div>;
} }
function Accounts({data,onEdit,onDelete,onAdd,onAddPerson,onEditPerson,onDeletePerson}){ function SectionTabs({items,value,onChange,label}){
return <div className="section-tabs" role="tablist" aria-label={label}>{items.map(item=><button type="button" role="tab" aria-selected={value===item.id} className={value===item.id?'active':''} key={item.id} onClick={()=>onChange(item.id)}><span>{item.label}</span>{item.count!=null&&<small>{Number(item.count).toLocaleString('de-DE')}</small>}</button>)}</div>;
}
function MasterDataPage({data,onEditAccount,onDeleteAccount,onAddAccount,onAddPerson,onEditPerson,onDeletePerson,onAddCategory,onEditCategory,onDeleteCategory}){
const [section,setSection]=useState('accounts');
const personUsage=id=>data.incomes.filter(x=>personIdsOf(x).includes(id)).length+data.expenses.filter(x=>personIdsOf(x).includes(id)).length+data.transfers.filter(x=>personIdsOf(x).includes(id)).length+(data.scenarios||[]).reduce((sum,s)=>sum+(s.adjustments||[]).filter(x=>personIdsOf(x).includes(id)).length,0); const personUsage=id=>data.incomes.filter(x=>personIdsOf(x).includes(id)).length+data.expenses.filter(x=>personIdsOf(x).includes(id)).length+data.transfers.filter(x=>personIdsOf(x).includes(id)).length+(data.scenarios||[]).reduce((sum,s)=>sum+(s.adjustments||[]).filter(x=>personIdsOf(x).includes(id)).length,0);
const categoryUsage=id=>data.incomes.filter(x=>x.categoryId===id).length+data.expenses.filter(x=>x.categoryId===id).length+data.transfers.filter(x=>x.categoryId===id).length+(data.scenarios||[]).reduce((sum,s)=>sum+(s.adjustments||[]).filter(x=>x.categoryId===id).length,0);
const ownedAccounts=id=>(data.accounts||[]).filter(a=>ownerIdsOf(a).includes(id)).length; const ownedAccounts=id=>(data.accounts||[]).filter(a=>ownerIdsOf(a).includes(id)).length;
const unassigned=[...data.incomes,...data.expenses,...data.transfers].filter(x=>personIdsOf(x).length===0).length; const unassigned=[...data.incomes,...data.expenses,...data.transfers].filter(x=>personIdsOf(x).length===0).length;
const unconfiguredAccounts=(data.accounts||[]).filter(a=>!ownershipDomainKey(a)).length; const unconfiguredAccounts=(data.accounts||[]).filter(a=>!ownershipDomainKey(a)).length;
return <div className="content-stack accounts-people-page"> const tabs=[{id:'accounts',label:'Konten',count:data.accounts.length},{id:'persons',label:'Personen',count:(data.persons||[]).length},{id:'categories',label:'Kategorien',count:data.categories.length}];
<section className="panel"><div className="section-head"><div><h2>Konten</h2><p>Eigentum bestimmt, wem Geld wirtschaftlich gehört. Die Kontofunktion bestimmt, ob eine Umbuchung z. B. Rücklage, Investment oder Bausparen ist.</p></div><button className="secondary" onClick={onAdd}>+ Konto</button></div>{unconfiguredAccounts>0&&<div className="account-config-note"><strong>{unconfiguredAccounts} {unconfiguredAccounts===1?'Konto braucht':'Konten brauchen'} noch eine Eigentumszuordnung.</strong><span>Bestehende Konten wurden absichtlich nicht automatisch geraten. Bitte einmal bearbeiten, Eigentum festlegen und die passende Kontofunktion wählen.</span></div>}{data.accounts.length===0?<Empty text="Noch keine Konten vorhanden."/>:<div className="table-wrap account-manager-table"><table><thead><tr><th>Konto</th><th>Eigentum</th><th>Funktion</th><th></th></tr></thead><tbody>{data.accounts.map(a=><tr key={a.id}><td><strong>{a.name}</strong></td><td><span className={`ownership-badge ${a.ownership||'unassigned'}`}>{accountOwnerships[a.ownership]||accountOwnerships.unassigned}</span><small className="account-owner-copy">{accountOwnerLabel(data,a)}</small></td><td><span className="account-function-badge">{accountFunctionLabel(a)}</span></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> return <div className="content-stack master-data-page">
<section className="panel"><div className="section-head"><div><h2>Personen</h2><p>Personen können Konten besitzen und einzelnen Bewegungen gemeinsam zugeordnet werden.</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 mindestens 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?'Bewegung':'Bewegungen'} · {ownedAccounts(person.id)} {ownedAccounts(person.id)===1?'Konto':'Konten'}</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 master-data-header"><div className="section-head"><div><h2>Stammdaten</h2><p>Hier definierst du die Bausteine, auf die Bewegungen und Auswertungen zugreifen. Änderungen wirken zentral in der gesamten Planung.</p></div><div className="master-data-counts"><span>{data.accounts.length} Konten</span><span>{(data.persons||[]).length} Personen</span><span>{data.categories.length} Kategorien</span></div></div><SectionTabs items={tabs} value={section} onChange={setSection} label="Stammdaten auswählen"/></section>
{section==='accounts'&&<section className="panel"><div className="section-head"><div><h2>Konten</h2><p>Eigentum bestimmt, wem Geld wirtschaftlich gehört. Die Kontofunktion bestimmt, ob eine Umbuchung z. B. Rücklage, Investment oder Bausparen ist.</p></div><button className="secondary" onClick={onAddAccount}>+ Konto</button></div>{unconfiguredAccounts>0&&<div className="account-config-note"><strong>{unconfiguredAccounts} {unconfiguredAccounts===1?'Konto braucht':'Konten brauchen'} noch eine Eigentumszuordnung.</strong><span>Bestehende Konten wurden absichtlich nicht automatisch geraten. Bitte einmal bearbeiten, Eigentum festlegen und die passende Kontofunktion wählen.</span></div>}{data.accounts.length===0?<Empty text="Noch keine Konten vorhanden."/>:<div className="table-wrap account-manager-table"><table><thead><tr><th>Konto</th><th>Eigentum</th><th>Funktion</th><th></th></tr></thead><tbody>{data.accounts.map(a=><tr key={a.id}><td><strong>{a.name}</strong></td><td><span className={`ownership-badge ${a.ownership||'unassigned'}`}>{accountOwnerships[a.ownership]||accountOwnerships.unassigned}</span><small className="account-owner-copy">{accountOwnerLabel(data,a)}</small></td><td><span className="account-function-badge">{accountFunctionLabel(a)}</span></td><td className="actions"><button onClick={()=>onEditAccount(a)}>Bearbeiten</button><button className="danger" onClick={()=>onDeleteAccount(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
{section==='persons'&&<section className="panel"><div className="section-head"><div><h2>Personen</h2><p>Personen können Konten besitzen und einzelnen Bewegungen gemeinsam zugeordnet werden.</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 mindestens 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?'Bewegung':'Bewegungen'} · {ownedAccounts(person.id)} {ownedAccounts(person.id)===1?'Konto':'Konten'}</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==='categories'&&<section className="panel"><div className="section-head"><div><h2>Kategorien</h2><p>Kategorien gelten zentral für Eingänge, Ausgänge und Transfers. Die Statistik wertet sie aus, verwaltet werden sie ausschließlich hier.</p></div><button className="secondary" onClick={onAddCategory}>+ Kategorie</button></div>{data.categories.length===0?<Empty text="Noch keine Kategorien angelegt."/>:<div className="category-manager-list">{data.categories.map(c=><div className="category-manager-row" key={c.id}><div><strong>{c.name}</strong><span>{categoryUsage(c.id)} {categoryUsage(c.id)===1?'zugeordneter Eintrag':'zugeordnete Einträge'}</span></div><div className="actions"><button onClick={()=>onEditCategory(c)}>Bearbeiten</button><button className="danger" onClick={()=>onDeleteCategory(c.id)}>Löschen</button></div></div>)}</div>}</section>}
</div>; </div>;
} }
function MovementsPage({data,onEdit,onDuplicate,onDelete,onAdd}){ function MovementsPage({data,onEdit,onDuplicate,onDelete,onAdd}){
const [section,setSection]=useState('incomes'); const [section,setSection]=useState('incomes');
const tabs=[['incomes','Eingänge',data.incomes.length],['expenses','Ausgänge',data.expenses.length],['transfers','Transfers',data.transfers.length]]; const tabs=[['incomes','Eingänge',data.incomes.length],['expenses','Ausgänge',data.expenses.length],['transfers','Transfers',data.transfers.length]];
@@ -761,20 +771,10 @@ function Entries({type,items,accounts,categories,persons,onEdit,onDuplicate,onDe
const isIncome=type==='incomes'; const isIncome=type==='incomes';
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
const currentAmount=x=>effectiveAmount(x); const currentAmount=x=>effectiveAmount(x);
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<> return <section className="panel movement-list-panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Nur Basisinfos bleiben sichtbar. Details und Aktionen öffnen sich je Bewegung per Akkordeon.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<div className="movement-accordion-list">{items.map(x=><details className="movement-accordion" key={x.id}>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Personen</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} Änderung{x.amountChanges.length===1?'':'en'}</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`person-badge ${personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div> <summary><div className="movement-summary-main"><div className="movement-summary-copy"><strong>{x.name}</strong><div className="compact-badges"><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span><span className={`person-badge ${personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span><span className="account-badge">{accountNameLocal(x.accountId)}</span><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span>{x.interval!=='monthly'&&<span className="due-badge">{dueText(x)}</span>}{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} geplant</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div></div><div className="movement-summary-value"><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</strong><small>{formatMoney(monthlyEquivalent(x))} / Monat</small></div><span className="accordion-chevron" aria-hidden="true"></span></div></summary>
<div className="movement-mobile-list">{items.map(x=><article className="movement-card" key={x.id}> <div className="movement-accordion-body"><div className="movement-detail-grid"><div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div><div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div><div><span>Kategorie</span><strong>{categoryNameLocal(x.categoryId)}</strong></div><div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div><div><span>Fälligkeit</span><strong>{dueText(x)}</strong></div><div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div>{x.endDate&&<div><span>Enddatum</span><strong>{formatDate(x.endDate)}</strong></div>}{!isIncome&&x.pushoverReminder&&<div><span>Erinnerung</span><strong>Am 1. des Fälligkeitsmonats</strong></div>}</div>{(x.amountChanges||[]).length>0&&<div className="movement-change-preview"><span>Geplante Betragsänderungen</span>{x.amountChanges.map(c=><div key={c.id||c.effectiveFrom}><strong>ab {formatDate(c.effectiveFrom)}</strong><span>{formatMoney(c.amount)}</span></div>)}</div>}<div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></div></div>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder am 1.</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} geplant</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</strong></div> </details>)}</div>}</section>;
<div className="movement-card-grid">
<div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div>
<div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div>
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div>
<div><span>Fälligkeit</span><strong>{dueText(x)}</strong></div>
<div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div>
</div>
<div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></div>
</article>)}</div>
</>}</section>;
} }
function Transfers({data,onEdit,onDuplicate,onDelete}){ function Transfers({data,onEdit,onDuplicate,onDelete}){
@@ -782,18 +782,12 @@ function Transfers({data,onEdit,onDuplicate,onDelete}){
const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';
const personLabelLocal=item=>transferPeopleLabel(data,item); const personLabelLocal=item=>transferPeopleLabel(data,item);
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Transfers werden automatisch anhand von Eigentum und Kontofunktion klassifiziert. Sie beeinflussen Kontoliquidität, sind aber keine Konsumausgaben.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<> return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Quelle, Ziel, Betrag und Einordnung bleiben direkt sichtbar. Weitere Details und Aktionen liegen im Akkordeon.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<div className="movement-accordion-list">{data.transfers.map(x=>{const cls=transferClassification(data,x);return <details className="movement-accordion transfer-accordion" key={x.id}>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Typ</th><th>Personen</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=>{const cls=transferClassification(data,x);return <tr key={x.id}><td><strong>{x.name}</strong>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`transfer-class-badge ${cls.key}`}>{cls.label}</span></td><td><span className={`person-badge ${personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>})}</tbody></table></div> <summary><div className="movement-summary-main"><div className="movement-summary-copy"><strong>{x.name}</strong><span className="movement-summary-route">{accountNameLocal(x.fromAccountId)} <b></b> {accountNameLocal(x.toAccountId)}</span><div className="compact-badges"><span className={`transfer-class-badge ${cls.key}`}>{cls.label}</span><span className={`person-badge ${cls.key==='person-transfer'||personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span>{x.interval!=='monthly'&&<span className="due-badge">{dueText(x)}</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div></div><div className="movement-summary-value"><strong>{formatMoney(effectiveAmount(x))}</strong><small>{formatMoney(monthlyEquivalent(x))} / Monat</small></div><span className="accordion-chevron" aria-hidden="true"></span></div></summary>
<div className="movement-mobile-list">{data.transfers.map(x=>{const cls=transferClassification(data,x);return <article className="movement-card transfer-card" key={x.id}> <div className="movement-accordion-body"><div className="movement-detail-grid"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div><div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div><div><span>Einordnung</span><strong>{cls.description}</strong></div><div><span>Kategorie</span><strong>{categoryNameLocal(x.categoryId)}</strong></div><div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div><div><span>Fälligkeit</span><strong>{dueText(x)}</strong></div><div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div>{x.endDate&&<div><span>Enddatum</span><strong>{formatDate(x.endDate)}</strong></div>}</div><div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></div></div>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`transfer-class-badge ${cls.key}`}>{cls.label}</span><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div><strong>{formatMoney(x.amount)}</strong></div> </details>})}</div>}</section>;
<div className="transfer-route"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><span className="route-arrow"></span><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div></div>
<div className="movement-card-grid"><div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div><div><span>Einordnung</span><strong>{cls.description}</strong></div><div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div><div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div></div>
<div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></div>
</article>})}</div>
</>}</section>;
} }
function transferTypeStats(data,accountId='all',personId='all'){ function transferTypeStats(data,accountId='all',personId='all'){
const isAll=accountId==='all';const buckets=new Map(); const isAll=accountId==='all';const buckets=new Map();
for(const item of data.transfers||[]){ for(const item of data.transfers||[]){
@@ -822,7 +816,7 @@ function CategoryDistribution({title,subtitle,stats,tone,data}){
</section>; </section>;
} }
function StatisticsPage({data,includeTransfers,onTransfersChange,onAddCategory,onEditCategory,onDeleteCategory}){ function StatisticsPage({data,includeTransfers,onTransfersChange}){
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,includeTransfers),[data,accountId,personId,includeTransfers]); const stats=useMemo(()=>categoryStats(data,accountId,personId,includeTransfers),[data,accountId,personId,includeTransfers]);
@@ -831,14 +825,15 @@ function StatisticsPage({data,includeTransfers,onTransfersChange,onAddCategory,o
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';
const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);
const usage=id=>data.incomes.filter(x=>x.categoryId===id).length+data.expenses.filter(x=>x.categoryId===id).length+data.transfers.filter(x=>x.categoryId===id).length;
const uncategorized=(stats.incomes.rows.find(x=>x.id==='__uncategorized__')?.count||0)+(stats.expenses.rows.find(x=>x.id==='__uncategorized__')?.count||0)+(includeTransfers?(stats.transfers.rows.find(x=>x.id==='__uncategorized__')?.count||0):0); const uncategorized=(stats.incomes.rows.find(x=>x.id==='__uncategorized__')?.count||0)+(stats.expenses.rows.find(x=>x.id==='__uncategorized__')?.count||0)+(includeTransfers?(stats.transfers.rows.find(x=>x.id==='__uncategorized__')?.count||0):0);
const personalIncomeLabel=personId==='all'?'Echte Eingänge Ø / Monat':'Eigene Kontoeingänge Ø / Monat'; const personalIncomeLabel=personId==='all'?'Echte Eingänge Ø / Monat':'Eigene Kontoeingänge Ø / Monat';
const selectionBalance=accountId==='all'?(personId==='all'?stats.incomes.total-stats.expenses.total:personFlows.totals.economicAvailable):personFlows.totals.net;
const selectionBalanceLabel=accountId==='all'?(personId==='all'?'Wirtschaftlicher Saldo Auswahl':'Wirtschaftlich verfügbar'):'Nettofluss Auswahl';
return <div className="content-stack stats-page"> return <div className="content-stack stats-page">
<section className="panel stats-overview-panel"><div className="section-head"><div><h2>Gesamtkennzahlen</h2><p>Wirtschaftliche Kennzahlen verwenden nur echte Einnahmen und echte Ausgaben. Umbuchungen verändern diese Werte nicht.</p></div></div><div className="stats-kpi-grid"><Metric label="Wirtschaftlicher Saldo / Monat" value={totals.balance} positive={totals.balance>=0} negative={totals.balance<0} featured/><TextMetric label="Fixkostenquote" value={`${kpis.fixedCostRatio.toFixed(1).replace('.',',')} %`} hint="Echte Ausgänge ÷ echte Eingänge"/><Metric label="Echte Kosten / Jahr" value={kpis.annualExpense} negative/><Metric label="Rücklage periodisch / Monat" value={kpis.reserveMonthly}/><TextMetric label="Teuerster Monat" value={kpis.expensive?.name||''} hint={kpis.expensive?formatMoney(kpis.expensive.displayExpense):''}/><TextMetric label="Günstigster Monat" value={kpis.cheap?.name||''} hint={kpis.cheap?formatMoney(kpis.cheap.displayExpense):''}/></div></section> <section className="panel stats-overview-panel"><div className="section-head"><div><h2>Gesamtkennzahlen</h2><p>Wirtschaftliche Kennzahlen verwenden nur echte Einnahmen und echte Ausgaben. Umbuchungen verändern diese Werte nicht.</p></div></div><div className="stats-kpi-grid"><Metric label="Wirtschaftlicher Saldo / Monat" value={totals.balance} positive={totals.balance>=0} negative={totals.balance<0} featured/><TextMetric label="Fixkostenquote" value={`${kpis.fixedCostRatio.toFixed(1).replace('.',',')} %`} hint="Echte Ausgänge ÷ echte Eingänge"/><Metric label="Echte Kosten / Jahr" value={kpis.annualExpense} negative/><Metric label="Rücklage periodisch / Monat" value={kpis.reserveMonthly}/><TextMetric label="Teuerster Monat" value={kpis.expensive?.name||''} hint={kpis.expensive?formatMoney(kpis.expensive.displayExpense):''}/><TextMetric label="Günstigster Monat" value={kpis.cheap?.name||''} hint={kpis.cheap?formatMoney(kpis.cheap.displayExpense):''}/></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>
<TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/> <TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/>
<section className="summary-grid stats-summary"><Metric label={personalIncomeLabel} value={stats.incomes.total} positive/><Metric label="Echte Kosten Ø / 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={personalIncomeLabel} value={stats.incomes.total} positive/><Metric label="Echte Kosten Ø / Monat" value={stats.expenses.total} negative/><Metric label={selectionBalanceLabel} value={selectionBalance} positive={selectionBalance>=0} negative={selectionBalance<0}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section>
<PersonFlowOverview stats={personFlows}/> <PersonFlowOverview stats={personFlows}/>
{accountId==='all'&&<SharedFundingOverview data={data} personId={personId} showTransfers={includeTransfers}/>} {accountId==='all'&&<SharedFundingOverview data={data} personId={personId} showTransfers={includeTransfers}/>}
{accountId==='all'&&<SharedIncomeBurdenOverview data={data} personId={personId} showTransfers={includeTransfers}/>} {accountId==='all'&&<SharedIncomeBurdenOverview data={data} personId={personId} showTransfers={includeTransfers}/>}
@@ -846,7 +841,6 @@ function StatisticsPage({data,includeTransfers,onTransfersChange,onAddCategory,o
<CategoryDistribution data={data} title="Kosten nach Kategorien" subtitle="Nur echte Ausgaben. Gemeinsam zugeordnete Kosten werden beim Personenfilter gleichmäßig geteilt; Transfers erscheinen hier niemals als Kosten." stats={stats.expenses} tone="expense"/> <CategoryDistribution data={data} title="Kosten nach Kategorien" subtitle="Nur echte Ausgaben. Gemeinsam zugeordnete Kosten werden beim Personenfilter gleichmäßig geteilt; Transfers erscheinen hier niemals als Kosten." stats={stats.expenses} tone="expense"/>
<CategoryDistribution data={data} title={personId==='all'?'Eingänge nach Kategorien':'Eigene Eingänge nach Kategorien'} subtitle={personId==='all'?'Nur echte Eingänge. Transfers bleiben getrennt.':'Nur Eingänge auf persönlich zugeordneten Konten dieser Person. Gemeinsame Einnahmen wie Kindergeld werden nicht als persönliche Einnahmen gezählt.'} stats={stats.incomes} tone="income"/> <CategoryDistribution data={data} title={personId==='all'?'Eingänge nach Kategorien':'Eigene Eingänge nach Kategorien'} subtitle={personId==='all'?'Nur echte Eingänge. Transfers bleiben getrennt.':'Nur Eingänge auf persönlich zugeordneten Konten dieser Person. Gemeinsame Einnahmen wie Kindergeld werden nicht als persönliche Einnahmen gezählt.'} stats={stats.incomes} tone="income"/>
{includeTransfers&&<CategoryDistribution data={data} title="Transfer-/Finanzierungsströme nach Kategorien" subtitle="Separat von Kosten und Einnahmen. Dazu gehören interne Umbuchungen, Vermögensaufbau und Gemeinschaftsfinanzierung." stats={stats.transfers} tone="transfer"/>} {includeTransfers&&<CategoryDistribution data={data} title="Transfer-/Finanzierungsströme nach Kategorien" subtitle="Separat von Kosten und Einnahmen. Dazu gehören interne Umbuchungen, Vermögensaufbau und Gemeinschaftsfinanzierung." stats={stats.transfers} tone="transfer"/>}
<section className="panel"><div className="section-head"><div><h2>Kategorien verwalten</h2><p>Kategorien gelten für Eingänge, Ausgänge und Transfers gemeinsam.</p></div><button className="secondary" onClick={onAddCategory}>+ Kategorie</button></div>{data.categories.length===0?<Empty text="Noch keine Kategorien angelegt."/>:<div className="category-manager-list">{data.categories.map(c=><div className="category-manager-row" key={c.id}><div><strong>{c.name}</strong><span>{usage(c.id)} {usage(c.id)===1?'zugeordneter Eintrag':'zugeordnete Einträge'}</span></div><div className="actions"><button onClick={()=>onEditCategory(c)}>Bearbeiten</button><button className="danger" onClick={()=>onDeleteCategory(c.id)}>Löschen</button></div></div>)}</div>}</section>
</div>; </div>;
} }
@@ -995,7 +989,8 @@ function StatementsPage({data,includeTransfers,onTransfersChange}){
return <div className="content-stack statements-page"><TransferToggle enabled={includeTransfers} onChange={onTransfersChange} context="export"/><section className="panel report-export-panel"><div className="section-head"><div><h2>Kontoauszug erstellen</h2><p>Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.</p></div><span className="report-badge">PDF · XLSX</span></div><div className="report-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person</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><div className="report-preview"><div><span>Kontoauszug</span><strong>{account} · {selectedPerson} · {year}</strong></div><p>PDF: Jahreswerte und monatlich gruppierte Kontobewegungen {includeTransfers?'inklusive Transfers':'ohne Transfers'}. Bei Person-Filter werden gemeinsam zugeordnete Bewegungen anteilig ausgewiesen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.</p></div><div className="button-row report-actions"><a className="primary button-link" href={`/api/report.pdf?${query}`}>PDF herunterladen</a><a className="secondary button-link" href={`/api/report.xlsx?${query}`}>Excel herunterladen</a></div><p className="report-disclaimer">Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.</p></section></div>; return <div className="content-stack statements-page"><TransferToggle enabled={includeTransfers} onChange={onTransfersChange} context="export"/><section className="panel report-export-panel"><div className="section-head"><div><h2>Kontoauszug erstellen</h2><p>Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.</p></div><span className="report-badge">PDF · XLSX</span></div><div className="report-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person</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><div className="report-preview"><div><span>Kontoauszug</span><strong>{account} · {selectedPerson} · {year}</strong></div><p>PDF: Jahreswerte und monatlich gruppierte Kontobewegungen {includeTransfers?'inklusive Transfers':'ohne Transfers'}. Bei Person-Filter werden gemeinsam zugeordnete Bewegungen anteilig ausgewiesen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.</p></div><div className="button-row report-actions"><a className="primary button-link" href={`/api/report.pdf?${query}`}>PDF herunterladen</a><a className="secondary button-link" href={`/api/report.xlsx?${query}`}>Excel herunterladen</a></div><p className="report-disclaimer">Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.</p></section></div>;
} }
function DataPage({data,importFile}){ function SettingsDataPage({data,importFile}){
const [section,setSection]=useState('notifications');
const summary=[ const summary=[
['Konten',data.accounts.length], ['Konten',data.accounts.length],
['Kategorien',data.categories.length], ['Kategorien',data.categories.length],
@@ -1006,14 +1001,18 @@ function DataPage({data,importFile}){
['Szenarien',(data.scenarios||[]).length], ['Szenarien',(data.scenarios||[]).length],
]; ];
const json=JSON.stringify(data,null,2); const json=JSON.stringify(data,null,2);
return <div className="content-stack data-page"> const tabs=[{id:'notifications',label:'Benachrichtigungen'},{id:'backup',label:'Backup & Daten'}];
<PushoverSettings/> return <div className="content-stack data-page settings-data-page">
<section className="panel data-backup-panel"><div className="section-head"><div><h2>Daten sichern</h2><p>Alle Daten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row data-actions"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section> <section className="panel settings-data-header"><div className="section-head"><div><h2>Einstellungen & Daten</h2><p>Systemfunktionen sind bewusst von den fachlichen Stammdaten getrennt.</p></div></div><SectionTabs items={tabs} value={section} onChange={setSection} label="Einstellungen und Daten auswählen"/></section>
{section==='notifications'&&<PushoverSettings/>}
{section==='backup'&&<>
<section className="panel data-backup-panel"><div className="section-head"><div><h2>Backup & Import</h2><p>Alle Planungsdaten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row data-actions"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section>
<section className="panel data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div> <section className="panel data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div>
<div className="data-summary-grid">{summary.map(([label,value])=><div className="data-summary-card" key={label}><span>{label}</span><strong>{Number(value).toLocaleString('de-DE')}</strong></div>)}</div> <div className="data-summary-grid">{summary.map(([label,value])=><div className="data-summary-card" key={label}><span>{label}</span><strong>{Number(value).toLocaleString('de-DE')}</strong></div>)}</div>
<div className="data-json-desktop"><div className="data-subhead"><strong>JSON-Rohdaten</strong><span>Nur zur Kontrolle und Fehlersuche.</span></div><pre className="json-preview">{json}</pre></div> <div className="data-json-desktop"><div className="data-subhead"><strong>JSON-Rohdaten</strong><span>Nur zur Kontrolle und Fehlersuche.</span></div><pre className="json-preview">{json}</pre></div>
<details className="data-json-mobile"><summary><span>JSON-Rohdaten anzeigen</span><small>Nur bei Bedarf öffnen</small></summary><pre className="json-preview">{json}</pre></details> <details className="data-json-mobile"><summary><span>JSON-Rohdaten anzeigen</span><small>Nur bei Bedarf öffnen</small></summary><pre className="json-preview">{json}</pre></details>
</section> </section>
</>}
</div>; </div>;
} }
function Empty({text}){return <div className="empty">{text}</div>} function Empty({text}){return <div className="empty">{text}</div>}
+93
View File
@@ -1171,3 +1171,96 @@ html.modal-open, body.modal-open { overflow:hidden !important; overscroll-behavi
.shared-income-person-row,.shared-income-person-row.compact { grid-template-columns:1fr 1fr;gap:7px 10px; } .shared-income-person-row,.shared-income-person-row.compact { grid-template-columns:1fr 1fr;gap:7px 10px; }
.shared-income-person-row > .person-badge { grid-column:1/-1; } .shared-income-person-row > .person-badge { grid-column:1/-1; }
} }
/* v3.28: kompakte Akkordeons für Planung und Bewegungen */
.compact-badges { display:flex; flex-wrap:wrap; gap:5px; align-items:center; min-width:0; }
.account-badge,.due-badge { display:inline-flex;align-items:center;width:max-content;max-width:100%;padding:4px 8px;border-radius:999px;border:1px solid #344154;background:#151d29;color:#b9c4d3;font-size:9px;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
.due-badge { border-color:#5b4e31;background:#261f13;color:#dfca8d; }
.funding-accordion-list { align-items:start; }
.funding-card.funding-accordion { padding:0; overflow:hidden; }
.funding-accordion > summary { list-style:none; cursor:pointer; display:flex; align-items:center; justify-content:space-between; gap:12px; padding:14px 15px; min-height:72px; }
.funding-accordion > summary::-webkit-details-marker { display:none; }
.funding-summary-copy { min-width:0; display:grid; gap:7px; }
.funding-summary-copy > strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; }
.funding-summary-right { flex:0 0 auto; display:flex; align-items:center; gap:8px; }
.funding-summary-right > span:first-child { font-size:11px; font-weight:850; white-space:nowrap; }
.funding-accordion[open] > summary { border-bottom:1px solid #202a39; }
.funding-accordion[open] .accordion-chevron { transform:rotate(180deg); }
.funding-detail { padding:0 15px 14px; }
.funding-detail .funding-numbers { margin-top:12px; }
.funding-result { margin-top:3px; padding-top:9px; border-top:1px solid #202a39; }
.funding-formula { margin:10px 0 0; color:var(--muted); font-size:9px; line-height:1.45; }
.movement-accordion-list { display:grid; gap:8px; }
.movement-accordion { border:1px solid #29364a; background:#0d131c; border-radius:13px; overflow:hidden; min-width:0; }
.movement-accordion > summary { list-style:none; cursor:pointer; padding:12px 13px; }
.movement-accordion > summary::-webkit-details-marker { display:none; }
.movement-summary-main { display:grid; grid-template-columns:minmax(0,1fr) auto 18px; align-items:center; gap:12px; min-width:0; }
.movement-summary-copy { min-width:0; display:grid; gap:7px; }
.movement-summary-copy > strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; }
.movement-summary-route { color:#8f9caf; font-size:9px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.movement-summary-route b { color:#9aa8ff; font-weight:900; }
.movement-summary-value { flex:0 0 auto; text-align:right; display:grid; gap:2px; }
.movement-summary-value > strong { font-size:13px; white-space:nowrap; }
.movement-summary-value > small { color:var(--muted); font-size:8px; white-space:nowrap; }
.movement-accordion[open] > summary { background:#101823; border-bottom:1px solid #202a39; }
.movement-accordion[open] .accordion-chevron { transform:rotate(180deg); }
.movement-accordion-body { padding:12px 13px 13px; }
.movement-detail-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:9px 12px; }
.movement-detail-grid > div { min-width:0; padding:8px 9px; border:1px solid #243144; background:#101722; border-radius:9px; }
.movement-detail-grid span { display:block; color:#718096; font-size:8px; text-transform:uppercase; letter-spacing:.055em; font-weight:800; margin-bottom:3px; }
.movement-detail-grid strong { display:block; font-size:10px; line-height:1.35; overflow:hidden; text-overflow:ellipsis; }
.movement-change-preview { display:grid; gap:6px; margin-top:10px; padding:9px 10px; border:1px solid #30405b; background:#11192a; border-radius:10px; }
.movement-change-preview > span { color:#aebbe0; font-size:9px; font-weight:850; }
.movement-change-preview > div { display:flex; justify-content:space-between; gap:10px; font-size:9px; }
.movement-change-preview > div span { color:#c3cceb; }
.movement-accordion .movement-card-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:7px; margin-top:11px; padding-top:11px; border-top:1px solid #202a39; }
.movement-accordion .movement-card-actions button { min-width:0; border:1px solid #2a374a; background:#171f2d; color:#d8e0ea; border-radius:9px; padding:8px 7px; font-size:9px; font-weight:750; }
.movement-accordion .movement-card-actions button.danger { color:#ff929a; }
@media (max-width:760px) {
.funding-accordion > summary { min-height:68px; padding:12px; }
.funding-summary-copy .ownership-badge,.funding-summary-copy .account-function-badge { font-size:8px; padding:3px 6px; }
.funding-summary-right > span:first-child { font-size:10px; }
.movement-summary-main { grid-template-columns:minmax(0,1fr) auto 15px; gap:8px; }
.movement-accordion > summary { padding:11px; }
.movement-summary-copy { gap:6px; }
.movement-summary-copy > strong { font-size:12px; }
.movement-summary-value > strong { font-size:12px; }
.movement-summary-value > small { font-size:7px; }
.movement-accordion .category-badge,.movement-accordion .person-badge,.movement-accordion .interval-badge,.movement-accordion .account-badge,.movement-accordion .due-badge,.movement-accordion .transfer-class-badge,.movement-accordion .reminder-badge,.movement-accordion .change-badge,.movement-accordion .end-badge { font-size:7px; padding:3px 6px; margin-left:0; margin-top:0; }
.movement-detail-grid { grid-template-columns:1fr 1fr; gap:7px; }
.movement-accordion-body { padding:10px 11px 11px; }
.movement-summary-route { font-size:8px; }
}
@media (max-width:430px) {
.movement-summary-main { grid-template-columns:minmax(0,1fr) auto 13px; }
.movement-summary-value > small { display:none; }
.movement-detail-grid { grid-template-columns:1fr 1fr; }
.compact-badges { gap:4px; }
.funding-summary-copy .ownership-badge { display:none; }
}
/* v3.29 gebündelte Navigation und zentrale Stammdaten */
.section-tabs { display:flex; flex-wrap:wrap; gap:7px; padding:4px; border:1px solid #263247; background:#0b1119; border-radius:13px; }
.section-tabs button { display:flex; align-items:center; justify-content:center; gap:7px; min-height:38px; border:0; border-radius:9px; padding:8px 13px; background:transparent; color:var(--muted); font-weight:800; }
.section-tabs button:hover { background:#151e2b; color:var(--text); }
.section-tabs button.active { background:#202a39; color:var(--text); box-shadow:inset 0 0 0 1px #35445d; }
.section-tabs button small { min-width:20px; padding:2px 6px; border-radius:999px; background:#182130; color:#9aa8ba; font-size:9px; }
.section-tabs button.active small { background:#34415a; color:#dfe5ee; }
.master-data-header .section-head,
.settings-data-header .section-head { margin-bottom:12px; }
.master-data-counts { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:6px; }
.master-data-counts span { border:1px solid #2b394f; background:#101722; border-radius:999px; padding:5px 8px; color:#aab5c6; font-size:9px; font-weight:800; white-space:nowrap; }
.master-data-page > .panel:not(.master-data-header),
.settings-data-page > .panel:not(.settings-data-header) { margin-top:0; }
@media (max-width:760px) {
.section-tabs { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px; }
.settings-data-header .section-tabs { grid-template-columns:repeat(2,minmax(0,1fr)); }
.section-tabs button { min-width:0; padding:8px 5px; font-size:11px; }
.master-data-header .section-head { align-items:flex-start; }
.master-data-counts { justify-content:flex-start; }
}