V3.7
This commit is contained in:
+99
-14
@@ -10,7 +10,7 @@ const intervals = {
|
||||
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
|
||||
yearly: { label: 'Jährlich', divisor: 12, step: 12 },
|
||||
};
|
||||
const emptyData = { version: 3, settings: { includePeriodicInBalance: true }, accounts: [], incomes: [], expenses: [], transfers: [] };
|
||||
const emptyData = { version: 5, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [] };
|
||||
|
||||
function formatMoney(value) { return euro.format(Number(value || 0)); }
|
||||
function parseAmount(value) {
|
||||
@@ -138,6 +138,31 @@ function annualForecast(data, accountId = 'all') {
|
||||
return { rows, unassigned, yearIncome, yearExpense, isAll, accountId };
|
||||
}
|
||||
|
||||
function categoryStats(data, accountId = 'all') {
|
||||
const categoryMap = new Map((data.categories || []).map(c => [c.id, c.name]));
|
||||
const filteredEntries = items => accountId === 'all' ? items : items.filter(x => x.accountId === accountId);
|
||||
const filteredTransfers = items => accountId === 'all' ? items : items.filter(x => x.fromAccountId === accountId || x.toAccountId === accountId);
|
||||
const aggregate = items => {
|
||||
const buckets = new Map();
|
||||
for (const item of items) {
|
||||
const key = item.categoryId || '__uncategorized__';
|
||||
const name = categoryMap.get(item.categoryId) || 'Ohne Kategorie';
|
||||
const current = buckets.get(key) || { id:key, name, amount:0, count:0, uncategorized:key==='__uncategorized__' };
|
||||
current.amount += monthlyEquivalent(item);
|
||||
current.count += 1;
|
||||
buckets.set(key,current);
|
||||
}
|
||||
const total = [...buckets.values()].reduce((sum,x)=>sum+x.amount,0);
|
||||
const rows = [...buckets.values()].sort((a,b)=>b.amount-a.amount).map(x=>({...x,percent:total>0?(x.amount/total)*100:0}));
|
||||
return { total, rows };
|
||||
};
|
||||
return {
|
||||
incomes: aggregate(filteredEntries(data.incomes)),
|
||||
expenses: aggregate(filteredEntries(data.expenses)),
|
||||
transfers: aggregate(filteredTransfers(data.transfers)),
|
||||
};
|
||||
}
|
||||
|
||||
function Icon({ name }) {
|
||||
const paths = {
|
||||
dashboard:<><rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/></>,
|
||||
@@ -145,6 +170,7 @@ function Icon({ name }) {
|
||||
plus:<><path d="M12 5v14M5 12h14"/></>, minus:<><path d="M5 12h14"/></>,
|
||||
transfer:<><path d="m17 3 4 4-4 4"/><path d="M3 7h18"/><path d="m7 21-4-4 4-4"/><path d="M21 17H3"/></>,
|
||||
calendar:<><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/></>,
|
||||
chart:<><path d="M4 20V10"/><path d="M10 20V4"/><path d="M16 20v-7"/><path d="M22 20H2"/></>,
|
||||
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"/></>,
|
||||
};
|
||||
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>;
|
||||
@@ -157,16 +183,17 @@ function App() {
|
||||
async function persist(next){setSaving(true);setError('');try{const r=await fetch('/api/data',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(next)});const result=await r.json();if(!r.ok)throw new Error(result.error||'Speichern fehlgeschlagen.');setData(result);}catch(e){setError(e.message);throw e;}finally{setSaving(false);}}
|
||||
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);if(used)return setError('Das Konto wird noch verwendet. Einträge oder Transfers 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)});}
|
||||
async function importFile(file){try{await persist(JSON.parse(await file.text()));setTab('dashboard');}catch(e){setError(`Import fehlgeschlagen: ${e.message}`);}}
|
||||
async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});}
|
||||
if(loading)return <div className="loading">FixFin wird geladen …</div>;
|
||||
|
||||
const nav=[['dashboard','dashboard','Übersicht'],['accounts','wallet','Konten'],['incomes','plus','Eingänge'],['expenses','minus','Ausgänge'],['transfers','transfer','Transfers'],['year','calendar','Jahr'],['data','data','Daten']];
|
||||
const nav=[['dashboard','dashboard','Übersicht'],['accounts','wallet','Konten'],['incomes','plus','Eingänge'],['expenses','minus','Ausgänge'],['transfers','transfer','Transfers'],['year','calendar','Jahr'],['stats','chart','Statistik'],['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>{!['dashboard','year','data'].includes(tab)&&<button className="primary" onClick={()=>setDialog({type:tab})}>+ Neu</button>}</header>
|
||||
<main><header className="topbar"><div><h1>{nav.find(x=>x[0]===tab)?.[2]}</h1><p>Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.</p></div>{!['dashboard','year','stats','data'].includes(tab)&&<button className="primary" onClick={()=>setDialog({type:tab})}>+ Neu</button>}</header>
|
||||
{error&&<div className="alert"><span>{error}</span><button onClick={()=>setError('')}>×</button></div>}
|
||||
{tab==='dashboard'&&<Dashboard totals={totals} onToggle={togglePeriodic}/>} {tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} {tab==='incomes'&&<Entries type="incomes" items={data.incomes} accounts={data.accounts} onEdit={x=>setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&<Entries type="expenses" items={data.expenses} accounts={data.accounts} onEdit={x=>setDialog({type:'expenses',item:x})} onDelete={removeItem}/>} {tab==='transfers'&&<Transfers data={data} onEdit={x=>setDialog({type:'transfers',item:x})} onDelete={removeItem}/>} {tab==='year'&&<YearPage data={data}/>} {tab==='data'&&<DataPage data={data} importFile={importFile}/>}
|
||||
{tab==='dashboard'&&<Dashboard totals={totals} onToggle={togglePeriodic}/>} {tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} {tab==='incomes'&&<Entries type="incomes" items={data.incomes} accounts={data.accounts} categories={data.categories} onEdit={x=>setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&<Entries type="expenses" items={data.expenses} accounts={data.accounts} categories={data.categories} onEdit={x=>setDialog({type:'expenses',item:x})} onDelete={removeItem}/>} {tab==='transfers'&&<Transfers data={data} onEdit={x=>setDialog({type:'transfers',item:x})} onDelete={removeItem}/>} {tab==='year'&&<YearPage data={data}/>} {tab==='stats'&&<StatisticsPage data={data} onAddCategory={()=>setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>} {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>
|
||||
{dialog&&<EditorDialog dialog={dialog} data={data} onClose={()=>setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>}
|
||||
@@ -183,9 +210,33 @@ function Dashboard({totals,onToggle}){
|
||||
</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 CountMetric({label,value}){return <div className="metric"><span>{label}</span><strong>{Number(value||0).toLocaleString('de-DE')}</strong></div>}
|
||||
function Accounts({data,onEdit,onDelete,onAdd}){return <section className="panel"><div className="section-head"><div><h2>Konten</h2><p>Konten dienen nur zur Zuordnung. Es gibt keine Kontostände.</p></div><button className="secondary desktop-only" onClick={onAdd}>+ Konto</button></div>{data.accounts.length===0?<Empty text="Noch keine Konten vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Konto</th><th></th></tr></thead><tbody>{data.accounts.map(a=><tr key={a.id}><td><strong>{a.name}</strong></td><td className="actions"><button onClick={()=>onEdit(a)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
|
||||
function Entries({type,items,accounts,onEdit,onDelete}){const accountName=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt';const isIncome=type==='incomes';return <section className="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.`}/>:<div className="table-wrap"><table><thead><tr><th>Bezeichnung</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></td><td>{accountName(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':'−'}{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
|
||||
function Transfers({data,onEdit,onDelete}){const accountName=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';return <section className="panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Bezeichnung</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td>{accountName(x.fromAccountId)}</td><td>{accountName(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
|
||||
function Entries({type,items,accounts,categories,onEdit,onDelete}){const accountName=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt';const categoryName=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie';const isIncome=type==='incomes';return <section className="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.`}/>:<div className="table-wrap"><table><thead><tr><th>Bezeichnung</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></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryName(x.categoryId)}</span></td><td>{accountName(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':'−'}{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
|
||||
function Transfers({data,onEdit,onDelete}){const accountName=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';const categoryName=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';return <section className="panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryName(x.categoryId)}</span></td><td>{accountName(x.fromAccountId)}</td><td>{accountName(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
|
||||
|
||||
function CategoryDistribution({title,subtitle,stats,tone}){
|
||||
return <section className="panel category-stat-panel"><div className="section-head"><div><h2>{title}</h2><p>{subtitle}</p></div><strong className={`category-total ${tone==='income'?'good-text':tone==='expense'?'bad-text':''}`}>{formatMoney(stats.total)} / Monat</strong></div>
|
||||
{stats.rows.length===0?<Empty text="Noch keine Werte für diese Auswahl vorhanden."/>:<div className="category-distribution">{stats.rows.map((row,index)=><div className="category-stat-row" key={row.id}><div className="category-stat-head"><div><span className="category-rank">{index+1}</span><strong>{row.name}</strong><small>{row.count} {row.count===1?'Position':'Positionen'}</small></div><div><strong>{formatMoney(row.amount)}</strong><span>{row.percent.toFixed(1).replace('.',',')} %</span></div></div><div className="category-bar"><span className={tone} style={{width:`${Math.max(row.percent,1)}%`}}/></div></div>)}</div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
|
||||
const [accountId,setAccountId]=useState('all');
|
||||
const stats=useMemo(()=>categoryStats(data,accountId),[data,accountId]);
|
||||
const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten';
|
||||
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 relevantEntries=[...data.incomes.filter(x=>accountId==='all'||x.accountId===accountId),...data.expenses.filter(x=>accountId==='all'||x.accountId===accountId),...data.transfers.filter(x=>accountId==='all'||x.fromAccountId===accountId||x.toAccountId===accountId)];
|
||||
const uncategorized=relevantEntries.filter(x=>!x.categoryId).length;
|
||||
return <div className="content-stack stats-page">
|
||||
<section className="year-account-filter stats-filter"><div className="year-filter-copy"><span>Statistik für</span><strong>{selectedName}</strong></div><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></section>
|
||||
<section className="summary-grid stats-summary"><Metric label="Eingänge Ø / Monat" value={stats.incomes.total} positive/><Metric label="Ausgänge Ø / Monat" value={stats.expenses.total} negative/><CountMetric label="Kategorien" value={data.categories.length}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section>
|
||||
<CategoryDistribution title="Ausgaben nach Kategorien" subtitle="Verteilung auf Basis des durchschnittlichen Monatswerts. Periodische Ausgaben werden heruntergerechnet." stats={stats.expenses} tone="expense"/>
|
||||
<CategoryDistribution title="Eingänge nach Kategorien" subtitle="Verteilung der regelmäßigen Eingänge auf Kategorien, ebenfalls als Monatsdurchschnitt." stats={stats.incomes} tone="income"/>
|
||||
<CategoryDistribution title="Transfers nach Kategorien" subtitle="Interne Verschiebungen separat nach Kategorien. Sie verändern den Gesamtsaldo weiterhin nicht." 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>;
|
||||
}
|
||||
|
||||
function YearPage({data}){
|
||||
const [accountId,setAccountId]=useState('all');
|
||||
@@ -201,7 +252,7 @@ function YearPage({data}){
|
||||
<div className="year-filter-copy"><span>Jahresansicht für</span><strong>{selectedName}</strong></div>
|
||||
<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>
|
||||
</section>
|
||||
<section className="summary-grid year-summary"><Metric label={forecast.isAll?'Einnahmen / Jahr':'Zuflüsse / Jahr'} value={forecast.yearIncome} positive/><Metric label={forecast.isAll?'Ausgaben / Jahr':'Abflüsse / Jahr'} value={forecast.yearExpense} negative/><Metric label="Saldo / Jahr" value={forecast.yearIncome-forecast.yearExpense} positive={forecast.yearIncome>=forecast.yearExpense} negative={forecast.yearIncome<forecast.yearExpense}/><Metric label="Nicht zugeordnet" value={forecast.unassigned.length}/></section>
|
||||
<section className="summary-grid year-summary"><Metric label={forecast.isAll?'Einnahmen / Jahr':'Zuflüsse / Jahr'} value={forecast.yearIncome} positive/><Metric label={forecast.isAll?'Ausgaben / Jahr':'Abflüsse / Jahr'} value={forecast.yearExpense} negative/><Metric label="Saldo / Jahr" value={forecast.yearIncome-forecast.yearExpense} positive={forecast.yearIncome>=forecast.yearExpense} negative={forecast.yearIncome<forecast.yearExpense}/><CountMetric label="Nicht zugeordnet" value={forecast.unassigned.length}/></section>
|
||||
<section className="panel year-forecast-panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>{forecast.isAll?'Gesamt über alle Konten. Transfers bleiben saldoneutral.':`Prognose für ${selectedName}. Transfers werden als Zu- oder Abfluss dieses Kontos berücksichtigt.`}</p></div></div>
|
||||
<div className="year-month-list">{forecast.rows.map(r=><details className="year-month-card" key={`${accountId}-${r.name}`}>
|
||||
<summary>
|
||||
@@ -226,15 +277,49 @@ function YearPage({data}){
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DataPage({data,importFile}){return <div className="content-stack"><section className="panel"><div className="section-head"><div><h2>JSON sichern</h2><p>Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row"><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"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>{data.accounts.length} Konten · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers</p></div></div><pre className="json-preview">{JSON.stringify(data,null,2)}</pre></section></div>}
|
||||
function DataPage({data,importFile}){return <div className="content-stack"><section className="panel"><div className="section-head"><div><h2>JSON sichern</h2><p>Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row"><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"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>{data.accounts.length} Konten · {data.categories.length} Kategorien · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers</p></div></div><pre className="json-preview">{JSON.stringify(data,null,2)}</pre></section></div>}
|
||||
function Empty({text}){return <div className="empty">{text}</div>}
|
||||
|
||||
function EditorDialog({dialog,data,onClose,onSave}){
|
||||
const type=dialog.type,item=dialog.item; const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[localError,setLocalError]=useState('');
|
||||
const labels={accounts:'Konto',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'};
|
||||
async function submit(e){e.preventDefault();setLocalError('');const numeric=parseAmount(amount);if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.');if(type!=='accounts'&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.');if(type!=='accounts'&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.');if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.');let next=structuredClone(data);const id=item?.id||uid();let value;if(type==='accounts')value={id,name:name.trim()};else {const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null)};if(type==='incomes'||type==='expenses')value={...common,accountId};if(type==='transfers')value={...common,fromAccountId,toAccountId};}next[type]=item?next[type].map(x=>x.id===item.id?value:x):[...next[type],value];try{await onSave(next);}catch(e){setLocalError(e.message);}}
|
||||
return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">{item?'BEARBEITEN':'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{localError&&<div className="alert">{localError}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={type==='accounts'?'z. B. Girokonto':'z. B. Kfz-Versicherung'}/></label>
|
||||
{type!=='accounts'&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span>€</span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}</>}
|
||||
{(type==='incomes'||type==='expenses')&&<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>}{type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>;
|
||||
const type=dialog.type,item=dialog.item;
|
||||
const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[localError,setLocalError]=useState('');
|
||||
const labels={accounts:'Konto',categories:'Kategorie',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'};
|
||||
async function submit(e){
|
||||
e.preventDefault();setLocalError('');
|
||||
const numeric=parseAmount(amount);
|
||||
if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.');
|
||||
if(type!=='accounts'&&type!=='categories'&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.');
|
||||
if(!['accounts','categories'].includes(type)&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.');
|
||||
if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.');
|
||||
let next=structuredClone(data);const id=item?.id||uid();let value;
|
||||
if(type==='accounts') value={id,name:name.trim()};
|
||||
else if(type==='categories') {
|
||||
const duplicate=next.categories.some(c=>c.id!==item?.id&&c.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE'));
|
||||
if(duplicate)return setLocalError('Diese Kategorie existiert bereits.');
|
||||
value={id,name:name.trim()};
|
||||
} else {
|
||||
const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null)};
|
||||
if(type==='incomes'||type==='expenses'||type==='transfers') {
|
||||
let selectedCategoryId=categoryId||null;
|
||||
if(categoryId==='__new__'){
|
||||
const categoryName=newCategoryName.trim();
|
||||
if(!categoryName)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');
|
||||
const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryName.toLocaleLowerCase('de-DE'));
|
||||
if(existing) selectedCategoryId=existing.id;
|
||||
else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryName}); }
|
||||
}
|
||||
if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId};
|
||||
else value={...common,accountId,categoryId:selectedCategoryId};
|
||||
}
|
||||
}
|
||||
next[type]=item?next[type].map(x=>x.id===item.id?value:x):[...next[type],value];
|
||||
try{await onSave(next);}catch(e){setLocalError(e.message);}
|
||||
}
|
||||
const simpleType=type==='accounts'||type==='categories';
|
||||
return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">{item?'BEARBEITEN':'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{localError&&<div className="alert">{localError}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={type==='accounts'?'z. B. Girokonto':type==='categories'?'z. B. Wohnen':'z. B. Kfz-Versicherung'}/></label>
|
||||
{!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span>€</span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}</>}
|
||||
{(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Kategorie</span><select value={categoryId} onChange={e=>setCategoryId(e.target.value)}><option value="">Ohne Kategorie</option>{data.categories.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}<option value="__new__">+ Neue Kategorie …</option></select></label>{categoryId==='__new__'&&<label className="new-category-field"><span>Neue Kategorie</span><input value={newCategoryName} onChange={e=>setNewCategoryName(e.target.value)} placeholder="z. B. Sparen"/></label>}</>}
|
||||
{(type==='incomes'||type==='expenses')&&<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>}
|
||||
{type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>;
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(<App/>);
|
||||
|
||||
@@ -312,3 +312,74 @@ code { color:#b8c3ff; }
|
||||
.year-account-filter label { min-width:0; width:100%; }
|
||||
.year-filter-copy strong { font-size:16px; }
|
||||
}
|
||||
|
||||
/* v3.6: frei definierbare Kategorien + Kategorie-Statistik */
|
||||
.category-badge {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
max-width:180px;
|
||||
padding:4px 8px;
|
||||
border-radius:999px;
|
||||
border:1px solid #3a4760;
|
||||
background:#172130;
|
||||
color:#c7d0df;
|
||||
font-size:10px;
|
||||
font-weight:800;
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.category-badge.muted-category {
|
||||
color:#778499;
|
||||
border-style:dashed;
|
||||
background:#101722;
|
||||
}
|
||||
.new-category-field {
|
||||
padding:10px;
|
||||
margin-top:-4px;
|
||||
border:1px solid #334158;
|
||||
background:#0d141f;
|
||||
border-radius:11px;
|
||||
}
|
||||
.stats-summary { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||||
.category-stat-panel .section-head { align-items:flex-end; }
|
||||
.category-total { flex:0 0 auto; font-size:18px; white-space:nowrap; }
|
||||
.category-distribution { display:grid; gap:15px; }
|
||||
.category-stat-row { min-width:0; }
|
||||
.category-stat-head { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-bottom:7px; }
|
||||
.category-stat-head > div:first-child { min-width:0; display:grid; grid-template-columns:auto minmax(0,1fr); align-items:center; column-gap:8px; }
|
||||
.category-stat-head > div:first-child strong { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; }
|
||||
.category-stat-head > div:first-child small { grid-column:2; color:var(--muted); font-size:9px; margin-top:1px; }
|
||||
.category-rank { grid-row:1 / span 2; width:24px; height:24px; display:grid; place-items:center; border-radius:8px; background:#182131; border:1px solid #2d3a50; color:#8996aa; font-size:9px; font-weight:850; }
|
||||
.category-stat-head > div:last-child { flex:0 0 auto; display:flex; align-items:baseline; gap:8px; text-align:right; }
|
||||
.category-stat-head > div:last-child strong { font-size:13px; white-space:nowrap; }
|
||||
.category-stat-head > div:last-child span { color:var(--muted); font-size:9px; min-width:42px; }
|
||||
.category-bar { height:8px; overflow:hidden; border-radius:999px; background:#0a1018; border:1px solid #202b3b; }
|
||||
.category-bar span { display:block; height:100%; border-radius:inherit; min-width:2px; }
|
||||
.category-bar span.expense { background:linear-gradient(90deg,#d65f69,#ff8a93); }
|
||||
.category-bar span.income { background:linear-gradient(90deg,#43ae85,#68d9aa); }
|
||||
.category-bar span.transfer { background:linear-gradient(90deg,#7087e8,#9eaeff); }
|
||||
.category-manager-list { display:grid; gap:8px; }
|
||||
.category-manager-row { display:flex; align-items:center; justify-content:space-between; gap:14px; border:1px solid #29364a; background:#0d131c; border-radius:12px; padding:11px 12px; }
|
||||
.category-manager-row > div:first-child { min-width:0; }
|
||||
.category-manager-row > div:first-child strong { display:block; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.category-manager-row > div:first-child span { display:block; color:var(--muted); font-size:9px; margin-top:2px; }
|
||||
|
||||
@media (max-width:760px) {
|
||||
.mobile-nav { grid-template-columns:repeat(8,minmax(50px,1fr)); }
|
||||
.stats-summary { grid-template-columns:1fr 1fr; }
|
||||
.category-stat-panel .section-head { align-items:flex-start; flex-direction:column; gap:7px; }
|
||||
.category-total { font-size:15px; }
|
||||
.category-stat-head { gap:8px; }
|
||||
.category-stat-head > div:last-child { gap:5px; }
|
||||
.category-stat-head > div:last-child strong { font-size:12px; }
|
||||
.category-stat-head > div:last-child span { min-width:38px; }
|
||||
.category-manager-row { align-items:flex-start; flex-direction:column; }
|
||||
.category-manager-row .actions { width:100%; text-align:left; }
|
||||
.category-manager-row .actions button { margin-left:0; margin-right:5px; }
|
||||
}
|
||||
|
||||
@media (max-width:430px) {
|
||||
.stats-summary { grid-template-columns:1fr 1fr; }
|
||||
.category-stat-head > div:last-child { flex-direction:column; align-items:flex-end; gap:0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user