From 6b8c428ced20cc2ea48bcf5b967d31d082c56db8 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Tue, 18 Aug 2026 17:21:43 +0200 Subject: [PATCH] V3.7 --- server/index.js | 9 ++-- src/main.jsx | 113 ++++++++++++++++++++++++++++++++++++++++++------ src/styles.css | 71 ++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 18 deletions(-) diff --git a/server/index.js b/server/index.js index 12b8acc..b45a8c8 100644 --- a/server/index.js +++ b/server/index.js @@ -7,15 +7,16 @@ import { fileURLToPath } from 'node:url'; 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 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); 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 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 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);} 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}`);}); diff --git a/src/main.jsx b/src/main.jsx index 4a138e4..b40a02e 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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:<>, @@ -145,6 +170,7 @@ function Icon({ name }) { plus:<>, minus:<>, transfer:<>, calendar:<>, + chart:<>, data:<>, }; return ; @@ -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
FixFin wird geladen …
; - 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
-

{nav.find(x=>x[0]===tab)?.[2]}

Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.

{!['dashboard','year','data'].includes(tab)&&}
+

{nav.find(x=>x[0]===tab)?.[2]}

Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.

{!['dashboard','year','stats','data'].includes(tab)&&}
{error&&
{error}
} - {tab==='dashboard'&&} {tab==='accounts'&&setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} {tab==='incomes'&&setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&setDialog({type:'expenses',item:x})} onDelete={removeItem}/>} {tab==='transfers'&&setDialog({type:'transfers',item:x})} onDelete={removeItem}/>} {tab==='year'&&} {tab==='data'&&} + {tab==='dashboard'&&} {tab==='accounts'&&setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} {tab==='incomes'&&setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&setDialog({type:'expenses',item:x})} onDelete={removeItem}/>} {tab==='transfers'&&setDialog({type:'transfers',item:x})} onDelete={removeItem}/>} {tab==='year'&&} {tab==='stats'&&setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>} {tab==='data'&&}
{dialog&&setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>} @@ -183,9 +210,33 @@ function Dashboard({totals,onToggle}){
; } function Metric({label,value,positive,negative,featured}){return
{label}{formatMoney(value)}
} +function CountMetric({label,value}){return
{label}{Number(value||0).toLocaleString('de-DE')}
} function Accounts({data,onEdit,onDelete,onAdd}){return

Konten

Konten dienen nur zur Zuordnung. Es gibt keine Kontostände.

{data.accounts.length===0?:
{data.accounts.map(a=>)}
Konto
{a.name}
}
} -function Entries({type,items,accounts,onEdit,onDelete}){const accountName=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt';const isIncome=type==='incomes';return

{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}

Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.

{items.length===0?:
{items.map(x=>)}
BezeichnungKontoIntervallMonatBetragØ / Monat
{x.name}{accountName(x.accountId)}{intervals[x.interval||'monthly'].label}{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}{isIncome?'+':'−'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
}
} -function Transfers({data,onEdit,onDelete}){const accountName=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';return

Fixe Transfers

Verschiebungen zwischen eigenen Konten. Global saldoneutral.

{data.transfers.length===0?:
{data.transfers.map(x=>)}
BezeichnungVonNachIntervallMonatBetragØ / Monat
{x.name}{accountName(x.fromAccountId)}{accountName(x.toAccountId)}{intervals[x.interval||'monthly'].label}{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
}
} +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

{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}

Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.

{items.length===0?:
{items.map(x=>)}
BezeichnungKategorieKontoIntervallMonatBetragØ / Monat
{x.name}{categoryName(x.categoryId)}{accountName(x.accountId)}{intervals[x.interval||'monthly'].label}{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}{isIncome?'+':'−'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
}
} +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

Fixe Transfers

Verschiebungen zwischen eigenen Konten. Global saldoneutral.

{data.transfers.length===0?:
{data.transfers.map(x=>)}
BezeichnungKategorieVonNachIntervallMonatBetragØ / Monat
{x.name}{categoryName(x.categoryId)}{accountName(x.fromAccountId)}{accountName(x.toAccountId)}{intervals[x.interval||'monthly'].label}{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
}
} + +function CategoryDistribution({title,subtitle,stats,tone}){ + return

{title}

{subtitle}

{formatMoney(stats.total)} / Monat
+ {stats.rows.length===0?:
{stats.rows.map((row,index)=>
{index+1}{row.name}{row.count} {row.count===1?'Position':'Positionen'}
{formatMoney(row.amount)}{row.percent.toFixed(1).replace('.',',')} %
)}
} +
; +} + +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
+
Statistik für{selectedName}
+
+ + + +

Kategorien verwalten

Kategorien gelten für Eingänge, Ausgänge und Transfers gemeinsam.

{data.categories.length===0?:
{data.categories.map(c=>
{c.name}{usage(c.id)} {usage(c.id)===1?'zugeordneter Eintrag':'zugeordnete Einträge'}
)}
}
+
; +} function YearPage({data}){ const [accountId,setAccountId]=useState('all'); @@ -201,7 +252,7 @@ function YearPage({data}){
Jahresansicht für{selectedName}
-
=forecast.yearExpense} negative={forecast.yearIncome
+
=forecast.yearExpense} negative={forecast.yearIncome

Jahresprognose

{forecast.isAll?'Gesamt über alle Konten. Transfers bleiben saldoneutral.':`Prognose für ${selectedName}. Transfers werden als Zu- oder Abfluss dieses Kontos berücksichtigt.`}

{forecast.rows.map(r=>
@@ -226,15 +277,49 @@ function YearPage({data}){
; } -function DataPage({data,importFile}){return

JSON sichern

Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig eine .bak-Datei angelegt.

JSON herunterladen

Aktueller Datenstand

{data.accounts.length} Konten · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers

{JSON.stringify(data,null,2)}
} +function DataPage({data,importFile}){return

JSON sichern

Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig eine .bak-Datei angelegt.

JSON herunterladen

Aktueller Datenstand

{data.accounts.length} Konten · {data.categories.length} Kategorien · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers

{JSON.stringify(data,null,2)}
} function Empty({text}){return
{text}
} 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
e.target===e.currentTarget&&onClose()}>
{item?'BEARBEITEN':'NEU'}

{labels[type]}

{localError&&
{localError}
} - {type!=='accounts'&&<>{interval!=='monthly'&&}} - {(type==='incomes'||type==='expenses')&&}{type==='transfers'&&<>}
; + 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
e.target===e.currentTarget&&onClose()}>
{item?'BEARBEITEN':'NEU'}

{labels[type]}

{localError&&
{localError}
} + {!simpleType&&<>{interval!=='monthly'&&}} + {(type==='incomes'||type==='expenses'||type==='transfers')&&<>{categoryId==='__new__'&&}} + {(type==='incomes'||type==='expenses')&&} + {type==='transfers'&&<>}
; } createRoot(document.getElementById('root')).render(); diff --git a/src/styles.css b/src/styles.css index 2f0b901..f53f5f5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } +}