diff --git a/README.md b/README.md index 01a2d08..ad042f0 100644 --- a/README.md +++ b/README.md @@ -107,4 +107,4 @@ Die Jahresprognose verwendet kompakte Monats-Akkordeons. Monat, Eingänge, Ausg - Szenarien: Was-wäre-wenn-Anpassungen für zusätzliche Eingänge, Ausgänge und Transfers, ohne den Basisplan zu verändern. - Bewegungen können dupliziert und anschließend angepasst werden. -Das JSON-Datenmodell verwendet ab dieser Version `version: 6` und ergänzt das Array `scenarios`. Bestehende Daten werden automatisch migriert und vorher als `.bak` gesichert. +Das JSON-Datenmodell verwendet aktuell `version: 9`. Neben Konten und Kategorien gibt es eine zentrale Personenliste (`persons`). Eingänge, Ausgänge, Transfers und Szenario-Anpassungen können über `personId` einer Person zugeordnet werden. Neue Bewegungen benötigen in der Oberfläche eine Person; ältere Daten werden verlustfrei migriert und bleiben bis zur manuellen Zuordnung als „Ohne Person“ erhalten. Vor einer Migration bzw. jedem Speichern wird weiterhin eine `.bak`-Datei angelegt. diff --git a/public/sw.js b/public/sw.js index f4feb9f..0ffd9ab 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL_CACHE='fixfin-shell-v3.18'; +const SHELL_CACHE='fixfin-shell-v3.19'; const DATA_CACHE='fixfin-data-v1'; const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; diff --git a/server/index.js b/server/index.js index d42af4e..afcff19 100644 --- a/server/index.js +++ b/server/index.js @@ -11,17 +11,17 @@ const pushoverFile=process.env.PUSHOVER_FILE||path.join(dataDir,'pushover.json') const reminderHour=Math.min(23,Math.max(0,Number(process.env.PUSHOVER_REMINDER_HOUR||8))); const PUSHOVER_ENDPOINT='https://api.pushover.net/1/messages.json'; const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']); const validScenarioTypes=new Set(['income','expense','transfer']); -const emptyData=()=>({version:8,settings:{includePeriodicInBalance:true},accounts:[],categories:[],incomes:[],expenses:[],transfers:[],scenarios:[]}); +const emptyData=()=>({version:9,settings:{includePeriodicInBalance:true},accounts:[],categories:[],persons:[],incomes:[],expenses:[],transfers:[],scenarios:[]}); 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, endDate: typeof item.endDate==='string' && item.endDate ? item.endDate : null }; } function normalizeAmountChanges(changes){return Array.isArray(changes)?changes.map(c=>({id:typeof c.id==='string'&&c.id?c.id:crypto.randomUUID(),effectiveFrom:typeof c.effectiveFrom==='string'?c.effectiveFrom:'',amount:Number(c.amount||0)})).sort((a,b)=>a.effectiveFrom.localeCompare(b.effectiveFrom)):[];} -function normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null }; } +function normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null, personId: typeof item.personId==='string' && item.personId ? item.personId : null }; } function normalizeIncome(item={}) { return { ...normalizeCategorizedMovement(item), amountChanges: normalizeAmountChanges(item.amountChanges) }; } function normalizeExpense(item={}) { return { ...normalizeCategorizedMovement(item), amountChanges: normalizeAmountChanges(item.amountChanges), pushoverReminder: item.pushoverReminder===true }; } function normalizeScenarioAdjustment(item={}) { const type=validScenarioTypes.has(item.type)?item.type:'expense'; - const base={id:item.id,name:item.name,type,amount:Number(item.amount||0)}; + const base={id:item.id,name:item.name,type,amount:Number(item.amount||0),personId:typeof item.personId==='string'&&item.personId?item.personId:null}; if(type==='transfer') return {...base,fromAccountId:item.fromAccountId,toAccountId:item.toAccountId}; return {...base,accountId:item.accountId}; } @@ -29,10 +29,11 @@ function normalizeScenario(item={}) { return {id:item.id,name:item.name,createdA function normalizeData(input){ if(!input||typeof input!=='object')return input; return { - version:8, + version:9, 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})):[], + persons:Array.isArray(input.persons)?input.persons.map(({id,name})=>({id,name})):[], incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeIncome):input.incomes, expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeExpense):input.expenses, transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers, @@ -44,13 +45,15 @@ function validateMovement(item){if(!item.id||typeof item.id!=='string')throw new function validateAmountChanges(item){if(!Array.isArray(item.amountChanges))throw new Error(`Ungültige Betragsänderungen bei '${item.name}'.`);const dates=new Set();for(const change of item.amountChanges){if(!change.id||typeof change.id!=='string')throw new Error(`Betragsänderung ohne ID bei '${item.name}'.`);if(!isValidDateString(change.effectiveFrom))throw new Error(`Ungültiges Änderungsdatum bei '${item.name}'.`);if(!isFiniteNumber(change.amount)||change.amount<0)throw new Error(`Ungültiger Änderungsbetrag bei '${item.name}'.`);if(item.endDate&&change.effectiveFrom.slice(0,7)>item.endDate.slice(0,7))throw new Error(`Betragsänderung bei '${item.name}' liegt nach dem Enddatum.`);if(dates.has(change.effectiveFrom))throw new Error(`Für '${item.name}' existieren zwei Änderungen am selben Datum.`);dates.add(change.effectiveFrom);}} function validateData(input){ if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.'); - for(const key of ['accounts','categories','incomes','expenses','transfers','scenarios'])if(!Array.isArray(input[key]))throw new Error(`Feld '${key}' muss ein Array sein.`); + for(const key of ['accounts','categories','persons','incomes','expenses','transfers','scenarios'])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);validateAmountChanges(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}'.`);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);} - 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}'.`);} + const personIds=new Set(); const personNames=new Set(); + for(const p of input.persons){if(!p.id||typeof p.id!=='string')throw new Error('Person ohne gültige ID.');if(!p.name||typeof p.name!=='string'||!p.name.trim())throw new Error('Person ohne Namen.');if(personIds.has(p.id))throw new Error('Doppelte Personen-ID.');const normalizedName=p.name.trim().toLocaleLowerCase('de-DE');if(personNames.has(normalizedName))throw new Error(`Person '${p.name}' existiert bereits.`);personIds.add(p.id);personNames.add(normalizedName);} + for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);validateAmountChanges(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}'.`);if(item.personId!==null&&item.personId!==undefined&&!personIds.has(item.personId))throw new Error(`Unbekannte Person bei '${item.name}'.`);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);} + 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}'.`);if(t.personId!==null&&t.personId!==undefined&&!personIds.has(t.personId))throw new Error(`Unbekannte Person beim Transfer '${t.name}'.`);} const scenarioIds=new Set(); for(const s of input.scenarios){ if(!s.id||typeof s.id!=='string')throw new Error('Szenario ohne gültige ID.'); @@ -63,6 +66,7 @@ function validateData(input){ if(!a.name||typeof a.name!=='string'||!a.name.trim())throw new Error(`Anpassung ohne Namen in '${s.name}'.`); if(!validScenarioTypes.has(a.type))throw new Error(`Ungültige Anpassungsart in '${s.name}'.`); if(!isFiniteNumber(a.amount)||a.amount<0)throw new Error(`Ungültiger Szenario-Betrag bei '${a.name}'.`); + if(a.personId!==null&&a.personId!==undefined&&!personIds.has(a.personId))throw new Error(`Unbekannte Person bei Szenario-Anpassung '${a.name}'.`); if(a.type==='transfer') { if(!accountIds.has(a.fromAccountId)||!accountIds.has(a.toAccountId))throw new Error(`Unbekanntes Konto bei Szenario-Transfer '${a.name}'.`); if(a.fromAccountId===a.toAccountId)throw new Error(`Quell- und Zielkonto bei '${a.name}' müssen verschieden sein.`); } else if(!accountIds.has(a.accountId))throw new Error(`Unbekanntes Konto bei Szenario-Anpassung '${a.name}'.`); } @@ -77,7 +81,7 @@ async function readPushoverConfig(){const value=await readJsonFile(pushoverFile, async function savePushoverConfig(input){const token=String(input?.token||'').trim();const user=String(input?.user||'').trim();if(!pushoverKeyPattern.test(token))throw new Error('Der Pushover Application API Token muss 30 alphanumerische Zeichen enthalten.');if(!pushoverKeyPattern.test(user))throw new Error('Der Pushover User/Group Key muss 30 alphanumerische Zeichen enthalten.');await writePrivateJson(pushoverFile,{token,user,updatedAt:new Date().toISOString()});return {configured:true,userMasked:maskKey(user)};} async function sendPushover(config,{title,message}){ const body=new URLSearchParams({token:config.token,user:config.user,title,message}); - const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.18'},body}); + const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.19'},body}); let payload={}; try{payload=await response.json();}catch{} if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`); return payload; @@ -112,7 +116,7 @@ async function runPushoverReminderCheck(){ } 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);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];if(parsed.version!==8||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||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);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==9||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.persons)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||movements.some(x=>!Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Object.hasOwn(x,'personId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||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);}});app.get('/api/report.xlsx',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');const buffer=await createExcelReport(data,{year,accountId});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}.xlsx"`);res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));}catch(e){next(e);}});app.get('/api/report.pdf',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');const buffer=await createPdfReport(data,{year,accountId});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}.pdf"`);res.type('application/pdf').send(buffer);}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}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);}});app.get('/api/report.xlsx',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createExcelReport(data,{year,accountId,personId});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.xlsx"`);res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));}catch(e){next(e);}});app.get('/api/report.pdf',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createPdfReport(data,{year,accountId,personId});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.pdf"`);res.type('application/pdf').send(buffer);}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}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}`);console.log(`Pushover-Reminder: Prüfung am 1. des Monats ab ${String(reminderHour).padStart(2,'0')}:00 Uhr (${process.env.TZ||'System-Zeitzone'})`);});setTimeout(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),5000);setInterval(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),15*60*1000); diff --git a/server/reports.js b/server/reports.js index 25b8583..fd4a43a 100644 --- a/server/reports.js +++ b/server/reports.js @@ -21,12 +21,14 @@ function effectiveAmount(item,year,monthIndex){ } function accountName(data,id){return data.accounts.find(a=>a.id===id)?.name||'Unbekannt';} function categoryName(data,id){return data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';} +function personName(data,id){return data.persons?.find(p=>p.id===id)?.name||'Ohne Person';} -export function buildAnnualForecast(data,year,accountId){ +export function buildAnnualForecast(data,year,accountId,personId='all'){ + const personMatches=item=>personId==='all'||item.personId===personId; const rows=MONTHS.map((name,index)=>({name,index,income:0,expense:0,transferIn:0,transferOut:0,displayIncome:0,displayExpense:0,balance:0,items:[]})); - for(const item of data.incomes||[]) if(item.accountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].income+=amount;rows[m].items.push({...item,kind:'income',reportAmount:amount});} - for(const item of data.expenses||[]) if(item.accountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});} - for(const item of data.transfers||[]) if(item.fromAccountId===accountId||item.toAccountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){ + for(const item of data.incomes||[]) if(personMatches(item)&&item.accountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].income+=amount;rows[m].items.push({...item,kind:'income',reportAmount:amount});} + for(const item of data.expenses||[]) if(personMatches(item)&&item.accountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});} + for(const item of data.transfers||[]) if(personMatches(item)&&(item.fromAccountId===accountId||item.toAccountId===accountId)) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){ const amount=Number(item.amount||0); if(item.toAccountId===accountId){rows[m].transferIn+=amount;rows[m].items.push({...item,kind:'transfer',direction:'in',reportAmount:amount});} if(item.fromAccountId===accountId){rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'out',reportAmount:amount});} @@ -35,15 +37,16 @@ export function buildAnnualForecast(data,year,accountId){ return {year,accountId,rows,yearIncome:rows.reduce((s,r)=>s+r.displayIncome,0),yearExpense:rows.reduce((s,r)=>s+r.displayExpense,0)}; } -function occurrenceRows(data,year,accountId){ +function occurrenceRows(data,year,accountId,personId='all'){ + const personMatches=item=>personId==='all'||item.personId===personId; const result=[]; const push=(item,kind,monthIndex,direction='')=>{ const amount=kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex); - result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,category:categoryName(data,item.categoryId),amount}); + result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,person:personName(data,item.personId),category:categoryName(data,item.categoryId),amount}); }; - for(const item of data.incomes||[])if(item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m); - for(const item of data.expenses||[])if(item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m); - for(const item of data.transfers||[])if(item.fromAccountId===accountId||item.toAccountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ + for(const item of data.incomes||[])if(personMatches(item)&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m); + for(const item of data.expenses||[])if(personMatches(item)&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m); + for(const item of data.transfers||[])if(personMatches(item)&&(item.fromAccountId===accountId||item.toAccountId===accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ if(item.toAccountId===accountId)push(item,'transfer',m,'in'); if(item.fromAccountId===accountId)push(item,'transfer',m,'out'); } @@ -55,28 +58,29 @@ function kindLabel(row){return row.kind==='income'?'Eingang':row.kind==='expense function isInflow(row){return row.kind==='income'||row.direction==='in';} function styleHeader(row){row.font={bold:true,color:{argb:'FFFFFFFF'}};row.fill={type:'pattern',pattern:'solid',fgColor:{argb:'FF263A67'}};row.alignment={vertical:'middle'};row.height=22;} -function addTitle(sheet,title,subtitle=''){ - sheet.mergeCells('A1:F1');const c=sheet.getCell('A1');c.value=title;c.font={bold:true,size:20,color:{argb:'FF17233B'}};c.alignment={vertical:'middle'};sheet.getRow(1).height=32; - if(subtitle){sheet.mergeCells('A2:F2');sheet.getCell('A2').value=subtitle;sheet.getCell('A2').font={italic:true,color:{argb:'FF667085'}};} +function addTitle(sheet,title,subtitle='',lastColumn='F'){ + sheet.mergeCells(`A1:${lastColumn}1`);const c=sheet.getCell('A1');c.value=title;c.font={bold:true,size:20,color:{argb:'FF17233B'}};c.alignment={vertical:'middle'};sheet.getRow(1).height=32; + if(subtitle){sheet.mergeCells(`A2:${lastColumn}2`);sheet.getCell('A2').value=subtitle;sheet.getCell('A2').font={italic:true,color:{argb:'FF667085'}};} } function widths(sheet,values){values.forEach((w,i)=>sheet.getColumn(i+1).width=w);} function euroCell(cell){cell.numFmt='#,##0.00 [$€-de-DE]';} -export async function createExcelReport(data,{year,accountId}={}){ +export async function createExcelReport(data,{year,accountId,personId='all'}={}){ const reportYear=Number(year)||new Date().getFullYear(); const account=data.accounts.find(a=>a.id===accountId); if(!account)throw new Error('Bitte ein gültiges Konto für den Kontoauszug auswählen.'); - const forecast=buildAnnualForecast(data,reportYear,accountId); const rows=occurrenceRows(data,reportYear,accountId); + const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); + const forecast=buildAnnualForecast(data,reportYear,accountId,personId); const rows=occurrenceRows(data,reportYear,accountId,personId); const wb=new ExcelJS.Workbook();wb.creator='FixFin';wb.created=new Date();wb.modified=new Date(); const statement=wb.addWorksheet('Kontoauszug',{views:[{state:'frozen',ySplit:4}]}); - addTitle(statement,`FixFin Kontoauszug ${reportYear}`,`${account.name} · Planungsdaten · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`); - statement.getRow(4).values=['Monat','Art','Bezeichnung','Kategorie','Zufluss','Abfluss'];styleHeader(statement.getRow(4)); - rows.forEach((r,i)=>{const rr=i+5;statement.getRow(rr).values=[r.month,kindLabel(r),r.name,r.category,isInflow(r)?r.amount:null,isInflow(r)?null:r.amount];euroCell(statement.getCell(rr,5));euroCell(statement.getCell(rr,6));if(isInflow(r))statement.getCell(rr,5).font={color:{argb:'FF067647'}};else statement.getCell(rr,6).font={color:{argb:'FFB42318'}};}); - const totalRow=rows.length+6;statement.getCell(totalRow,1).value='Jahressumme';statement.getCell(totalRow,1).font={bold:true};statement.getCell(totalRow,5).value=forecast.yearIncome;statement.getCell(totalRow,6).value=forecast.yearExpense;euroCell(statement.getCell(totalRow,5));euroCell(statement.getCell(totalRow,6));statement.getCell(totalRow,5).font={bold:true,color:{argb:'FF067647'}};statement.getCell(totalRow,6).font={bold:true,color:{argb:'FFB42318'}}; - statement.autoFilter={from:'A4',to:'F4'};widths(statement,[16,18,34,24,18,18]); + addTitle(statement,`FixFin Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · Planungsdaten · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`,'G'); + statement.getRow(4).values=['Monat','Art','Bezeichnung','Person','Kategorie','Zufluss','Abfluss'];styleHeader(statement.getRow(4)); + rows.forEach((r,i)=>{const rr=i+5;statement.getRow(rr).values=[r.month,kindLabel(r),r.name,r.person,r.category,isInflow(r)?r.amount:null,isInflow(r)?null:r.amount];euroCell(statement.getCell(rr,6));euroCell(statement.getCell(rr,7));if(isInflow(r))statement.getCell(rr,6).font={color:{argb:'FF067647'}};else statement.getCell(rr,7).font={color:{argb:'FFB42318'}};}); + const totalRow=rows.length+6;statement.getCell(totalRow,1).value='Jahressumme';statement.getCell(totalRow,1).font={bold:true};statement.getCell(totalRow,6).value=forecast.yearIncome;statement.getCell(totalRow,7).value=forecast.yearExpense;euroCell(statement.getCell(totalRow,6));euroCell(statement.getCell(totalRow,7));statement.getCell(totalRow,6).font={bold:true,color:{argb:'FF067647'}};statement.getCell(totalRow,7).font={bold:true,color:{argb:'FFB42318'}}; + statement.autoFilter={from:'A4',to:'G4'};widths(statement,[16,18,30,20,24,18,18]); const summary=wb.addWorksheet('Monatsübersicht',{views:[{state:'frozen',ySplit:4}]}); - addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · Liquiditätssicht inklusive Transfers`); + addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · ${selectedPerson} · Liquiditätssicht inklusive Transfers`,'D'); summary.getRow(4).values=['Monat','Zuflüsse','Abflüsse','Nettofluss'];styleHeader(summary.getRow(4)); forecast.rows.forEach((r,i)=>{const rr=i+5;summary.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance];for(let c=2;c<=4;c++)euroCell(summary.getCell(rr,c));summary.getCell(rr,4).font={color:{argb:r.balance<0?'FFB42318':'FF067647'}};}); summary.getRow(18).values=['Jahr',forecast.yearIncome,forecast.yearExpense,forecast.yearIncome-forecast.yearExpense];summary.getRow(18).font={bold:true};for(let c=2;c<=4;c++)euroCell(summary.getCell(18,c));summary.getCell(18,4).font={bold:true,color:{argb:forecast.yearIncome-forecast.yearExpense<0?'FFB42318':'FF067647'}};widths(summary,[20,20,20,20]); @@ -92,22 +96,23 @@ function pdfMetricRow(doc,items){addPageIfNeeded(doc,48);const y=doc.y;const wid function pdfTableHeader(doc,cols){const y=doc.y;doc.rect(50,y,495,20).fill('#263A67');let x=50;for(const col of cols){doc.fillColor('#FFFFFF').font('Helvetica-Bold').fontSize(7.5).text(col.label,x+5,y+6,{width:col.width-10,align:col.align||'left'});x+=col.width;}doc.y=y+20;} function drawTableRow(doc,cols,values,{toneCols={}}={}){const y=doc.y;doc.rect(50,y,495,21).fill('#FFFFFF');doc.strokeColor('#E4E7EC').moveTo(50,y+21).lineTo(545,y+21).stroke();let x=50;cols.forEach((col,i)=>{doc.fillColor(toneCols[i]||'#344054').font('Helvetica').fontSize(7.5).text(String(values[i]??''),x+5,y+6,{width:col.width-10,align:col.align||'left',ellipsis:true});x+=col.width;});doc.y=y+21;} -export async function createPdfReport(data,{year,accountId}={}){ +export async function createPdfReport(data,{year,accountId,personId='all'}={}){ const reportYear=Number(year)||new Date().getFullYear(); const account=data.accounts.find(a=>a.id===accountId); if(!account)throw new Error('Bitte ein gültiges Konto für den Kontoauszug auswählen.'); - const forecast=buildAnnualForecast(data,reportYear,accountId);const rows=occurrenceRows(data,reportYear,accountId); + const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); + const forecast=buildAnnualForecast(data,reportYear,accountId,personId);const rows=occurrenceRows(data,reportYear,accountId,personId); const doc=new PDFDocument({size:'A4',margin:50,info:{Title:`FixFin Kontoauszug ${reportYear} - ${account.name}`,Author:'FixFin'}});const done=collectPdfBuffer(doc); - drawPdfHeader(doc,`FixFin Kontoauszug ${reportYear}`,`${account.name} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`); + drawPdfHeader(doc,`FixFin Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`); pdfMetricRow(doc,[{label:'Zuflüsse / Jahr',value:pdfMoney(forecast.yearIncome),positive:true},{label:'Abflüsse / Jahr',value:pdfMoney(forecast.yearExpense),negative:true},{label:'Nettofluss / Jahr',value:pdfMoney(forecast.yearIncome-forecast.yearExpense),positive:forecast.yearIncome>=forecast.yearExpense,negative:forecast.yearIncomer.monthIndex===monthIndex);const month=forecast.rows[monthIndex]; // Jeder Monat beginnt auf einer eigenen Seite. Der Monatsname steht immer // links und nutzt die volle Inhaltsbreite, damit er niemals umbrechen kann. doc.addPage(); - drawPdfHeader(doc,`Kontoauszug ${reportYear}`,account.name); + drawPdfHeader(doc,`Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson}`); const monthY=doc.y; doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(16) .text(month.name,50,monthY,{width:495,align:'left',lineBreak:false}); @@ -117,8 +122,8 @@ export async function createPdfReport(data,{year,accountId}={}){ if(!monthRows.length){doc.fillColor('#667085').font('Helvetica').fontSize(8).text('Keine geplanten Kontobewegungen.');doc.moveDown(.8);continue;} pdfTableHeader(doc,cols); for(const row of monthRows){ - if(doc.y+22>760){doc.addPage();drawPdfHeader(doc,`Kontoauszug ${reportYear}`,`${account.name} · ${month.name} · Fortsetzung`);pdfTableHeader(doc,cols);} - const inflow=isInflow(row);drawTableRow(doc,cols,[kindLabel(row),row.name,row.category,inflow?pdfMoney(row.amount):'',inflow?'':pdfMoney(row.amount)],{toneCols:inflow?{3:'#067647'}:{4:'#B42318'}}); + if(doc.y+22>760){doc.addPage();drawPdfHeader(doc,`Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · ${month.name} · Fortsetzung`);pdfTableHeader(doc,cols);} + const inflow=isInflow(row);drawTableRow(doc,cols,[kindLabel(row),row.name,row.person,row.category,inflow?pdfMoney(row.amount):'',inflow?'':pdfMoney(row.amount)],{toneCols:inflow?{4:'#067647'}:{5:'#B42318'}}); } doc.moveDown(.8); } diff --git a/src/main.jsx b/src/main.jsx index 38de8b9..f43aa78 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: 8, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [], scenarios: [] }; +const emptyData = { version: 9, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], persons: [], incomes: [], expenses: [], transfers: [], scenarios: [] }; function formatMoney(value) { return euro.format(Number(value || 0)); } function parseAmount(value) { @@ -58,6 +58,7 @@ function occursInMonth(item, monthIndex, year = new Date().getFullYear()) { return monthIndex >= start && (monthIndex - start) % step === 0; } function categoryName(data, id) { return data.categories.find(c => c.id === id)?.name || 'Ohne Kategorie'; } +function personName(data, id) { return data.persons?.find(p => p.id === id)?.name || 'Ohne Person'; } function accountName(data, id) { return data.accounts.find(a => a.id === id)?.name || 'Unbekannt'; } function calc(data) { @@ -93,12 +94,13 @@ function calc(data) { return { accounts: [...accountStats.values()], income, expense, transferVolume, periodicExpenseReserve, periodicIncomeAverage, balance: income - expense, includePeriodic }; } -function annualForecast(data, accountId = 'all', year = new Date().getFullYear()) { +function annualForecast(data, accountId = 'all', year = new Date().getFullYear(), personId = 'all') { const isAll = !accountId || accountId === 'all'; + const personMatches = item => personId === 'all' || item.personId === personId; const rows = months.map((name, index) => ({ name, index, income: 0, expense: 0, transferIn: 0, transferOut: 0, balance: 0, items: [] })); - const relevantIncome = item => isAll || item.accountId === accountId; - const relevantExpense = item => isAll || item.accountId === accountId; - const relevantTransfer = item => isAll || item.fromAccountId === accountId || item.toAccountId === accountId; + const relevantIncome = item => personMatches(item) && (isAll || item.accountId === accountId); + const relevantExpense = item => personMatches(item) && (isAll || item.accountId === accountId); + const relevantTransfer = item => personMatches(item) && (isAll || item.fromAccountId === accountId || item.toAccountId === accountId); for (const item of data.incomes.filter(relevantIncome)) { for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m); rows[m].income += amount; rows[m].items.push({ ...item, kind:'income', forecastAmount:amount }); } } @@ -141,9 +143,10 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear() return { rows, unassigned, yearIncome, yearExpense, isAll, accountId, year }; } -function categoryStats(data, accountId = 'all') { +function categoryStats(data, accountId = 'all', personId = 'all') { const categoryMap = new Map((data.categories || []).map(c => [c.id, c.name])); const isAll = accountId === 'all'; + const personMatches = item => personId === 'all' || item.personId === personId; const aggregate = items => { const buckets = new Map(); for (const item of items) { @@ -157,14 +160,14 @@ function categoryStats(data, accountId = 'all') { 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 }; }; - if (isAll) return { incomes: aggregate(data.incomes.map(x=>({...x,_statType:'income'}))), expenses: aggregate(data.expenses.map(x=>({...x,_statType:'expense'}))), transfers: aggregate(data.transfers.map(x=>({...x,_statType:'transfer'}))), transfersMerged:false }; + if (isAll) return { incomes: aggregate(data.incomes.filter(personMatches).map(x=>({...x,_statType:'income'}))), expenses: aggregate(data.expenses.filter(personMatches).map(x=>({...x,_statType:'expense'}))), transfers: aggregate(data.transfers.filter(personMatches).map(x=>({...x,_statType:'transfer'}))), transfersMerged:false }; const incomeItems = [ - ...data.incomes.filter(x => x.accountId === accountId).map(x=>({...x,_statType:'income'})), - ...data.transfers.filter(x => x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})), + ...data.incomes.filter(x => personMatches(x) && x.accountId === accountId).map(x=>({...x,_statType:'income'})), + ...data.transfers.filter(x => personMatches(x) && x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})), ]; const expenseItems = [ - ...data.expenses.filter(x => x.accountId === accountId).map(x=>({...x,_statType:'expense'})), - ...data.transfers.filter(x => x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})), + ...data.expenses.filter(x => personMatches(x) && x.accountId === accountId).map(x=>({...x,_statType:'expense'})), + ...data.transfers.filter(x => personMatches(x) && x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})), ]; return { incomes: aggregate(incomeItems), expenses: aggregate(expenseItems), transfers:{total:0,rows:[]}, transfersMerged:true }; } @@ -202,7 +205,7 @@ function scenarioData(data, scenario) { if (!scenario) return data; const next = { ...data, incomes:[...data.incomes], expenses:[...data.expenses], transfers:[...data.transfers] }; for (const a of scenario.adjustments || []) { - const common = { id:`scenario-${scenario.id}-${a.id}`, name:a.name, amount:Number(a.amount||0), interval:'monthly', dueMonth:null, categoryId:a.categoryId || null }; + const common = { id:`scenario-${scenario.id}-${a.id}`, name:a.name, amount:Number(a.amount||0), interval:'monthly', dueMonth:null, categoryId:a.categoryId || null, personId:a.personId || null }; if (a.type === 'income') next.incomes.push({...common,accountId:a.accountId}); if (a.type === 'expense') next.expenses.push({...common,accountId:a.accountId}); if (a.type === 'transfer') next.transfers.push({...common,fromAccountId:a.fromAccountId,toAccountId:a.toAccountId}); @@ -233,7 +236,7 @@ const PAGE_META={ year:{label:'Jahr',description:'Jahresplanung zwischen wirtschaftlicher Sicht und Kontoliquidität.'}, stats:{label:'Statistik',description:'Kategorien, Verteilungen und Kennzahlen aus der Planung.'}, statements:{label:'Auszüge',description:'Schlanke Kontoauszüge aus den geplanten Kontobewegungen.'}, - accounts:{label:'Konten',description:'Konten für Einnahmen, Ausgaben und Transfers verwalten.'}, + accounts:{label:'Konten & Personen',description:'Konten und Personen für deine Bewegungen verwalten.'}, scenarios:{label:'Szenarien',description:'Was-wäre-wenn-Varianten ohne Änderung der echten Planung.'}, data:{label:'Daten',description:'Backup, Import und Pushover-Einstellungen.'}, }; @@ -244,7 +247,7 @@ const NAV_GROUPS={ {tab:'statements',icon:'statement',label:'Auszüge',description:'PDF- & Excel-Kontoauszüge'}, ]}, more:{label:'Mehr',items:[ - {tab:'accounts',icon:'wallet',label:'Konten',description:'Konten verwalten'}, + {tab:'accounts',icon:'wallet',label:'Konten & Personen',description:'Zuordnungen verwalten'}, {tab:'scenarios',icon:'flask',label:'Szenarien',description:'Was-wäre-wenn-Planungen'}, {tab:'data',icon:'data',label:'Daten',description:'Backup, Import & Pushover'}, ]}, @@ -278,6 +281,7 @@ function App() { function removeAccount(id){const used=data.incomes.some(x=>x.accountId===id)||data.expenses.some(x=>x.accountId===id)||data.transfers.some(x=>x.fromAccountId===id||x.toAccountId===id)||(data.scenarios||[]).some(s=>(s.adjustments||[]).some(x=>x.accountId===id||x.fromAccountId===id||x.toAccountId===id));if(used)return setError('Das Konto wird noch verwendet. Einträge, Transfers oder Szenario-Anpassungen bitte zuerst löschen.');if(confirm('Konto wirklich löschen?'))persist({...data,accounts:data.accounts.filter(x=>x.id!==id)});} function removeItem(type,id){if(confirm('Eintrag wirklich löschen?'))persist({...data,[type]:data[type].filter(x=>x.id!==id)});} function removeCategory(id){const used=[...data.incomes,...data.expenses,...data.transfers].filter(x=>x.categoryId===id).length;const message=used?`Kategorie wird bei ${used} ${used===1?'Eintrag':'Einträgen'} entfernt. Kategorie wirklich löschen?`:'Kategorie wirklich löschen?';if(!confirm(message))return;persist({...data,categories:data.categories.filter(x=>x.id!==id),incomes:data.incomes.map(x=>x.categoryId===id?{...x,categoryId:null}:x),expenses:data.expenses.map(x=>x.categoryId===id?{...x,categoryId:null}:x),transfers:data.transfers.map(x=>x.categoryId===id?{...x,categoryId:null}:x),scenarios:(data.scenarios||[]).map(s=>({...s,adjustments:(s.adjustments||[]).map(x=>x.categoryId===id?{...x,categoryId:null}:x)}))});} + function removePerson(id){const used=[...data.incomes,...data.expenses,...data.transfers].filter(x=>x.personId===id).length+(data.scenarios||[]).reduce((sum,s)=>sum+(s.adjustments||[]).filter(x=>x.personId===id).length,0);const message=used?`Person wird bei ${used} ${used===1?'Bewegung':'Bewegungen'} entfernt. Zuordnung wirklich löschen?`:'Person wirklich löschen?';if(!confirm(message))return;persist({...data,persons:(data.persons||[]).filter(x=>x.id!==id),incomes:data.incomes.map(x=>x.personId===id?{...x,personId:null}:x),expenses:data.expenses.map(x=>x.personId===id?{...x,personId:null}:x),transfers:data.transfers.map(x=>x.personId===id?{...x,personId:null}:x),scenarios:(data.scenarios||[]).map(s=>({...s,adjustments:(s.adjustments||[]).map(x=>x.personId===id?{...x,personId:null}:x)}))});} async function importFile(file){try{await persist(JSON.parse(await file.text()));selectTab('dashboard');}catch(e){setError(`Import fehlgeschlagen: ${e.message}`);}} async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});} function selectTab(next){setTab(next);setNavMenu(null);} @@ -288,10 +292,10 @@ function App() { return
-

{meta.label}

{meta.description}

{tab==='accounts'&&}
+

{meta.label}

{meta.description}

{error&&
{error}
} {tab==='dashboard'&&} - {tab==='accounts'&&setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} + {tab==='accounts'&&setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})} onAddPerson={()=>setDialog({type:'persons'})} onEditPerson={x=>setDialog({type:'persons',item:x})} onDeletePerson={removePerson}/>} {tab==='movements'&&setDialog({type,item:x})} onDuplicate={(type,x)=>setDialog({type,item:{...x,id:null},duplicate:true})} onDelete={removeItem} onAdd={type=>setDialog({type})}/>} {tab==='year'&&} {tab==='planning'&&} @@ -385,29 +389,38 @@ function Metric({label,value,positive,negative,featured}){return
{label}{value}{hint&&{hint}}
} 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 Accounts({data,onEdit,onDelete,onAdd,onAddPerson,onEditPerson,onDeletePerson}){ + const personUsage=id=>data.incomes.filter(x=>x.personId===id).length+data.expenses.filter(x=>x.personId===id).length+data.transfers.filter(x=>x.personId===id).length+(data.scenarios||[]).reduce((sum,s)=>sum+(s.adjustments||[]).filter(x=>x.personId===id).length,0); + const unassigned=[...data.incomes,...data.expenses,...data.transfers].filter(x=>!x.personId).length; + return
+

Konten

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

{data.accounts.length===0?:
{data.accounts.map(a=>)}
Konto
{a.name}
}
+

Personen

Jede Bewegung wird einer Person zugeordnet. Das gilt genauso für Umbuchungen.

{unassigned>0&&
{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person.Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen immer eine Person.
}{(data.persons||[]).length===0?:
{data.persons.map(person=>
{person.name}{personUsage(person.id)} {personUsage(person.id)===1?'zugeordnete Bewegung':'zugeordnete Bewegungen'}
)}
}
+
; +} function MovementsPage({data,onEdit,onDuplicate,onDelete,onAdd}){ const [section,setSection]=useState('incomes'); const tabs=[['incomes','Eingänge',data.incomes.length],['expenses','Ausgänge',data.expenses.length],['transfers','Transfers',data.transfers.length]]; return
{tabs.map(([id,label,count])=>)}
- {section==='incomes'&&onEdit('incomes',x)} onDuplicate={x=>onDuplicate('incomes',x)} onDelete={onDelete}/>} - {section==='expenses'&&onEdit('expenses',x)} onDuplicate={x=>onDuplicate('expenses',x)} onDelete={onDelete}/>} + {section==='incomes'&&onEdit('incomes',x)} onDuplicate={x=>onDuplicate('incomes',x)} onDelete={onDelete}/>} + {section==='expenses'&&onEdit('expenses',x)} onDuplicate={x=>onDuplicate('expenses',x)} onDelete={onDelete}/>} {section==='transfers'&&onEdit('transfers',x)} onDuplicate={x=>onDuplicate('transfers',x)} onDelete={onDelete}/>}
; } -function Entries({type,items,accounts,categories,onEdit,onDuplicate,onDelete}){ +function Entries({type,items,accounts,categories,persons,onEdit,onDuplicate,onDelete}){ const accountNameLocal=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt'; const categoryNameLocal=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; + const personNameLocal=id=>persons.find(p=>p.id===id)?.name||'Ohne Person'; const isIncome=type==='incomes'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; const currentAmount=x=>effectiveAmount(x); 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}{!isIncome&&x.pushoverReminder&&Reminder}{(x.amountChanges||[]).length>0&&{x.amountChanges.length} Änderung{x.amountChanges.length===1?'':'en'}}{x.endDate&&bis {formatDate(x.endDate)}}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.accountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{isIncome?'+':'−'}{formatMoney(currentAmount(x))}{formatMoney(monthlyEquivalent(x))}
+
{items.map(x=>)}
BezeichnungPersonKategorieKontoIntervallMonatBetragØ / Monat
{x.name}{!isIncome&&x.pushoverReminder&&Reminder}{(x.amountChanges||[]).length>0&&{x.amountChanges.length} Änderung{x.amountChanges.length===1?'':'en'}}{x.endDate&&bis {formatDate(x.endDate)}}{personNameLocal(x.personId)}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.accountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{isIncome?'+':'−'}{formatMoney(currentAmount(x))}{formatMoney(monthlyEquivalent(x))}
{items.map(x=>
{x.name}{categoryNameLocal(x.categoryId)}{!isIncome&&x.pushoverReminder&&Reminder am 1.}{(x.amountChanges||[]).length>0&&{x.amountChanges.length} geplant}{x.endDate&&bis {formatDate(x.endDate)}}
{isIncome?'+':'−'}{formatMoney(currentAmount(x))}
+
Person{personNameLocal(x.personId)}
Konto{accountNameLocal(x.accountId)}
Intervall{intervals[x.interval||'monthly'].label}
Fälligkeit{dueText(x)}
@@ -421,13 +434,15 @@ function Entries({type,items,accounts,categories,onEdit,onDuplicate,onDelete}){ function Transfers({data,onEdit,onDuplicate,onDelete}){ const accountNameLocal=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt'; const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; + const personNameLocal=id=>(data.persons||[]).find(p=>p.id===id)?.name||'Ohne Person'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; return

Fixe Transfers

Verschiebungen zwischen eigenen Konten. Global saldoneutral.

{data.transfers.length===0?:<> -
{data.transfers.map(x=>)}
BezeichnungKategorieVonNachIntervallMonatBetragØ / Monat
{x.name}{x.endDate&&bis {formatDate(x.endDate)}}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.fromAccountId)}{accountNameLocal(x.toAccountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
+
{data.transfers.map(x=>)}
BezeichnungPersonKategorieVonNachIntervallMonatBetragØ / Monat
{x.name}{x.endDate&&bis {formatDate(x.endDate)}}{personNameLocal(x.personId)}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.fromAccountId)}{accountNameLocal(x.toAccountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
{data.transfers.map(x=>
{x.name}{categoryNameLocal(x.categoryId)}{x.endDate&&bis {formatDate(x.endDate)}}
{formatMoney(x.amount)}
Von{accountNameLocal(x.fromAccountId)}
Nach{accountNameLocal(x.toAccountId)}
+
Person{personNameLocal(x.personId)}
Intervall{intervals[x.interval||'monthly'].label}
Fälligkeit{dueText(x)}
Ø / Monat{formatMoney(monthlyEquivalent(x))}
@@ -445,18 +460,21 @@ function CategoryDistribution({title,subtitle,stats,tone,data}){ return `${item._statType==='income'?'Eingang':'Ausgang'} · ${accountName(data,item.accountId)}`; } 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('.',',')} %
{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=>
{item.name}{itemMeta(item)} · {intervals[item.interval||'monthly'].label}
{formatMoney(item.statAmount)} / 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('.',',')} %
{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=>
{item.name}{itemMeta(item)} · {personName(data,item.personId)} · {intervals[item.interval||'monthly'].label}
{formatMoney(item.statAmount)} / Monat
)}
)}
}
; } function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){ const [accountId,setAccountId]=useState('all'); - const stats=useMemo(()=>categoryStats(data,accountId),[data,accountId]); + const [personId,setPersonId]=useState('all'); + const stats=useMemo(()=>categoryStats(data,accountId,personId),[data,accountId,personId]); const totals=useMemo(()=>calc(data),[data]); const kpis=useMemo(()=>dashboardKpis(data,totals),[data,totals]); const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten'; + const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); 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 personMatches=x=>personId==='all'||x.personId===personId; + const relevantEntries=[...data.incomes.filter(x=>personMatches(x)&&(accountId==='all'||x.accountId===accountId)),...data.expenses.filter(x=>personMatches(x)&&(accountId==='all'||x.accountId===accountId)),...data.transfers.filter(x=>personMatches(x)&&(accountId==='all'||x.fromAccountId===accountId||x.toAccountId===accountId))]; const uncategorized=relevantEntries.filter(x=>!x.categoryId).length; return

Gesamtkennzahlen

Die übergeordneten Kennzahlen liegen hier statt auf der Übersicht.

@@ -467,7 +485,7 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
-
Statistik für{selectedName}
+
Statistik für{selectedName} · {selectedPerson}
@@ -479,10 +497,12 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){ function YearPage({data}){ const currentYear=new Date().getFullYear(); const [accountId,setAccountId]=useState('all'); + const [personId,setPersonId]=useState('all'); const [year,setYear]=useState(currentYear); - const forecast=useMemo(()=>annualForecast(data,accountId,year),[data,accountId,year]); + const forecast=useMemo(()=>annualForecast(data,accountId,year,personId),[data,accountId,year,personId]); const selectedAccount=data.accounts.find(a=>a.id===accountId); const selectedName=selectedAccount?.name||'Alle Konten'; + const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); const labels=forecast.isAll ? { income:'Einnahmen', expense:'Ausgaben', balance:'Überschuss', yearIncome:'Einnahmen / Jahr', yearExpense:'Ausgaben / Jahr', yearBalance:'Überschuss / Jahr' } : { income:'Zuflüsse', expense:'Abflüsse', balance:'Nettofluss', yearIncome:'Zuflüsse / Jahr', yearExpense:'Abflüsse / Jahr', yearBalance:'Nettofluss / Jahr' }; @@ -493,10 +513,10 @@ function YearPage({data}){ const maxExpense=Math.max(1,...forecast.rows.map(r=>r.displayExpense)); const maxRow=forecast.rows.reduce((a,b)=>b.displayExpense>a.displayExpense?b:a,forecast.rows[0]); const yearBalance=forecast.yearIncome-forecast.yearExpense; - const detailRow=(x,i)=>
{kindLabel(x)}{x.name}
{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}
; + const detailRow=(x,i)=>
{kindLabel(x)}{x.name}{personName(data,x.personId)}
{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}
; return
-
Jahresansicht für{selectedName} · {year}
+
Jahresansicht für{selectedName} · {selectedPerson} · {year}
@@ -550,7 +570,7 @@ function ScenariosPage({data,persist}){
Szenario{scenarios.length?:Noch keines angelegt}
{scenario&&<>}
{!scenario?
:<>
=0} negative={baseTotals.balance<0}/>=0} negative={projected.balance<0} featured/>=0} negative={projected.balance-baseTotals.balance<0}/>
-

Was-wäre-wenn-Anpassungen

Der Basisplan bleibt unverändert. Hier simulierst du zusätzliche Eingänge, Ausgänge oder Transfers.

{(scenario.adjustments||[]).length===0?:
{scenario.adjustments.map(a=>
{a.type==='income'?'Eingang':a.type==='expense'?'Ausgang':'Transfer'}{a.name}{a.type==='transfer'?`${accountName(data,a.fromAccountId)} → ${accountName(data,a.toAccountId)}`:accountName(data,a.accountId)}
{a.type==='income'?'+':a.type==='expense'?'−':''}{formatMoney(a.amount)} / Monat
)}
}
+

Was-wäre-wenn-Anpassungen

Der Basisplan bleibt unverändert. Hier simulierst du zusätzliche Eingänge, Ausgänge oder Transfers.

{(scenario.adjustments||[]).length===0?:
{scenario.adjustments.map(a=>
{a.type==='income'?'Eingang':a.type==='expense'?'Ausgang':'Transfer'}{a.name}{a.type==='transfer'?`${accountName(data,a.fromAccountId)} → ${accountName(data,a.toAccountId)}`:accountName(data,a.accountId)} · {personName(data,a.personId)}
{a.type==='income'?'+':a.type==='expense'?'−':''}{formatMoney(a.amount)} / Monat
)}
}

Auswirkung je Konto

Vergleich des monatlichen Saldos zwischen Basisplan und Szenario.

{projected.accounts.map(p=>{const b=baseTotals.accounts.find(x=>x.account.id===p.account.id);const diff=p.balance-(b?.balance||0);return
{p.account.name}
Aktuell{formatMoney(b?.balance||0)}
Szenario{formatMoney(p.balance)}
Änderung=0?'good-text':'bad-text'}>{diff>=0?'+':''}{formatMoney(diff)}
})}
} {adjustmentDialog&&scenario&&setAdjustmentDialog(null)} onSave={saveAdjustment}/>} @@ -559,9 +579,9 @@ function ScenariosPage({data,persist}){ function ScenarioAdjustmentDialog({data,item,onClose,onSave}){ const [name,setName]=useState(item?.name||''); const [type,setType]=useState(item?.type||'expense'); const [amount,setAmount]=useState(item?.amount??''); - const [accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''); const [fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''); const [toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''); const [error,setError]=useState(''); - function submit(e){e.preventDefault();const numeric=parseAmount(amount);if(!name.trim())return setError('Bitte eine Bezeichnung eingeben.');if(!Number.isFinite(numeric)||numeric<0)return setError('Bitte einen gültigen Betrag eingeben.');if(type==='transfer'&&fromAccountId===toAccountId)return setError('Quell- und Zielkonto müssen verschieden sein.');const value={id:item?.id||uid(),name:name.trim(),type,amount:numeric};if(type==='transfer'){value.fromAccountId=fromAccountId;value.toAccountId=toAccountId;}else value.accountId=accountId;onSave(value);} - return
e.target===e.currentTarget&&onClose()}>
SZENARIO

{item?'Anpassung bearbeiten':'Anpassung hinzufügen'}

{error&&
{error}
}{type!=='transfer'?:<>}
; + const [accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''); const [fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''); const [toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''); const [personId,setPersonId]=useState(item?.personId||''); const [error,setError]=useState(''); + function submit(e){e.preventDefault();const numeric=parseAmount(amount);if(!name.trim())return setError('Bitte eine Bezeichnung eingeben.');if(!Number.isFinite(numeric)||numeric<0)return setError('Bitte einen gültigen Betrag eingeben.');if(!personId)return setError('Bitte eine Person auswählen.');if(type==='transfer'&&fromAccountId===toAccountId)return setError('Quell- und Zielkonto müssen verschieden sein.');const value={id:item?.id||uid(),name:name.trim(),type,amount:numeric,personId:personId||null};if(type==='transfer'){value.fromAccountId=fromAccountId;value.toAccountId=toAccountId;}else value.accountId=accountId;onSave(value);} + return
e.target===e.currentTarget&&onClose()}>
SZENARIO

{item?'Anpassung bearbeiten':'Anpassung hinzufügen'}

{error&&
{error}
}{type!=='transfer'?:<>}
; } function PushoverSettings(){ @@ -584,16 +604,18 @@ function StatementsPage({data}){ const currentYear=new Date().getFullYear(); const [year,setYear]=useState(currentYear); const [accountId,setAccountId]=useState(data.accounts[0]?.id||''); + const [personId,setPersonId]=useState('all'); useEffect(()=>{if(accountId&&!data.accounts.some(a=>a.id===accountId))setAccountId(data.accounts[0]?.id||'');if(!accountId&&data.accounts[0])setAccountId(data.accounts[0].id);},[data.accounts,accountId]); if(!data.accounts.length)return
; - const account=accountName(data,accountId);const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}`; - return

