diff --git a/public/sw.js b/public/sw.js index 0cd5852..92a6511 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL_CACHE='fixfin-shell-v3.16'; +const SHELL_CACHE='fixfin-shell-v3.17'; 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 1da2a37..30c0f86 100644 --- a/server/index.js +++ b/server/index.js @@ -77,7 +77,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.16'},body}); + const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.17'},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; @@ -114,5 +114,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||[])];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 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:'all';if(accountId!=='all'&&!data.accounts.some(a=>a.id===accountId))throw new Error('Unbekanntes Konto für den Export.');const buffer=await createExcelReport(data,{year,accountId});const suffix=accountId==='all'?'alle-konten':(data.accounts.find(a=>a.id===accountId)?.name||'konto').replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-${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:'all';if(accountId!=='all'&&!data.accounts.some(a=>a.id===accountId))throw new Error('Unbekanntes Konto für den Export.');const buffer=await createPdfReport(data,{year,accountId});const suffix=accountId==='all'?'alle-konten':(data.accounts.find(a=>a.id===accountId)?.name||'konto').replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-${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 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);}}); 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 014adeb..c4dd964 100644 --- a/server/reports.js +++ b/server/reports.js @@ -21,157 +21,98 @@ 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 scopeAccounts(data,accountId){return accountId&&accountId!=='all'?data.accounts.filter(a=>a.id===accountId):data.accounts;} -function inScope(item,kind,accountId){if(!accountId||accountId==='all')return true;if(kind==='transfer')return item.fromAccountId===accountId||item.toAccountId===accountId;return item.accountId===accountId;} -export function buildAnnualForecast(data,year,accountId='all'){ - const isAll=!accountId||accountId==='all'; +export function buildAnnualForecast(data,year,accountId){ 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(inScope(item,'income',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(inScope(item,'expense',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(inScope(item,'transfer',accountId)) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){ + 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)){ const amount=Number(item.amount||0); - if(isAll){rows[m].transferIn+=amount;rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'neutral',reportAmount:amount});} - else {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});}} + 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});} } - for(const r of rows){r.displayIncome=isAll?r.income:r.income+r.transferIn;r.displayExpense=isAll?r.expense:r.expense+r.transferOut;r.balance=r.displayIncome-r.displayExpense;} - return {year,accountId,isAll,rows,yearIncome:rows.reduce((s,r)=>s+r.displayIncome,0),yearExpense:rows.reduce((s,r)=>s+r.displayExpense,0)}; + for(const r of rows){r.displayIncome=r.income+r.transferIn;r.displayExpense=r.expense+r.transferOut;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 reportWarnings(data,year,accountId='all'){ - const warnings=[]; - for(const account of scopeAccounts(data,accountId)){ - const forecast=buildAnnualForecast(data,year,account.id); - const negatives=forecast.rows.filter(r=>r.balance<0); - if(negatives.length){ - const worst=negatives.reduce((a,b)=>b.balanceinScope(x,'expense',accountId)&&x.interval!=='monthly'&&occursInMonth(x,year,m)); - if(due.length>=3){ - const total=due.reduce((sum,x)=>sum+effectiveAmount(x,year,m),0); - warnings.push({level:'Hinweis',title:`${due.length} periodische Ausgaben im ${MONTHS[m]}`,text:`Zusammen ${money.format(total)}. Mehrere größere Fälligkeiten liegen im selben Monat.`,sort:1,month:m}); - } - } - for(const [kind,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]]){ - for(const item of items){ - if(!inScope(item,kind==='Eingang'?'income':'expense',accountId))continue; - for(const change of item.amountChanges||[]){ - if(String(change.effectiveFrom||'').startsWith(`${year}-`))warnings.push({level:'Info',title:`${item.name}: Betrag ändert sich`,text:`${kind} auf ${accountName(data,item.accountId)} ab ${change.effectiveFrom}: ${money.format(Number(change.amount||0))}.`,sort:2,month:Number(change.effectiveFrom.slice(5,7))-1}); - } - if(String(item.endDate||'').startsWith(`${year}-`))warnings.push({level:'Info',title:`${item.name} läuft aus`,text:`${kind} auf ${accountName(data,item.accountId)} endet am ${item.endDate}.`,sort:3,month:Number(item.endDate.slice(5,7))-1}); - } - } - return warnings.sort((a,b)=>a.sort-b.sort||a.month-b.month||a.title.localeCompare(b.title,'de')).slice(0,25); -} - -function occurrenceRows(data,year,accountId='all'){ +function occurrenceRows(data,year,accountId){ const result=[]; const push=(item,kind,monthIndex,direction='')=>{ - let amount=kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex); - let account=''; - if(kind==='transfer')account=`${accountName(data,item.fromAccountId)} -> ${accountName(data,item.toAccountId)}`; - else account=accountName(data,item.accountId); - result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,category:categoryName(data,item.categoryId),account,amount,interval:INTERVALS[item.interval||'monthly']?.label||item.interval,endDate:item.endDate||'',id:item.id}); + 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}); }; - for(const item of data.incomes||[])if(inScope(item,'income',accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m); - for(const item of data.expenses||[])if(inScope(item,'expense',accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m); - for(const item of data.transfers||[])if(inScope(item,'transfer',accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ - if(accountId==='all'||!accountId)push(item,'transfer',m,'neutral'); - else {if(item.toAccountId===accountId)push(item,'transfer',m,'in');if(item.fromAccountId===accountId)push(item,'transfer',m,'out');} + 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)){ + if(item.toAccountId===accountId)push(item,'transfer',m,'in'); + if(item.fromAccountId===accountId)push(item,'transfer',m,'out'); } - return result.sort((a,b)=>a.monthIndex-b.monthIndex||a.kind.localeCompare(b.kind,'de')||a.name.localeCompare(b.name,'de')); + const order={income:0,transferIn:1,expense:2,transferOut:3}; + const typeOrder=r=>r.kind==='income'?order.income:r.kind==='expense'?order.expense:r.direction==='in'?order.transferIn:order.transferOut; + return result.sort((a,b)=>a.monthIndex-b.monthIndex||typeOrder(a)-typeOrder(b)||a.name.localeCompare(b.name,'de')); } +function kindLabel(row){return row.kind==='income'?'Eingang':row.kind==='expense'?'Ausgabe':row.direction==='in'?'Transfer rein':'Transfer raus';} +function isInflow(row){return row.kind==='income'||row.direction==='in';} -function safeSheetName(name){return name.replace(/[\\/*?:\[\]]/g,' ').slice(0,31)||'Konto';} 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:H1');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:H2');sheet.getCell('A2').value=subtitle;sheet.getCell('A2').font={italic:true,color:{argb:'FF667085'}};} + 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 widths(sheet,widths){widths.forEach((w,i)=>sheet.getColumn(i+1).width=w);} +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='all'}={}){ - const reportYear=Number(year)||new Date().getFullYear();const forecast=buildAnnualForecast(data,reportYear,accountId); +export async function createExcelReport(data,{year,accountId}={}){ + 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 wb=new ExcelJS.Workbook();wb.creator='FixFin';wb.created=new Date();wb.modified=new Date(); - const scope=accountId==='all'?'Alle Konten':accountName(data,accountId); - const overview=wb.addWorksheet('Übersicht',{views:[{state:'frozen',ySplit:4}]});addTitle(overview,`FixFin Planungs-Auszug ${reportYear}`,`Umfang: ${scope} · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`); - overview.getRow(4).values=['Kennzahl','Wert'];overview.getRow(5).values=[forecast.isAll?'Einnahmen / Jahr':'Zuflüsse / Jahr',forecast.yearIncome];overview.getRow(6).values=[forecast.isAll?'Ausgaben / Jahr':'Abflüsse / Jahr',forecast.yearExpense];overview.getRow(7).values=[forecast.isAll?'Überschuss / Jahr':'Nettofluss / Jahr',forecast.yearIncome-forecast.yearExpense];styleHeader(overview.getRow(4));for(let r=5;r<=7;r++)euroCell(overview.getCell(r,2)); - let row=10;overview.getCell(row,1).value='Kontenübersicht';overview.getCell(row,1).font={bold:true,size:14};row++; - overview.getRow(row).values=['Konto','Zuflüsse / Jahr','Abflüsse / Jahr','Nettofluss / Jahr'];styleHeader(overview.getRow(row));row++; - for(const account of scopeAccounts(data,accountId)){const f=buildAnnualForecast(data,reportYear,account.id);overview.getRow(row).values=[account.name,f.yearIncome,f.yearExpense,f.yearIncome-f.yearExpense];for(let c=2;c<=4;c++)euroCell(overview.getCell(row,c));row++;} - widths(overview,[28,20,20,20,14,14,14,14]); - const annual=wb.addWorksheet('Jahresplan',{views:[{state:'frozen',ySplit:4}]});addTitle(annual,`Jahresplan ${reportYear}`,forecast.isAll?'Wirtschaftliche Sicht: Transfers bleiben saldoneutral.':`Liquiditätssicht für ${scope}: Transfers werden als Zu- bzw. Abfluss berücksichtigt.`); - annual.getRow(4).values=['Monat',forecast.isAll?'Einnahmen':'Zuflüsse',forecast.isAll?'Ausgaben':'Abflüsse',forecast.isAll?'Überschuss':'Nettofluss','Echte Einnahmen','Echte Ausgaben','Transfers rein','Transfers raus'];styleHeader(annual.getRow(4)); - forecast.rows.forEach((r,i)=>{const rr=i+5;annual.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance,r.income,r.expense,r.transferIn,r.transferOut];for(let c=2;c<=8;c++)euroCell(annual.getCell(rr,c));});widths(annual,[18,18,18,18,18,18,18,18]); + 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]); - const warnings=reportWarnings(data,reportYear,accountId); - const warningSheet=wb.addWorksheet('Hinweise');addTitle(warningSheet,`Planungshinweise ${reportYear}`,`Automatisch aus den hinterlegten Zu- und Abflüssen, Laufzeiten und Betragsänderungen abgeleitet.`);warningSheet.getRow(4).values=['Stufe','Hinweis','Details'];styleHeader(warningSheet.getRow(4));warnings.forEach((w,i)=>{warningSheet.getRow(i+5).values=[w.level,w.title,w.text];});if(!warnings.length)warningSheet.getRow(5).values=['OK','Keine auffälligen Planungshinweise','Für den gewählten Zeitraum wurden keine der definierten Warnbedingungen ausgelöst.'];widths(warningSheet,[14,42,74]); + const summary=wb.addWorksheet('Monatsübersicht',{views:[{state:'frozen',ySplit:4}]}); + addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · Liquiditätssicht inklusive Transfers`); + 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]); - const moves=wb.addWorksheet('Bewegungen',{views:[{state:'frozen',ySplit:4}]});addTitle(moves,`Geplante Bewegungen ${reportYear}`,`Alle im gewählten Umfang tatsächlich fälligen Positionen, aufbereitet wie ein Planungs-Kontoauszug.`); - moves.getRow(4).values=['Monat','Art','Bezeichnung','Kategorie','Konto / Route','Betrag','Intervall','Enddatum'];styleHeader(moves.getRow(4)); - const kindLabel=r=>r.kind==='income'?'Eingang':r.kind==='expense'?'Ausgang':r.direction==='in'?'Transfer rein':r.direction==='out'?'Transfer raus':'Transfer'; - occurrenceRows(data,reportYear,accountId).forEach((r,i)=>{const rr=i+5;moves.getRow(rr).values=[r.month,kindLabel(r),r.name,r.category,r.account,r.amount,r.interval,r.endDate];euroCell(moves.getCell(rr,6));});widths(moves,[14,16,30,22,34,16,18,14]); - - const changes=wb.addWorksheet('Änderungen');addTitle(changes,'Geplante Betragsänderungen','Terminierte Änderungen an Eingängen und Ausgängen.');changes.getRow(4).values=['Art','Bezeichnung','Konto','Wirksam ab','Neuer Betrag','Enddatum'];styleHeader(changes.getRow(4));let cr=5; - for(const [kind,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]])for(const item of items)if(inScope(item,kind==='Eingang'?'income':'expense',accountId))for(const change of item.amountChanges||[]){changes.getRow(cr).values=[kind,item.name,accountName(data,item.accountId),change.effectiveFrom,Number(change.amount||0),item.endDate||''];euroCell(changes.getCell(cr,5));cr++;}widths(changes,[14,30,28,14,18,14]); - - const master=wb.addWorksheet('Stammdaten');addTitle(master,'Stammdaten','Definitionen der geplanten Einnahmen, Ausgaben und Transfers.');master.getRow(4).values=['Art','Bezeichnung','Konto / Route','Kategorie','Basisbetrag','Intervall','Fälligkeitsmonat','Enddatum','Reminder'];styleHeader(master.getRow(4));let mr=5; - const addMaster=(kind,item,route)=>{master.getRow(mr).values=[kind,item.name,route,categoryName(data,item.categoryId),Number(item.amount||0),INTERVALS[item.interval||'monthly']?.label||item.interval,item.dueMonth?MONTHS[Number(item.dueMonth)-1]:'',item.endDate||'',item.pushoverReminder?'Ja':''];euroCell(master.getCell(mr,5));mr++;}; - for(const x of data.incomes||[])if(inScope(x,'income',accountId))addMaster('Eingang',x,accountName(data,x.accountId));for(const x of data.expenses||[])if(inScope(x,'expense',accountId))addMaster('Ausgang',x,accountName(data,x.accountId));for(const x of data.transfers||[])if(inScope(x,'transfer',accountId))addMaster('Transfer',x,`${accountName(data,x.fromAccountId)} -> ${accountName(data,x.toAccountId)}`);widths(master,[14,30,34,22,16,18,18,14,12]); - - const system=wb.addWorksheet('Konten & Kategorien');addTitle(system,'Konten, Kategorien & Einstellungen','Technische Zugangsdaten wie Pushover-Token werden bewusst nicht exportiert.'); - system.getRow(4).values=['Einstellung','Wert'];styleHeader(system.getRow(4));system.getRow(5).values=['Periodische Posten im Monats-Saldo',data.settings?.includePeriodicInBalance===false?'Nein':'Ja']; - let sysRow=8;system.getCell(sysRow,1).value='Konten';system.getCell(sysRow,1).font={bold:true,size:14};sysRow++;system.getRow(sysRow).values=['Konto-ID','Kontoname'];styleHeader(system.getRow(sysRow));sysRow++;for(const account of scopeAccounts(data,accountId)){system.getRow(sysRow).values=[account.id,account.name];sysRow++;} - sysRow+=2;system.getCell(sysRow,1).value='Kategorien';system.getCell(sysRow,1).font={bold:true,size:14};sysRow++;system.getRow(sysRow).values=['Kategorie-ID','Kategoriename'];styleHeader(system.getRow(sysRow));sysRow++;for(const category of data.categories||[]){system.getRow(sysRow).values=[category.id,category.name];sysRow++;}widths(system,[42,42,18,18,18,18,18,18]); - - const scenarios=wb.addWorksheet('Szenarien');addTitle(scenarios,'Szenarien','Was-wäre-wenn-Anpassungen aus FixFin.');scenarios.getRow(4).values=['Szenario','Art','Bezeichnung','Konto / Route','Betrag / Monat'];styleHeader(scenarios.getRow(4));let sr=5;for(const s of data.scenarios||[])for(const a of s.adjustments||[]){const relevant=accountId==='all'||(a.type==='transfer'?(a.fromAccountId===accountId||a.toAccountId===accountId):a.accountId===accountId);if(!relevant)continue;scenarios.getRow(sr).values=[s.name,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),Number(a.amount||0)];euroCell(scenarios.getCell(sr,5));sr++;}widths(scenarios,[28,14,30,34,18]); - - for(const [accountIndex,account] of scopeAccounts(data,accountId).entries()){ - const f=buildAnnualForecast(data,reportYear,account.id);const sh=wb.addWorksheet(safeSheetName(`Konto ${account.name}`.slice(0,27)+` ${accountIndex+1}`),{views:[{state:'frozen',ySplit:4}]});addTitle(sh,account.name,`Liquiditäts-Auszug ${reportYear} · Zuflüsse inkl. Transfers rein, Abflüsse inkl. Transfers raus.`);sh.getRow(4).values=['Monat','Zuflüsse','Abflüsse','Nettofluss'];styleHeader(sh.getRow(4));f.rows.forEach((r,i)=>{const rr=i+5;sh.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance];for(let c=2;c<=4;c++)euroCell(sh.getCell(rr,c));});let rr=19;sh.getCell(rr,1).value='Einzelpositionen';sh.getCell(rr,1).font={bold:true,size:14};rr++;sh.getRow(rr).values=['Monat','Art','Bezeichnung','Kategorie','Betrag'];styleHeader(sh.getRow(rr));rr++;for(const r of occurrenceRows(data,reportYear,account.id)){sh.getRow(rr).values=[r.month,kindLabel(r),r.name,r.category,r.amount];euroCell(sh.getCell(rr,5));rr++;}widths(sh,[16,18,32,24,18]); - } return wb.xlsx.writeBuffer(); } function collectPdfBuffer(doc){return new Promise((resolve,reject)=>{const chunks=[];doc.on('data',c=>chunks.push(c));doc.on('end',()=>resolve(Buffer.concat(chunks)));doc.on('error',reject);});} -function pdfMoney(v){return money.format(Number(v||0));} +function pdfMoney(value){return money.format(Number(value||0));} function drawPdfHeader(doc,title,subtitle){doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(20).text(title);doc.moveDown(.2).fillColor('#667085').font('Helvetica').fontSize(9).text(subtitle);doc.moveDown(.7);doc.strokeColor('#CCD5E1').moveTo(50,doc.y).lineTo(545,doc.y).stroke();doc.moveDown(.7);} -function ensurePdfSpace(doc,height=60){if(doc.y+height>770)doc.addPage();} -function pdfSection(doc,title,subtitle=''){ensurePdfSpace(doc,50);doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(14).text(title);if(subtitle)doc.fillColor('#667085').font('Helvetica').fontSize(8.5).text(subtitle);doc.moveDown(.45);} -function pdfMetricRow(doc,items){ensurePdfSpace(doc,48);const y=doc.y;const width=495/items.length;items.forEach((x,i)=>{const x0=50+i*width;doc.roundedRect(x0,y,width-8,40,6).fillAndStroke('#F4F6FA','#D6DCE7');doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(x.label,x0+8,y+7,{width:width-24});doc.fillColor(x.negative?'#B42318':x.positive?'#067647':'#17233B').font('Helvetica-Bold').fontSize(11).text(x.value,x0+8,y+20,{width:width-24});});doc.y=y+50;} -function pdfTableHeader(doc,cols){ensurePdfSpace(doc,30);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 pdfTableRow(doc,cols,values,{bold=false,tone=null}={}){ensurePdfSpace(doc,24);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(tone&&i===values.length-1?tone:'#344054').font(bold?'Helvetica-Bold':'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;} +function addPageIfNeeded(doc,height=80){if(doc.y+height>760)doc.addPage();} +function pdfMetricRow(doc,items){addPageIfNeeded(doc,48);const y=doc.y;const width=495/items.length;items.forEach((item,i)=>{const x=50+i*width;doc.roundedRect(x,y,width-8,40,6).fillAndStroke('#F4F6FA','#D6DCE7');doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(item.label,x+8,y+7,{width:width-24});doc.fillColor(item.negative?'#B42318':item.positive?'#067647':'#17233B').font('Helvetica-Bold').fontSize(11).text(item.value,x+8,y+20,{width:width-24});});doc.y=y+50;} +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='all'}={}){ - const reportYear=Number(year)||new Date().getFullYear();const scope=accountId==='all'?'Alle Konten':accountName(data,accountId);const forecast=buildAnnualForecast(data,reportYear,accountId); - const doc=new PDFDocument({size:'A4',margin:50,info:{Title:`FixFin Planungs-Auszug ${reportYear}`,Author:'FixFin'}});const done=collectPdfBuffer(doc); - drawPdfHeader(doc,`FixFin Planungs-Auszug ${reportYear}`,`Umfang: ${scope} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())} · Planungsdaten, kein Bankkontoauszug`); - pdfMetricRow(doc,[{label:forecast.isAll?'Einnahmen / Jahr':'Zuflüsse / Jahr',value:pdfMoney(forecast.yearIncome),positive:true},{label:forecast.isAll?'Ausgaben / Jahr':'Abflüsse / Jahr',value:pdfMoney(forecast.yearExpense),negative:true},{label:forecast.isAll?'Überschuss / Jahr':'Nettofluss / Jahr',value:pdfMoney(forecast.yearIncome-forecast.yearExpense),positive:forecast.yearIncome>=forecast.yearExpense,negative:forecast.yearIncomea.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 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())}`); + 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=f.yearExpense,negative:f.yearIncomer.kind==='income'?'Eingang':r.kind==='expense'?'Ausgang':r.direction==='in'?'Transfer rein':'Transfer raus';for(const r of rows)pdfTableRow(doc,dcols,[r.month.slice(0,3),kindLabel(r),r.name,r.category,pdfMoney(r.amount)],{tone:r.kind==='income'||r.direction==='in'?'#067647':'#B42318'}); - } - doc.addPage();drawPdfHeader(doc,'Stammdaten & Termine',`${scope} - Definitionen, Laufzeiten und geplante Betragsänderungen`); - pdfSection(doc,'Konten & Einstellungen');doc.fillColor('#344054').font('Helvetica').fontSize(8).text(`Periodische Posten im Monats-Saldo: ${data.settings?.includePeriodicInBalance===false?'Nein':'Ja'}`);doc.moveDown(.35);for(const account of scopeAccounts(data,accountId)){doc.fillColor('#344054').font('Helvetica').fontSize(8).text(`Konto: ${account.name} (${account.id})`);}doc.moveDown(.5);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text('Pushover-Zugangsdaten werden aus Sicherheitsgründen nicht in Berichte exportiert.');doc.moveDown(.6); - const defs=[]; - for(const x of data.incomes||[])if(inScope(x,'income',accountId))defs.push({kind:'Eingang',item:x,route:accountName(data,x.accountId)}); - for(const x of data.expenses||[])if(inScope(x,'expense',accountId))defs.push({kind:'Ausgang',item:x,route:accountName(data,x.accountId)}); - for(const x of data.transfers||[])if(inScope(x,'transfer',accountId))defs.push({kind:'Transfer',item:x,route:`${accountName(data,x.fromAccountId)} -> ${accountName(data,x.toAccountId)}`}); - for(const def of defs){ensurePdfSpace(doc,46);doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(9).text(`${def.kind} - ${def.item.name}`);const due=def.item.interval==='monthly'?'monatlich':def.item.dueMonth?`${INTERVALS[def.item.interval]?.label||def.item.interval}, ab ${MONTHS[Number(def.item.dueMonth)-1]}`:(INTERVALS[def.item.interval]?.label||def.item.interval);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(`${def.route} | ${categoryName(data,def.item.categoryId)} | Basis ${pdfMoney(def.item.amount)} | ${due}${def.item.endDate?` | Ende ${def.item.endDate}`:''}${def.item.pushoverReminder?' | Reminder am 1.':''}`);for(const change of def.item.amountChanges||[]){doc.fillColor('#344054').font('Helvetica').fontSize(7.5).text(` Änderung ab ${change.effectiveFrom}: ${pdfMoney(change.amount)}`);}doc.moveDown(.45);} - if((data.categories||[]).length){pdfSection(doc,'Kategorien');doc.fillColor('#344054').font('Helvetica').fontSize(8).text(data.categories.map(c=>c.name).join(' | '));} - - if((data.scenarios||[]).length){doc.addPage();drawPdfHeader(doc,'Szenarien',`Was-wäre-wenn-Planungen - ${scope}`);for(const s of data.scenarios||[]){const adjustments=(s.adjustments||[]).filter(a=>accountId==='all'||(a.type==='transfer'?(a.fromAccountId===accountId||a.toAccountId===accountId):a.accountId===accountId));if(!adjustments.length)continue;pdfSection(doc,s.name,`${adjustments.length} Anpassungen`);for(const a of adjustments){ensurePdfSpace(doc,28);const route=a.type==='transfer'?`${accountName(data,a.fromAccountId)} -> ${accountName(data,a.toAccountId)}`:accountName(data,a.accountId);doc.fillColor('#344054').font('Helvetica-Bold').fontSize(8.5).text(`${a.type==='income'?'Eingang':a.type==='expense'?'Ausgang':'Transfer'} · ${a.name}`);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(`${route} · ${pdfMoney(a.amount)} / Monat`);doc.moveDown(.35);}} + const cols=[{label:'Art',width:88},{label:'Bezeichnung',width:167},{label:'Kategorie',width:105},{label:'Zufluss',width:68,align:'right'},{label:'Abfluss',width:67,align:'right'}]; + for(let monthIndex=0;monthIndex<12;monthIndex++){ + const monthRows=rows.filter(r=>r.monthIndex===monthIndex);const month=forecast.rows[monthIndex]; + addPageIfNeeded(doc,115);doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(14).text(month.name);doc.moveDown(.35); + pdfMetricRow(doc,[{label:'Zuflüsse',value:pdfMoney(month.displayIncome),positive:true},{label:'Abflüsse',value:pdfMoney(month.displayExpense),negative:true},{label:'Nettofluss',value:pdfMoney(month.balance),positive:month.balance>=0,negative:month.balance<0}]); + 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'}}); + } + doc.moveDown(.8); } + addPageIfNeeded(doc,40);doc.strokeColor('#D6DCE7').moveTo(50,doc.y).lineTo(545,doc.y).stroke();doc.moveDown(.45);doc.fillColor('#667085').font('Helvetica').fontSize(7).text('Erstellt aus FixFin-Planungsdaten. Kein von einer Bank ausgestellter Kontoauszug.'); doc.end();return done; } diff --git a/src/main.jsx b/src/main.jsx index 93d54ae..38de8b9 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -220,13 +220,54 @@ function Icon({ name }) { flask:<>, planning:<>, data:<>, + statement:<>, + more:<>, }; return ; } +const PAGE_META={ + dashboard:{label:'Übersicht',description:'Monatliche Salden und die wichtigsten Hinweise auf einen Blick.'}, + movements:{label:'Bewegungen',description:'Eingänge, Ausgänge und interne Transfers verwalten.'}, + planning:{label:'Planung',description:'Kontobedarf und Rücklagen für die laufende Planung.'}, + 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.'}, + scenarios:{label:'Szenarien',description:'Was-wäre-wenn-Varianten ohne Änderung der echten Planung.'}, + data:{label:'Daten',description:'Backup, Import und Pushover-Einstellungen.'}, +}; +const NAV_GROUPS={ + analytics:{label:'Auswertung',items:[ + {tab:'year',icon:'calendar',label:'Jahr',description:'Jahresplan & Liquidität'}, + {tab:'stats',icon:'chart',label:'Statistik',description:'Kategorien & Kennzahlen'}, + {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:'scenarios',icon:'flask',label:'Szenarien',description:'Was-wäre-wenn-Planungen'}, + {tab:'data',icon:'data',label:'Daten',description:'Backup, Import & Pushover'}, + ]}, +}; +const MAIN_NAV=[ + {tab:'dashboard',icon:'dashboard',label:'Übersicht'}, + {tab:'movements',icon:'transfer',label:'Bewegungen'}, + {tab:'planning',icon:'planning',label:'Planung'}, + {menu:'analytics',icon:'chart',label:'Auswertung'}, + {menu:'more',icon:'more',label:'Mehr'}, +]; + +function NavGroupMenu({group,tab,onClose,onSelect}){ + const config=NAV_GROUPS[group]; + useEffect(()=>{const key=e=>e.key==='Escape'&&onClose();window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[onClose]); + if(!config)return null; + return
e.stopPropagation()}>
Navigation{config.label}
{config.items.map(item=>)}
; +} + function App() { const [data,setData]=useState(emptyData); const [tab,setTab]=useState('dashboard'); + const [navMenu,setNavMenu]=useState(null); const [loading,setLoading]=useState(true); const [saving,setSaving]=useState(false); const [error,setError]=useState(''); @@ -237,14 +278,17 @@ 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)}))});} - async function importFile(file){try{await persist(JSON.parse(await file.text()));setTab('dashboard');}catch(e){setError(`Import fehlgeschlagen: ${e.message}`);}} + async function importFile(file){try{await persist(JSON.parse(await file.text()));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);} + function mainActive(item){if(item.tab)return tab===item.tab;return NAV_GROUPS[item.menu]?.items.some(x=>x.tab===tab);} + function activateMain(item){if(item.tab)selectTab(item.tab);else setNavMenu(current=>current===item.menu?null:item.menu);} if(loading)return
FixFin wird geladen …
; + const meta=PAGE_META[tab]||PAGE_META.dashboard; - const nav=[['dashboard','dashboard','Übersicht'],['accounts','wallet','Konten'],['movements','transfer','Bewegungen'],['year','calendar','Jahr'],['planning','planning','Planung'],['stats','chart','Statistik'],['scenarios','flask','Szenarien'],['data','data','Daten']]; return
- -

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

Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.

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

{meta.label}

{meta.description}

{tab==='accounts'&&}
{error&&
{error}
} {tab==='dashboard'&&} {tab==='accounts'&&setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} @@ -252,10 +296,12 @@ function App() { {tab==='year'&&} {tab==='planning'&&} {tab==='stats'&&setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>} + {tab==='statements'&&} {tab==='scenarios'&&} {tab==='data'&&}
- + + {navMenu&&setNavMenu(null)} onSelect={selectTab}/>} {dialog&&setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>}
; } @@ -534,13 +580,14 @@ function PushoverSettings(){ ; } -function ReportExports({data}){ +function StatementsPage({data}){ const currentYear=new Date().getFullYear(); const [year,setYear]=useState(currentYear); - const [accountId,setAccountId]=useState('all'); - const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}`; - const scope=accountId==='all'?'Alle Konten':accountName(data,accountId); - return

Auszüge & Berichte

Aufbereitete Planungsdaten als PDF oder Excel. Die Kontosicht enthält echte Ein-/Ausgänge und Transfers als getrennte Zahlungsströme.

PDF · XLSX
Auszug{scope} · {year}

PDF: druckfertiger Planungs-Auszug im Kontoauszug-Stil. Excel: Übersicht, Planungshinweise, Jahresplan, Bewegungen, Änderungen, Stammdaten, Konten/Kategorien, Szenarien und Konto-Blätter.

Planungs-Auszug aus FixFin, kein von einer Bank ausgestellter Kontoauszug.

; + const [accountId,setAccountId]=useState(data.accounts[0]?.id||''); + 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.

; } function DataPage({data,importFile}){ @@ -554,7 +601,6 @@ function DataPage({data,importFile}){ ]; const json=JSON.stringify(data,null,2); return
-

Daten sichern

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

JSON herunterladen

Aktueller Datenstand

Kurzübersicht über den Inhalt deiner FixFin-Datei.

diff --git a/src/styles.css b/src/styles.css index 9dfb994..eb4e60b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -963,3 +963,39 @@ code { color:#b8c3ff; } .amount-change-add { grid-template-columns:1fr; } .amount-change-add button { grid-column:auto; } } + +/* v3.17: reduzierte Hauptnavigation + Kontoauszüge */ +.sidebar nav button { position:relative; } +.nav-group-caret { margin-left:auto; color:#66758a; font-size:18px; line-height:1; } +.sidebar nav button.active .nav-group-caret { color:#a7b2ff; } + +.nav-menu-backdrop { position:fixed; inset:0; z-index:70; background:#02050a80; backdrop-filter:blur(3px); } +.nav-menu-sheet { position:fixed; left:270px; top:132px; width:min(360px,calc(100vw - 300px)); background:#111722; border:1px solid #334158; border-radius:18px; padding:15px; box-shadow:0 28px 90px #000a; } +.nav-menu-head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:2px 3px 11px; border-bottom:1px solid #253145; } +.nav-menu-head div > span { display:block; color:#718096; text-transform:uppercase; letter-spacing:.08em; font-size:8px; font-weight:850; margin-bottom:2px; } +.nav-menu-head div > strong { display:block; font-size:18px; } +.nav-menu-items { display:grid; gap:7px; margin-top:10px; } +.nav-menu-items button { width:100%; display:grid; grid-template-columns:38px minmax(0,1fr) 20px; align-items:center; gap:10px; border:1px solid transparent; background:#0d131c; color:var(--text); border-radius:12px; padding:10px; text-align:left; } +.nav-menu-items button:hover { background:#151e2b; border-color:#30405a; } +.nav-menu-items button.active { background:#18213a; border-color:#4b5fa6; } +.nav-menu-icon { width:36px; height:36px; display:grid; place-items:center; border-radius:10px; background:#182130; color:#9ba9ff; } +.nav-menu-icon svg { width:19px; height:19px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } +.nav-menu-copy { min-width:0; } +.nav-menu-copy strong { display:block; font-size:13px; margin-bottom:2px; } +.nav-menu-copy small { display:block; color:var(--muted); font-size:9px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.nav-menu-arrow { color:#718096; font-size:20px; text-align:right; } + +.statements-page { max-width:820px; } +.statements-page .report-export-panel { border-color:#35466a; } + +@media (max-width:760px) { + .mobile-nav { grid-template-columns:repeat(5,minmax(0,1fr)) !important; overflow-x:hidden !important; } + .mobile-nav button { min-width:0 !important; font-size:9px !important; padding:6px 2px !important; } + .mobile-nav svg { width:19px !important; height:19px !important; } + .nav-menu-sheet { left:12px; right:12px; top:auto; bottom:calc(72px + env(safe-area-inset-bottom)); width:auto; padding:13px; border-radius:17px; } + .nav-menu-backdrop { background:#02050aa8; } + .nav-menu-items button { grid-template-columns:40px minmax(0,1fr) 20px; padding:11px; } + .nav-menu-copy strong { font-size:14px; } + .nav-menu-copy small { font-size:10px; } + .statements-page { max-width:none; } +}