This commit is contained in:
2026-08-19 11:24:28 +02:00
parent 4f5e300b4a
commit 38b6b8b1fa
6 changed files with 334 additions and 43 deletions
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "fixfin", "name": "fixfin",
"version": "1.4.0", "version": "1.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -11,7 +11,9 @@
"dependencies": { "dependencies": {
"express": "5.1.0", "express": "5.1.0",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1" "react-dom": "19.1.1",
"exceljs": "4.4.0",
"pdfkit": "0.19.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "5.0.2", "@vitejs/plugin-react": "5.0.2",
+1 -1
View File
@@ -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 DATA_CACHE='fixfin-data-v1';
const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png'];
+22 -13
View File
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url'; 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 __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__filename); const app=express();
const port=Number(process.env.PORT||3000); const dataFile=process.env.DATA_FILE||'/app/data/finance.json'; const dataDir=path.dirname(dataFile); const backupFile=`${dataFile}.bak`; const 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 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 validIntervals=new Set(['monthly','quarterly','semiannual','yearly']);
const validScenarioTypes=new Set(['income','expense','transfer']); 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); 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 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={}) { function normalizeScenarioAdjustment(item={}) {
const type=validScenarioTypes.has(item.type)?item.type:'expense'; const type=validScenarioTypes.has(item.type)?item.type:'expense';
const base={id:item.id,name:item.name,type,amount:Number(item.amount||0)}; const base={id:item.id,name:item.name,type,amount:Number(item.amount||0)};
@@ -26,17 +29,19 @@ function normalizeScenario(item={}) { return {id:item.id,name:item.name,createdA
function normalizeData(input){ function normalizeData(input){
if(!input||typeof input!=='object')return input; if(!input||typeof input!=='object')return input;
return { return {
version:7, version:8,
settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false}, settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},
accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts, 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})):[], 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, expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeExpense):input.expenses,
transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers, transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers,
scenarios:Array.isArray(input.scenarios)?input.scenarios.map(normalizeScenario):[], 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){ function validateData(input){
if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.'); if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.');
for(const key of ['accounts','categories','incomes','expenses','transfers','scenarios'])if(!Array.isArray(input[key]))throw new Error(`Feld '${key}' muss ein Array sein.`); for(const key of ['accounts','categories','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);} 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(); 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 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}'.`);} 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(); const scenarioIds=new Set();
for(const s of input.scenarios){ 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 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}){ async function sendPushover(config,{title,message}){
const body=new URLSearchParams({token:config.token,user:config.user,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{} 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})`); if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`);
return payload; 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 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 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; 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){ function reminderMessage(data,expense,now){
const account=data.accounts.find(a=>a.id===expense.accountId)?.name||'Unbekanntes Konto'; 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); 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.`}; 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 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 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; 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; 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);} 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 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);} 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); 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);
+177
View File
@@ -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.balance<a.balance?b:a);
warnings.push({level:'Achtung',title:`${account.name}: geplante Unterdeckung im ${worst.name}`,text:`Abflüsse übersteigen die Zuflüsse um ${money.format(Math.abs(worst.balance))}.`,sort:0,month:worst.index});
}
}
for(let m=0;m<12;m++){
const due=(data.expenses||[]).filter(x=>inScope(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<forecast.yearExpense}]);
const warnings=reportWarnings(data,reportYear,accountId);if(warnings.length){pdfSection(doc,'Planungshinweise');for(const warning of warnings.slice(0,8)){ensurePdfSpace(doc,30);doc.fillColor(warning.level==='Achtung'?'#B42318':'#17233B').font('Helvetica-Bold').fontSize(8.5).text(`${warning.level}: ${warning.title}`);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(warning.text);doc.moveDown(.3);}}
pdfSection(doc,'Jahresübersicht',forecast.isAll?'Wirtschaftliche Sicht. Transfers werden nicht als Einnahmen oder Ausgaben gezählt.':'Liquiditätssicht. Transfers rein zählen als Zufluss, Transfers raus als Abfluss.');
const mcols=[{label:'Monat',width:125},{label:forecast.isAll?'Einnahmen':'Zuflüsse',width:120,align:'right'},{label:forecast.isAll?'Ausgaben':'Abflüsse',width:120,align:'right'},{label:forecast.isAll?'Überschuss':'Nettofluss',width:130,align:'right'}];pdfTableHeader(doc,mcols);for(const r of forecast.rows)pdfTableRow(doc,mcols,[r.name,pdfMoney(r.displayIncome),pdfMoney(r.displayExpense),pdfMoney(r.balance)],{tone:r.balance<0?'#B42318':'#067647'});
for(const account of scopeAccounts(data,accountId)){
doc.addPage();const f=buildAnnualForecast(data,reportYear,account.id);drawPdfHeader(doc,account.name,`Liquiditäts-Auszug ${reportYear} · Geplante Kontobewegungen`);pdfMetricRow(doc,[{label:'Zuflüsse',value:pdfMoney(f.yearIncome),positive:true},{label:'Abflüsse',value:pdfMoney(f.yearExpense),negative:true},{label:'Nettofluss',value:pdfMoney(f.yearIncome-f.yearExpense),positive:f.yearIncome>=f.yearExpense,negative:f.yearIncome<f.yearExpense}]);
const accountCols=[{label:'Monat',width:125},{label:'Zuflüsse',width:120,align:'right'},{label:'Abflüsse',width:120,align:'right'},{label:'Nettofluss',width:130,align:'right'}];pdfTableHeader(doc,accountCols);for(const r of f.rows)pdfTableRow(doc,accountCols,[r.name,pdfMoney(r.displayIncome),pdfMoney(r.displayExpense),pdfMoney(r.balance)],{tone:r.balance<0?'#B42318':'#067647'});
pdfSection(doc,'Einzelpositionen','Echte Ein-/Ausgaben und interne Transfers bleiben getrennt gekennzeichnet.');
const rows=occurrenceRows(data,reportYear,account.id);const dcols=[{label:'Monat',width:72},{label:'Art',width:82},{label:'Bezeichnung',width:166},{label:'Kategorie',width:100},{label:'Betrag',width:75,align:'right'}];pdfTableHeader(doc,dcols);const kindLabel=r=>r.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;
}
+65 -27
View File
@@ -10,7 +10,7 @@ const intervals = {
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 }, semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
yearly: { label: 'Jährlich', divisor: 12, step: 12 }, 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 formatMoney(value) { return euro.format(Number(value || 0)); }
function parseAmount(value) { 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)}`; 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 annualEquivalent(item) { return monthlyEquivalent(item) * 12; }
function isPeriodic(item) { return (item.interval || 'monthly') !== 'monthly'; } function isPeriodic(item) { return (item.interval || 'monthly') !== 'monthly'; }
function balanceAmount(item, includePeriodic) { function balanceAmount(item, includePeriodic) {
if (!isPeriodic(item)) return Number(item.amount || 0); const now = new Date(); if (!itemActiveInMonth(item, now.getFullYear(), now.getMonth())) return 0;
return includePeriodic ? monthlyEquivalent(item) : 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'; const interval = item.interval || 'monthly';
if (interval === 'monthly') return true; if (interval === 'monthly') return true;
const dueMonth = Number(item.dueMonth); 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 }; 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 isAll = !accountId || accountId === 'all';
const rows = months.map((name, index) => ({ name, index, income: 0, expense: 0, transferIn: 0, transferOut: 0, balance: 0, items: [] })); 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 relevantIncome = item => isAll || item.accountId === accountId;
const relevantExpense = item => isAll || item.accountId === accountId; const relevantExpense = item => isAll || item.accountId === accountId;
const relevantTransfer = item => isAll || item.fromAccountId === accountId || item.toAccountId === accountId; const relevantTransfer = item => isAll || item.fromAccountId === accountId || item.toAccountId === accountId;
for (const item of data.incomes.filter(relevantIncome)) { 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 (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 (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); const amount = Number(item.amount || 0);
if (isAll) { 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 { } else {
if (item.toAccountId === accountId) { rows[m].transferIn += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'in' }); } 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' }); } 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'}); if (x.fromAccountId === accountId) entries.push({...x, kind:'transfer', direction:'out'});
return entries; 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 yearIncome = rows.reduce((s,r)=>s+r.displayIncome,0);
const yearExpense = rows.reduce((s,r)=>s+r.displayExpense,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') { 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) }; 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.balance<a.balance?b:a);warnings.push({kind:'danger',title:`${account.name}: Unterdeckung im ${worst.name}`,text:`Geplante Abflüsse übersteigen die Zuflüsse um ${formatMoney(Math.abs(worst.balance))}.`,sort:0});}}
for(let m=currentMonth;m<12;m++){const due=(data.expenses||[]).filter(x=>isPeriodic(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) { function scenarioData(data, scenario) {
if (!scenario) return data; if (!scenario) return data;
const next = { ...data, incomes:[...data.incomes], expenses:[...data.expenses], transfers:[...data.transfers] }; 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 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 selectedBalance=visibleAccounts.reduce((sum,s)=>sum+s.balance,0);
const allSelected=filter.mode==='all'||validSelected.length===accountIds.length; const allSelected=filter.mode==='all'||validSelected.length===accountIds.length;
const warnings=useMemo(()=>buildWarnings(data),[data]);
useEffect(()=>{ useEffect(()=>{
if(filter.mode==='some'&&accountIds.length>0&&validSelected.length===0) setFilter({mode:'all',ids:[]}); 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?<Empty text="Keine Konten ausgewählt."/>:<div className="account-balance-list">{visibleAccounts.map(s=><details className="account-balance-card" key={s.account.id}><summary><div className="account-balance-title"><h3>{s.account.name}</h3><span>monatlicher Fix-Saldo</span></div><div className="account-balance-right"><strong className={`account-balance-value ${s.balance>=0?'good-text':'bad-text'}`}>{s.balance>=0?'+':''}{formatMoney(s.balance)}</strong><span className="accordion-chevron" aria-hidden="true"></span></div></summary><div className="account-balance-details"><div className="mini-grid"><div><span>Eingänge</span><strong className="good-text">+{formatMoney(s.income)}</strong></div><div><span>Ausgänge</span><strong className="bad-text">{formatMoney(s.expense)}</strong></div><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(s.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">{formatMoney(s.transferOut)}</strong></div></div></div></details>)}</div>} {visibleAccounts.length===0?<Empty text="Keine Konten ausgewählt."/>:<div className="account-balance-list">{visibleAccounts.map(s=><details className="account-balance-card" key={s.account.id}><summary><div className="account-balance-title"><h3>{s.account.name}</h3><span>monatlicher Fix-Saldo</span></div><div className="account-balance-right"><strong className={`account-balance-value ${s.balance>=0?'good-text':'bad-text'}`}>{s.balance>=0?'+':''}{formatMoney(s.balance)}</strong><span className="accordion-chevron" aria-hidden="true"></span></div></summary><div className="account-balance-details"><div className="mini-grid"><div><span>Eingänge</span><strong className="good-text">+{formatMoney(s.income)}</strong></div><div><span>Ausgänge</span><strong className="bad-text">{formatMoney(s.expense)}</strong></div><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(s.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">{formatMoney(s.transferOut)}</strong></div></div></div></details>)}</div>}
</section> </section>
<section className="panel fixfin-warnings"><div className="section-head"><div><h2>Hinweise</h2><p>FixFin prüft deinen aktuellen Plan auf Liquiditätslücken, gebündelte Fälligkeiten, anstehende Änderungen und auslaufende Positionen.</p></div><span className={`warning-count ${warnings.some(w=>w.kind==='danger')?'danger':''}`}>{warnings.length}</span></div>{warnings.length===0?<div className="warning-clear"><strong>Keine Auffälligkeiten</strong><span>Für die nächsten Monate sieht der Plan unauffällig aus.</span></div>:<div className="warning-list">{warnings.map((w,i)=><div className={`warning-item ${w.kind}`} key={`${w.title}-${i}`}><span className="warning-dot"/><div><strong>{w.title}</strong><span>{w.text}</span></div></div>)}</div>}</section>
{!totals.includePeriodic&&totals.periodicExpenseReserve>0&&<div className="info-note">Periodische Ausgaben von rechnerisch <strong>{formatMoney(totals.periodicExpenseReserve)} / Monat</strong> sind derzeit nicht im Saldo enthalten.</div>} {!totals.includePeriodic&&totals.periodicExpenseReserve>0&&<div className="info-note">Periodische Ausgaben von rechnerisch <strong>{formatMoney(totals.periodicExpenseReserve)} / Monat</strong> sind derzeit nicht im Saldo enthalten.</div>}
<section className="toggle-panel dashboard-toggle"><div><strong>Periodische Kosten in Monats-Saldo einrechnen</strong><span>Jährliche, halbjährliche und vierteljährliche Beträge werden auf einen Monatswert heruntergerechnet.</span></div><button className={`switch ${totals.includePeriodic?'on':''}`} onClick={onToggle} role="switch" aria-checked={totals.includePeriodic}><span/></button></section> <section className="toggle-panel dashboard-toggle"><div><strong>Periodische Kosten in Monats-Saldo einrechnen</strong><span>Jährliche, halbjährliche und vierteljährliche Beträge werden auf einen Monatswert heruntergerechnet.</span></div><button className={`switch ${totals.includePeriodic?'on':''}`} onClick={onToggle} role="switch" aria-checked={totals.includePeriodic}><span/></button></section>
</div>; </div>;
@@ -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 categoryNameLocal=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie';
const isIncome=type==='incomes'; const isIncome=type==='incomes';
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
const currentAmount=x=>effectiveAmount(x);
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<> return <section className="panel movement-list-panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}</td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div> <div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} Änderung{x.amountChanges.length===1?'':'en'}</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
<div className="movement-mobile-list">{items.map(x=><article className="movement-card" key={x.id}> <div className="movement-mobile-list">{items.map(x=><article className="movement-card" key={x.id}>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder am 1.</span>}</div><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(x.amount)}</strong></div> <div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder am 1.</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} geplant</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</strong></div>
<div className="movement-card-grid"> <div className="movement-card-grid">
<div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div> <div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div>
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div> <div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div>
@@ -354,9 +377,9 @@ function Transfers({data,onEdit,onDuplicate,onDelete}){
const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; 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'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<> return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div> <div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
<div className="movement-mobile-list">{data.transfers.map(x=><article className="movement-card transfer-card" key={x.id}> <div className="movement-mobile-list">{data.transfers.map(x=><article className="movement-card transfer-card" key={x.id}>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></div><strong>{formatMoney(x.amount)}</strong></div> <div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div><strong>{formatMoney(x.amount)}</strong></div>
<div className="transfer-route"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><span className="route-arrow"></span><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div></div> <div className="transfer-route"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><span className="route-arrow"></span><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div></div>
<div className="movement-card-grid"> <div className="movement-card-grid">
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div> <div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div>
@@ -408,8 +431,10 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
} }
function YearPage({data}){ function YearPage({data}){
const currentYear=new Date().getFullYear();
const [accountId,setAccountId]=useState('all'); 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 selectedAccount=data.accounts.find(a=>a.id===accountId);
const selectedName=selectedAccount?.name||'Alle Konten'; const selectedName=selectedAccount?.name||'Alle Konten';
const labels=forecast.isAll 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 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 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 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 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 maxRow=forecast.rows.reduce((a,b)=>b.displayExpense>a.displayExpense?b:a,forecast.rows[0]);
const yearBalance=forecast.yearIncome-forecast.yearExpense; const yearBalance=forecast.yearIncome-forecast.yearExpense;
const detailRow=(x,i)=><div className="year-detail-row" key={`${x.id}-${x.direction||x.kind}-${i}`}><div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel(x)}</span><strong>{x.name}</strong></div><span className={`year-detail-amount ${kindTone(x)}`}>{kindSign(x)}{formatMoney(x.amount)}</span></div>; const detailRow=(x,i)=><div className="year-detail-row" key={`${x.id}-${x.direction||x.kind}-${i}`}><div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel(x)}</span><strong>{x.name}</strong></div><span className={`year-detail-amount ${kindTone(x)}`}>{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}</span></div>;
return <div className="content-stack year-page"> return <div className="content-stack year-page">
<section className="year-account-filter"><div className="year-filter-copy"><span>Jahresansicht für</span><strong>{selectedName}</strong></div><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></section> <section className="year-account-filter"><div className="year-filter-copy"><span>Jahresansicht für</span><strong>{selectedName} · {year}</strong></div><div className="year-filter-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></div></section>
<section className="summary-grid year-summary"> <section className="summary-grid year-summary">
<Metric label={labels.yearIncome} value={forecast.yearIncome} positive/> <Metric label={labels.yearIncome} value={forecast.yearIncome} positive/>
@@ -440,7 +465,7 @@ function YearPage({data}){
<div className="year-month-list">{forecast.rows.map(r=>{ <div className="year-month-list">{forecast.rows.map(r=>{
const inflows=r.items.filter(x=>x.kind==='income'||x.direction==='in'); const inflows=r.items.filter(x=>x.kind==='income'||x.direction==='in');
const outflows=r.items.filter(x=>x.kind==='expense'||x.direction==='out'); const outflows=r.items.filter(x=>x.kind==='expense'||x.direction==='out');
return <details className="year-month-card" key={`${accountId}-${r.name}`}><summary><div className="year-month-heading"><strong>{r.name}</strong><span>{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}</span></div><div className="year-month-basics"><div><span>{labels.income}</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div><div><span>{labels.expense}</span><strong className="bad-text">{formatMoney(r.displayExpense)}</strong></div><div className="year-month-balance"><span>{labels.balance}</span><strong className={r.balance>=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}</strong></div></div><span className="accordion-chevron" aria-hidden="true"></span></summary> return <details className="year-month-card" key={`${accountId}-${year}-${r.name}`}><summary><div className="year-month-heading"><strong>{r.name}</strong><span>{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}</span></div><div className="year-month-basics"><div><span>{labels.income}</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div><div><span>{labels.expense}</span><strong className="bad-text">{formatMoney(r.displayExpense)}</strong></div><div className="year-month-balance"><span>{labels.balance}</span><strong className={r.balance>=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}</strong></div></div><span className="accordion-chevron" aria-hidden="true"></span></summary>
<div className="year-month-details"> <div className="year-month-details">
{r.items.length===0?<div className="year-detail-empty">In diesem Monat sind keine einzelnen Fixpositionen fällig.</div>:forecast.isAll?<>{r.items.map(detailRow)}{(r.transferIn>0||r.transferOut>0)&&<div className="year-transfer-note">Interne Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie bleiben vollständig außerhalb von Einnahmen, Ausgaben und Überschuss.</div>}</>:<> {r.items.length===0?<div className="year-detail-empty">In diesem Monat sind keine einzelnen Fixpositionen fällig.</div>:forecast.isAll?<>{r.items.map(detailRow)}{(r.transferIn>0||r.transferOut>0)&&<div className="year-transfer-note">Interne Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie bleiben vollständig außerhalb von Einnahmen, Ausgaben und Überschuss.</div>}</>:<>
<div className="year-flow-breakdown"> <div className="year-flow-breakdown">
@@ -509,6 +534,15 @@ function PushoverSettings(){
</section>; </section>;
} }
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 <section className="panel report-export-panel"><div className="section-head"><div><h2>Auszüge & Berichte</h2><p>Aufbereitete Planungsdaten als PDF oder Excel. Die Kontosicht enthält echte Ein-/Ausgänge und Transfers als getrennte Zahlungsströme.</p></div><span className="report-badge">PDF · XLSX</span></div><div className="report-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Umfang</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></div><div className="report-preview"><div><span>Auszug</span><strong>{scope} · {year}</strong></div><p>PDF: druckfertiger Planungs-Auszug im Kontoauszug-Stil. Excel: Übersicht, Planungshinweise, Jahresplan, Bewegungen, Änderungen, Stammdaten, Konten/Kategorien, Szenarien und Konto-Blätter.</p></div><div className="button-row report-actions"><a className="primary button-link" href={`/api/report.pdf?${query}`}>PDF herunterladen</a><a className="secondary button-link" href={`/api/report.xlsx?${query}`}>Excel herunterladen</a></div><p className="report-disclaimer">Planungs-Auszug aus FixFin, kein von einer Bank ausgestellter Kontoauszug.</p></section>;
}
function DataPage({data,importFile}){ function DataPage({data,importFile}){
const summary=[ const summary=[
['Konten',data.accounts.length], ['Konten',data.accounts.length],
@@ -520,6 +554,7 @@ function DataPage({data,importFile}){
]; ];
const json=JSON.stringify(data,null,2); const json=JSON.stringify(data,null,2);
return <div className="content-stack data-page"> return <div className="content-stack data-page">
<ReportExports data={data}/>
<PushoverSettings/> <PushoverSettings/>
<section className="panel data-backup-panel"><div className="section-head"><div><h2>Daten sichern</h2><p>Alle Daten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row data-actions"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section> <section className="panel data-backup-panel"><div className="section-head"><div><h2>Daten sichern</h2><p>Alle Daten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row data-actions"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section>
<section className="panel data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div> <section className="panel data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div>
@@ -533,8 +568,9 @@ function Empty({text}){return <div className="empty">{text}</div>}
function EditorDialog({dialog,data,onClose,onSave}){ function EditorDialog({dialog,data,onClose,onSave}){
const type=dialog.type,item=dialog.item; const editing=Boolean(item?.id); 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'}; 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){ async function submit(e){
e.preventDefault();setLocalError(''); const numeric=parseAmount(amount); e.preventDefault();setLocalError(''); const numeric=parseAmount(amount);
if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.'); 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(!['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==='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(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; let next=structuredClone(data); const id=editing?item.id:uid(); let value;
if(type==='accounts') value={id,name:name.trim()}; 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 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 { 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; 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(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]; 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);} try{await onSave(next);}catch(e){setLocalError(e.message);}
} }
const simpleType=type==='accounts'||type==='categories'; const simpleType=type==='accounts'||type==='categories';
return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{localError&&<div className="alert">{localError}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={type==='accounts'?'z. B. Girokonto':type==='categories'?'z. B. Wohnen':'z. B. Kfz-Versicherung'}/></label> return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{localError&&<div className="alert">{localError}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={type==='accounts'?'z. B. Girokonto':type==='categories'?'z. B. Wohnen':'z. B. Kfz-Versicherung'}/></label>
{!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}</>} {!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}<label><span>Enddatum / Laufzeit (optional)</span><input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)}/><small className="field-help">Der Monat des Enddatums wird noch vollständig eingeplant.</small></label></>}
{(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Kategorie</span><select value={categoryId} onChange={e=>setCategoryId(e.target.value)}><option value="">Ohne Kategorie</option>{data.categories.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}<option value="__new__">+ Neue Kategorie </option></select></label>{categoryId==='__new__'&&<label className="new-category-field"><span>Neue Kategorie</span><input value={newCategoryName} onChange={e=>setNewCategoryName(e.target.value)} placeholder="z. B. Sparen"/></label>}</>} {(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Kategorie</span><select value={categoryId} onChange={e=>setCategoryId(e.target.value)}><option value="">Ohne Kategorie</option>{data.categories.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}<option value="__new__">+ Neue Kategorie </option></select></label>{categoryId==='__new__'&&<label className="new-category-field"><span>Neue Kategorie</span><input value={newCategoryName} onChange={e=>setNewCategoryName(e.target.value)} placeholder="z. B. Sparen"/></label>}</>}
{(type==='incomes'||type==='expenses')&&<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>} {(type==='incomes'||type==='expenses')&&<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>}
{(type==='incomes'||type==='expenses')&&<section className="amount-change-panel"><div className="amount-change-head"><div><strong>Geplante Betragsänderungen</strong><span>Ein neuer Betrag gilt ab dem Monat des gewählten Datums. Der Basisbetrag oben bleibt für frühere Monate erhalten.</span></div><span className="change-count">{amountChanges.length}</span></div>{amountChanges.length>0&&<div className="amount-change-list">{amountChanges.map(c=><div className="amount-change-row" key={c.id}><div><span>ab {formatDate(c.effectiveFrom)}</span><strong>{formatMoney(c.amount)}</strong></div><button type="button" className="danger" onClick={()=>setAmountChanges(amountChanges.filter(x=>x.id!==c.id))}>Entfernen</button></div>)}</div>}<div className="amount-change-add"><label><span>Wirksam ab</span><input type="date" value={changeDate} onChange={e=>setChangeDate(e.target.value)}/></label><label><span>Neuer Betrag</span><div className="money-input"><input inputMode="decimal" value={changeAmount} onChange={e=>setChangeAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><button type="button" className="secondary" onClick={addAmountChange}>+ Änderung</button></div></section>}
{type==='expenses'&&<button type="button" className={`reminder-toggle ${pushoverReminder?'active':''}`} aria-pressed={pushoverReminder} onClick={()=>setPushoverReminder(x=>!x)}><span className={`checkbox-mark ${pushoverReminder?'checked':''}`}>{pushoverReminder?'✓':''}</span><span><strong>Pushover-Erinnerung</strong><small>Am 1. des Fälligkeitsmonats mit Betrag und Konto erinnern.</small></span></button>} {type==='expenses'&&<button type="button" className={`reminder-toggle ${pushoverReminder?'active':''}`} aria-pressed={pushoverReminder} onClick={()=>setPushoverReminder(x=>!x)}><span className={`checkbox-mark ${pushoverReminder?'checked':''}`}>{pushoverReminder?'✓':''}</span><span><strong>Pushover-Erinnerung</strong><small>Am 1. des Fälligkeitsmonats mit Betrag und Konto erinnern.</small></span></button>}
{type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>; {type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>;
} }
+65
View File
@@ -898,3 +898,68 @@ code { color:#b8c3ff; }
.pushover-actions button { width:100%; min-height:42px; } .pushover-actions button { width:100%; min-height:42px; }
.reminder-toggle { padding:10px; } .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; }
}