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 {paths[name]} ;
@@ -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
-
+
{error&&{error} setError('')}>×
}
- {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'&&}
{nav.map(([id,icon,label])=>setTab(id)}>{label} )}
{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.
+ Konto {data.accounts.length===0? :Konto {data.accounts.map(a=>{a.name} onEdit(a)}>Bearbeiten onDelete(a.id)}>Löschen )}
} }
-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? :Bezeichnung Konto Intervall Monat Betrag Ø / Monat {items.map(x=>{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))} onEdit(x)}>Bearbeiten onDelete(type,x.id)}>Löschen )}
} }
-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? :Bezeichnung Von Nach Intervall Monat Betrag Ø / Monat {data.transfers.map(x=>{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))} onEdit(x)}>Bearbeiten onDelete('transfers',x.id)}>Löschen )}
} }
+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? :Bezeichnung Kategorie Konto Intervall Monat Betrag Ø / Monat {items.map(x=>{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))} onEdit(x)}>Bearbeiten onDelete(type,x.id)}>Löschen )}
} }
+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? :Bezeichnung Kategorie Von Nach Intervall Monat Betrag Ø / Monat {data.transfers.map(x=>{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))} onEdit(x)}>Bearbeiten onDelete('transfers',x.id)}>Löschen )}
} }
+
+function CategoryDistribution({title,subtitle,stats,tone}){
+ return {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}
Konto auswählen setAccountId(e.target.value)}>Alle Konten {data.accounts.map(a=>{a.name} )}
+
+
+
+
+
Kategorien verwalten Kategorien gelten für Eingänge, Ausgänge und Transfers gemeinsam.
+ Kategorie {data.categories.length===0? :{data.categories.map(c=>
{c.name} {usage(c.id)} {usage(c.id)===1?'zugeordneter Eintrag':'zugeordnete Einträge'}
onEditCategory(c)}>Bearbeiten onDeleteCategory(c.id)}>Löschen
)}
}
+
;
+}
function YearPage({data}){
const [accountId,setAccountId]=useState('all');
@@ -201,7 +252,7 @@ function YearPage({data}){
Jahresansicht für {selectedName}
Konto auswählen setAccountId(e.target.value)}>Alle Konten {data.accounts.map(a=>{a.name} )}
- =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.
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.
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()}>
;
+ 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()}>
;
}
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; }
+}