This commit is contained in:
2026-08-19 21:51:24 +02:00
parent b4d260421b
commit 1f50eeb429
4 changed files with 90 additions and 74 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
const SHELL_CACHE='fixfin-shell-v3.22'; const SHELL_CACHE='fixfin-shell-v3.23';
const DATA_CACHE='fixfin-data-v1'; const 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'];
+1 -1
View File
@@ -136,5 +136,5 @@ async function runPushoverReminderCheck(){
async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}} async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}}
async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==10||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.persons)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||movements.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;} async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==10||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.persons)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||movements.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;}
async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);} async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);}
app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}});app.get('/api/report.xlsx',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createExcelReport(data,{year,accountId,personId});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.xlsx"`);res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));}catch(e){next(e);}});app.get('/api/report.pdf',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createPdfReport(data,{year,accountId,personId});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.pdf"`);res.type('application/pdf').send(buffer);}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}catch(e){next(e);}}); app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}});app.get('/api/report.xlsx',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const includeTransfers=req.query.transfers!=='0';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createExcelReport(data,{year,accountId,personId,includeTransfers});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.xlsx"`);res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));}catch(e){next(e);}});app.get('/api/report.pdf',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const includeTransfers=req.query.transfers!=='0';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createPdfReport(data,{year,accountId,personId,includeTransfers});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.pdf"`);res.type('application/pdf').send(buffer);}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}catch(e){next(e);}});
const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);console.log(`Pushover-Reminder: Prüfung am 1. des Monats ab ${String(reminderHour).padStart(2,'0')}:00 Uhr (${process.env.TZ||'System-Zeitzone'})`);});setTimeout(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),5000);setInterval(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),15*60*1000); 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);
+11 -11
View File
@@ -26,7 +26,7 @@ function personIdsOf(item){return Array.isArray(item?.personIds)?item.personIds.
function personLabel(data,item){const ids=personIdsOf(item);return ids.length?ids.map(id=>personName(data,id)).join(', '):'Ohne Person';} function personLabel(data,item){const ids=personIdsOf(item);return ids.length?ids.map(id=>personName(data,id)).join(', '):'Ohne Person';}
function personFactor(item,personId='all'){if(personId==='all')return 1;const ids=personIdsOf(item);return ids.includes(personId)&&ids.length?1/ids.length:0;} function personFactor(item,personId='all'){if(personId==='all')return 1;const ids=personIdsOf(item);return ids.includes(personId)&&ids.length?1/ids.length:0;}
export function buildAnnualForecast(data,year,accountId,personId='all'){ export function buildAnnualForecast(data,year,accountId,personId='all',includeTransfers=true){
const rows=MONTHS.map((name,index)=>({name,index,income:0,expense:0,transferIn:0,transferOut:0,displayIncome:0,displayExpense:0,balance:0,items:[]})); 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||[]){ for(const item of data.incomes||[]){
const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue; const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue;
@@ -36,7 +36,7 @@ export function buildAnnualForecast(data,year,accountId,personId='all'){
const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue; const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue;
for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m)*factor;rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});} for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m)*factor;rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});}
} }
for(const item of data.transfers||[]){ if(includeTransfers) for(const item of data.transfers||[]){
const factor=personFactor(item,personId); if(!factor||(item.fromAccountId!==accountId&&item.toAccountId!==accountId))continue; const factor=personFactor(item,personId); if(!factor||(item.fromAccountId!==accountId&&item.toAccountId!==accountId))continue;
for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
const amount=Number(item.amount||0)*factor; const amount=Number(item.amount||0)*factor;
@@ -44,11 +44,11 @@ export function buildAnnualForecast(data,year,accountId,personId='all'){
if(item.fromAccountId===accountId){rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'out',reportAmount:amount});} if(item.fromAccountId===accountId){rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'out',reportAmount:amount});}
} }
} }
for(const r of rows){r.displayIncome=r.income+r.transferIn;r.displayExpense=r.expense+r.transferOut;r.balance=r.displayIncome-r.displayExpense;} for(const r of rows){r.displayIncome=r.income+(includeTransfers?r.transferIn:0);r.displayExpense=r.expense+(includeTransfers?r.transferOut:0);r.balance=r.displayIncome-r.displayExpense;}
return {year,accountId,rows,yearIncome:rows.reduce((s,r)=>s+r.displayIncome,0),yearExpense:rows.reduce((s,r)=>s+r.displayExpense,0)}; return {year,accountId,rows,yearIncome:rows.reduce((s,r)=>s+r.displayIncome,0),yearExpense:rows.reduce((s,r)=>s+r.displayExpense,0)};
} }
function occurrenceRows(data,year,accountId,personId='all'){ function occurrenceRows(data,year,accountId,personId='all',includeTransfers=true){
const result=[]; const result=[];
const push=(item,kind,monthIndex,direction='',factor=1)=>{ const push=(item,kind,monthIndex,direction='',factor=1)=>{
const amount=(kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex))*factor; const amount=(kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex))*factor;
@@ -56,7 +56,7 @@ function occurrenceRows(data,year,accountId,personId='all'){
}; };
for(const item of data.incomes||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m,'',factor);} for(const item of data.incomes||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m,'',factor);}
for(const item of data.expenses||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m,'',factor);} for(const item of data.expenses||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m,'',factor);}
for(const item of data.transfers||[]){const factor=personFactor(item,personId);if(factor&&(item.fromAccountId===accountId||item.toAccountId===accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ if(includeTransfers) for(const item of data.transfers||[]){const factor=personFactor(item,personId);if(factor&&(item.fromAccountId===accountId||item.toAccountId===accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
if(item.toAccountId===accountId)push(item,'transfer',m,'in',factor); if(item.toAccountId===accountId)push(item,'transfer',m,'in',factor);
if(item.fromAccountId===accountId)push(item,'transfer',m,'out',factor); if(item.fromAccountId===accountId)push(item,'transfer',m,'out',factor);
}} }}
@@ -76,11 +76,11 @@ function addTitle(sheet,title,subtitle='',lastColumn='F'){
function widths(sheet,values){values.forEach((w,i)=>sheet.getColumn(i+1).width=w);} function widths(sheet,values){values.forEach((w,i)=>sheet.getColumn(i+1).width=w);}
function euroCell(cell){cell.numFmt='#,##0.00 [$€-de-DE]';} function euroCell(cell){cell.numFmt='#,##0.00 [$€-de-DE]';}
export async function createExcelReport(data,{year,accountId,personId='all'}={}){ export async function createExcelReport(data,{year,accountId,personId='all',includeTransfers=true}={}){
const reportYear=Number(year)||new Date().getFullYear(); const reportYear=Number(year)||new Date().getFullYear();
const account=data.accounts.find(a=>a.id===accountId); if(!account)throw new Error('Bitte ein gültiges Konto für den Kontoauszug auswählen.'); const account=data.accounts.find(a=>a.id===accountId); if(!account)throw new Error('Bitte ein gültiges Konto für den Kontoauszug auswählen.');
const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);
const forecast=buildAnnualForecast(data,reportYear,accountId,personId); const rows=occurrenceRows(data,reportYear,accountId,personId); const forecast=buildAnnualForecast(data,reportYear,accountId,personId,includeTransfers); const rows=occurrenceRows(data,reportYear,accountId,personId,includeTransfers);
const wb=new ExcelJS.Workbook();wb.creator='FixFin';wb.created=new Date();wb.modified=new Date(); const wb=new ExcelJS.Workbook();wb.creator='FixFin';wb.created=new Date();wb.modified=new Date();
const statement=wb.addWorksheet('Kontoauszug',{views:[{state:'frozen',ySplit:4}]}); const statement=wb.addWorksheet('Kontoauszug',{views:[{state:'frozen',ySplit:4}]});
@@ -91,7 +91,7 @@ export async function createExcelReport(data,{year,accountId,personId='all'}={})
statement.autoFilter={from:'A4',to:'G4'};widths(statement,[16,18,30,20,24,18,18]); statement.autoFilter={from:'A4',to:'G4'};widths(statement,[16,18,30,20,24,18,18]);
const summary=wb.addWorksheet('Monatsübersicht',{views:[{state:'frozen',ySplit:4}]}); const summary=wb.addWorksheet('Monatsübersicht',{views:[{state:'frozen',ySplit:4}]});
addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · ${selectedPerson} · Liquiditätssicht inklusive Transfers`,'D'); addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · ${selectedPerson} · ${includeTransfers?'Liquiditätssicht inklusive Transfers':'Auswertung ohne Transfers'}`,'D');
summary.getRow(4).values=['Monat','Zuflüsse','Abflüsse','Nettofluss'];styleHeader(summary.getRow(4)); summary.getRow(4).values=['Monat','Zuflüsse','Abflüsse','Nettofluss'];styleHeader(summary.getRow(4));
forecast.rows.forEach((r,i)=>{const rr=i+5;summary.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance];for(let c=2;c<=4;c++)euroCell(summary.getCell(rr,c));summary.getCell(rr,4).font={color:{argb:r.balance<0?'FFB42318':'FF067647'}};}); forecast.rows.forEach((r,i)=>{const rr=i+5;summary.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance];for(let c=2;c<=4;c++)euroCell(summary.getCell(rr,c));summary.getCell(rr,4).font={color:{argb:r.balance<0?'FFB42318':'FF067647'}};});
summary.getRow(18).values=['Jahr',forecast.yearIncome,forecast.yearExpense,forecast.yearIncome-forecast.yearExpense];summary.getRow(18).font={bold:true};for(let c=2;c<=4;c++)euroCell(summary.getCell(18,c));summary.getCell(18,4).font={bold:true,color:{argb:forecast.yearIncome-forecast.yearExpense<0?'FFB42318':'FF067647'}};widths(summary,[20,20,20,20]); summary.getRow(18).values=['Jahr',forecast.yearIncome,forecast.yearExpense,forecast.yearIncome-forecast.yearExpense];summary.getRow(18).font={bold:true};for(let c=2;c<=4;c++)euroCell(summary.getCell(18,c));summary.getCell(18,4).font={bold:true,color:{argb:forecast.yearIncome-forecast.yearExpense<0?'FFB42318':'FF067647'}};widths(summary,[20,20,20,20]);
@@ -107,13 +107,13 @@ function pdfMetricRow(doc,items){addPageIfNeeded(doc,48);const y=doc.y;const wid
function pdfTableHeader(doc,cols){const y=doc.y;doc.rect(50,y,495,20).fill('#263A67');let x=50;for(const col of cols){doc.fillColor('#FFFFFF').font('Helvetica-Bold').fontSize(7.5).text(col.label,x+5,y+6,{width:col.width-10,align:col.align||'left'});x+=col.width;}doc.y=y+20;} function pdfTableHeader(doc,cols){const y=doc.y;doc.rect(50,y,495,20).fill('#263A67');let x=50;for(const col of cols){doc.fillColor('#FFFFFF').font('Helvetica-Bold').fontSize(7.5).text(col.label,x+5,y+6,{width:col.width-10,align:col.align||'left'});x+=col.width;}doc.y=y+20;}
function drawTableRow(doc,cols,values,{toneCols={}}={}){const y=doc.y;doc.rect(50,y,495,21).fill('#FFFFFF');doc.strokeColor('#E4E7EC').moveTo(50,y+21).lineTo(545,y+21).stroke();let x=50;cols.forEach((col,i)=>{doc.fillColor(toneCols[i]||'#344054').font('Helvetica').fontSize(7.5).text(String(values[i]??''),x+5,y+6,{width:col.width-10,align:col.align||'left',ellipsis:true});x+=col.width;});doc.y=y+21;} function drawTableRow(doc,cols,values,{toneCols={}}={}){const y=doc.y;doc.rect(50,y,495,21).fill('#FFFFFF');doc.strokeColor('#E4E7EC').moveTo(50,y+21).lineTo(545,y+21).stroke();let x=50;cols.forEach((col,i)=>{doc.fillColor(toneCols[i]||'#344054').font('Helvetica').fontSize(7.5).text(String(values[i]??''),x+5,y+6,{width:col.width-10,align:col.align||'left',ellipsis:true});x+=col.width;});doc.y=y+21;}
export async function createPdfReport(data,{year,accountId,personId='all'}={}){ export async function createPdfReport(data,{year,accountId,personId='all',includeTransfers=true}={}){
const reportYear=Number(year)||new Date().getFullYear(); const reportYear=Number(year)||new Date().getFullYear();
const account=data.accounts.find(a=>a.id===accountId); if(!account)throw new Error('Bitte ein gültiges Konto für den Kontoauszug auswählen.'); const account=data.accounts.find(a=>a.id===accountId); if(!account)throw new Error('Bitte ein gültiges Konto für den Kontoauszug auswählen.');
const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);
const forecast=buildAnnualForecast(data,reportYear,accountId,personId);const rows=occurrenceRows(data,reportYear,accountId,personId); const forecast=buildAnnualForecast(data,reportYear,accountId,personId,includeTransfers);const rows=occurrenceRows(data,reportYear,accountId,personId,includeTransfers);
const doc=new PDFDocument({size:'A4',margin:50,info:{Title:`FixFin Kontoauszug ${reportYear} - ${account.name}`,Author:'FixFin'}});const done=collectPdfBuffer(doc); const doc=new PDFDocument({size:'A4',margin:50,info:{Title:`FixFin Kontoauszug ${reportYear} - ${account.name}`,Author:'FixFin'}});const done=collectPdfBuffer(doc);
drawPdfHeader(doc,`FixFin Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`); drawPdfHeader(doc,`FixFin Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · ${includeTransfers?'inkl. Transfers':'ohne Transfers'} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`);
pdfMetricRow(doc,[{label:'Zuflüsse / Jahr',value:pdfMoney(forecast.yearIncome),positive:true},{label:'Abflüsse / Jahr',value:pdfMoney(forecast.yearExpense),negative:true},{label:'Nettofluss / Jahr',value:pdfMoney(forecast.yearIncome-forecast.yearExpense),positive:forecast.yearIncome>=forecast.yearExpense,negative:forecast.yearIncome<forecast.yearExpense}]); pdfMetricRow(doc,[{label:'Zuflüsse / Jahr',value:pdfMoney(forecast.yearIncome),positive:true},{label:'Abflüsse / Jahr',value:pdfMoney(forecast.yearExpense),negative:true},{label:'Nettofluss / Jahr',value:pdfMoney(forecast.yearIncome-forecast.yearExpense),positive:forecast.yearIncome>=forecast.yearExpense,negative:forecast.yearIncome<forecast.yearExpense}]);
const cols=[{label:'Art',width:70},{label:'Bezeichnung',width:130},{label:'Personen',width:80},{label:'Kategorie',width:90},{label:'Zufluss',width:63,align:'right'},{label:'Abfluss',width:62,align:'right'}]; const cols=[{label:'Art',width:70},{label:'Bezeichnung',width:130},{label:'Personen',width:80},{label:'Kategorie',width:90},{label:'Zufluss',width:63,align:'right'},{label:'Abfluss',width:62,align:'right'}];
+77 -61
View File
@@ -91,7 +91,7 @@ function personFactor(item, personId = 'all') {
function personMatches(item, personId = 'all') { return personFactor(item,personId) > 0; } function personMatches(item, personId = 'all') { return personFactor(item,personId) > 0; }
function accountName(data, id) { return data.accounts.find(a => a.id === id)?.name || 'Unbekannt'; } function accountName(data, id) { return data.accounts.find(a => a.id === id)?.name || 'Unbekannt'; }
function calc(data) { function calc(data, includeTransfers = true) {
const includePeriodic = data.settings?.includePeriodicInBalance !== false; const includePeriodic = data.settings?.includePeriodicInBalance !== false;
const accountStats = new Map(data.accounts.map(a => [a.id, { account: a, income: 0, expense: 0, transferIn: 0, transferOut: 0, periodicReserve: 0, balance: 0, requiredOutflow: 0, coveredInflow: 0, fundingNeed: 0, surplus: 0 }])); const accountStats = new Map(data.accounts.map(a => [a.id, { account: a, income: 0, expense: 0, transferIn: 0, transferOut: 0, periodicReserve: 0, balance: 0, requiredOutflow: 0, coveredInflow: 0, fundingNeed: 0, surplus: 0 }]));
for (const item of data.incomes) { for (const item of data.incomes) {
@@ -103,28 +103,28 @@ function calc(data) {
stat.expense += balanceAmount(item, includePeriodic); stat.expense += balanceAmount(item, includePeriodic);
if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item); if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item);
} }
for (const item of data.transfers) { if (includeTransfers) for (const item of data.transfers) {
const value = balanceAmount(item, includePeriodic); const value = balanceAmount(item, includePeriodic);
const from = accountStats.get(item.fromAccountId); const to = accountStats.get(item.toAccountId); const from = accountStats.get(item.fromAccountId); const to = accountStats.get(item.toAccountId);
if (from) from.transferOut += value; if (from) from.transferOut += value;
if (to) to.transferIn += value; if (to) to.transferIn += value;
} }
for (const stat of accountStats.values()) { for (const stat of accountStats.values()) {
stat.balance = stat.income - stat.expense + stat.transferIn - stat.transferOut; stat.balance = stat.income - stat.expense + (includeTransfers ? stat.transferIn - stat.transferOut : 0);
stat.requiredOutflow = stat.expense + stat.transferOut; stat.requiredOutflow = stat.expense + (includeTransfers ? stat.transferOut : 0);
stat.coveredInflow = stat.income + stat.transferIn; stat.coveredInflow = stat.income + (includeTransfers ? stat.transferIn : 0);
stat.fundingNeed = Math.max(0, stat.requiredOutflow - stat.coveredInflow); stat.fundingNeed = Math.max(0, stat.requiredOutflow - stat.coveredInflow);
stat.surplus = Math.max(0, stat.coveredInflow - stat.requiredOutflow); stat.surplus = Math.max(0, stat.coveredInflow - stat.requiredOutflow);
} }
const income = data.incomes.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0); const income = data.incomes.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0);
const expense = data.expenses.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0); const expense = data.expenses.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0);
const transferVolume = data.transfers.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0); const transferVolume = includeTransfers ? data.transfers.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0) : 0;
const periodicExpenseReserve = data.expenses.filter(isPeriodic).reduce((s,x) => s + monthlyEquivalent(x), 0); const periodicExpenseReserve = data.expenses.filter(isPeriodic).reduce((s,x) => s + monthlyEquivalent(x), 0);
const periodicIncomeAverage = data.incomes.filter(isPeriodic).reduce((s,x) => s + monthlyEquivalent(x), 0); const periodicIncomeAverage = data.incomes.filter(isPeriodic).reduce((s,x) => s + monthlyEquivalent(x), 0);
return { accounts: [...accountStats.values()], income, expense, transferVolume, periodicExpenseReserve, periodicIncomeAverage, balance: income - expense, includePeriodic }; return { accounts: [...accountStats.values()], income, expense, transferVolume, periodicExpenseReserve, periodicIncomeAverage, balance: income - expense, includePeriodic, includeTransfers };
} }
function annualForecast(data, accountId = 'all', year = new Date().getFullYear(), personId = 'all') { function annualForecast(data, accountId = 'all', year = new Date().getFullYear(), personId = 'all', includeTransfers = true) {
const isAll = !accountId || accountId === 'all'; const 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 => personMatches(item,personId) && (isAll || item.accountId === accountId); const relevantIncome = item => personMatches(item,personId) && (isAll || item.accountId === accountId);
@@ -138,7 +138,7 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear()
const factor=personFactor(item,personId); const factor=personFactor(item,personId);
for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].expense += amount; rows[m].items.push({ ...item, kind:'expense', forecastAmount:amount }); } for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].expense += amount; rows[m].items.push({ ...item, kind:'expense', forecastAmount:amount }); }
} }
for (const item of data.transfers.filter(relevantTransfer)) { if (includeTransfers) for (const item of data.transfers.filter(relevantTransfer)) {
const factor=personFactor(item,personId); const factor=personFactor(item,personId);
for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) {
const amount = Number(item.amount || 0) * factor; const amount = Number(item.amount || 0) * factor;
@@ -151,28 +151,28 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear()
} }
} }
rows.forEach(r => { rows.forEach(r => {
r.displayIncome = isAll ? r.income : r.income + r.transferIn; r.displayIncome = isAll || !includeTransfers ? r.income : r.income + r.transferIn;
r.displayExpense = isAll ? r.expense : r.expense + r.transferOut; r.displayExpense = isAll || !includeTransfers ? r.expense : r.expense + r.transferOut;
r.balance = r.displayIncome - r.displayExpense; r.balance = r.displayIncome - r.displayExpense;
}); });
const hasMonth = x => Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12; const hasMonth = x => Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12;
const unassigned = [ const unassigned = [
...data.incomes.filter(relevantIncome).map(x => ({...x, kind:'income'})), ...data.incomes.filter(relevantIncome).map(x => ({...x, kind:'income'})),
...data.expenses.filter(relevantExpense).map(x => ({...x, kind:'expense'})), ...data.expenses.filter(relevantExpense).map(x => ({...x, kind:'expense'})),
...data.transfers.filter(relevantTransfer).flatMap(x => { ...(includeTransfers ? data.transfers.filter(relevantTransfer).flatMap(x => {
if (isAll) return [{...x, kind:'transfer', direction:'neutral'}]; if (isAll) return [{...x, kind:'transfer', direction:'neutral'}];
const entries = []; const entries = [];
if (x.toAccountId === accountId) entries.push({...x, kind:'transfer', direction:'in'}); if (x.toAccountId === accountId) entries.push({...x, kind:'transfer', direction:'in'});
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) && months.some((_,m)=>itemActiveInMonth(x,year,m))); ].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, year }; return { rows, unassigned, yearIncome, yearExpense, isAll, accountId, year, includeTransfers };
} }
function categoryStats(data, accountId = 'all', personId = 'all') { function categoryStats(data, accountId = 'all', personId = 'all', includeTransfers = true) {
const categoryMap = new Map((data.categories || []).map(c => [c.id, c.name])); const categoryMap = new Map((data.categories || []).map(c => [c.id, c.name]));
const isAll = accountId === 'all'; const isAll = accountId === 'all';
const aggregate = items => { const aggregate = items => {
@@ -193,22 +193,22 @@ function categoryStats(data, accountId = 'all', personId = 'all') {
if (isAll) return { if (isAll) return {
incomes: aggregate(data.incomes.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'income'}))), incomes: aggregate(data.incomes.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'income'}))),
expenses: aggregate(data.expenses.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'expense'}))), expenses: aggregate(data.expenses.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'expense'}))),
transfers: aggregate(data.transfers.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'transfer'}))), transfers: includeTransfers ? aggregate(data.transfers.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'transfer'}))) : {total:0,rows:[]},
transfersMerged:false transfersMerged:false, includeTransfers
}; };
const incomeItems = [ const incomeItems = [
...data.incomes.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'income'})), ...data.incomes.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'income'})),
...data.transfers.filter(x => personMatches(x,personId) && x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})), ...(includeTransfers ? data.transfers.filter(x => personMatches(x,personId) && x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})) : []),
]; ];
const expenseItems = [ const expenseItems = [
...data.expenses.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'expense'})), ...data.expenses.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'expense'})),
...data.transfers.filter(x => personMatches(x,personId) && x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})), ...(includeTransfers ? data.transfers.filter(x => personMatches(x,personId) && x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})) : []),
]; ];
return { incomes: aggregate(incomeItems), expenses: aggregate(expenseItems), transfers:{total:0,rows:[]}, transfersMerged:true }; return { incomes: aggregate(incomeItems), expenses: aggregate(expenseItems), transfers:{total:0,rows:[]}, transfersMerged:includeTransfers, includeTransfers };
} }
function personFlowStats(data, accountId = 'all', personId = 'all') { function personFlowStats(data, accountId = 'all', personId = 'all', includeTransfers = true) {
const isAll = accountId === 'all'; const isAll = accountId === 'all';
const unassignedId = '__unassigned__'; const unassignedId = '__unassigned__';
const buckets = new Map(); const buckets = new Map();
@@ -241,7 +241,7 @@ function personFlowStats(data, accountId = 'all', personId = 'all') {
const amount=monthlyEquivalent(item); const amount=monthlyEquivalent(item);
for(const [id,factor] of splitTargets(item)) ensure(id).expense += amount*factor; for(const [id,factor] of splitTargets(item)) ensure(id).expense += amount*factor;
} }
for (const item of data.transfers || []) { if (includeTransfers) for (const item of data.transfers || []) {
const amount = monthlyEquivalent(item); const amount = monthlyEquivalent(item);
for(const [id,factor] of splitTargets(item)) { for(const [id,factor] of splitTargets(item)) {
const bucket = ensure(id); const share=amount*factor; const bucket = ensure(id); const share=amount*factor;
@@ -253,8 +253,8 @@ function personFlowStats(data, accountId = 'all', personId = 'all') {
} }
} }
for (const row of buckets.values()) { for (const row of buckets.values()) {
row.displayIn = isAll ? row.income : row.income + row.transferIn; row.displayIn = isAll || !includeTransfers ? row.income : row.income + row.transferIn;
row.displayOut = isAll ? row.expense : row.expense + row.transferOut; row.displayOut = isAll || !includeTransfers ? row.expense : row.expense + row.transferOut;
row.net = row.displayIn - row.displayOut; row.net = row.displayIn - row.displayOut;
} }
let rows = [...buckets.values()]; let rows = [...buckets.values()];
@@ -269,12 +269,15 @@ function personFlowStats(data, accountId = 'all', personId = 'all') {
transferInternal:sum.transferInternal + row.transferInternal, transferInternal:sum.transferInternal + row.transferInternal,
net:sum.net + row.net, net:sum.net + row.net,
}), {displayIn:0,displayOut:0,transferIn:0,transferOut:0,transferInternal:0,net:0}); }), {displayIn:0,displayOut:0,transferIn:0,transferOut:0,transferInternal:0,net:0});
return { rows, totals, isAll, accountId, personId }; return { rows, totals, isAll, accountId, personId, includeTransfers };
} }
function PersonFlowOverview({stats}){ function PersonFlowOverview({stats}){
return <section className="panel person-flow-panel"><div className="section-head"><div><h2>Personenbezogene Zahlungsströme</h2><p>{stats.isAll?'Wirtschaftliche Sicht pro Person. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; interne Transfers bleiben separat und saldoneutral.':'Liquiditätssicht pro Person auf dem gewählten Konto. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; Transfers zählen als echte Zu- und Abflüsse.'}</p></div><strong className={`person-flow-total ${stats.totals.net>=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat</strong></div> const description = stats.isAll
{stats.rows.length===0?<Empty text="Noch keine personenbezogenen Werte für diese Auswahl vorhanden."/>:<div className="person-flow-list">{stats.rows.map(row=><div className="person-flow-row" key={row.id}><div className="person-flow-head"><div><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><small>{stats.isAll?'wirtschaftlicher Monatssaldo':'Nettofluss auf diesem Konto'}</small></div><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div><div className="person-flow-grid">{stats.isAll?<><div><span>Einnahmen</span><strong className="good-text">+{formatMoney(row.income)}</strong></div><div><span>Ausgaben</span><strong className="bad-text">{formatMoney(row.expense)}</strong></div><div><span>Interne Transfers</span><strong>{formatMoney(row.transferInternal)}</strong></div><div><span>Saldo</span><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div></>:<><div><span>Zuflüsse</span><strong className="good-text">+{formatMoney(row.displayIn)}</strong></div><div><span>Abflüsse</span><strong className="bad-text">{formatMoney(row.displayOut)}</strong></div><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(row.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">{formatMoney(row.transferOut)}</strong></div></>}</div></div>)}</div>} ? (stats.includeTransfers?'Wirtschaftliche Sicht pro Person. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; interne Transfers bleiben separat und saldoneutral.':'Wirtschaftliche Sicht pro Person ohne Transfers. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt.')
: (stats.includeTransfers?'Liquiditätssicht pro Person auf dem gewählten Konto. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; Transfers zählen als echte Zu- und Abflüsse.':'Personenbezogene Zahlungsströme auf dem gewählten Konto ohne Transfers.');
return <section className="panel person-flow-panel"><div className="section-head"><div><h2>Personenbezogene Zahlungsströme</h2><p>{description}</p></div><strong className={`person-flow-total ${stats.totals.net>=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat</strong></div>
{stats.rows.length===0?<Empty text="Noch keine personenbezogenen Werte für diese Auswahl vorhanden."/>:<div className="person-flow-list">{stats.rows.map(row=><div className="person-flow-row" key={row.id}><div className="person-flow-head"><div><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><small>{stats.isAll?'wirtschaftlicher Monatssaldo':'Nettofluss auf diesem Konto'}</small></div><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div><div className="person-flow-grid">{stats.isAll?<><div><span>Einnahmen</span><strong className="good-text">+{formatMoney(row.income)}</strong></div><div><span>Ausgaben</span><strong className="bad-text">{formatMoney(row.expense)}</strong></div>{stats.includeTransfers&&<div><span>Interne Transfers</span><strong>{formatMoney(row.transferInternal)}</strong></div>}<div><span>Saldo</span><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div></>:<><div><span>Zuflüsse</span><strong className="good-text">+{formatMoney(row.displayIn)}</strong></div><div><span>Abflüsse</span><strong className="bad-text">{formatMoney(row.displayOut)}</strong></div>{stats.includeTransfers&&<><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(row.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">{formatMoney(row.transferOut)}</strong></div></>}</>}</div></div>)}</div>}
</section>; </section>;
} }
@@ -299,9 +302,9 @@ function reserveOverview(data) {
} }
function formatDate(value){if(!value)return '';const d=new Date(`${value}T12:00:00`);return Number.isNaN(d.getTime())?value:new Intl.DateTimeFormat('de-DE',{dateStyle:'medium'}).format(d);} function formatDate(value){if(!value)return '';const d=new Date(`${value}T12:00:00`);return Number.isNaN(d.getTime())?value:new Intl.DateTimeFormat('de-DE',{dateStyle:'medium'}).format(d);}
function buildWarnings(data){ function buildWarnings(data, includeTransfers = true){
const now=new Date();const year=now.getFullYear();const currentMonth=now.getMonth();const horizon=new Date(now);horizon.setDate(horizon.getDate()+90);const warnings=[]; 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(const account of data.accounts){const f=annualForecast(data,account.id,year,'all',includeTransfers);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(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});}} 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); return warnings.sort((a,b)=>a.sort-b.sort||(a.date?.getTime()||0)-(b.date?.getTime()||0)).slice(0,10);
@@ -373,6 +376,12 @@ function NavGroupMenu({group,tab,onClose,onSelect}){
return <div className="nav-menu-backdrop" onClick={onClose}><section className="nav-menu-sheet" onClick={e=>e.stopPropagation()}><div className="nav-menu-head"><div><span>Navigation</span><strong>{config.label}</strong></div><button className="close" onClick={onClose}>×</button></div><div className="nav-menu-items">{config.items.map(item=><button key={item.tab} className={tab===item.tab?'active':''} onClick={()=>onSelect(item.tab)}><span className="nav-menu-icon"><Icon name={item.icon}/></span><span className="nav-menu-copy"><strong>{item.label}</strong><small>{item.description}</small></span><span className="nav-menu-arrow"></span></button>)}</div></section></div>; return <div className="nav-menu-backdrop" onClick={onClose}><section className="nav-menu-sheet" onClick={e=>e.stopPropagation()}><div className="nav-menu-head"><div><span>Navigation</span><strong>{config.label}</strong></div><button className="close" onClick={onClose}>×</button></div><div className="nav-menu-items">{config.items.map(item=><button key={item.tab} className={tab===item.tab?'active':''} onClick={()=>onSelect(item.tab)}><span className="nav-menu-icon"><Icon name={item.icon}/></span><span className="nav-menu-copy"><strong>{item.label}</strong><small>{item.description}</small></span><span className="nav-menu-arrow"></span></button>)}</div></section></div>;
} }
const TRANSFER_PREF_KEY='fixfin.include-transfers.v1';
function readTransferPreference(){try{return localStorage.getItem(TRANSFER_PREF_KEY)!=='false';}catch{return true;}}
function TransferToggle({enabled,onChange,compact=false}){
return <section className={`toggle-panel transfer-toggle ${compact?'compact-transfer-toggle':''}`}><div><strong>Transfers einbeziehen</strong><span>{enabled?'Transfers werden in konto-bezogenen Zu-/Abflüssen und Auswertungen berücksichtigt.':'Transfers werden aus Berechnungen und Auswertungen ausgeblendet.'}</span></div><button type="button" className={`switch ${enabled?'on':''}`} onClick={()=>onChange(!enabled)} role="switch" aria-checked={enabled} aria-label="Transfers einbeziehen"><span/></button></section>;
}
function App() { function App() {
const [data,setData]=useState(emptyData); const [data,setData]=useState(emptyData);
const [tab,setTab]=useState('dashboard'); const [tab,setTab]=useState('dashboard');
@@ -381,7 +390,9 @@ function App() {
const [saving,setSaving]=useState(false); const [saving,setSaving]=useState(false);
const [error,setError]=useState(''); const [error,setError]=useState('');
const [dialog,setDialog]=useState(null); const [dialog,setDialog]=useState(null);
const totals=useMemo(()=>calc(data),[data]); const [includeTransfers,setIncludeTransfers]=useState(readTransferPreference);
useEffect(()=>{try{localStorage.setItem(TRANSFER_PREF_KEY,String(includeTransfers));}catch{}},[includeTransfers]);
const totals=useMemo(()=>calc(data,includeTransfers),[data,includeTransfers]);
useEffect(()=>{ fetch('/api/data').then(async r=>{if(!r.ok)throw new Error((await r.json()).error||'Daten konnten nicht geladen werden.');return r.json();}).then(setData).catch(e=>setError(e.message)).finally(()=>setLoading(false)); },[]); useEffect(()=>{ fetch('/api/data').then(async r=>{if(!r.ok)throw new Error((await r.json()).error||'Daten konnten nicht geladen werden.');return r.json();}).then(setData).catch(e=>setError(e.message)).finally(()=>setLoading(false)); },[]);
async function persist(next){setSaving(true);setError('');try{const r=await fetch('/api/data',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(next)});const result=await r.json();if(!r.ok)throw new Error(result.error||'Speichern fehlgeschlagen.');setData(result);return result;}catch(e){setError(e.message);throw e;}finally{setSaving(false);}} async function persist(next){setSaving(true);setError('');try{const r=await fetch('/api/data',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(next)});const result=await r.json();if(!r.ok)throw new Error(result.error||'Speichern fehlgeschlagen.');setData(result);return result;}catch(e){setError(e.message);throw e;}finally{setSaving(false);}}
function removeAccount(id){const used=data.incomes.some(x=>x.accountId===id)||data.expenses.some(x=>x.accountId===id)||data.transfers.some(x=>x.fromAccountId===id||x.toAccountId===id)||(data.scenarios||[]).some(s=>(s.adjustments||[]).some(x=>x.accountId===id||x.fromAccountId===id||x.toAccountId===id));if(used)return setError('Das Konto wird noch verwendet. Einträge, Transfers oder Szenario-Anpassungen bitte zuerst löschen.');if(confirm('Konto wirklich löschen?'))persist({...data,accounts:data.accounts.filter(x=>x.id!==id)});} function removeAccount(id){const used=data.incomes.some(x=>x.accountId===id)||data.expenses.some(x=>x.accountId===id)||data.transfers.some(x=>x.fromAccountId===id||x.toAccountId===id)||(data.scenarios||[]).some(s=>(s.adjustments||[]).some(x=>x.accountId===id||x.fromAccountId===id||x.toAccountId===id));if(used)return setError('Das Konto wird noch verwendet. Einträge, Transfers oder Szenario-Anpassungen bitte zuerst löschen.');if(confirm('Konto wirklich löschen?'))persist({...data,accounts:data.accounts.filter(x=>x.id!==id)});}
@@ -413,14 +424,14 @@ function App() {
<aside className="sidebar"><div className="brand"><div className="brand-mark">F</div><div><strong>FixFin</strong><span>Fixkostenplanung</span></div></div><nav>{MAIN_NAV.map(item=><button key={item.tab||item.menu} className={mainActive(item)?'active':''} onClick={()=>activateMain(item)}><Icon name={item.icon}/><span>{item.label}</span>{item.menu&&<small className="nav-group-caret"></small>}</button>)}</nav><div className="save-state">{saving?'Speichert …':'Automatisch gespeichert'}</div></aside> <aside className="sidebar"><div className="brand"><div className="brand-mark">F</div><div><strong>FixFin</strong><span>Fixkostenplanung</span></div></div><nav>{MAIN_NAV.map(item=><button key={item.tab||item.menu} className={mainActive(item)?'active':''} onClick={()=>activateMain(item)}><Icon name={item.icon}/><span>{item.label}</span>{item.menu&&<small className="nav-group-caret"></small>}</button>)}</nav><div className="save-state">{saving?'Speichert …':'Automatisch gespeichert'}</div></aside>
<main><header className="topbar"><div><h1>{meta.label}</h1><p>{meta.description}</p></div></header> <main><header className="topbar"><div><h1>{meta.label}</h1><p>{meta.description}</p></div></header>
{error&&<div className="alert"><span>{error}</span><button onClick={()=>setError('')}>×</button></div>} {error&&<div className="alert"><span>{error}</span><button onClick={()=>setError('')}>×</button></div>}
{tab==='dashboard'&&<Dashboard data={data} totals={totals} onToggle={togglePeriodic}/>} {tab==='dashboard'&&<Dashboard data={data} totals={totals} onToggle={togglePeriodic} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})} onAddPerson={()=>setDialog({type:'persons'})} onEditPerson={x=>setDialog({type:'persons',item:x})} onDeletePerson={removePerson}/>} {tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})} onAddPerson={()=>setDialog({type:'persons'})} onEditPerson={x=>setDialog({type:'persons',item:x})} onDeletePerson={removePerson}/>}
{tab==='movements'&&<MovementsPage data={data} onEdit={(type,x)=>setDialog({type,item:x})} onDuplicate={(type,x)=>setDialog({type,item:{...x,id:null},duplicate:true})} onDelete={removeItem} onAdd={type=>setDialog({type})}/>} {tab==='movements'&&<MovementsPage data={data} onEdit={(type,x)=>setDialog({type,item:x})} onDuplicate={(type,x)=>setDialog({type,item:{...x,id:null},duplicate:true})} onDelete={removeItem} onAdd={type=>setDialog({type})}/>}
{tab==='year'&&<YearPage data={data}/>} {tab==='year'&&<YearPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='planning'&&<PlanningPage data={data} totals={totals}/>} {tab==='planning'&&<PlanningPage data={data} totals={totals} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='stats'&&<StatisticsPage data={data} onAddCategory={()=>setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>} {tab==='stats'&&<StatisticsPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers} onAddCategory={()=>setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>}
{tab==='statements'&&<StatementsPage data={data}/>} {tab==='statements'&&<StatementsPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='scenarios'&&<ScenariosPage data={data} persist={persist}/>} {tab==='scenarios'&&<ScenariosPage data={data} persist={persist} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
{tab==='data'&&<DataPage data={data} importFile={importFile}/>} {tab==='data'&&<DataPage data={data} importFile={importFile}/>}
</main> </main>
<nav className="mobile-nav">{MAIN_NAV.map(item=><button key={item.tab||item.menu} className={mainActive(item)?'active':''} onClick={()=>activateMain(item)}><Icon name={item.icon}/><span>{item.label}</span></button>)}</nav> <nav className="mobile-nav">{MAIN_NAV.map(item=><button key={item.tab||item.menu} className={mainActive(item)?'active':''} onClick={()=>activateMain(item)}><Icon name={item.icon}/><span>{item.label}</span></button>)}</nav>
@@ -429,7 +440,7 @@ function App() {
</div>; </div>;
} }
function Dashboard({data,totals,onToggle}){ function Dashboard({data,totals,onToggle,includeTransfers,onTransfersChange}){
const FILTER_KEY='fixfin.dashboard.account-filter.v1'; const FILTER_KEY='fixfin.dashboard.account-filter.v1';
const [filter,setFilter]=useState(()=>{ const [filter,setFilter]=useState(()=>{
try { try {
@@ -444,7 +455,7 @@ function Dashboard({data,totals,onToggle}){
const visibleAccounts=useMemo(()=>totals.accounts.filter(s=>selectedSet.has(s.account.id)),[totals.accounts,selectedSet]); const 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]); const warnings=useMemo(()=>buildWarnings(data,includeTransfers),[data,includeTransfers]);
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:[]});
@@ -469,6 +480,7 @@ function Dashboard({data,totals,onToggle}){
const filterLabel=allSelected?'Alle Konten':`${validSelected.length} von ${accountIds.length} Konten`; const filterLabel=allSelected?'Alle Konten':`${validSelected.length} von ${accountIds.length} Konten`;
return <div className="content-stack dashboard-page"> return <div className="content-stack dashboard-page">
<TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/>
<details className="dashboard-account-filter"> <details className="dashboard-account-filter">
<summary><div><span>Kontenfilter</span><strong>{filterLabel}</strong></div><span className="accordion-chevron" aria-hidden="true"></span></summary> <summary><div><span>Kontenfilter</span><strong>{filterLabel}</strong></div><span className="accordion-chevron" aria-hidden="true"></span></summary>
<div className="dashboard-filter-options"> <div className="dashboard-filter-options">
@@ -478,7 +490,7 @@ function Dashboard({data,totals,onToggle}){
</details> </details>
<section className="panel dashboard-balances"><div className="section-head"><div><h2>Salden</h2><p>{allSelected?'Alle Konten im monatlichen Fix-Saldo.':'Nur die ausgewählten Konten.'}</p></div><div className="dashboard-selected-total"><span>{allSelected?'Gesamtsaldo':'Saldo Auswahl'}</span><strong className={selectedBalance>=0?'good-text':'bad-text'}>{selectedBalance>=0?'+':''}{formatMoney(selectedBalance)}</strong></div></div> <section className="panel dashboard-balances"><div className="section-head"><div><h2>Salden</h2><p>{allSelected?'Alle Konten im monatlichen Fix-Saldo.':'Nur die ausgewählten Konten.'}</p></div><div className="dashboard-selected-total"><span>{allSelected?'Gesamtsaldo':'Saldo Auswahl'}</span><strong className={selectedBalance>=0?'good-text':'bad-text'}>{selectedBalance>=0?'+':''}{formatMoney(selectedBalance)}</strong></div></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>} {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>{includeTransfers&&<><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> <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>
@@ -488,18 +500,19 @@ function Dashboard({data,totals,onToggle}){
</div>; </div>;
} }
function PlanningPage({data,totals}){ function PlanningPage({data,totals,includeTransfers,onTransfersChange}){
const reserves=useMemo(()=>reserveOverview(data),[data]); const reserves=useMemo(()=>reserveOverview(data),[data]);
const totalRequired=totals.accounts.reduce((s,x)=>s+x.requiredOutflow,0); const totalRequired=totals.accounts.reduce((s,x)=>s+x.requiredOutflow,0);
const totalFundingNeed=totals.accounts.reduce((s,x)=>s+x.fundingNeed,0); const totalFundingNeed=totals.accounts.reduce((s,x)=>s+x.fundingNeed,0);
return <div className="content-stack planning-page"> return <div className="content-stack planning-page">
<TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/>
<section className="summary-grid planning-summary"> <section className="summary-grid planning-summary">
<Metric label="Abflussbedarf aller Konten / Monat" value={totalRequired}/> <Metric label="Abflussbedarf aller Konten / Monat" value={totalRequired}/>
<Metric label="Zusätzliche Zuführung" value={totalFundingNeed} negative={totalFundingNeed>0} positive={totalFundingNeed===0}/> <Metric label="Zusätzliche Zuführung" value={totalFundingNeed} negative={totalFundingNeed>0} positive={totalFundingNeed===0}/>
<Metric label="Rücklage / Monat" value={reserves.monthly}/> <Metric label="Rücklage / Monat" value={reserves.monthly}/>
<Metric label="Periodischer Bedarf / Jahr" value={reserves.annual}/> <Metric label="Periodischer Bedarf / Jahr" value={reserves.annual}/>
</section> </section>
<section className="panel"><div className="section-head"><div><h2>Benötigt pro Konto</h2><p>Liquiditätsbedarf je Konto: echte Ausgaben plus Transfers raus, gedeckt durch echte Eingänge plus Transfers rein.</p></div></div>{totals.accounts.length===0?<Empty text="Lege zuerst ein Konto an."/>:<div className="funding-grid">{totals.accounts.map(s=><div className="funding-card" key={s.account.id}><div className="funding-head"><strong>{s.account.name}</strong><span className={s.fundingNeed>0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}</span></div><div className="funding-numbers"><div><span>Abflussbedarf</span><strong>{formatMoney(s.requiredOutflow)}</strong></div><div><span>Geplante Zuflüsse</span><strong>{formatMoney(s.coveredInflow)}</strong></div><div><span>Zusätzliche Zuführung</span><strong className={s.fundingNeed>0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}</strong></div></div></div>)}</div>}</section> <section className="panel"><div className="section-head"><div><h2>Benötigt pro Konto</h2><p>{includeTransfers?'Liquiditätsbedarf je Konto: echte Ausgaben plus Transfers raus, gedeckt durch echte Eingänge plus Transfers rein.':'Kontobedarf ohne Transfers: nur echte Ausgaben und echte Eingänge werden berücksichtigt.'}</p></div></div>{totals.accounts.length===0?<Empty text="Lege zuerst ein Konto an."/>:<div className="funding-grid">{totals.accounts.map(s=><div className="funding-card" key={s.account.id}><div className="funding-head"><strong>{s.account.name}</strong><span className={s.fundingNeed>0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}</span></div><div className="funding-numbers"><div><span>Abflussbedarf</span><strong>{formatMoney(s.requiredOutflow)}</strong></div><div><span>Geplante Zuflüsse</span><strong>{formatMoney(s.coveredInflow)}</strong></div><div><span>Zusätzliche Zuführung</span><strong className={s.fundingNeed>0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}</strong></div></div></div>)}</div>}</section>
<section className="panel reserve-panel"><div className="section-head"><div><h2>Rücklagenübersicht</h2><p>Periodische Ausgaben auf einen gleichmäßigen Monatsbetrag heruntergerechnet.</p></div><div className="reserve-total"><span>{formatMoney(reserves.annual)} / Jahr</span><strong>{formatMoney(reserves.monthly)} / Monat</strong></div></div>{reserves.items.length===0?<Empty text="Keine periodischen Ausgaben vorhanden."/>:<div className="reserve-list">{reserves.items.map(x=><div className="reserve-row" key={x.id}><div><strong>{x.name}</strong><span>{accountName(data,x.accountId)} · {categoryName(data,x.categoryId)} · {intervals[x.interval].label}</span></div><div><span>{formatMoney(x.annualNeed)} / Jahr</span><strong>{formatMoney(x.monthlyReserve)} / Monat</strong></div></div>)}</div>}</section> <section className="panel reserve-panel"><div className="section-head"><div><h2>Rücklagenübersicht</h2><p>Periodische Ausgaben auf einen gleichmäßigen Monatsbetrag heruntergerechnet.</p></div><div className="reserve-total"><span>{formatMoney(reserves.annual)} / Jahr</span><strong>{formatMoney(reserves.monthly)} / Monat</strong></div></div>{reserves.items.length===0?<Empty text="Keine periodischen Ausgaben vorhanden."/>:<div className="reserve-list">{reserves.items.map(x=><div className="reserve-row" key={x.id}><div><strong>{x.name}</strong><span>{accountName(data,x.accountId)} · {categoryName(data,x.categoryId)} · {intervals[x.interval].label}</span></div><div><span>{formatMoney(x.annualNeed)} / Jahr</span><strong>{formatMoney(x.monthlyReserve)} / Monat</strong></div></div>)}</div>}</section>
</div>; </div>;
} }
@@ -593,18 +606,18 @@ function CategoryDistribution({title,subtitle,stats,tone,data}){
</section>; </section>;
} }
function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){ function StatisticsPage({data,includeTransfers,onTransfersChange,onAddCategory,onEditCategory,onDeleteCategory}){
const [accountId,setAccountId]=useState('all'); const [accountId,setAccountId]=useState('all');
const [personId,setPersonId]=useState('all'); const [personId,setPersonId]=useState('all');
const stats=useMemo(()=>categoryStats(data,accountId,personId),[data,accountId,personId]); const stats=useMemo(()=>categoryStats(data,accountId,personId,includeTransfers),[data,accountId,personId,includeTransfers]);
const personFlows=useMemo(()=>personFlowStats(data,accountId,personId),[data,accountId,personId]); const personFlows=useMemo(()=>personFlowStats(data,accountId,personId,includeTransfers),[data,accountId,personId,includeTransfers]);
const totals=useMemo(()=>calc(data),[data]); const totals=useMemo(()=>calc(data,includeTransfers),[data,includeTransfers]);
const kpis=useMemo(()=>dashboardKpis(data,totals),[data,totals]); const kpis=useMemo(()=>dashboardKpis(data,totals),[data,totals]);
const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten'; const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten';
const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);
const usage=id=>data.incomes.filter(x=>x.categoryId===id).length+data.expenses.filter(x=>x.categoryId===id).length+data.transfers.filter(x=>x.categoryId===id).length; const usage=id=>data.incomes.filter(x=>x.categoryId===id).length+data.expenses.filter(x=>x.categoryId===id).length+data.transfers.filter(x=>x.categoryId===id).length;
const personMatchesLocal=x=>personMatches(x,personId); const personMatchesLocal=x=>personMatches(x,personId);
const relevantEntries=[...data.incomes.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...data.expenses.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...data.transfers.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.fromAccountId===accountId||x.toAccountId===accountId))]; const relevantEntries=[...data.incomes.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...data.expenses.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...(includeTransfers?data.transfers.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.fromAccountId===accountId||x.toAccountId===accountId)):[])];
const uncategorized=relevantEntries.filter(x=>!x.categoryId).length; const uncategorized=relevantEntries.filter(x=>!x.categoryId).length;
return <div className="content-stack stats-page"> return <div className="content-stack stats-page">
<section className="panel stats-overview-panel"><div className="section-head"><div><h2>Gesamtkennzahlen</h2><p>Die übergeordneten Kennzahlen liegen hier statt auf der Übersicht.</p></div></div><div className="stats-kpi-grid"> <section className="panel stats-overview-panel"><div className="section-head"><div><h2>Gesamtkennzahlen</h2><p>Die übergeordneten Kennzahlen liegen hier statt auf der Übersicht.</p></div></div><div className="stats-kpi-grid">
@@ -616,21 +629,22 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
<TextMetric label="Günstigster Monat" value={kpis.cheap?.name||''} hint={kpis.cheap?formatMoney(kpis.cheap.displayExpense):''}/> <TextMetric label="Günstigster Monat" value={kpis.cheap?.name||''} hint={kpis.cheap?formatMoney(kpis.cheap.displayExpense):''}/>
</div></section> </div></section>
<section className="year-account-filter stats-filter"><div className="year-filter-copy"><span>Statistik für</span><strong>{selectedName} · {selectedPerson}</strong></div><div className="stats-filter-controls"><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><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section> <section className="year-account-filter stats-filter"><div className="year-filter-copy"><span>Statistik für</span><strong>{selectedName} · {selectedPerson}</strong></div><div className="stats-filter-controls"><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><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section>
<TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/>
<section className="summary-grid stats-summary"><Metric label="Eingänge Ø / Monat" value={stats.incomes.total} positive/><Metric label="Ausgänge Ø / Monat" value={stats.expenses.total} negative/><CountMetric label="Kategorien" value={data.categories.length}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section> <section className="summary-grid stats-summary"><Metric label="Eingänge Ø / Monat" value={stats.incomes.total} positive/><Metric label="Ausgänge Ø / Monat" value={stats.expenses.total} negative/><CountMetric label="Kategorien" value={data.categories.length}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section>
<PersonFlowOverview stats={personFlows}/> <PersonFlowOverview stats={personFlows}/>
<CategoryDistribution data={data} title="Ausgaben nach Kategorien" subtitle={stats.transfersMerged?'Ausgaben dieses Kontos inklusive ausgehender Transfers. Kategorie anklicken für die Einzelpositionen.':'Verteilung auf Basis des durchschnittlichen Monatswerts. Kategorie anklicken für die Einzelpositionen.'} stats={stats.expenses} tone="expense"/> <CategoryDistribution data={data} title="Ausgaben nach Kategorien" subtitle={stats.transfersMerged?'Ausgaben dieses Kontos inklusive ausgehender Transfers. Kategorie anklicken für die Einzelpositionen.':includeTransfers?'Verteilung auf Basis des durchschnittlichen Monatswerts. Transfers werden separat ausgewiesen.':'Verteilung ohne Transfers auf Basis des durchschnittlichen Monatswerts.'} stats={stats.expenses} tone="expense"/>
<CategoryDistribution data={data} title="Eingänge nach Kategorien" subtitle={stats.transfersMerged?'Eingänge dieses Kontos inklusive eingehender Transfers. Kategorie anklicken für die Einzelpositionen.':'Verteilung der regelmäßigen Eingänge. Kategorie anklicken für die Einzelpositionen.'} stats={stats.incomes} tone="income"/> <CategoryDistribution data={data} title="Eingänge nach Kategorien" subtitle={stats.transfersMerged?'Eingänge dieses Kontos inklusive eingehender Transfers. Kategorie anklicken für die Einzelpositionen.':includeTransfers?'Verteilung der regelmäßigen Eingänge. Transfers werden separat ausgewiesen.':'Verteilung der regelmäßigen Eingänge ohne Transfers.'} stats={stats.incomes} tone="income"/>
{!stats.transfersMerged&&<CategoryDistribution data={data} title="Transfers nach Kategorien" subtitle="Nur bei Alle Konten separat. Bei Einzelkonten fließen Transfers je nach Richtung in Ein- oder Ausgänge ein." stats={stats.transfers} tone="transfer"/>} {includeTransfers&&!stats.transfersMerged&&<CategoryDistribution data={data} title="Transfers nach Kategorien" subtitle="Nur bei Alle Konten separat. Bei Einzelkonten fließen Transfers je nach Richtung in Ein- oder Ausgänge ein." stats={stats.transfers} tone="transfer"/>}
<section className="panel"><div className="section-head"><div><h2>Kategorien verwalten</h2><p>Kategorien gelten für Eingänge, Ausgänge und Transfers gemeinsam.</p></div><button className="secondary" onClick={onAddCategory}>+ Kategorie</button></div>{data.categories.length===0?<Empty text="Noch keine Kategorien angelegt."/>:<div className="category-manager-list">{data.categories.map(c=><div className="category-manager-row" key={c.id}><div><strong>{c.name}</strong><span>{usage(c.id)} {usage(c.id)===1?'zugeordneter Eintrag':'zugeordnete Einträge'}</span></div><div className="actions"><button onClick={()=>onEditCategory(c)}>Bearbeiten</button><button className="danger" onClick={()=>onDeleteCategory(c.id)}>Löschen</button></div></div>)}</div>}</section> <section className="panel"><div className="section-head"><div><h2>Kategorien verwalten</h2><p>Kategorien gelten für Eingänge, Ausgänge und Transfers gemeinsam.</p></div><button className="secondary" onClick={onAddCategory}>+ Kategorie</button></div>{data.categories.length===0?<Empty text="Noch keine Kategorien angelegt."/>:<div className="category-manager-list">{data.categories.map(c=><div className="category-manager-row" key={c.id}><div><strong>{c.name}</strong><span>{usage(c.id)} {usage(c.id)===1?'zugeordneter Eintrag':'zugeordnete Einträge'}</span></div><div className="actions"><button onClick={()=>onEditCategory(c)}>Bearbeiten</button><button className="danger" onClick={()=>onDeleteCategory(c.id)}>Löschen</button></div></div>)}</div>}</section>
</div>; </div>;
} }
function YearPage({data}){ function YearPage({data,includeTransfers,onTransfersChange}){
const currentYear=new Date().getFullYear(); const currentYear=new Date().getFullYear();
const [accountId,setAccountId]=useState('all'); const [accountId,setAccountId]=useState('all');
const [personId,setPersonId]=useState('all'); const [personId,setPersonId]=useState('all');
const [year,setYear]=useState(currentYear); const [year,setYear]=useState(currentYear);
const forecast=useMemo(()=>annualForecast(data,accountId,year,personId),[data,accountId,year,personId]); const forecast=useMemo(()=>annualForecast(data,accountId,year,personId,includeTransfers),[data,accountId,year,personId,includeTransfers]);
const selectedAccount=data.accounts.find(a=>a.id===accountId); const selectedAccount=data.accounts.find(a=>a.id===accountId);
const selectedName=selectedAccount?.name||'Alle Konten'; const selectedName=selectedAccount?.name||'Alle Konten';
const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);
@@ -649,6 +663,7 @@ function YearPage({data}){
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} · {selectedPerson} · {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><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section> <section className="year-account-filter"><div className="year-filter-copy"><span>Jahresansicht für</span><strong>{selectedName} · {selectedPerson} · {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><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section>
<TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/>
<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/>
<Metric label={labels.yearExpense} value={forecast.yearExpense} negative/> <Metric label={labels.yearExpense} value={forecast.yearExpense} negative/>
@@ -656,15 +671,15 @@ function YearPage({data}){
<CountMetric label="Nicht zugeordnet" value={forecast.unassigned.length}/> <CountMetric label="Nicht zugeordnet" value={forecast.unassigned.length}/>
</section> </section>
<section className="panel burden-profile"><div className="section-head"><div><h2>{forecast.isAll?'Belastungsprofil':'Abflussprofil'}</h2><p>{forecast.isAll?'Echte Ausgaben pro Monat. Interne Transfers bleiben außen vor.':`Was ${selectedName} pro Monat tatsächlich verlässt: echte Ausgaben plus Transfers raus.`}</p></div></div><div className="burden-bars">{forecast.rows.map(r=><div className={`burden-row ${r===maxRow?'peak':''}`} key={r.name}><span>{r.name.slice(0,3)}</span><div><i style={{width:`${Math.max(2,r.displayExpense/maxExpense*100)}%`}}/></div><strong>{formatMoney(r.displayExpense)}</strong></div>)}</div></section> <section className="panel burden-profile"><div className="section-head"><div><h2>{forecast.isAll?'Belastungsprofil':'Abflussprofil'}</h2><p>{forecast.isAll?'Echte Ausgaben pro Monat. Interne Transfers bleiben saldoneutral.':includeTransfers?`Was ${selectedName} pro Monat tatsächlich verlässt: echte Ausgaben plus Transfers raus.`:`Echte Ausgaben von ${selectedName}; Transfers sind ausgeblendet.`}</p></div></div><div className="burden-bars">{forecast.rows.map(r=><div className={`burden-row ${r===maxRow?'peak':''}`} key={r.name}><span>{r.name.slice(0,3)}</span><div><i style={{width:`${Math.max(2,r.displayExpense/maxExpense*100)}%`}}/></div><strong>{formatMoney(r.displayExpense)}</strong></div>)}</div></section>
<section className="panel year-forecast-panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>{forecast.isAll?'Wirtschaftliche Sicht über alle Konten: nur echte Einnahmen und Ausgaben. Transfers verschieben Geld nur innerhalb des Zahlungspools.':`Liquiditätssicht für ${selectedName}: Transfers rein zählen als Zufluss, Transfers raus als Abfluss. Im Detail bleiben sie klar von echten Einnahmen und Ausgaben getrennt.`}</p></div></div> <section className="panel year-forecast-panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>{forecast.isAll?(includeTransfers?'Wirtschaftliche Sicht über alle Konten: Transfers werden angezeigt, bleiben aber saldoneutral.':'Wirtschaftliche Sicht über alle Konten ohne Transfers.'):includeTransfers?`Liquiditätssicht für ${selectedName}: Transfers rein zählen als Zufluss, Transfers raus als Abfluss. Im Detail bleiben sie klar von echten Einnahmen und Ausgaben getrennt.`:`Kontosicht für ${selectedName} ohne Transfers: nur echte Eingänge und Ausgaben.`}</p></div></div>
<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}-${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> 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)}{includeTransfers&&(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>}</>:includeTransfers?<>
<div className="year-flow-breakdown"> <div className="year-flow-breakdown">
<div><span>Echte Eingänge</span><strong className="good-text">+{formatMoney(r.income)}</strong></div> <div><span>Echte Eingänge</span><strong className="good-text">+{formatMoney(r.income)}</strong></div>
<div><span>Transfers rein</span><strong className="good-text">+{formatMoney(r.transferIn)}</strong></div> <div><span>Transfers rein</span><strong className="good-text">+{formatMoney(r.transferIn)}</strong></div>
@@ -674,7 +689,7 @@ function YearPage({data}){
{inflows.length>0&&<div className="year-detail-group"><div className="year-detail-group-title"><span>Zuflüsse</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div>{inflows.map(detailRow)}</div>} {inflows.length>0&&<div className="year-detail-group"><div className="year-detail-group-title"><span>Zuflüsse</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div>{inflows.map(detailRow)}</div>}
{outflows.length>0&&<div className="year-detail-group"><div className="year-detail-group-title"><span>Abflüsse</span><strong className="bad-text">{formatMoney(r.displayExpense)}</strong></div>{outflows.map(detailRow)}</div>} {outflows.length>0&&<div className="year-detail-group"><div className="year-detail-group-title"><span>Abflüsse</span><strong className="bad-text">{formatMoney(r.displayExpense)}</strong></div>{outflows.map(detailRow)}</div>}
<div className="year-transfer-note">Kontosicht: Transfers werden im Nettofluss berücksichtigt, bleiben aber als Transfers gekennzeichnet. So siehst du den echten Liquiditätsbedarf des Kontos, ohne sie mit Konsumausgaben zu verwechseln.</div> <div className="year-transfer-note">Kontosicht: Transfers werden im Nettofluss berücksichtigt, bleiben aber als Transfers gekennzeichnet. So siehst du den echten Liquiditätsbedarf des Kontos, ohne sie mit Konsumausgaben zu verwechseln.</div>
</>} </>:<>{r.items.map(detailRow)}</>}
</div> </div>
</details>; </details>;
})}</div> })}</div>
@@ -683,21 +698,22 @@ function YearPage({data}){
</div>; </div>;
} }
function ScenariosPage({data,persist}){ function ScenariosPage({data,persist,includeTransfers,onTransfersChange}){
const scenarios=data.scenarios||[]; const scenarios=data.scenarios||[];
const [selectedId,setSelectedId]=useState(scenarios[0]?.id||''); const [selectedId,setSelectedId]=useState(scenarios[0]?.id||'');
const [adjustmentDialog,setAdjustmentDialog]=useState(null); const [adjustmentDialog,setAdjustmentDialog]=useState(null);
useEffect(()=>{if(selectedId&&!scenarios.some(s=>s.id===selectedId))setSelectedId(scenarios[0]?.id||'');if(!selectedId&&scenarios[0])setSelectedId(scenarios[0].id);},[scenarios,selectedId]); useEffect(()=>{if(selectedId&&!scenarios.some(s=>s.id===selectedId))setSelectedId(scenarios[0]?.id||'');if(!selectedId&&scenarios[0])setSelectedId(scenarios[0].id);},[scenarios,selectedId]);
const scenario=scenarios.find(s=>s.id===selectedId)||scenarios[0]; const scenario=scenarios.find(s=>s.id===selectedId)||scenarios[0];
const baseTotals=useMemo(()=>calc(data),[data]); const baseTotals=useMemo(()=>calc(data,includeTransfers),[data,includeTransfers]);
const projectedData=useMemo(()=>scenarioData(data,scenario),[data,scenario]); const projectedData=useMemo(()=>scenarioData(data,scenario),[data,scenario]);
const projected=useMemo(()=>calc(projectedData),[projectedData]); const projected=useMemo(()=>calc(projectedData,includeTransfers),[projectedData,includeTransfers]);
async function addScenario(){const name=prompt('Name des neuen Szenarios:','Was wäre wenn …');if(!name?.trim())return;const next={...data,scenarios:[...scenarios,{id:uid(),name:name.trim(),createdAt:new Date().toISOString(),adjustments:[]}]};const saved=await persist(next);setSelectedId(saved.scenarios[saved.scenarios.length-1]?.id||'');} async function addScenario(){const name=prompt('Name des neuen Szenarios:','Was wäre wenn …');if(!name?.trim())return;const next={...data,scenarios:[...scenarios,{id:uid(),name:name.trim(),createdAt:new Date().toISOString(),adjustments:[]}]};const saved=await persist(next);setSelectedId(saved.scenarios[saved.scenarios.length-1]?.id||'');}
async function renameScenario(){if(!scenario)return;const name=prompt('Neuer Name:',scenario.name);if(!name?.trim())return;await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,name:name.trim()}:s)});} async function renameScenario(){if(!scenario)return;const name=prompt('Neuer Name:',scenario.name);if(!name?.trim())return;await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,name:name.trim()}:s)});}
async function deleteScenario(){if(!scenario||!confirm(`Szenario „${scenario.name}“ wirklich löschen?`))return;await persist({...data,scenarios:scenarios.filter(s=>s.id!==scenario.id)});} async function deleteScenario(){if(!scenario||!confirm(`Szenario „${scenario.name}“ wirklich löschen?`))return;await persist({...data,scenarios:scenarios.filter(s=>s.id!==scenario.id)});}
async function deleteAdjustment(id){if(!scenario||!confirm('Anpassung wirklich löschen?'))return;await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,adjustments:(s.adjustments||[]).filter(a=>a.id!==id)}:s)});} async function deleteAdjustment(id){if(!scenario||!confirm('Anpassung wirklich löschen?'))return;await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,adjustments:(s.adjustments||[]).filter(a=>a.id!==id)}:s)});}
async function saveAdjustment(value){await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,adjustments:adjustmentDialog?.item?.id?(s.adjustments||[]).map(a=>a.id===adjustmentDialog.item.id?value:a):[...(s.adjustments||[]),value]}:s)});setAdjustmentDialog(null);} async function saveAdjustment(value){await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,adjustments:adjustmentDialog?.item?.id?(s.adjustments||[]).map(a=>a.id===adjustmentDialog.item.id?value:a):[...(s.adjustments||[]),value]}:s)});setAdjustmentDialog(null);}
return <div className="content-stack scenario-page"> return <div className="content-stack scenario-page">
<TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/>
<section className="scenario-toolbar"><div><span>Szenario</span>{scenarios.length?<select value={scenario?.id||''} onChange={e=>setSelectedId(e.target.value)}>{scenarios.map(s=><option key={s.id} value={s.id}>{s.name}</option>)}</select>:<strong>Noch keines angelegt</strong>}</div><div className="button-row"><button className="primary" onClick={addScenario}>+ Szenario</button>{scenario&&<><button className="secondary" onClick={renameScenario}>Umbenennen</button><button className="secondary danger-button" onClick={deleteScenario}>Löschen</button></>}</div></section> <section className="scenario-toolbar"><div><span>Szenario</span>{scenarios.length?<select value={scenario?.id||''} onChange={e=>setSelectedId(e.target.value)}>{scenarios.map(s=><option key={s.id} value={s.id}>{s.name}</option>)}</select>:<strong>Noch keines angelegt</strong>}</div><div className="button-row"><button className="primary" onClick={addScenario}>+ Szenario</button>{scenario&&<><button className="secondary" onClick={renameScenario}>Umbenennen</button><button className="secondary danger-button" onClick={deleteScenario}>Löschen</button></>}</div></section>
{!scenario?<section className="panel"><Empty text="Lege ein Szenario an. Es verwendet deinen aktuellen Plan als Basis und ergänzt nur die simulierten Änderungen."/></section>:<> {!scenario?<section className="panel"><Empty text="Lege ein Szenario an. Es verwendet deinen aktuellen Plan als Basis und ergänzt nur die simulierten Änderungen."/></section>:<>
<section className="summary-grid scenario-summary"><Metric label="Aktuell frei / Monat" value={baseTotals.balance} positive={baseTotals.balance>=0} negative={baseTotals.balance<0}/><Metric label="Im Szenario frei / Monat" value={projected.balance} positive={projected.balance>=0} negative={projected.balance<0} featured/><Metric label="Veränderung" value={projected.balance-baseTotals.balance} positive={projected.balance-baseTotals.balance>=0} negative={projected.balance-baseTotals.balance<0}/><CountMetric label="Anpassungen" value={(scenario.adjustments||[]).length}/></section> <section className="summary-grid scenario-summary"><Metric label="Aktuell frei / Monat" value={baseTotals.balance} positive={baseTotals.balance>=0} negative={baseTotals.balance<0}/><Metric label="Im Szenario frei / Monat" value={projected.balance} positive={projected.balance>=0} negative={projected.balance<0} featured/><Metric label="Veränderung" value={projected.balance-baseTotals.balance} positive={projected.balance-baseTotals.balance>=0} negative={projected.balance-baseTotals.balance<0}/><CountMetric label="Anpassungen" value={(scenario.adjustments||[]).length}/></section>
@@ -756,15 +772,15 @@ function PushoverSettings(){
</section>; </section>;
} }
function StatementsPage({data}){ function StatementsPage({data,includeTransfers,onTransfersChange}){
const currentYear=new Date().getFullYear(); const currentYear=new Date().getFullYear();
const [year,setYear]=useState(currentYear); const [year,setYear]=useState(currentYear);
const [accountId,setAccountId]=useState(data.accounts[0]?.id||''); const [accountId,setAccountId]=useState(data.accounts[0]?.id||'');
const [personId,setPersonId]=useState('all'); const [personId,setPersonId]=useState('all');
useEffect(()=>{if(accountId&&!data.accounts.some(a=>a.id===accountId))setAccountId(data.accounts[0]?.id||'');if(!accountId&&data.accounts[0])setAccountId(data.accounts[0].id);},[data.accounts,accountId]); useEffect(()=>{if(accountId&&!data.accounts.some(a=>a.id===accountId))setAccountId(data.accounts[0]?.id||'');if(!accountId&&data.accounts[0])setAccountId(data.accounts[0].id);},[data.accounts,accountId]);
if(!data.accounts.length)return <div className="content-stack statements-page"><section className="panel"><Empty text="Lege zuerst ein Konto an, um einen Kontoauszug zu erstellen."/></section></div>; if(!data.accounts.length)return <div className="content-stack statements-page"><section className="panel"><Empty text="Lege zuerst ein Konto an, um einen Kontoauszug zu erstellen."/></section></div>;
const account=accountName(data,accountId);const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}&person=${encodeURIComponent(personId)}`; const account=accountName(data,accountId);const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}&person=${encodeURIComponent(personId)}&transfers=${includeTransfers?'1':'0'}`;
return <div className="content-stack statements-page"><section className="panel report-export-panel"><div className="section-head"><div><h2>Kontoauszug erstellen</h2><p>Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.</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>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><label><span>Person</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div><div className="report-preview"><div><span>Kontoauszug</span><strong>{account} · {selectedPerson} · {year}</strong></div><p>PDF: Jahreswerte und monatlich gruppierte Kontobewegungen. Bei Person-Filter werden gemeinsam zugeordnete Bewegungen anteilig ausgewiesen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.</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">Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.</p></section></div>; return <div className="content-stack statements-page"><TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/><section className="panel report-export-panel"><div className="section-head"><div><h2>Kontoauszug erstellen</h2><p>Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.</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>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><label><span>Person</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div><div className="report-preview"><div><span>Kontoauszug</span><strong>{account} · {selectedPerson} · {year}</strong></div><p>PDF: Jahreswerte und monatlich gruppierte Kontobewegungen {includeTransfers?'inklusive Transfers':'ohne Transfers'}. Bei Person-Filter werden gemeinsam zugeordnete Bewegungen anteilig ausgewiesen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.</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">Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.</p></section></div>;
} }
function DataPage({data,importFile}){ function DataPage({data,importFile}){