V3.23
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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}){
|
||||||
|
|||||||
Reference in New Issue
Block a user