From 1f50eeb42954d79466cd63ce3689893a1d72bd39 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 19 Aug 2026 21:51:24 +0200 Subject: [PATCH] V3.23 --- public/sw.js | 2 +- server/index.js | 2 +- server/reports.js | 22 ++++---- src/main.jsx | 138 ++++++++++++++++++++++++++-------------------- 4 files changed, 90 insertions(+), 74 deletions(-) diff --git a/public/sw.js b/public/sw.js index 72c62e8..9749146 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL_CACHE='fixfin-shell-v3.22'; +const SHELL_CACHE='fixfin-shell-v3.23'; 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 b9910dd..b97a432 100644 --- a/server/index.js +++ b/server/index.js @@ -136,5 +136,5 @@ 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||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==10||!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=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Array.isArray(x.personIds)||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 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);}}); +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 includeTransfers=req.query.transfers!=='0';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,includeTransfers});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 includeTransfers=req.query.transfers!=='0';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,includeTransfers});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 af558e9..7d54fc6 100644 --- a/server/reports.js +++ b/server/reports.js @@ -26,7 +26,7 @@ function personIdsOf(item){return Array.isArray(item?.personIds)?item.personIds. function personLabel(data,item){const ids=personIdsOf(item);return ids.length?ids.map(id=>personName(data,id)).join(', '):'Ohne Person';} function personFactor(item,personId='all'){if(personId==='all')return 1;const ids=personIdsOf(item);return ids.includes(personId)&&ids.length?1/ids.length:0;} -export function buildAnnualForecast(data,year,accountId,personId='all'){ +export function buildAnnualForecast(data,year,accountId,personId='all',includeTransfers=true){ 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||[]){ const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue; @@ -36,7 +36,7 @@ export function buildAnnualForecast(data,year,accountId,personId='all'){ const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue; for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m)*factor;rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});} } - for(const item of data.transfers||[]){ + if(includeTransfers) for(const item of data.transfers||[]){ const factor=personFactor(item,personId); if(!factor||(item.fromAccountId!==accountId&&item.toAccountId!==accountId))continue; for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ const amount=Number(item.amount||0)*factor; @@ -44,11 +44,11 @@ export function buildAnnualForecast(data,year,accountId,personId='all'){ if(item.fromAccountId===accountId){rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'out',reportAmount:amount});} } } - for(const r of rows){r.displayIncome=r.income+r.transferIn;r.displayExpense=r.expense+r.transferOut;r.balance=r.displayIncome-r.displayExpense;} + for(const r of rows){r.displayIncome=r.income+(includeTransfers?r.transferIn:0);r.displayExpense=r.expense+(includeTransfers?r.transferOut:0);r.balance=r.displayIncome-r.displayExpense;} 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,personId='all'){ +function occurrenceRows(data,year,accountId,personId='all',includeTransfers=true){ const result=[]; const push=(item,kind,monthIndex,direction='',factor=1)=>{ const amount=(kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex))*factor; @@ -56,7 +56,7 @@ function occurrenceRows(data,year,accountId,personId='all'){ }; for(const item of data.incomes||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m,'',factor);} for(const item of data.expenses||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m,'',factor);} - for(const item of data.transfers||[]){const factor=personFactor(item,personId);if(factor&&(item.fromAccountId===accountId||item.toAccountId===accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ + if(includeTransfers) for(const item of data.transfers||[]){const factor=personFactor(item,personId);if(factor&&(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',factor); if(item.fromAccountId===accountId)push(item,'transfer',m,'out',factor); }} @@ -76,11 +76,11 @@ function addTitle(sheet,title,subtitle='',lastColumn='F'){ 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,personId='all'}={}){ +export async function createExcelReport(data,{year,accountId,personId='all',includeTransfers=true}={}){ 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 selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); - const forecast=buildAnnualForecast(data,reportYear,accountId,personId); const rows=occurrenceRows(data,reportYear,accountId,personId); + const forecast=buildAnnualForecast(data,reportYear,accountId,personId,includeTransfers); const rows=occurrenceRows(data,reportYear,accountId,personId,includeTransfers); 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}]}); @@ -91,7 +91,7 @@ export async function createExcelReport(data,{year,accountId,personId='all'}={}) 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} · ${selectedPerson} · Liquiditätssicht inklusive Transfers`,'D'); + addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · ${selectedPerson} · ${includeTransfers?'Liquiditätssicht inklusive Transfers':'Auswertung ohne 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]); @@ -107,13 +107,13 @@ 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,personId='all'}={}){ +export async function createPdfReport(data,{year,accountId,personId='all',includeTransfers=true}={}){ 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 selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); - const forecast=buildAnnualForecast(data,reportYear,accountId,personId);const rows=occurrenceRows(data,reportYear,accountId,personId); + const forecast=buildAnnualForecast(data,reportYear,accountId,personId,includeTransfers);const rows=occurrenceRows(data,reportYear,accountId,personId,includeTransfers); 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} · ${selectedPerson} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`); + drawPdfHeader(doc,`FixFin Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · ${includeTransfers?'inkl. Transfers':'ohne Transfers'} · 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.yearIncome 0; } function accountName(data, id) { return data.accounts.find(a => a.id === id)?.name || 'Unbekannt'; } -function calc(data) { +function calc(data, includeTransfers = true) { const includePeriodic = data.settings?.includePeriodicInBalance !== false; const accountStats = new Map(data.accounts.map(a => [a.id, { account: a, income: 0, expense: 0, transferIn: 0, transferOut: 0, periodicReserve: 0, balance: 0, requiredOutflow: 0, coveredInflow: 0, fundingNeed: 0, surplus: 0 }])); for (const item of data.incomes) { @@ -103,28 +103,28 @@ function calc(data) { stat.expense += balanceAmount(item, includePeriodic); if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item); } - for (const item of data.transfers) { + if (includeTransfers) for (const item of data.transfers) { const value = balanceAmount(item, includePeriodic); const from = accountStats.get(item.fromAccountId); const to = accountStats.get(item.toAccountId); if (from) from.transferOut += value; if (to) to.transferIn += value; } for (const stat of accountStats.values()) { - stat.balance = stat.income - stat.expense + stat.transferIn - stat.transferOut; - stat.requiredOutflow = stat.expense + stat.transferOut; - stat.coveredInflow = stat.income + stat.transferIn; + stat.balance = stat.income - stat.expense + (includeTransfers ? stat.transferIn - stat.transferOut : 0); + stat.requiredOutflow = stat.expense + (includeTransfers ? stat.transferOut : 0); + stat.coveredInflow = stat.income + (includeTransfers ? stat.transferIn : 0); stat.fundingNeed = Math.max(0, stat.requiredOutflow - stat.coveredInflow); stat.surplus = Math.max(0, stat.coveredInflow - stat.requiredOutflow); } const income = data.incomes.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0); const expense = data.expenses.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0); - const transferVolume = data.transfers.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0); + const transferVolume = includeTransfers ? data.transfers.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0) : 0; const periodicExpenseReserve = data.expenses.filter(isPeriodic).reduce((s,x) => s + monthlyEquivalent(x), 0); const periodicIncomeAverage = data.incomes.filter(isPeriodic).reduce((s,x) => s + monthlyEquivalent(x), 0); - return { accounts: [...accountStats.values()], income, expense, transferVolume, periodicExpenseReserve, periodicIncomeAverage, balance: income - expense, includePeriodic }; + return { accounts: [...accountStats.values()], income, expense, transferVolume, periodicExpenseReserve, periodicIncomeAverage, balance: income - expense, includePeriodic, includeTransfers }; } -function annualForecast(data, accountId = 'all', year = new Date().getFullYear(), personId = 'all') { +function annualForecast(data, accountId = 'all', year = new Date().getFullYear(), personId = 'all', includeTransfers = true) { const isAll = !accountId || accountId === 'all'; const rows = months.map((name, index) => ({ name, index, income: 0, expense: 0, transferIn: 0, transferOut: 0, balance: 0, items: [] })); const relevantIncome = item => personMatches(item,personId) && (isAll || item.accountId === accountId); @@ -138,7 +138,7 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear() const factor=personFactor(item,personId); for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].expense += amount; rows[m].items.push({ ...item, kind:'expense', forecastAmount:amount }); } } - for (const item of data.transfers.filter(relevantTransfer)) { + if (includeTransfers) for (const item of data.transfers.filter(relevantTransfer)) { const factor=personFactor(item,personId); for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount = Number(item.amount || 0) * factor; @@ -151,28 +151,28 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear() } } rows.forEach(r => { - r.displayIncome = isAll ? r.income : r.income + r.transferIn; - r.displayExpense = isAll ? r.expense : r.expense + r.transferOut; + r.displayIncome = isAll || !includeTransfers ? r.income : r.income + r.transferIn; + r.displayExpense = isAll || !includeTransfers ? r.expense : r.expense + r.transferOut; r.balance = r.displayIncome - r.displayExpense; }); const hasMonth = x => Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12; const unassigned = [ ...data.incomes.filter(relevantIncome).map(x => ({...x, kind:'income'})), ...data.expenses.filter(relevantExpense).map(x => ({...x, kind:'expense'})), - ...data.transfers.filter(relevantTransfer).flatMap(x => { + ...(includeTransfers ? data.transfers.filter(relevantTransfer).flatMap(x => { if (isAll) return [{...x, kind:'transfer', direction:'neutral'}]; const entries = []; if (x.toAccountId === accountId) entries.push({...x, kind:'transfer', direction:'in'}); if (x.fromAccountId === accountId) entries.push({...x, kind:'transfer', direction:'out'}); return entries; - }), + }) : []), ].filter(x => isPeriodic(x) && !hasMonth(x) && months.some((_,m)=>itemActiveInMonth(x,year,m))); const yearIncome = rows.reduce((s,r)=>s+r.displayIncome,0); const yearExpense = rows.reduce((s,r)=>s+r.displayExpense,0); - return { rows, unassigned, yearIncome, yearExpense, isAll, accountId, year }; + return { rows, unassigned, yearIncome, yearExpense, isAll, accountId, year, includeTransfers }; } -function categoryStats(data, accountId = 'all', personId = 'all') { +function categoryStats(data, accountId = 'all', personId = 'all', includeTransfers = true) { const categoryMap = new Map((data.categories || []).map(c => [c.id, c.name])); const isAll = accountId === 'all'; const aggregate = items => { @@ -193,22 +193,22 @@ function categoryStats(data, accountId = 'all', personId = 'all') { if (isAll) return { incomes: aggregate(data.incomes.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'income'}))), expenses: aggregate(data.expenses.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'expense'}))), - transfers: aggregate(data.transfers.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'transfer'}))), - transfersMerged:false + transfers: includeTransfers ? aggregate(data.transfers.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'transfer'}))) : {total:0,rows:[]}, + transfersMerged:false, includeTransfers }; const incomeItems = [ ...data.incomes.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'income'})), - ...data.transfers.filter(x => personMatches(x,personId) && x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})), + ...(includeTransfers ? data.transfers.filter(x => personMatches(x,personId) && x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})) : []), ]; const expenseItems = [ ...data.expenses.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'expense'})), - ...data.transfers.filter(x => personMatches(x,personId) && x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})), + ...(includeTransfers ? data.transfers.filter(x => personMatches(x,personId) && x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})) : []), ]; - return { incomes: aggregate(incomeItems), expenses: aggregate(expenseItems), transfers:{total:0,rows:[]}, transfersMerged:true }; + return { incomes: aggregate(incomeItems), expenses: aggregate(expenseItems), transfers:{total:0,rows:[]}, transfersMerged:includeTransfers, includeTransfers }; } -function personFlowStats(data, accountId = 'all', personId = 'all') { +function personFlowStats(data, accountId = 'all', personId = 'all', includeTransfers = true) { const isAll = accountId === 'all'; const unassignedId = '__unassigned__'; const buckets = new Map(); @@ -241,7 +241,7 @@ function personFlowStats(data, accountId = 'all', personId = 'all') { const amount=monthlyEquivalent(item); for(const [id,factor] of splitTargets(item)) ensure(id).expense += amount*factor; } - for (const item of data.transfers || []) { + if (includeTransfers) for (const item of data.transfers || []) { const amount = monthlyEquivalent(item); for(const [id,factor] of splitTargets(item)) { const bucket = ensure(id); const share=amount*factor; @@ -253,8 +253,8 @@ function personFlowStats(data, accountId = 'all', personId = 'all') { } } for (const row of buckets.values()) { - row.displayIn = isAll ? row.income : row.income + row.transferIn; - row.displayOut = isAll ? row.expense : row.expense + row.transferOut; + row.displayIn = isAll || !includeTransfers ? row.income : row.income + row.transferIn; + row.displayOut = isAll || !includeTransfers ? row.expense : row.expense + row.transferOut; row.net = row.displayIn - row.displayOut; } let rows = [...buckets.values()]; @@ -269,12 +269,15 @@ function personFlowStats(data, accountId = 'all', personId = 'all') { transferInternal:sum.transferInternal + row.transferInternal, net:sum.net + row.net, }), {displayIn:0,displayOut:0,transferIn:0,transferOut:0,transferInternal:0,net:0}); - return { rows, totals, isAll, accountId, personId }; + return { rows, totals, isAll, accountId, personId, includeTransfers }; } function PersonFlowOverview({stats}){ - return

Personenbezogene Zahlungsströme

{stats.isAll?'Wirtschaftliche Sicht pro Person. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; interne Transfers bleiben separat und saldoneutral.':'Liquiditätssicht pro Person auf dem gewählten Konto. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; Transfers zählen als echte Zu- und Abflüsse.'}

=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat
- {stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}{stats.isAll?'wirtschaftlicher Monatssaldo':'Nettofluss auf diesem Konto'}
=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
{stats.isAll?<>
Einnahmen+{formatMoney(row.income)}
Ausgaben−{formatMoney(row.expense)}
Interne Transfers{formatMoney(row.transferInternal)}
Saldo=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
:<>
Zuflüsse+{formatMoney(row.displayIn)}
Abflüsse−{formatMoney(row.displayOut)}
Transfers rein+{formatMoney(row.transferIn)}
Transfers raus−{formatMoney(row.transferOut)}
}
)}
} + const description = stats.isAll + ? (stats.includeTransfers?'Wirtschaftliche Sicht pro Person. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; interne Transfers bleiben separat und saldoneutral.':'Wirtschaftliche Sicht pro Person ohne Transfers. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt.') + : (stats.includeTransfers?'Liquiditätssicht pro Person auf dem gewählten Konto. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; Transfers zählen als echte Zu- und Abflüsse.':'Personenbezogene Zahlungsströme auf dem gewählten Konto ohne Transfers.'); + return

