This commit is contained in:
2026-08-19 11:56:40 +02:00
parent 38b6b8b1fa
commit 28d4f51d7d
5 changed files with 160 additions and 137 deletions
+57 -11
View File
@@ -220,13 +220,54 @@ function Icon({ name }) {
flask:<><path d="M9 3h6"/><path d="M10 3v6l-5 8a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-8V3"/><path d="M8 14h8"/></>,
planning:<><path d="M4 19V9"/><path d="M10 19V5"/><path d="M16 19v-7"/><path d="M22 19H2"/><path d="m4 7 6-4 6 6 5-4"/></>,
data:<><path d="M12 3c5 0 9 1.3 9 3s-4 3-9 3-9-1.3-9-3 4-3 9-3Z"/><path d="M3 6v6c0 1.7 4 3 9 3s9-1.3 9-3V6"/><path d="M3 12v6c0 1.7 4 3 9 3s9-1.3 9-3v-6"/></>,
statement:<><path d="M6 2h9l4 4v16H6z"/><path d="M14 2v5h5"/><path d="M9 12h7M9 16h7"/></>,
more:<><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
};
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>;
}
const PAGE_META={
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.'},
planning:{label:'Planung',description:'Kontobedarf und Rücklagen für die laufende Planung.'},
year:{label:'Jahr',description:'Jahresplanung zwischen wirtschaftlicher Sicht und Kontoliquidität.'},
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',description:'Konten für Einnahmen, Ausgaben und Transfers verwalten.'},
scenarios:{label:'Szenarien',description:'Was-wäre-wenn-Varianten ohne Änderung der echten Planung.'},
data:{label:'Daten',description:'Backup, Import und Pushover-Einstellungen.'},
};
const NAV_GROUPS={
analytics:{label:'Auswertung',items:[
{tab:'year',icon:'calendar',label:'Jahr',description:'Jahresplan & Liquidität'},
{tab:'stats',icon:'chart',label:'Statistik',description:'Kategorien & Kennzahlen'},
{tab:'statements',icon:'statement',label:'Auszüge',description:'PDF- & Excel-Kontoauszüge'},
]},
more:{label:'Mehr',items:[
{tab:'accounts',icon:'wallet',label:'Konten',description:'Konten verwalten'},
{tab:'scenarios',icon:'flask',label:'Szenarien',description:'Was-wäre-wenn-Planungen'},
{tab:'data',icon:'data',label:'Daten',description:'Backup, Import & Pushover'},
]},
};
const MAIN_NAV=[
{tab:'dashboard',icon:'dashboard',label:'Übersicht'},
{tab:'movements',icon:'transfer',label:'Bewegungen'},
{tab:'planning',icon:'planning',label:'Planung'},
{menu:'analytics',icon:'chart',label:'Auswertung'},
{menu:'more',icon:'more',label:'Mehr'},
];
function NavGroupMenu({group,tab,onClose,onSelect}){
const config=NAV_GROUPS[group];
useEffect(()=>{const key=e=>e.key==='Escape'&&onClose();window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[onClose]);
if(!config)return null;
return <div className="nav-menu-backdrop" onClick={onClose}><section className="nav-menu-sheet" onClick={e=>e.stopPropagation()}><div className="nav-menu-head"><div><span>Navigation</span><strong>{config.label}</strong></div><button className="close" onClick={onClose}>×</button></div><div className="nav-menu-items">{config.items.map(item=><button key={item.tab} className={tab===item.tab?'active':''} onClick={()=>onSelect(item.tab)}><span className="nav-menu-icon"><Icon name={item.icon}/></span><span className="nav-menu-copy"><strong>{item.label}</strong><small>{item.description}</small></span><span className="nav-menu-arrow"></span></button>)}</div></section></div>;
}
function App() {
const [data,setData]=useState(emptyData);
const [tab,setTab]=useState('dashboard');
const [navMenu,setNavMenu]=useState(null);
const [loading,setLoading]=useState(true);
const [saving,setSaving]=useState(false);
const [error,setError]=useState('');
@@ -237,14 +278,17 @@ function App() {
function removeAccount(id){const used=data.incomes.some(x=>x.accountId===id)||data.expenses.some(x=>x.accountId===id)||data.transfers.some(x=>x.fromAccountId===id||x.toAccountId===id)||(data.scenarios||[]).some(s=>(s.adjustments||[]).some(x=>x.accountId===id||x.fromAccountId===id||x.toAccountId===id));if(used)return setError('Das Konto wird noch verwendet. Einträge, Transfers oder Szenario-Anpassungen bitte zuerst löschen.');if(confirm('Konto wirklich löschen?'))persist({...data,accounts:data.accounts.filter(x=>x.id!==id)});}
function removeItem(type,id){if(confirm('Eintrag wirklich löschen?'))persist({...data,[type]:data[type].filter(x=>x.id!==id)});}
function removeCategory(id){const used=[...data.incomes,...data.expenses,...data.transfers].filter(x=>x.categoryId===id).length;const message=used?`Kategorie wird bei ${used} ${used===1?'Eintrag':'Einträgen'} entfernt. Kategorie wirklich löschen?`:'Kategorie wirklich löschen?';if(!confirm(message))return;persist({...data,categories:data.categories.filter(x=>x.id!==id),incomes:data.incomes.map(x=>x.categoryId===id?{...x,categoryId:null}:x),expenses:data.expenses.map(x=>x.categoryId===id?{...x,categoryId:null}:x),transfers:data.transfers.map(x=>x.categoryId===id?{...x,categoryId:null}:x),scenarios:(data.scenarios||[]).map(s=>({...s,adjustments:(s.adjustments||[]).map(x=>x.categoryId===id?{...x,categoryId:null}:x)}))});}
async function importFile(file){try{await persist(JSON.parse(await file.text()));setTab('dashboard');}catch(e){setError(`Import fehlgeschlagen: ${e.message}`);}}
async function importFile(file){try{await persist(JSON.parse(await file.text()));selectTab('dashboard');}catch(e){setError(`Import fehlgeschlagen: ${e.message}`);}}
async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});}
function selectTab(next){setTab(next);setNavMenu(null);}
function mainActive(item){if(item.tab)return tab===item.tab;return NAV_GROUPS[item.menu]?.items.some(x=>x.tab===tab);}
function activateMain(item){if(item.tab)selectTab(item.tab);else setNavMenu(current=>current===item.menu?null:item.menu);}
if(loading)return <div className="loading">FixFin wird geladen </div>;
const meta=PAGE_META[tab]||PAGE_META.dashboard;
const nav=[['dashboard','dashboard','Übersicht'],['accounts','wallet','Konten'],['movements','transfer','Bewegungen'],['year','calendar','Jahr'],['planning','planning','Planung'],['stats','chart','Statistik'],['scenarios','flask','Szenarien'],['data','data','Daten']];
return <div className="app-shell">
<aside className="sidebar"><div className="brand"><div className="brand-mark">F</div><div><strong>FixFin</strong><span>Fixkostenplanung</span></div></div><nav>{nav.map(([id,icon,label])=><button key={id} className={tab===id?'active':''} onClick={()=>setTab(id)}><Icon name={icon}/><span>{label}</span></button>)}</nav><div className="save-state">{saving?'Speichert …':'Automatisch gespeichert'}</div></aside>
<main><header className="topbar"><div><h1>{nav.find(x=>x[0]===tab)?.[2]}</h1><p>Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.</p></div>{tab==='accounts'&&<button className="primary" onClick={()=>setDialog({type:'accounts'})}>+ Konto</button>}</header>
<aside className="sidebar"><div className="brand"><div className="brand-mark">F</div><div><strong>FixFin</strong><span>Fixkostenplanung</span></div></div><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>{item.menu&&<small className="nav-group-caret"></small>}</button>)}</nav><div className="save-state">{saving?'Speichert …':'Automatisch gespeichert'}</div></aside>
<main><header className="topbar"><div><h1>{meta.label}</h1><p>{meta.description}</p></div>{tab==='accounts'&&<button className="primary" onClick={()=>setDialog({type:'accounts'})}>+ Konto</button>}</header>
{error&&<div className="alert"><span>{error}</span><button onClick={()=>setError('')}>×</button></div>}
{tab==='dashboard'&&<Dashboard data={data} totals={totals} onToggle={togglePeriodic}/>}
{tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>}
@@ -252,10 +296,12 @@ function App() {
{tab==='year'&&<YearPage data={data}/>}
{tab==='planning'&&<PlanningPage data={data} totals={totals}/>}
{tab==='stats'&&<StatisticsPage data={data} onAddCategory={()=>setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>}
{tab==='statements'&&<StatementsPage data={data}/>}
{tab==='scenarios'&&<ScenariosPage data={data} persist={persist}/>}
{tab==='data'&&<DataPage data={data} importFile={importFile}/>}
</main>
<nav className="mobile-nav">{nav.map(([id,icon,label])=><button key={id} className={tab===id?'active':''} onClick={()=>setTab(id)}><Icon name={icon}/><span>{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}/>}
{dialog&&<EditorDialog dialog={dialog} data={data} onClose={()=>setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>}
</div>;
}
@@ -534,13 +580,14 @@ function PushoverSettings(){
</section>;
}
function ReportExports({data}){
function StatementsPage({data}){
const currentYear=new Date().getFullYear();
const [year,setYear]=useState(currentYear);
const [accountId,setAccountId]=useState('all');
const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}`;
const scope=accountId==='all'?'Alle Konten':accountName(data,accountId);
return <section className="panel report-export-panel"><div className="section-head"><div><h2>Auszüge & Berichte</h2><p>Aufbereitete Planungsdaten als PDF oder Excel. Die Kontosicht enthält echte Ein-/Ausgänge und Transfers als getrennte Zahlungsströme.</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>Umfang</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></div><div className="report-preview"><div><span>Auszug</span><strong>{scope} · {year}</strong></div><p>PDF: druckfertiger Planungs-Auszug im Kontoauszug-Stil. Excel: Übersicht, Planungshinweise, Jahresplan, Bewegungen, Änderungen, Stammdaten, Konten/Kategorien, Szenarien und Konto-Blätter.</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">Planungs-Auszug aus FixFin, kein von einer Bank ausgestellter Kontoauszug.</p></section>;
const [accountId,setAccountId]=useState(data.accounts[0]?.id||'');
useEffect(()=>{if(accountId&&!data.accounts.some(a=>a.id===accountId))setAccountId(data.accounts[0]?.id||'');if(!accountId&&data.accounts[0])setAccountId(data.accounts[0].id);},[data.accounts,accountId]);
if(!data.accounts.length)return <div className="content-stack statements-page"><section className="panel"><Empty text="Lege zuerst ein Konto an, um einen Kontoauszug zu erstellen."/></section></div>;
const account=accountName(data,accountId);const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}`;
return <div className="content-stack statements-page"><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></div><div className="report-preview"><div><span>Kontoauszug</span><strong>{account} · {year}</strong></div><p>PDF: Jahreswerte und monatlich gruppierte Kontobewegungen. 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}){
@@ -554,7 +601,6 @@ function DataPage({data,importFile}){
];
const json=JSON.stringify(data,null,2);
return <div className="content-stack data-page">
<ReportExports data={data}/>
<PushoverSettings/>
<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 data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div>
+36
View File
@@ -963,3 +963,39 @@ code { color:#b8c3ff; }
.amount-change-add { grid-template-columns:1fr; }
.amount-change-add button { grid-column:auto; }
}
/* v3.17: reduzierte Hauptnavigation + Kontoauszüge */
.sidebar nav button { position:relative; }
.nav-group-caret { margin-left:auto; color:#66758a; font-size:18px; line-height:1; }
.sidebar nav button.active .nav-group-caret { color:#a7b2ff; }
.nav-menu-backdrop { position:fixed; inset:0; z-index:70; background:#02050a80; backdrop-filter:blur(3px); }
.nav-menu-sheet { position:fixed; left:270px; top:132px; width:min(360px,calc(100vw - 300px)); background:#111722; border:1px solid #334158; border-radius:18px; padding:15px; box-shadow:0 28px 90px #000a; }
.nav-menu-head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:2px 3px 11px; border-bottom:1px solid #253145; }
.nav-menu-head div > span { display:block; color:#718096; text-transform:uppercase; letter-spacing:.08em; font-size:8px; font-weight:850; margin-bottom:2px; }
.nav-menu-head div > strong { display:block; font-size:18px; }
.nav-menu-items { display:grid; gap:7px; margin-top:10px; }
.nav-menu-items button { width:100%; display:grid; grid-template-columns:38px minmax(0,1fr) 20px; align-items:center; gap:10px; border:1px solid transparent; background:#0d131c; color:var(--text); border-radius:12px; padding:10px; text-align:left; }
.nav-menu-items button:hover { background:#151e2b; border-color:#30405a; }
.nav-menu-items button.active { background:#18213a; border-color:#4b5fa6; }
.nav-menu-icon { width:36px; height:36px; display:grid; place-items:center; border-radius:10px; background:#182130; color:#9ba9ff; }
.nav-menu-icon svg { width:19px; height:19px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }
.nav-menu-copy { min-width:0; }
.nav-menu-copy strong { display:block; font-size:13px; margin-bottom:2px; }
.nav-menu-copy small { display:block; color:var(--muted); font-size:9px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.nav-menu-arrow { color:#718096; font-size:20px; text-align:right; }
.statements-page { max-width:820px; }
.statements-page .report-export-panel { border-color:#35466a; }
@media (max-width:760px) {
.mobile-nav { grid-template-columns:repeat(5,minmax(0,1fr)) !important; overflow-x:hidden !important; }
.mobile-nav button { min-width:0 !important; font-size:9px !important; padding:6px 2px !important; }
.mobile-nav svg { width:19px !important; height:19px !important; }
.nav-menu-sheet { left:12px; right:12px; top:auto; bottom:calc(72px + env(safe-area-inset-bottom)); width:auto; padding:13px; border-radius:17px; }
.nav-menu-backdrop { background:#02050aa8; }
.nav-menu-items button { grid-template-columns:40px minmax(0,1fr) 20px; padding:11px; }
.nav-menu-copy strong { font-size:14px; }
.nav-menu-copy small { font-size:10px; }
.statements-page { max-width:none; }
}