Kontoauszug erstellen

Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.

PDF · XLSX
Kontoauszug{account} · {year}

PDF: Jahreswerte und monatlich gruppierte Kontobewegungen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.

Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.

; + const account=accountName(data,accountId);const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}&person=${encodeURIComponent(personId)}`; + return

Kontoauszug erstellen

Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.

PDF · XLSX
Kontoauszug{account} · {selectedPerson} · {year}

PDF: Jahreswerte und monatlich gruppierte Kontobewegungen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.

Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.

; } function DataPage({data,importFile}){ const summary=[ ['Konten',data.accounts.length], ['Kategorien',data.categories.length], + ['Personen',(data.persons||[]).length], ['Eingänge',data.incomes.length], ['Ausgänge',data.expenses.length], ['Transfers',data.transfers.length], @@ -614,33 +636,38 @@ function Empty({text}){return
{text}
} function EditorDialog({dialog,data,onClose,onSave}){ const type=dialog.type,item=dialog.item; const editing=Boolean(item?.id); - 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(''),[pushoverReminder,setPushoverReminder]=useState(item?.pushoverReminder===true),[endDate,setEndDate]=useState(item?.endDate||''),[amountChanges,setAmountChanges]=useState(Array.isArray(item?.amountChanges)?item.amountChanges:[]),[changeDate,setChangeDate]=useState(''),[changeAmount,setChangeAmount]=useState(''),[localError,setLocalError]=useState(''); - const labels={accounts:'Konto',categories:'Kategorie',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(''),[personId,setPersonId]=useState(item?.personId||''),[newPersonName,setNewPersonName]=useState(''),[pushoverReminder,setPushoverReminder]=useState(item?.pushoverReminder===true),[endDate,setEndDate]=useState(item?.endDate||''),[amountChanges,setAmountChanges]=useState(Array.isArray(item?.amountChanges)?item.amountChanges:[]),[changeDate,setChangeDate]=useState(''),[changeAmount,setChangeAmount]=useState(''),[localError,setLocalError]=useState(''); + const labels={accounts:'Konto',categories:'Kategorie',persons:'Person',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'}; function addAmountChange(){const numeric=parseAmount(changeAmount);if(!changeDate)return setLocalError('Bitte ein Datum für die Betragsänderung auswählen.');if(!Number.isFinite(numeric)||numeric<0)return setLocalError('Bitte einen gültigen neuen Betrag eingeben.');if(endDate&&changeDate.slice(0,7)>endDate.slice(0,7))return setLocalError('Die Betragsänderung liegt nach dem Enddatum.');if(amountChanges.some(c=>c.effectiveFrom===changeDate))return setLocalError('Für dieses Datum existiert bereits eine Betragsänderung.');setAmountChanges([...amountChanges,{id:uid(),effectiveFrom:changeDate,amount:numeric}].sort((a,b)=>a.effectiveFrom.localeCompare(b.effectiveFrom)));setChangeDate('');setChangeAmount('');setLocalError('');} 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(!['accounts','categories','persons'].includes(type)&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.'); + if(!['accounts','categories','persons'].includes(type)&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.'); + if(['incomes','expenses','transfers'].includes(type)&&!personId)return setLocalError('Bitte eine Person auswählen oder direkt neu anlegen.'); if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.'); if(type==='expenses'&&pushoverReminder&&interval!=='monthly'&&!dueMonth)return setLocalError('Für den Reminder bitte einen Fälligkeitsmonat festlegen.'); if(endDate&&amountChanges.some(c=>c.effectiveFrom.slice(0,7)>endDate.slice(0,7)))return setLocalError('Eine geplante Betragsänderung liegt nach dem Enddatum.'); let next=structuredClone(data); const id=editing?item.id:uid(); let value; if(type==='accounts') value={id,name:name.trim()}; + else if(type==='persons') { const duplicate=(next.persons||[]).some(p=>p.id!==item?.id&&p.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Person existiert bereits.'); 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),endDate:endDate||null}; let selectedCategoryId=categoryId||null; if(categoryId==='__new__'){const categoryNameValue=newCategoryName.trim();if(!categoryNameValue)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedCategoryId=existing.id;else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryNameValue}); }} - if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,amountChanges,pushoverReminder}; else value={...common,accountId,categoryId:selectedCategoryId,amountChanges}; + let selectedPersonId=personId||null; + if(personId==='__new__'){const personNameValue=newPersonName.trim();if(!personNameValue)return setLocalError('Bitte einen Namen für die neue Person eingeben.');const existing=(next.persons||[]).find(p=>p.name.trim().toLocaleLowerCase('de-DE')===personNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedPersonId=existing.id;else { selectedPersonId=uid(); next.persons=[...(next.persons||[]),{id:selectedPersonId,name:personNameValue}]; }} + if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId,personId:selectedPersonId}; else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,personId:selectedPersonId,amountChanges,pushoverReminder}; else value={...common,accountId,categoryId:selectedCategoryId,personId:selectedPersonId,amountChanges}; } next[type]=editing?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()}>
{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}

{labels[type]}

{localError&&
{localError}
} + const simpleType=type==='accounts'||type==='categories'||type==='persons'; + return
e.target===e.currentTarget&&onClose()}>
{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}

{labels[type]}

{localError&&
{localError}
} {!simpleType&&<>{interval!=='monthly'&&}} {(type==='incomes'||type==='expenses'||type==='transfers')&&<>{categoryId==='__new__'&&}} + {(type==='incomes'||type==='expenses'||type==='transfers')&&<>{personId==='__new__'&&}} {(type==='incomes'||type==='expenses')&&} {(type==='incomes'||type==='expenses')&&
Geplante BetragsänderungenEin neuer Betrag gilt ab dem Monat des gewählten Datums. Der Basisbetrag oben bleibt für frühere Monate erhalten.
{amountChanges.length}
{amountChanges.length>0&&
{amountChanges.map(c=>
ab {formatDate(c.effectiveFrom)}{formatMoney(c.amount)}
)}
}
} {type==='expenses'&&} diff --git a/src/styles.css b/src/styles.css index eb4e60b..bd0001a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -178,6 +178,7 @@ code { color:#b8c3ff; } .year-detail-row:last-of-type { border-bottom:0; } .year-detail-name { display:flex; align-items:center; gap:8px; min-width:0; } .year-detail-name strong { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.year-detail-name small { color:var(--muted);font-size:9px;white-space:nowrap; } .year-kind { flex:0 0 auto; border-radius:999px; padding:3px 6px; font-size:8px; font-weight:850; text-transform:uppercase; letter-spacing:.04em; background:#1c2635; border:1px solid #304057; color:#b8c5d7; } .year-kind.income { border-color:#245947; color:#7de0b5; background:#112c24; } .year-kind.expense { border-color:#67333b; color:#ff9aa2; background:#321a20; } @@ -314,7 +315,8 @@ code { color:#b8c3ff; } } /* v3.6: frei definierbare Kategorien + Kategorie-Statistik */ -.category-badge { +.category-badge, +.person-badge { display:inline-flex; align-items:center; max-width:180px; @@ -329,11 +331,13 @@ code { color:#b8c3ff; } overflow:hidden; text-overflow:ellipsis; } -.category-badge.muted-category { +.category-badge.muted-category, +.person-badge.muted-person { color:#778499; border-style:dashed; background:#101722; } +.person-badge { border-color:#4b4168; background:#211a31; color:#d8c8ff; } .new-category-field { padding:10px; margin-top:-4px; @@ -602,7 +606,8 @@ code { color:#b8c3ff; } font-size:15px; white-space:nowrap; } - .movement-card .category-badge { font-size:8px; padding:3px 6px; } + .movement-card .category-badge, + .movement-card .person-badge { font-size:8px; padding:3px 6px; } .movement-card-grid { display:grid; grid-template-columns:1fr 1fr; @@ -661,7 +666,7 @@ code { color:#b8c3ff; } margin:0 !important; } .transfer-card .movement-card-grid { - grid-template-columns:repeat(3,minmax(0,1fr)); + grid-template-columns:repeat(2,minmax(0,1fr)); } .data-page .section-head { margin-bottom:12px; } @@ -935,10 +940,12 @@ code { color:#b8c3ff; } .year-filter-controls { display:flex;gap:8px;align-items:end; } .year-filter-controls label:first-child { min-width:110px; } +.stats-filter-controls { display:flex;gap:8px;align-items:end; } +.stats-filter-controls label { min-width:180px; } .report-export-panel { border-color:#35466a; } .report-badge { padding:6px 9px;border-radius:999px;background:#1d2848;border:1px solid #41558b;color:#b4c0ff;font-size:10px;font-weight:850; } -.report-controls { display:grid;grid-template-columns:160px minmax(220px,1fr);gap:10px;max-width:560px;margin-bottom:12px; } +.report-controls { display:grid;grid-template-columns:140px minmax(190px,1fr) minmax(170px,1fr);gap:10px;max-width:760px;margin-bottom:12px; } .report-controls label { display:grid;gap:5px; } .report-controls label span { color:var(--muted);font-size:10px;font-weight:700; } .report-controls select { width:100%;background:#0b111a;color:#f5f7fb;border:1px solid #2a374a;border-radius:10px;padding:10px 34px 10px 11px;outline:none; } @@ -950,9 +957,11 @@ code { color:#b8c3ff; } @media (max-width:760px) { .year-account-filter { align-items:stretch;flex-direction:column; } - .year-filter-controls { width:100%;display:grid;grid-template-columns:100px 1fr; } + .year-filter-controls { width:100%;display:grid;grid-template-columns:90px 1fr 1fr; } .year-filter-controls label { min-width:0 !important; } - .report-controls { grid-template-columns:1fr 1.5fr;max-width:none; } + .stats-filter-controls { width:100%;display:grid;grid-template-columns:1fr 1fr; } + .stats-filter-controls label { min-width:0; } + .report-controls { grid-template-columns:1fr 1fr 1fr;max-width:none; } .report-actions { display:grid;grid-template-columns:1fr 1fr; } .report-actions .button-link { text-align:center;justify-content:center; } .amount-change-add { grid-template-columns:1fr 1fr; } @@ -999,3 +1008,10 @@ code { color:#b8c3ff; } .nav-menu-copy small { font-size:10px; } .statements-page { max-width:none; } } + +/* v3.19: Personenzuordnung für Eingänge, Ausgänge und Umbuchungen */ +.accounts-people-page { align-items:stretch; } + +.person-unassigned-note { display:grid;gap:3px;padding:10px 12px;margin:0 0 12px;border:1px solid #5a4d24;background:#211b0d;border-radius:11px; } +.person-unassigned-note strong { font-size:11px;color:#f2dc8a; } +.person-unassigned-note span { font-size:9px;line-height:1.45;color:#a99b73; }