Personenbezogene Zahlungsströme

{description}

=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat
+ {stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}{stats.isAll?'wirtschaftlicher Monatssaldo':'Nettofluss auf diesem Konto'}
=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
{stats.isAll?<>
Einnahmen+{formatMoney(row.income)}
Ausgaben−{formatMoney(row.expense)}
{stats.includeTransfers&&
Interne Transfers{formatMoney(row.transferInternal)}
}
Saldo=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
:<>
Zuflüsse+{formatMoney(row.displayIn)}
Abflüsse−{formatMoney(row.displayOut)}
{stats.includeTransfers&&<>
Transfers rein+{formatMoney(row.transferIn)}
Transfers raus−{formatMoney(row.transferOut)}
}}
)}
}
; } @@ -299,9 +302,9 @@ function reserveOverview(data) { } function formatDate(value){if(!value)return '';const d=new Date(`${value}T12:00:00`);return Number.isNaN(d.getTime())?value:new Intl.DateTimeFormat('de-DE',{dateStyle:'medium'}).format(d);} -function buildWarnings(data){ +function buildWarnings(data, includeTransfers = true){ const now=new Date();const year=now.getFullYear();const currentMonth=now.getMonth();const horizon=new Date(now);horizon.setDate(horizon.getDate()+90);const warnings=[]; - for(const account of data.accounts){const f=annualForecast(data,account.id,year);const negatives=f.rows.slice(currentMonth).filter(r=>r.balance<0);if(negatives.length){const worst=negatives.reduce((a,b)=>b.balancer.balance<0);if(negatives.length){const worst=negatives.reduce((a,b)=>b.balanceisPeriodic(x)&&occursInMonth(x,m,year));if(due.length>=3){const total=due.reduce((sum,x)=>sum+effectiveAmount(x,year,m),0);warnings.push({kind:'warning',title:`${due.length} periodische Ausgaben im ${months[m]}`,text:`Zusammen ${formatMoney(total)}. Dieser Monat bündelt mehrere größere Fälligkeiten.`,sort:1});}} for(const [type,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]])for(const item of items){for(const change of item.amountChanges||[]){const d=new Date(`${change.effectiveFrom}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name}: Betrag ändert sich`,text:`${type} auf ${accountName(data,item.accountId)} ab ${formatDate(change.effectiveFrom)}: ${formatMoney(change.amount)}.`,sort:2,date:d});}if(item.endDate){const d=new Date(`${item.endDate}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name} läuft aus`,text:`${type} auf ${accountName(data,item.accountId)} endet am ${formatDate(item.endDate)}.`,sort:3,date:d});}} return warnings.sort((a,b)=>a.sort-b.sort||(a.date?.getTime()||0)-(b.date?.getTime()||0)).slice(0,10); @@ -373,6 +376,12 @@ function NavGroupMenu({group,tab,onClose,onSelect}){ return
e.stopPropagation()}>
Navigation{config.label}
{config.items.map(item=>)}
; } +const TRANSFER_PREF_KEY='fixfin.include-transfers.v1'; +function readTransferPreference(){try{return localStorage.getItem(TRANSFER_PREF_KEY)!=='false';}catch{return true;}} +function TransferToggle({enabled,onChange,compact=false}){ + return
Transfers einbeziehen{enabled?'Transfers werden in konto-bezogenen Zu-/Abflüssen und Auswertungen berücksichtigt.':'Transfers werden aus Berechnungen und Auswertungen ausgeblendet.'}
; +} + function App() { const [data,setData]=useState(emptyData); const [tab,setTab]=useState('dashboard'); @@ -381,7 +390,9 @@ function App() { const [saving,setSaving]=useState(false); const [error,setError]=useState(''); const [dialog,setDialog]=useState(null); - const totals=useMemo(()=>calc(data),[data]); + const [includeTransfers,setIncludeTransfers]=useState(readTransferPreference); + useEffect(()=>{try{localStorage.setItem(TRANSFER_PREF_KEY,String(includeTransfers));}catch{}},[includeTransfers]); + const totals=useMemo(()=>calc(data,includeTransfers),[data,includeTransfers]); useEffect(()=>{ fetch('/api/data').then(async r=>{if(!r.ok)throw new Error((await r.json()).error||'Daten konnten nicht geladen werden.');return r.json();}).then(setData).catch(e=>setError(e.message)).finally(()=>setLoading(false)); },[]); async function persist(next){setSaving(true);setError('');try{const r=await fetch('/api/data',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(next)});const result=await r.json();if(!r.ok)throw new Error(result.error||'Speichern fehlgeschlagen.');setData(result);return 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)||(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)});} @@ -413,14 +424,14 @@ function App() {

{meta.label}

{meta.description}

{error&&
{error}
} - {tab==='dashboard'&&} + {tab==='dashboard'&&} {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'&&} - {tab==='stats'&&setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>} - {tab==='statements'&&} - {tab==='scenarios'&&} + {tab==='year'&&} + {tab==='planning'&&} + {tab==='stats'&&setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>} + {tab==='statements'&&} + {tab==='scenarios'&&} {tab==='data'&&}
@@ -429,7 +440,7 @@ function App() { ; } -function Dashboard({data,totals,onToggle}){ +function Dashboard({data,totals,onToggle,includeTransfers,onTransfersChange}){ const FILTER_KEY='fixfin.dashboard.account-filter.v1'; const [filter,setFilter]=useState(()=>{ try { @@ -444,7 +455,7 @@ function Dashboard({data,totals,onToggle}){ const visibleAccounts=useMemo(()=>totals.accounts.filter(s=>selectedSet.has(s.account.id)),[totals.accounts,selectedSet]); const selectedBalance=visibleAccounts.reduce((sum,s)=>sum+s.balance,0); const allSelected=filter.mode==='all'||validSelected.length===accountIds.length; - const warnings=useMemo(()=>buildWarnings(data),[data]); + const warnings=useMemo(()=>buildWarnings(data,includeTransfers),[data,includeTransfers]); useEffect(()=>{ if(filter.mode==='some'&&accountIds.length>0&&validSelected.length===0) setFilter({mode:'all',ids:[]}); @@ -469,6 +480,7 @@ function Dashboard({data,totals,onToggle}){ const filterLabel=allSelected?'Alle Konten':`${validSelected.length} von ${accountIds.length} Konten`; return
+
Kontenfilter{filterLabel}
@@ -478,7 +490,7 @@ function Dashboard({data,totals,onToggle}){

Salden

{allSelected?'Alle Konten im monatlichen Fix-Saldo.':'Nur die ausgewählten Konten.'}

{allSelected?'Gesamtsaldo':'Saldo Auswahl'}=0?'good-text':'bad-text'}>{selectedBalance>=0?'+':''}{formatMoney(selectedBalance)}
- {visibleAccounts.length===0?:
{visibleAccounts.map(s=>

{s.account.name}

monatlicher Fix-Saldo
=0?'good-text':'bad-text'}`}>{s.balance>=0?'+':''}{formatMoney(s.balance)}
Eingänge+{formatMoney(s.income)}
Ausgänge−{formatMoney(s.expense)}
Transfers rein+{formatMoney(s.transferIn)}
Transfers raus−{formatMoney(s.transferOut)}
)}
} + {visibleAccounts.length===0?:
{visibleAccounts.map(s=>

{s.account.name}

monatlicher Fix-Saldo
=0?'good-text':'bad-text'}`}>{s.balance>=0?'+':''}{formatMoney(s.balance)}
Eingänge+{formatMoney(s.income)}
Ausgänge−{formatMoney(s.expense)}
{includeTransfers&&<>
Transfers rein+{formatMoney(s.transferIn)}
Transfers raus−{formatMoney(s.transferOut)}
}
)}
}

Hinweise

FixFin prüft deinen aktuellen Plan auf Liquiditätslücken, gebündelte Fälligkeiten, anstehende Änderungen und auslaufende Positionen.

w.kind==='danger')?'danger':''}`}>{warnings.length}
{warnings.length===0?
Keine AuffälligkeitenFür die nächsten Monate sieht der Plan unauffällig aus.
:
{warnings.map((w,i)=>
{w.title}{w.text}
)}
}
@@ -488,18 +500,19 @@ function Dashboard({data,totals,onToggle}){
; } -function PlanningPage({data,totals}){ +function PlanningPage({data,totals,includeTransfers,onTransfersChange}){ const reserves=useMemo(()=>reserveOverview(data),[data]); const totalRequired=totals.accounts.reduce((s,x)=>s+x.requiredOutflow,0); const totalFundingNeed=totals.accounts.reduce((s,x)=>s+x.fundingNeed,0); return
+
0} positive={totalFundingNeed===0}/>
-

Benötigt pro Konto

Liquiditätsbedarf je Konto: echte Ausgaben plus Transfers raus, gedeckt durch echte Eingänge plus Transfers rein.

{totals.accounts.length===0?:
{totals.accounts.map(s=>
{s.account.name}0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}
Abflussbedarf{formatMoney(s.requiredOutflow)}
Geplante Zuflüsse{formatMoney(s.coveredInflow)}
Zusätzliche Zuführung0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}
)}
}
+

Benötigt pro Konto

{includeTransfers?'Liquiditätsbedarf je Konto: echte Ausgaben plus Transfers raus, gedeckt durch echte Eingänge plus Transfers rein.':'Kontobedarf ohne Transfers: nur echte Ausgaben und echte Eingänge werden berücksichtigt.'}

{totals.accounts.length===0?:
{totals.accounts.map(s=>
{s.account.name}0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}
Abflussbedarf{formatMoney(s.requiredOutflow)}
Geplante Zuflüsse{formatMoney(s.coveredInflow)}
Zusätzliche Zuführung0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}
)}
}

Rücklagenübersicht

Periodische Ausgaben auf einen gleichmäßigen Monatsbetrag heruntergerechnet.

{formatMoney(reserves.annual)} / Jahr{formatMoney(reserves.monthly)} / Monat
{reserves.items.length===0?:
{reserves.items.map(x=>
{x.name}{accountName(data,x.accountId)} · {categoryName(data,x.categoryId)} · {intervals[x.interval].label}
{formatMoney(x.annualNeed)} / Jahr{formatMoney(x.monthlyReserve)} / Monat
)}
}
; } @@ -593,18 +606,18 @@ function CategoryDistribution({title,subtitle,stats,tone,data}){
; } -function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){ +function StatisticsPage({data,includeTransfers,onTransfersChange,onAddCategory,onEditCategory,onDeleteCategory}){ const [accountId,setAccountId]=useState('all'); const [personId,setPersonId]=useState('all'); - const stats=useMemo(()=>categoryStats(data,accountId,personId),[data,accountId,personId]); - const personFlows=useMemo(()=>personFlowStats(data,accountId,personId),[data,accountId,personId]); - const totals=useMemo(()=>calc(data),[data]); + const stats=useMemo(()=>categoryStats(data,accountId,personId,includeTransfers),[data,accountId,personId,includeTransfers]); + const personFlows=useMemo(()=>personFlowStats(data,accountId,personId,includeTransfers),[data,accountId,personId,includeTransfers]); + const totals=useMemo(()=>calc(data,includeTransfers),[data,includeTransfers]); 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 personMatchesLocal=x=>personMatches(x,personId); - const relevantEntries=[...data.incomes.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...data.expenses.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...data.transfers.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.fromAccountId===accountId||x.toAccountId===accountId))]; + const relevantEntries=[...data.incomes.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...data.expenses.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...(includeTransfers?data.transfers.filter(x=>personMatchesLocal(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.

@@ -616,21 +629,22 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
Statistik für{selectedName} · {selectedPerson}
+
- - - {!stats.transfersMerged&&} + + + {includeTransfers&&!stats.transfersMerged&&}

Kategorien verwalten

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

{data.categories.length===0?:
{data.categories.map(c=>
{c.name}{usage(c.id)} {usage(c.id)===1?'zugeordneter Eintrag':'zugeordnete Einträge'}
)}
}
; } -function YearPage({data}){ +function YearPage({data,includeTransfers,onTransfersChange}){ 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,personId),[data,accountId,year,personId]); + const forecast=useMemo(()=>annualForecast(data,accountId,year,personId,includeTransfers),[data,accountId,year,personId,includeTransfers]); const selectedAccount=data.accounts.find(a=>a.id===accountId); const selectedName=selectedAccount?.name||'Alle Konten'; const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); @@ -649,6 +663,7 @@ function YearPage({data}){ return
Jahresansicht für{selectedName} · {selectedPerson} · {year}
+
@@ -656,15 +671,15 @@ function YearPage({data}){
-

{forecast.isAll?'Belastungsprofil':'Abflussprofil'}

{forecast.isAll?'Echte Ausgaben pro Monat. Interne Transfers bleiben außen vor.':`Was ${selectedName} pro Monat tatsächlich verlässt: echte Ausgaben plus Transfers raus.`}

{forecast.rows.map(r=>
{r.name.slice(0,3)}
{formatMoney(r.displayExpense)}
)}
+

{forecast.isAll?'Belastungsprofil':'Abflussprofil'}

{forecast.isAll?'Echte Ausgaben pro Monat. Interne Transfers bleiben saldoneutral.':includeTransfers?`Was ${selectedName} pro Monat tatsächlich verlässt: echte Ausgaben plus Transfers raus.`:`Echte Ausgaben von ${selectedName}; Transfers sind ausgeblendet.`}

{forecast.rows.map(r=>
{r.name.slice(0,3)}
{formatMoney(r.displayExpense)}
)}
-

Jahresprognose

{forecast.isAll?'Wirtschaftliche Sicht über alle Konten: nur echte Einnahmen und Ausgaben. Transfers verschieben Geld nur innerhalb des Zahlungspools.':`Liquiditätssicht für ${selectedName}: Transfers rein zählen als Zufluss, Transfers raus als Abfluss. Im Detail bleiben sie klar von echten Einnahmen und Ausgaben getrennt.`}

+

Jahresprognose

{forecast.isAll?(includeTransfers?'Wirtschaftliche Sicht über alle Konten: Transfers werden angezeigt, bleiben aber saldoneutral.':'Wirtschaftliche Sicht über alle Konten ohne Transfers.'):includeTransfers?`Liquiditätssicht für ${selectedName}: Transfers rein zählen als Zufluss, Transfers raus als Abfluss. Im Detail bleiben sie klar von echten Einnahmen und Ausgaben getrennt.`:`Kontosicht für ${selectedName} ohne Transfers: nur echte Eingänge und Ausgaben.`}

{forecast.rows.map(r=>{ const inflows=r.items.filter(x=>x.kind==='income'||x.direction==='in'); const outflows=r.items.filter(x=>x.kind==='expense'||x.direction==='out'); return
{r.name}{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}
{labels.income}+{formatMoney(r.displayIncome)}
{labels.expense}−{formatMoney(r.displayExpense)}
{labels.balance}=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}
- {r.items.length===0?
In diesem Monat sind keine einzelnen Fixpositionen fällig.
:forecast.isAll?<>{r.items.map(detailRow)}{(r.transferIn>0||r.transferOut>0)&&
Interne Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie bleiben vollständig außerhalb von Einnahmen, Ausgaben und Überschuss.
}:<> + {r.items.length===0?
In diesem Monat sind keine einzelnen Fixpositionen fällig.
:forecast.isAll?<>{r.items.map(detailRow)}{includeTransfers&&(r.transferIn>0||r.transferOut>0)&&
Interne Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie bleiben vollständig außerhalb von Einnahmen, Ausgaben und Überschuss.
}:includeTransfers?<>
Echte Eingänge+{formatMoney(r.income)}
Transfers rein+{formatMoney(r.transferIn)}
@@ -674,7 +689,7 @@ function YearPage({data}){ {inflows.length>0&&
Zuflüsse+{formatMoney(r.displayIncome)}
{inflows.map(detailRow)}
} {outflows.length>0&&
Abflüsse−{formatMoney(r.displayExpense)}
{outflows.map(detailRow)}
}
Kontosicht: Transfers werden im Nettofluss berücksichtigt, bleiben aber als Transfers gekennzeichnet. So siehst du den echten Liquiditätsbedarf des Kontos, ohne sie mit Konsumausgaben zu verwechseln.
- } + :<>{r.items.map(detailRow)}}
; })}
@@ -683,21 +698,22 @@ function YearPage({data}){
; } -function ScenariosPage({data,persist}){ +function ScenariosPage({data,persist,includeTransfers,onTransfersChange}){ const scenarios=data.scenarios||[]; const [selectedId,setSelectedId]=useState(scenarios[0]?.id||''); const [adjustmentDialog,setAdjustmentDialog]=useState(null); useEffect(()=>{if(selectedId&&!scenarios.some(s=>s.id===selectedId))setSelectedId(scenarios[0]?.id||'');if(!selectedId&&scenarios[0])setSelectedId(scenarios[0].id);},[scenarios,selectedId]); const scenario=scenarios.find(s=>s.id===selectedId)||scenarios[0]; - const baseTotals=useMemo(()=>calc(data),[data]); + const baseTotals=useMemo(()=>calc(data,includeTransfers),[data,includeTransfers]); const projectedData=useMemo(()=>scenarioData(data,scenario),[data,scenario]); - const projected=useMemo(()=>calc(projectedData),[projectedData]); + const projected=useMemo(()=>calc(projectedData,includeTransfers),[projectedData,includeTransfers]); async function addScenario(){const name=prompt('Name des neuen Szenarios:','Was wäre wenn …');if(!name?.trim())return;const next={...data,scenarios:[...scenarios,{id:uid(),name:name.trim(),createdAt:new Date().toISOString(),adjustments:[]}]};const saved=await persist(next);setSelectedId(saved.scenarios[saved.scenarios.length-1]?.id||'');} async function renameScenario(){if(!scenario)return;const name=prompt('Neuer Name:',scenario.name);if(!name?.trim())return;await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,name:name.trim()}:s)});} async function deleteScenario(){if(!scenario||!confirm(`Szenario „${scenario.name}“ wirklich löschen?`))return;await persist({...data,scenarios:scenarios.filter(s=>s.id!==scenario.id)});} async function deleteAdjustment(id){if(!scenario||!confirm('Anpassung wirklich löschen?'))return;await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,adjustments:(s.adjustments||[]).filter(a=>a.id!==id)}:s)});} async function saveAdjustment(value){await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,adjustments:adjustmentDialog?.item?.id?(s.adjustments||[]).map(a=>a.id===adjustmentDialog.item.id?value:a):[...(s.adjustments||[]),value]}:s)});setAdjustmentDialog(null);} return
+
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}/>
@@ -756,15 +772,15 @@ function PushoverSettings(){ ; } -function StatementsPage({data}){ +function StatementsPage({data,includeTransfers,onTransfersChange}){ 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 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. Bei Person-Filter werden gemeinsam zugeordnete Bewegungen anteilig ausgewiesen. 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)}&transfers=${includeTransfers?'1':'0'}`; + 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 {includeTransfers?'inklusive Transfers':'ohne Transfers'}. Bei Person-Filter werden gemeinsam zugeordnete Bewegungen anteilig ausgewiesen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.

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

; } function DataPage({data,importFile}){