This commit is contained in:
2026-08-18 17:21:43 +02:00
parent 309249b7e7
commit 6b8c428ced
3 changed files with 175 additions and 18 deletions
+5 -4
View File
@@ -7,15 +7,16 @@ import { fileURLToPath } from 'node:url';
const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__filename); const app=express(); const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__filename); const app=express();
const port=Number(process.env.PORT||3000); const dataFile=process.env.DATA_FILE||'/app/data/finance.json'; const dataDir=path.dirname(dataFile); const backupFile=`${dataFile}.bak`; const port=Number(process.env.PORT||3000); const dataFile=process.env.DATA_FILE||'/app/data/finance.json'; const dataDir=path.dirname(dataFile); const backupFile=`${dataFile}.bak`;
const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']); const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']);
const emptyData=()=>({version:3,settings:{includePeriodicInBalance:true},accounts:[],incomes:[],expenses:[],transfers:[]}); const emptyData=()=>({version:5,settings:{includePeriodicInBalance:true},accounts:[],categories:[],incomes:[],expenses:[],transfers:[]});
const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value); const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value);
function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null }; } function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null }; }
function normalizeData(input){if(!input||typeof input!=='object')return input;return {version:3,settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts,incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeMovement):input.incomes,expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeMovement):input.expenses,transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeMovement):input.transfers};} function normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null }; }
function normalizeData(input){if(!input||typeof input!=='object')return input;return {version:5,settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts,categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[],incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeCategorizedMovement):input.incomes,expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeCategorizedMovement):input.expenses,transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers};}
function validateMovement(item){if(!item.id||typeof item.id!=='string')throw new Error('Eintrag ohne gültige ID.');if(!item.name||typeof item.name!=='string')throw new Error('Eintrag ohne Namen.');if(!isFiniteNumber(item.amount)||item.amount<0)throw new Error(`Ungültiger Betrag bei '${item.name}'.`);if(!validIntervals.has(item.interval))throw new Error(`Ungültiges Intervall bei '${item.name}'.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);} function validateMovement(item){if(!item.id||typeof item.id!=='string')throw new Error('Eintrag ohne gültige ID.');if(!item.name||typeof item.name!=='string')throw new Error('Eintrag ohne Namen.');if(!isFiniteNumber(item.amount)||item.amount<0)throw new Error(`Ungültiger Betrag bei '${item.name}'.`);if(!validIntervals.has(item.interval))throw new Error(`Ungültiges Intervall bei '${item.name}'.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);}
function validateData(input){if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.');for(const key of ['accounts','incomes','expenses','transfers'])if(!Array.isArray(input[key]))throw new Error(`Feld '${key}' muss ein Array sein.`);const accountIds=new Set();for(const a of input.accounts){if(!a.id||typeof a.id!=='string')throw new Error('Konto ohne gültige ID.');if(!a.name||typeof a.name!=='string')throw new Error('Konto ohne Namen.');if(accountIds.has(a.id))throw new Error('Doppelte Konto-ID.');accountIds.add(a.id);}for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);}for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');}} function validateData(input){if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.');for(const key of ['accounts','categories','incomes','expenses','transfers'])if(!Array.isArray(input[key]))throw new Error(`Feld '${key}' muss ein Array sein.`);const accountIds=new Set();for(const a of input.accounts){if(!a.id||typeof a.id!=='string')throw new Error('Konto ohne gültige ID.');if(!a.name||typeof a.name!=='string')throw new Error('Konto ohne Namen.');if(accountIds.has(a.id))throw new Error('Doppelte Konto-ID.');accountIds.add(a.id);}const categoryIds=new Set();const categoryNames=new Set();for(const c of input.categories){if(!c.id||typeof c.id!=='string')throw new Error('Kategorie ohne gültige ID.');if(!c.name||typeof c.name!=='string'||!c.name.trim())throw new Error('Kategorie ohne Namen.');if(categoryIds.has(c.id))throw new Error('Doppelte Kategorie-ID.');const normalizedName=c.name.trim().toLocaleLowerCase('de-DE');if(categoryNames.has(normalizedName))throw new Error(`Kategorie '${c.name}' existiert bereits.`);categoryIds.add(c.id);categoryNames.add(normalizedName);}for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);}for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);}}
async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}} async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}}
async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);if(parsed.version!==3||!parsed.settings||[...parsed.incomes||[],...parsed.expenses||[],...parsed.transfers||[]].some(x=>!x.interval)||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;} async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);if(parsed.version!==5||!parsed.settings||!Array.isArray(parsed.categories)||[...parsed.incomes||[],...parsed.expenses||[],...parsed.transfers||[]].some(x=>!x.interval)||[...parsed.incomes||[],...parsed.expenses||[],...parsed.transfers||[]].some(x=>!Object.hasOwn(x,'categoryId'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;}
async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);} async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);}
app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}}); app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}});
const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);}); const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);});
+99 -14
View File
@@ -10,7 +10,7 @@ const intervals = {
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 }, semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
yearly: { label: 'Jährlich', divisor: 12, step: 12 }, 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 formatMoney(value) { return euro.format(Number(value || 0)); }
function parseAmount(value) { function parseAmount(value) {
@@ -138,6 +138,31 @@ function annualForecast(data, accountId = 'all') {
return { rows, unassigned, yearIncome, yearExpense, isAll, accountId }; 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 }) { function Icon({ name }) {
const paths = { 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"/></>, 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"/></>, 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"/></>, 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"/></>, 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"/></>, 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>; 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);}} 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 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 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 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)}});} async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});}
if(loading)return <div className="loading">FixFin wird geladen </div>; 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"> 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> <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>} {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> </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">{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);}}/>} {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>; </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 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 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 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';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 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}){ function YearPage({data}){
const [accountId,setAccountId]=useState('all'); 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> <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> <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>
<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> <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}`}> <div className="year-month-list">{forecast.rows.map(r=><details className="year-month-card" key={`${accountId}-${r.name}`}>
<summary> <summary>
@@ -226,15 +277,49 @@ function YearPage({data}){
</div>; </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 Empty({text}){return <div className="empty">{text}</div>}
function EditorDialog({dialog,data,onClose,onSave}){ 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 type=dialog.type,item=dialog.item;
const labels={accounts:'Konto',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'}; 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('');
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);}} const labels={accounts:'Konto',categories:'Kategorie',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'};
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> async function submit(e){
{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>}</>} e.preventDefault();setLocalError('');
{(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 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/>); createRoot(document.getElementById('root')).render(<App/>);
+71
View File
@@ -312,3 +312,74 @@ code { color:#b8c3ff; }
.year-account-filter label { min-width:0; width:100%; } .year-account-filter label { min-width:0; width:100%; }
.year-filter-copy strong { font-size:16px; } .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; }
}