diff --git a/package.json b/package.json index cba78b1..b545b8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fixfin", - "version": "1.4.0", + "version": "1.5.0", "private": true, "type": "module", "scripts": { @@ -11,7 +11,9 @@ "dependencies": { "express": "5.1.0", "react": "19.1.1", - "react-dom": "19.1.1" + "react-dom": "19.1.1", + "exceljs": "4.4.0", + "pdfkit": "0.19.1" }, "devDependencies": { "@vitejs/plugin-react": "5.0.2", diff --git a/public/sw.js b/public/sw.js index 12b1028..0cd5852 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL_CACHE='fixfin-shell-v3.15'; +const SHELL_CACHE='fixfin-shell-v3.16'; 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 460df40..1da2a37 100644 --- a/server/index.js +++ b/server/index.js @@ -3,6 +3,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; +import { createExcelReport, createPdfReport } from './reports.js'; const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__filename); const app=express(); const port=Number(process.env.PORT||3000); const dataFile=process.env.DATA_FILE||'/app/data/finance.json'; const dataDir=path.dirname(dataFile); const backupFile=`${dataFile}.bak`; @@ -10,12 +11,14 @@ const pushoverFile=process.env.PUSHOVER_FILE||path.join(dataDir,'pushover.json') const reminderHour=Math.min(23,Math.max(0,Number(process.env.PUSHOVER_REMINDER_HOUR||8))); const PUSHOVER_ENDPOINT='https://api.pushover.net/1/messages.json'; const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']); const validScenarioTypes=new Set(['income','expense','transfer']); -const emptyData=()=>({version:7,settings:{includePeriodicInBalance:true},accounts:[],categories:[],incomes:[],expenses:[],transfers:[],scenarios:[]}); +const emptyData=()=>({version:8,settings:{includePeriodicInBalance:true},accounts:[],categories:[],incomes:[],expenses:[],transfers:[],scenarios:[]}); const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value); -function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null }; } +function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null, endDate: typeof item.endDate==='string' && item.endDate ? item.endDate : null }; } +function normalizeAmountChanges(changes){return Array.isArray(changes)?changes.map(c=>({id:typeof c.id==='string'&&c.id?c.id:crypto.randomUUID(),effectiveFrom:typeof c.effectiveFrom==='string'?c.effectiveFrom:'',amount:Number(c.amount||0)})).sort((a,b)=>a.effectiveFrom.localeCompare(b.effectiveFrom)):[];} function normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null }; } -function normalizeExpense(item={}) { return { ...normalizeCategorizedMovement(item), pushoverReminder: item.pushoverReminder===true }; } +function normalizeIncome(item={}) { return { ...normalizeCategorizedMovement(item), amountChanges: normalizeAmountChanges(item.amountChanges) }; } +function normalizeExpense(item={}) { return { ...normalizeCategorizedMovement(item), amountChanges: normalizeAmountChanges(item.amountChanges), pushoverReminder: item.pushoverReminder===true }; } function normalizeScenarioAdjustment(item={}) { const type=validScenarioTypes.has(item.type)?item.type:'expense'; const base={id:item.id,name:item.name,type,amount:Number(item.amount||0)}; @@ -26,17 +29,19 @@ function normalizeScenario(item={}) { return {id:item.id,name:item.name,createdA function normalizeData(input){ if(!input||typeof input!=='object')return input; return { - version:7, + version:8, settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false}, accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts, categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[], - incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeCategorizedMovement):input.incomes, + incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeIncome):input.incomes, expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeExpense):input.expenses, transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers, scenarios:Array.isArray(input.scenarios)?input.scenarios.map(normalizeScenario):[], }; } -function validateMovement(item){if(!item.id||typeof item.id!=='string')throw new Error('Eintrag ohne gültige ID.');if(!item.name||typeof item.name!=='string')throw new Error('Eintrag ohne Namen.');if(!isFiniteNumber(item.amount)||item.amount<0)throw new Error(`Ungültiger Betrag bei '${item.name}'.`);if(!validIntervals.has(item.interval))throw new Error(`Ungültiges Intervall bei '${item.name}'.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);} +function isValidDateString(value){if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(value))return false;const d=new Date(`${value}T00:00:00Z`);return !Number.isNaN(d.getTime())&&d.toISOString().slice(0,10)===value;} +function validateMovement(item){if(!item.id||typeof item.id!=='string')throw new Error('Eintrag ohne gültige ID.');if(!item.name||typeof item.name!=='string')throw new Error('Eintrag ohne Namen.');if(!isFiniteNumber(item.amount)||item.amount<0)throw new Error(`Ungültiger Betrag bei '${item.name}'.`);if(!validIntervals.has(item.interval))throw new Error(`Ungültiges Intervall bei '${item.name}'.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);if(item.endDate!==null&&item.endDate!==undefined&&!isValidDateString(item.endDate))throw new Error(`Ungültiges Enddatum bei '${item.name}'.`);} +function validateAmountChanges(item){if(!Array.isArray(item.amountChanges))throw new Error(`Ungültige Betragsänderungen bei '${item.name}'.`);const dates=new Set();for(const change of item.amountChanges){if(!change.id||typeof change.id!=='string')throw new Error(`Betragsänderung ohne ID bei '${item.name}'.`);if(!isValidDateString(change.effectiveFrom))throw new Error(`Ungültiges Änderungsdatum bei '${item.name}'.`);if(!isFiniteNumber(change.amount)||change.amount<0)throw new Error(`Ungültiger Änderungsbetrag bei '${item.name}'.`);if(item.endDate&&change.effectiveFrom.slice(0,7)>item.endDate.slice(0,7))throw new Error(`Betragsänderung bei '${item.name}' liegt nach dem Enddatum.`);if(dates.has(change.effectiveFrom))throw new Error(`Für '${item.name}' existieren zwei Änderungen am selben Datum.`);dates.add(change.effectiveFrom);}} function validateData(input){ if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.'); for(const key of ['accounts','categories','incomes','expenses','transfers','scenarios'])if(!Array.isArray(input[key]))throw new Error(`Feld '${key}' muss ein Array sein.`); @@ -44,7 +49,7 @@ function validateData(input){ for(const a of input.accounts){if(!a.id||typeof a.id!=='string')throw new Error('Konto ohne gültige ID.');if(!a.name||typeof a.name!=='string')throw new Error('Konto ohne Namen.');if(accountIds.has(a.id))throw new Error('Doppelte Konto-ID.');accountIds.add(a.id);} const categoryIds=new Set(); const categoryNames=new Set(); for(const c of input.categories){if(!c.id||typeof c.id!=='string')throw new Error('Kategorie ohne gültige ID.');if(!c.name||typeof c.name!=='string'||!c.name.trim())throw new Error('Kategorie ohne Namen.');if(categoryIds.has(c.id))throw new Error('Doppelte Kategorie-ID.');const normalizedName=c.name.trim().toLocaleLowerCase('de-DE');if(categoryNames.has(normalizedName))throw new Error(`Kategorie '${c.name}' existiert bereits.`);categoryIds.add(c.id);categoryNames.add(normalizedName);} - for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);} + for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);validateAmountChanges(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);} for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);} const scenarioIds=new Set(); for(const s of input.scenarios){ @@ -72,12 +77,16 @@ 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.15'},body}); + const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.16'},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; } -function occursInReminderMonth(item,monthIndex){ +function monthKey(year,monthIndex){return `${year}-${String(monthIndex+1).padStart(2,'0')}`;} +function itemActiveInMonth(item,year,monthIndex){const end=typeof item.endDate==='string'?item.endDate.slice(0,7):'';return !end||monthKey(year,monthIndex)<=end;} +function amountInMonth(item,year,monthIndex){const key=monthKey(year,monthIndex);let amount=Number(item.amount||0);let best='';for(const change of item.amountChanges||[]){const mk=String(change.effectiveFrom||'').slice(0,7);if(mk&&mk<=key&&mk>=best){best=mk;amount=Number(change.amount||0);}}return amount;} +function occursInReminderMonth(item,year,monthIndex){ + if(!itemActiveInMonth(item,year,monthIndex))return false; const interval=item.interval||'monthly'; if(interval==='monthly')return true; const dueMonth=Number(item.dueMonth); if(!Number.isInteger(dueMonth)||dueMonth<1||dueMonth>12)return false; const start=dueMonth-1; const step=interval==='quarterly'?3:interval==='semiannual'?6:12; @@ -85,7 +94,7 @@ function occursInReminderMonth(item,monthIndex){ } function reminderMessage(data,expense,now){ const account=data.accounts.find(a=>a.id===expense.accountId)?.name||'Unbekanntes Konto'; - const amount=new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(Number(expense.amount||0)); + const amount=new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(amountInMonth(expense,now.getFullYear(),now.getMonth())); const month=new Intl.DateTimeFormat('de-DE',{month:'long'}).format(now); return {title:`FixFin · ${expense.name}`,message:`Ausgabe im ${month}: ${amount}\nKonto: ${account}\nBitte prüfen, ob eine Umbuchung nötig ist.`}; } @@ -94,7 +103,7 @@ async function runPushoverReminderCheck(){ const config=await readPushoverConfig(); if(!config)return; const data=await readData(); const state=await readJsonFile(pushoverStateFile,{sent:{}}); if(!state.sent||typeof state.sent!=='object')state.sent={}; const monthKey=`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`; let changed=false; - for(const expense of data.expenses.filter(x=>x.pushoverReminder===true&&occursInReminderMonth(x,now.getMonth()))){ + for(const expense of data.expenses.filter(x=>x.pushoverReminder===true&&occursInReminderMonth(x,now.getFullYear(),now.getMonth()))){ const key=`${monthKey}:${expense.id}`; if(state.sent[key])continue; try{await sendPushover(config,reminderMessage(data,expense,now));state.sent[key]=new Date().toISOString();changed=true;console.log(`[Pushover] Reminder gesendet: ${expense.name}`);}catch(error){console.error(`[Pushover] Reminder fehlgeschlagen (${expense.name}):`,error.message);} } @@ -103,7 +112,7 @@ async function runPushoverReminderCheck(){ } async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}} -async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];if(parsed.version!==7||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||(parsed.expenses||[]).some(x=>!Object.hasOwn(x,'pushoverReminder'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;} +async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];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/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:'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);}}); 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 new file mode 100644 index 0000000..014adeb --- /dev/null +++ b/server/reports.js @@ -0,0 +1,177 @@ +import ExcelJS from 'exceljs'; +import PDFDocument from 'pdfkit'; + +const MONTHS=['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; +const INTERVALS={monthly:{label:'Monatlich',step:1},quarterly:{label:'Vierteljährlich',step:3},semiannual:{label:'Halbjährlich',step:6},yearly:{label:'Jährlich',step:12}}; +const money=new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}); + +function monthKey(year,monthIndex){return `${year}-${String(monthIndex+1).padStart(2,'0')}`;} +function dateMonth(value){return typeof value==='string'&&/^\d{4}-\d{2}-\d{2}$/.test(value)?value.slice(0,7):null;} +function activeInMonth(item,year,monthIndex){const key=monthKey(year,monthIndex);const end=dateMonth(item.endDate);return !end||key<=end;} +function occursInMonth(item,year,monthIndex){ + if(!activeInMonth(item,year,monthIndex))return false; + const interval=item.interval||'monthly'; if(interval==='monthly')return true; + const dueMonth=Number(item.dueMonth); if(!Number.isInteger(dueMonth)||dueMonth<1||dueMonth>12)return false; + const start=dueMonth-1; const step=INTERVALS[interval]?.step||12; return monthIndex>=start&&(monthIndex-start)%step===0; +} +function effectiveAmount(item,year,monthIndex){ + const key=monthKey(year,monthIndex); let amount=Number(item.amount||0); let best=''; + for(const change of item.amountChanges||[]){const mk=dateMonth(change.effectiveFrom);if(mk&&mk<=key&&mk>=best){best=mk;amount=Number(change.amount||0);}} + return amount; +} +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'; + 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)){ + 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});}} + } + 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)}; +} + + +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'){ + 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}); + }; + 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');} + } + return result.sort((a,b)=>a.monthIndex-b.monthIndex||a.kind.localeCompare(b.kind,'de')||a.name.localeCompare(b.name,'de')); +} + +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'}};} +} +function widths(sheet,widths){widths.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); + 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 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 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 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;} + +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.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);}} + } + doc.end();return done; +} diff --git a/src/main.jsx b/src/main.jsx index 4bdd3ef..93d54ae 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -10,7 +10,7 @@ const intervals = { semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 }, yearly: { label: 'Jährlich', divisor: 12, step: 12 }, }; -const emptyData = { version: 7, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [], scenarios: [] }; +const emptyData = { version: 8, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [], scenarios: [] }; function formatMoney(value) { return euro.format(Number(value || 0)); } function parseAmount(value) { @@ -31,14 +31,24 @@ function uid() { } return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`; } -function monthlyEquivalent(item) { return Number(item.amount || 0) / (intervals[item.interval]?.divisor || 1); } +function monthKey(year, monthIndex) { return `${year}-${String(monthIndex + 1).padStart(2, '0')}`; } +function dateMonth(value) { return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value.slice(0, 7) : null; } +function itemActiveInMonth(item, year, monthIndex) { const end = dateMonth(item.endDate); return !end || monthKey(year, monthIndex) <= end; } +function effectiveAmount(item, year = new Date().getFullYear(), monthIndex = new Date().getMonth()) { + const key = monthKey(year, monthIndex); let amount = Number(item.amount || 0); let best = ''; + for (const change of item.amountChanges || []) { const mk = dateMonth(change.effectiveFrom); if (mk && mk <= key && mk >= best) { best = mk; amount = Number(change.amount || 0); } } + return amount; +} +function monthlyEquivalent(item, year = new Date().getFullYear(), monthIndex = new Date().getMonth()) { return itemActiveInMonth(item, year, monthIndex) ? effectiveAmount(item, year, monthIndex) / (intervals[item.interval]?.divisor || 1) : 0; } function annualEquivalent(item) { return monthlyEquivalent(item) * 12; } function isPeriodic(item) { return (item.interval || 'monthly') !== 'monthly'; } function balanceAmount(item, includePeriodic) { - if (!isPeriodic(item)) return Number(item.amount || 0); - return includePeriodic ? monthlyEquivalent(item) : 0; + const now = new Date(); if (!itemActiveInMonth(item, now.getFullYear(), now.getMonth())) return 0; + if (!isPeriodic(item)) return effectiveAmount(item, now.getFullYear(), now.getMonth()); + return includePeriodic ? monthlyEquivalent(item, now.getFullYear(), now.getMonth()) : 0; } -function occursInMonth(item, monthIndex) { +function occursInMonth(item, monthIndex, year = new Date().getFullYear()) { + if (!itemActiveInMonth(item, year, monthIndex)) return false; const interval = item.interval || 'monthly'; if (interval === 'monthly') return true; const dueMonth = Number(item.dueMonth); @@ -83,26 +93,26 @@ function calc(data) { return { accounts: [...accountStats.values()], income, expense, transferVolume, periodicExpenseReserve, periodicIncomeAverage, balance: income - expense, includePeriodic }; } -function annualForecast(data, accountId = 'all') { +function annualForecast(data, accountId = 'all', year = new Date().getFullYear()) { 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 => isAll || item.accountId === accountId; const relevantExpense = item => isAll || item.accountId === accountId; const relevantTransfer = item => isAll || item.fromAccountId === accountId || item.toAccountId === accountId; for (const item of data.incomes.filter(relevantIncome)) { - for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].income += Number(item.amount || 0); rows[m].items.push({ ...item, kind:'income' }); } + for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m); rows[m].income += amount; rows[m].items.push({ ...item, kind:'income', forecastAmount:amount }); } } for (const item of data.expenses.filter(relevantExpense)) { - for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].expense += Number(item.amount || 0); rows[m].items.push({ ...item, kind:'expense' }); } + for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m); rows[m].expense += amount; rows[m].items.push({ ...item, kind:'expense', forecastAmount:amount }); } } for (const item of data.transfers.filter(relevantTransfer)) { - for (let m=0;m<12;m++) if (occursInMonth(item,m)) { + for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { 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' }); + rows[m].transferIn += amount; rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'neutral', forecastAmount:amount }); } else { - if (item.toAccountId === accountId) { rows[m].transferIn += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'in' }); } - if (item.fromAccountId === accountId) { rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'out' }); } + if (item.toAccountId === accountId) { rows[m].transferIn += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'in', forecastAmount:amount }); } + if (item.fromAccountId === accountId) { rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'out', forecastAmount:amount }); } } } } @@ -125,10 +135,10 @@ function annualForecast(data, accountId = 'all') { if (x.fromAccountId === accountId) entries.push({...x, kind:'transfer', direction:'out'}); return entries; }), - ].filter(x => isPeriodic(x) && !hasMonth(x)); + ].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 }; + return { rows, unassigned, yearIncome, yearExpense, isAll, accountId, year }; } function categoryStats(data, accountId = 'all') { @@ -179,6 +189,15 @@ function reserveOverview(data) { return { items, monthly:items.reduce((s,x)=>s+x.monthlyReserve,0), annual:items.reduce((s,x)=>s+x.annualNeed,0) }; } +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){ + 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.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); +} + function scenarioData(data, scenario) { if (!scenario) return data; const next = { ...data, incomes:[...data.incomes], expenses:[...data.expenses], transfers:[...data.transfers] }; @@ -256,6 +275,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]); useEffect(()=>{ if(filter.mode==='some'&&accountIds.length>0&&validSelected.length===0) setFilter({mode:'all',ids:[]}); @@ -292,6 +312,8 @@ function Dashboard({data,totals,onToggle}){ {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)}
)}
} +

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}
)}
}
+ {!totals.includePeriodic&&totals.periodicExpenseReserve>0&&
Periodische Ausgaben von rechnerisch {formatMoney(totals.periodicExpenseReserve)} / Monat sind derzeit nicht im Saldo enthalten.
}
Periodische Kosten in Monats-Saldo einrechnenJährliche, halbjährliche und vierteljährliche Beträge werden auf einen Monatswert heruntergerechnet.
; @@ -334,10 +356,11 @@ function Entries({type,items,accounts,categories,onEdit,onDuplicate,onDelete}){ const categoryNameLocal=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; const isIncome=type==='incomes'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; + const currentAmount=x=>effectiveAmount(x); return

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

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

{items.length===0?:<> -
{items.map(x=>)}
BezeichnungKategorieKontoIntervallMonatBetragØ / Monat
{x.name}{!isIncome&&x.pushoverReminder&&Reminder}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.accountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{isIncome?'+':'−'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
+
{items.map(x=>)}
BezeichnungKategorieKontoIntervallMonatBetragØ / Monat
{x.name}{!isIncome&&x.pushoverReminder&&Reminder}{(x.amountChanges||[]).length>0&&{x.amountChanges.length} Änderung{x.amountChanges.length===1?'':'en'}}{x.endDate&&bis {formatDate(x.endDate)}}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.accountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{isIncome?'+':'−'}{formatMoney(currentAmount(x))}{formatMoney(monthlyEquivalent(x))}
{items.map(x=>
-
{x.name}{categoryNameLocal(x.categoryId)}{!isIncome&&x.pushoverReminder&&Reminder am 1.}
{isIncome?'+':'−'}{formatMoney(x.amount)}
+
{x.name}{categoryNameLocal(x.categoryId)}{!isIncome&&x.pushoverReminder&&Reminder am 1.}{(x.amountChanges||[]).length>0&&{x.amountChanges.length} geplant}{x.endDate&&bis {formatDate(x.endDate)}}
{isIncome?'+':'−'}{formatMoney(currentAmount(x))}
Konto{accountNameLocal(x.accountId)}
Intervall{intervals[x.interval||'monthly'].label}
@@ -354,9 +377,9 @@ function Transfers({data,onEdit,onDuplicate,onDelete}){ const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; return

Fixe Transfers

Verschiebungen zwischen eigenen Konten. Global saldoneutral.

{data.transfers.length===0?:<> -
{data.transfers.map(x=>)}
BezeichnungKategorieVonNachIntervallMonatBetragØ / Monat
{x.name}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.fromAccountId)}{accountNameLocal(x.toAccountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
+
{data.transfers.map(x=>)}
BezeichnungKategorieVonNachIntervallMonatBetragØ / Monat
{x.name}{x.endDate&&bis {formatDate(x.endDate)}}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.fromAccountId)}{accountNameLocal(x.toAccountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
{data.transfers.map(x=>
-
{x.name}{categoryNameLocal(x.categoryId)}
{formatMoney(x.amount)}
+
{x.name}{categoryNameLocal(x.categoryId)}{x.endDate&&bis {formatDate(x.endDate)}}
{formatMoney(x.amount)}
Von{accountNameLocal(x.fromAccountId)}
Nach{accountNameLocal(x.toAccountId)}
Intervall{intervals[x.interval||'monthly'].label}
@@ -408,8 +431,10 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){ } function YearPage({data}){ + const currentYear=new Date().getFullYear(); const [accountId,setAccountId]=useState('all'); - const forecast=useMemo(()=>annualForecast(data,accountId),[data,accountId]); + const [year,setYear]=useState(currentYear); + const forecast=useMemo(()=>annualForecast(data,accountId,year),[data,accountId,year]); const selectedAccount=data.accounts.find(a=>a.id===accountId); const selectedName=selectedAccount?.name||'Alle Konten'; const labels=forecast.isAll @@ -418,14 +443,14 @@ function YearPage({data}){ const kindLabel=x=>x.kind==='income'?(forecast.isAll?'Eingang':'Echter Eingang'):x.kind==='expense'?(forecast.isAll?'Ausgang':'Echte Ausgabe'):x.direction==='in'?'Transfer rein':x.direction==='out'?'Transfer raus':'Transfer'; const kindSign=x=>x.kind==='income'||x.direction==='in'?'+':x.kind==='expense'||x.direction==='out'?'−':''; const kindTone=x=>x.kind==='income'||x.direction==='in'?'good-text':x.kind==='expense'||x.direction==='out'?'bad-text':''; - const chipLabel=x=>`${x.name} · ${intervals[x.interval||'monthly'].label} · ${kindLabel(x)} · ${formatMoney(x.amount)}`; + const chipLabel=x=>`${x.name} · ${intervals[x.interval||'monthly'].label} · ${kindLabel(x)} · ${formatMoney(x.forecastAmount??x.amount)}`; const maxExpense=Math.max(1,...forecast.rows.map(r=>r.displayExpense)); const maxRow=forecast.rows.reduce((a,b)=>b.displayExpense>a.displayExpense?b:a,forecast.rows[0]); const yearBalance=forecast.yearIncome-forecast.yearExpense; - const detailRow=(x,i)=>
{kindLabel(x)}{x.name}
{kindSign(x)}{formatMoney(x.amount)}
; + const detailRow=(x,i)=>
{kindLabel(x)}{x.name}
{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}
; return
-
Jahresansicht für{selectedName}
+
Jahresansicht für{selectedName} · {year}
@@ -440,7 +465,7 @@ function YearPage({data}){
{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)}
+ 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.
}:<>
@@ -509,6 +534,15 @@ function PushoverSettings(){
; } +function ReportExports({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.

; +} + function DataPage({data,importFile}){ const summary=[ ['Konten',data.accounts.length], @@ -520,6 +554,7 @@ 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.

@@ -533,8 +568,9 @@ function Empty({text}){return
{text}
} function EditorDialog({dialog,data,onClose,onSave}){ const type=dialog.type,item=dialog.item; const editing=Boolean(item?.id); - const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[pushoverReminder,setPushoverReminder]=useState(item?.pushoverReminder===true),[localError,setLocalError]=useState(''); + const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[pushoverReminder,setPushoverReminder]=useState(item?.pushoverReminder===true),[endDate,setEndDate]=useState(item?.endDate||''),[amountChanges,setAmountChanges]=useState(Array.isArray(item?.amountChanges)?item.amountChanges:[]),[changeDate,setChangeDate]=useState(''),[changeAmount,setChangeAmount]=useState(''),[localError,setLocalError]=useState(''); const labels={accounts:'Konto',categories:'Kategorie',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'}; + function addAmountChange(){const numeric=parseAmount(changeAmount);if(!changeDate)return setLocalError('Bitte ein Datum für die Betragsänderung auswählen.');if(!Number.isFinite(numeric)||numeric<0)return setLocalError('Bitte einen gültigen neuen Betrag eingeben.');if(endDate&&changeDate.slice(0,7)>endDate.slice(0,7))return setLocalError('Die Betragsänderung liegt nach dem Enddatum.');if(amountChanges.some(c=>c.effectiveFrom===changeDate))return setLocalError('Für dieses Datum existiert bereits eine Betragsänderung.');setAmountChanges([...amountChanges,{id:uid(),effectiveFrom:changeDate,amount:numeric}].sort((a,b)=>a.effectiveFrom.localeCompare(b.effectiveFrom)));setChangeDate('');setChangeAmount('');setLocalError('');} async function submit(e){ e.preventDefault();setLocalError(''); const numeric=parseAmount(amount); if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.'); @@ -542,23 +578,25 @@ function EditorDialog({dialog,data,onClose,onSave}){ if(!['accounts','categories'].includes(type)&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.'); if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.'); if(type==='expenses'&&pushoverReminder&&interval!=='monthly'&&!dueMonth)return setLocalError('Für den Reminder bitte einen Fälligkeitsmonat festlegen.'); + if(endDate&&amountChanges.some(c=>c.effectiveFrom.slice(0,7)>endDate.slice(0,7)))return setLocalError('Eine geplante Betragsänderung liegt nach dem Enddatum.'); let next=structuredClone(data); const id=editing?item.id:uid(); let value; if(type==='accounts') value={id,name:name.trim()}; else if(type==='categories') { const duplicate=next.categories.some(c=>c.id!==item?.id&&c.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Kategorie existiert bereits.'); value={id,name:name.trim()}; } else { - const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null)}; + const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null),endDate:endDate||null}; let selectedCategoryId=categoryId||null; if(categoryId==='__new__'){const categoryNameValue=newCategoryName.trim();if(!categoryNameValue)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedCategoryId=existing.id;else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryNameValue}); }} - if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,pushoverReminder}; else value={...common,accountId,categoryId:selectedCategoryId}; + if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,amountChanges,pushoverReminder}; else value={...common,accountId,categoryId:selectedCategoryId,amountChanges}; } next[type]=editing?next[type].map(x=>x.id===item.id?value:x):[...next[type],value]; try{await onSave(next);}catch(e){setLocalError(e.message);} } const simpleType=type==='accounts'||type==='categories'; return
e.target===e.currentTarget&&onClose()}>
{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}

{labels[type]}

{localError&&
{localError}
} - {!simpleType&&<>{interval!=='monthly'&&}} + {!simpleType&&<>{interval!=='monthly'&&}} {(type==='incomes'||type==='expenses'||type==='transfers')&&<>{categoryId==='__new__'&&}} {(type==='incomes'||type==='expenses')&&} + {(type==='incomes'||type==='expenses')&&
Geplante BetragsänderungenEin neuer Betrag gilt ab dem Monat des gewählten Datums. Der Basisbetrag oben bleibt für frühere Monate erhalten.
{amountChanges.length}
{amountChanges.length>0&&
{amountChanges.map(c=>
ab {formatDate(c.effectiveFrom)}{formatMoney(c.amount)}
)}
}
} {type==='expenses'&&} {type==='transfers'&&<>}
; } diff --git a/src/styles.css b/src/styles.css index 15199e2..9dfb994 100644 --- a/src/styles.css +++ b/src/styles.css @@ -898,3 +898,68 @@ code { color:#b8c3ff; } .pushover-actions button { width:100%; min-height:42px; } .reminder-toggle { padding:10px; } } + +/* v3.16: Terminierungen, Warnungen und Reportcenter */ +.fixfin-warnings .section-head { align-items:center; } +.warning-count { min-width:34px;height:34px;border-radius:999px;display:grid;place-items:center;background:#1b2635;border:1px solid #31415a;color:#cbd5e1;font-weight:850; } +.warning-count.danger { background:#321a20;border-color:#67333b;color:#ff9aa2; } +.warning-list { display:grid;gap:8px; } +.warning-item { display:grid;grid-template-columns:10px 1fr;gap:11px;align-items:start;padding:11px 12px;border-radius:11px;border:1px solid #29364a;background:#0d141e; } +.warning-item strong { display:block;font-size:13px; } +.warning-item span:not(.warning-dot) { display:block;color:var(--muted);font-size:11px;margin-top:3px;line-height:1.45; } +.warning-dot { width:8px;height:8px;border-radius:50%;margin-top:5px;background:#8293ff; } +.warning-item.danger { border-color:#5f3038;background:#26171c; } +.warning-item.danger .warning-dot { background:#ff7f8a; } +.warning-item.warning { border-color:#5a4e2b;background:#242016; } +.warning-item.warning .warning-dot { background:#e4c45f; } +.warning-clear { padding:14px;border-radius:11px;background:#10241f;border:1px solid #245947; } +.warning-clear strong { display:block;color:#7de0b5; } +.warning-clear span { display:block;color:#8eb8a8;font-size:11px;margin-top:3px; } + +.change-badge,.end-badge { display:inline-flex;align-items:center;margin-left:6px;padding:3px 6px;border-radius:999px;font-size:8px;font-weight:800;white-space:nowrap;vertical-align:middle; } +.change-badge { background:#1d2848;border:1px solid #41558b;color:#b4c0ff; } +.end-badge { background:#2d2515;border:1px solid #65562a;color:#e9d98b; } +.amount-change-panel { margin-top:4px;padding:13px;border-radius:12px;border:1px solid #2a374a;background:#0d141e; } +.amount-change-head { display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:10px; } +.amount-change-head strong { display:block;font-size:13px; } +.amount-change-head span:not(.change-count) { display:block;color:var(--muted);font-size:10px;line-height:1.4;margin-top:3px; } +.change-count { min-width:26px;height:26px;border-radius:999px;display:grid;place-items:center;background:#1d2848;color:#b4c0ff;font-size:10px;font-weight:850; } +.amount-change-list { display:grid;gap:6px;margin-bottom:10px; } +.amount-change-row { display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 9px;border-radius:9px;background:#121b28;border:1px solid #243247; } +.amount-change-row div span { display:block;color:var(--muted);font-size:9px; } +.amount-change-row div strong { display:block;font-size:12px;margin-top:2px; } +.amount-change-row button { padding:6px 8px;font-size:9px; } +.amount-change-add { display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:end; } +.amount-change-add label { margin:0; } +.amount-change-add button { min-height:41px; } + +.year-filter-controls { display:flex;gap:8px;align-items:end; } +.year-filter-controls label:first-child { min-width:110px; } + +.report-export-panel { border-color:#35466a; } +.report-badge { padding:6px 9px;border-radius:999px;background:#1d2848;border:1px solid #41558b;color:#b4c0ff;font-size:10px;font-weight:850; } +.report-controls { display:grid;grid-template-columns:160px minmax(220px,1fr);gap:10px;max-width:560px;margin-bottom:12px; } +.report-controls label { display:grid;gap:5px; } +.report-controls label span { color:var(--muted);font-size:10px;font-weight:700; } +.report-controls select { width:100%;background:#0b111a;color:#f5f7fb;border:1px solid #2a374a;border-radius:10px;padding:10px 34px 10px 11px;outline:none; } +.report-preview { padding:13px 14px;border-radius:12px;border:1px solid #29364a;background:#0d141e;margin-bottom:12px; } +.report-preview div span { display:block;color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.07em;font-weight:800; } +.report-preview div strong { display:block;font-size:16px;margin-top:2px; } +.report-preview p { color:var(--muted);font-size:11px;line-height:1.5;margin:8px 0 0; } +.report-disclaimer { color:#718096;font-size:9px;margin:9px 0 0; } + +@media (max-width:760px) { + .year-account-filter { align-items:stretch;flex-direction:column; } + .year-filter-controls { width:100%;display:grid;grid-template-columns:100px 1fr; } + .year-filter-controls label { min-width:0 !important; } + .report-controls { grid-template-columns:1fr 1.5fr;max-width:none; } + .report-actions { display:grid;grid-template-columns:1fr 1fr; } + .report-actions .button-link { text-align:center;justify-content:center; } + .amount-change-add { grid-template-columns:1fr 1fr; } + .amount-change-add button { grid-column:1 / -1; } +} +@media (max-width:430px) { + .report-controls,.report-actions { grid-template-columns:1fr; } + .amount-change-add { grid-template-columns:1fr; } + .amount-change-add button { grid-column:auto; } +}