V3.17
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const SHELL_CACHE='fixfin-shell-v3.16';
|
const SHELL_CACHE='fixfin-shell-v3.17';
|
||||||
const DATA_CACHE='fixfin-data-v1';
|
const 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'];
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -77,7 +77,7 @@ async function readPushoverConfig(){const value=await readJsonFile(pushoverFile,
|
|||||||
async function savePushoverConfig(input){const token=String(input?.token||'').trim();const user=String(input?.user||'').trim();if(!pushoverKeyPattern.test(token))throw new Error('Der Pushover Application API Token muss 30 alphanumerische Zeichen enthalten.');if(!pushoverKeyPattern.test(user))throw new Error('Der Pushover User/Group Key muss 30 alphanumerische Zeichen enthalten.');await writePrivateJson(pushoverFile,{token,user,updatedAt:new Date().toISOString()});return {configured:true,userMasked:maskKey(user)};}
|
async function savePushoverConfig(input){const token=String(input?.token||'').trim();const user=String(input?.user||'').trim();if(!pushoverKeyPattern.test(token))throw new Error('Der Pushover Application API Token muss 30 alphanumerische Zeichen enthalten.');if(!pushoverKeyPattern.test(user))throw new Error('Der Pushover User/Group Key muss 30 alphanumerische Zeichen enthalten.');await writePrivateJson(pushoverFile,{token,user,updatedAt:new Date().toISOString()});return {configured:true,userMasked:maskKey(user)};}
|
||||||
async function sendPushover(config,{title,message}){
|
async function sendPushover(config,{title,message}){
|
||||||
const body=new URLSearchParams({token:config.token,user:config.user,title,message});
|
const body=new URLSearchParams({token:config.token,user:config.user,title,message});
|
||||||
const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.16'},body});
|
const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.17'},body});
|
||||||
let payload={}; try{payload=await response.json();}catch{}
|
let payload={}; try{payload=await response.json();}catch{}
|
||||||
if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`);
|
if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`);
|
||||||
return payload;
|
return payload;
|
||||||
@@ -114,5 +114,5 @@ async function runPushoverReminderCheck(){
|
|||||||
async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}}
|
async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}}
|
||||||
async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];if(parsed.version!==8||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;}
|
async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];if(parsed.version!==8||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;}
|
||||||
async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);}
|
async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);}
|
||||||
app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}});app.get('/api/report.xlsx',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'all';if(accountId!=='all'&&!data.accounts.some(a=>a.id===accountId))throw new Error('Unbekanntes Konto für den Export.');const buffer=await createExcelReport(data,{year,accountId});const suffix=accountId==='all'?'alle-konten':(data.accounts.find(a=>a.id===accountId)?.name||'konto').replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-${year}-${suffix}.xlsx"`);res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));}catch(e){next(e);}});app.get('/api/report.pdf',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'all';if(accountId!=='all'&&!data.accounts.some(a=>a.id===accountId))throw new Error('Unbekanntes Konto für den Export.');const buffer=await createPdfReport(data,{year,accountId});const suffix=accountId==='all'?'alle-konten':(data.accounts.find(a=>a.id===accountId)?.name||'konto').replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-${year}-${suffix}.pdf"`);res.type('application/pdf').send(buffer);}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}catch(e){next(e);}});
|
app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}});app.get('/api/report.xlsx',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');const buffer=await createExcelReport(data,{year,accountId});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}.xlsx"`);res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));}catch(e){next(e);}});app.get('/api/report.pdf',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');const buffer=await createPdfReport(data,{year,accountId});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}.pdf"`);res.type('application/pdf').send(buffer);}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}catch(e){next(e);}});
|
||||||
const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);console.log(`Pushover-Reminder: Prüfung am 1. des Monats ab ${String(reminderHour).padStart(2,'0')}:00 Uhr (${process.env.TZ||'System-Zeitzone'})`);});setTimeout(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),5000);setInterval(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),15*60*1000);
|
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);
|
||||||
|
|||||||
+63
-122
@@ -21,157 +21,98 @@ function effectiveAmount(item,year,monthIndex){
|
|||||||
}
|
}
|
||||||
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 categoryName(data,id){return data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';}
|
function categoryName(data,id){return data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';}
|
||||||
function scopeAccounts(data,accountId){return accountId&&accountId!=='all'?data.accounts.filter(a=>a.id===accountId):data.accounts;}
|
|
||||||
function inScope(item,kind,accountId){if(!accountId||accountId==='all')return true;if(kind==='transfer')return item.fromAccountId===accountId||item.toAccountId===accountId;return item.accountId===accountId;}
|
|
||||||
|
|
||||||
export function buildAnnualForecast(data,year,accountId='all'){
|
export function buildAnnualForecast(data,year,accountId){
|
||||||
const isAll=!accountId||accountId==='all';
|
|
||||||
const rows=MONTHS.map((name,index)=>({name,index,income:0,expense:0,transferIn:0,transferOut:0,displayIncome:0,displayExpense:0,balance:0,items:[]}));
|
const rows=MONTHS.map((name,index)=>({name,index,income:0,expense:0,transferIn:0,transferOut:0,displayIncome:0,displayExpense:0,balance:0,items:[]}));
|
||||||
for(const item of data.incomes||[]) if(inScope(item,'income',accountId)) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].income+=amount;rows[m].items.push({...item,kind:'income',reportAmount:amount});}
|
for(const item of data.incomes||[]) if(item.accountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].income+=amount;rows[m].items.push({...item,kind:'income',reportAmount:amount});}
|
||||||
for(const item of data.expenses||[]) if(inScope(item,'expense',accountId)) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});}
|
for(const item of data.expenses||[]) if(item.accountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});}
|
||||||
for(const item of data.transfers||[]) if(inScope(item,'transfer',accountId)) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){
|
for(const item of data.transfers||[]) if(item.fromAccountId===accountId||item.toAccountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){
|
||||||
const amount=Number(item.amount||0);
|
const amount=Number(item.amount||0);
|
||||||
if(isAll){rows[m].transferIn+=amount;rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'neutral',reportAmount:amount});}
|
if(item.toAccountId===accountId){rows[m].transferIn+=amount;rows[m].items.push({...item,kind:'transfer',direction:'in',reportAmount:amount});}
|
||||||
else {if(item.toAccountId===accountId){rows[m].transferIn+=amount;rows[m].items.push({...item,kind:'transfer',direction:'in',reportAmount:amount});}if(item.fromAccountId===accountId){rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'out',reportAmount:amount});}}
|
if(item.fromAccountId===accountId){rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'out',reportAmount:amount});}
|
||||||
}
|
}
|
||||||
for(const r of rows){r.displayIncome=isAll?r.income:r.income+r.transferIn;r.displayExpense=isAll?r.expense:r.expense+r.transferOut;r.balance=r.displayIncome-r.displayExpense;}
|
for(const r of rows){r.displayIncome=r.income+r.transferIn;r.displayExpense=r.expense+r.transferOut;r.balance=r.displayIncome-r.displayExpense;}
|
||||||
return {year,accountId,isAll,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){
|
||||||
function reportWarnings(data,year,accountId='all'){
|
|
||||||
const warnings=[];
|
|
||||||
for(const account of scopeAccounts(data,accountId)){
|
|
||||||
const forecast=buildAnnualForecast(data,year,account.id);
|
|
||||||
const negatives=forecast.rows.filter(r=>r.balance<0);
|
|
||||||
if(negatives.length){
|
|
||||||
const worst=negatives.reduce((a,b)=>b.balance<a.balance?b:a);
|
|
||||||
warnings.push({level:'Achtung',title:`${account.name}: geplante Unterdeckung im ${worst.name}`,text:`Abflüsse übersteigen die Zuflüsse um ${money.format(Math.abs(worst.balance))}.`,sort:0,month:worst.index});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for(let m=0;m<12;m++){
|
|
||||||
const due=(data.expenses||[]).filter(x=>inScope(x,'expense',accountId)&&x.interval!=='monthly'&&occursInMonth(x,year,m));
|
|
||||||
if(due.length>=3){
|
|
||||||
const total=due.reduce((sum,x)=>sum+effectiveAmount(x,year,m),0);
|
|
||||||
warnings.push({level:'Hinweis',title:`${due.length} periodische Ausgaben im ${MONTHS[m]}`,text:`Zusammen ${money.format(total)}. Mehrere größere Fälligkeiten liegen im selben Monat.`,sort:1,month:m});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for(const [kind,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]]){
|
|
||||||
for(const item of items){
|
|
||||||
if(!inScope(item,kind==='Eingang'?'income':'expense',accountId))continue;
|
|
||||||
for(const change of item.amountChanges||[]){
|
|
||||||
if(String(change.effectiveFrom||'').startsWith(`${year}-`))warnings.push({level:'Info',title:`${item.name}: Betrag ändert sich`,text:`${kind} auf ${accountName(data,item.accountId)} ab ${change.effectiveFrom}: ${money.format(Number(change.amount||0))}.`,sort:2,month:Number(change.effectiveFrom.slice(5,7))-1});
|
|
||||||
}
|
|
||||||
if(String(item.endDate||'').startsWith(`${year}-`))warnings.push({level:'Info',title:`${item.name} läuft aus`,text:`${kind} auf ${accountName(data,item.accountId)} endet am ${item.endDate}.`,sort:3,month:Number(item.endDate.slice(5,7))-1});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return warnings.sort((a,b)=>a.sort-b.sort||a.month-b.month||a.title.localeCompare(b.title,'de')).slice(0,25);
|
|
||||||
}
|
|
||||||
|
|
||||||
function occurrenceRows(data,year,accountId='all'){
|
|
||||||
const result=[];
|
const result=[];
|
||||||
const push=(item,kind,monthIndex,direction='')=>{
|
const push=(item,kind,monthIndex,direction='')=>{
|
||||||
let amount=kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex);
|
const amount=kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex);
|
||||||
let account='';
|
result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,category:categoryName(data,item.categoryId),amount});
|
||||||
if(kind==='transfer')account=`${accountName(data,item.fromAccountId)} -> ${accountName(data,item.toAccountId)}`;
|
|
||||||
else account=accountName(data,item.accountId);
|
|
||||||
result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,category:categoryName(data,item.categoryId),account,amount,interval:INTERVALS[item.interval||'monthly']?.label||item.interval,endDate:item.endDate||'',id:item.id});
|
|
||||||
};
|
};
|
||||||
for(const item of data.incomes||[])if(inScope(item,'income',accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m);
|
for(const item of data.incomes||[])if(item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m);
|
||||||
for(const item of data.expenses||[])if(inScope(item,'expense',accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m);
|
for(const item of data.expenses||[])if(item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m);
|
||||||
for(const item of data.transfers||[])if(inScope(item,'transfer',accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
|
for(const item of data.transfers||[])if(item.fromAccountId===accountId||item.toAccountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
|
||||||
if(accountId==='all'||!accountId)push(item,'transfer',m,'neutral');
|
if(item.toAccountId===accountId)push(item,'transfer',m,'in');
|
||||||
else {if(item.toAccountId===accountId)push(item,'transfer',m,'in');if(item.fromAccountId===accountId)push(item,'transfer',m,'out');}
|
if(item.fromAccountId===accountId)push(item,'transfer',m,'out');
|
||||||
}
|
}
|
||||||
return result.sort((a,b)=>a.monthIndex-b.monthIndex||a.kind.localeCompare(b.kind,'de')||a.name.localeCompare(b.name,'de'));
|
const order={income:0,transferIn:1,expense:2,transferOut:3};
|
||||||
|
const typeOrder=r=>r.kind==='income'?order.income:r.kind==='expense'?order.expense:r.direction==='in'?order.transferIn:order.transferOut;
|
||||||
|
return result.sort((a,b)=>a.monthIndex-b.monthIndex||typeOrder(a)-typeOrder(b)||a.name.localeCompare(b.name,'de'));
|
||||||
}
|
}
|
||||||
|
function kindLabel(row){return row.kind==='income'?'Eingang':row.kind==='expense'?'Ausgabe':row.direction==='in'?'Transfer rein':'Transfer raus';}
|
||||||
|
function isInflow(row){return row.kind==='income'||row.direction==='in';}
|
||||||
|
|
||||||
function safeSheetName(name){return name.replace(/[\\/*?:\[\]]/g,' ').slice(0,31)||'Konto';}
|
|
||||||
function styleHeader(row){row.font={bold:true,color:{argb:'FFFFFFFF'}};row.fill={type:'pattern',pattern:'solid',fgColor:{argb:'FF263A67'}};row.alignment={vertical:'middle'};row.height=22;}
|
function styleHeader(row){row.font={bold:true,color:{argb:'FFFFFFFF'}};row.fill={type:'pattern',pattern:'solid',fgColor:{argb:'FF263A67'}};row.alignment={vertical:'middle'};row.height=22;}
|
||||||
function addTitle(sheet,title,subtitle=''){
|
function addTitle(sheet,title,subtitle=''){
|
||||||
sheet.mergeCells('A1:H1');const c=sheet.getCell('A1');c.value=title;c.font={bold:true,size:20,color:{argb:'FF17233B'}};c.alignment={vertical:'middle'};sheet.getRow(1).height=32;
|
sheet.mergeCells('A1:F1');const c=sheet.getCell('A1');c.value=title;c.font={bold:true,size:20,color:{argb:'FF17233B'}};c.alignment={vertical:'middle'};sheet.getRow(1).height=32;
|
||||||
if(subtitle){sheet.mergeCells('A2:H2');sheet.getCell('A2').value=subtitle;sheet.getCell('A2').font={italic:true,color:{argb:'FF667085'}};}
|
if(subtitle){sheet.mergeCells('A2:F2');sheet.getCell('A2').value=subtitle;sheet.getCell('A2').font={italic:true,color:{argb:'FF667085'}};}
|
||||||
}
|
}
|
||||||
function widths(sheet,widths){widths.forEach((w,i)=>sheet.getColumn(i+1).width=w);}
|
function widths(sheet,values){values.forEach((w,i)=>sheet.getColumn(i+1).width=w);}
|
||||||
function euroCell(cell){cell.numFmt='#,##0.00 [$€-de-DE]';}
|
function euroCell(cell){cell.numFmt='#,##0.00 [$€-de-DE]';}
|
||||||
|
|
||||||
export async function createExcelReport(data,{year,accountId='all'}={}){
|
export async function createExcelReport(data,{year,accountId}={}){
|
||||||
const reportYear=Number(year)||new Date().getFullYear();const forecast=buildAnnualForecast(data,reportYear,accountId);
|
const reportYear=Number(year)||new Date().getFullYear();
|
||||||
|
const account=data.accounts.find(a=>a.id===accountId); if(!account)throw new Error('Bitte ein gültiges Konto für den Kontoauszug auswählen.');
|
||||||
|
const forecast=buildAnnualForecast(data,reportYear,accountId); const rows=occurrenceRows(data,reportYear,accountId);
|
||||||
const wb=new ExcelJS.Workbook();wb.creator='FixFin';wb.created=new Date();wb.modified=new Date();
|
const wb=new ExcelJS.Workbook();wb.creator='FixFin';wb.created=new Date();wb.modified=new Date();
|
||||||
const scope=accountId==='all'?'Alle Konten':accountName(data,accountId);
|
|
||||||
const overview=wb.addWorksheet('Übersicht',{views:[{state:'frozen',ySplit:4}]});addTitle(overview,`FixFin Planungs-Auszug ${reportYear}`,`Umfang: ${scope} · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`);
|
|
||||||
overview.getRow(4).values=['Kennzahl','Wert'];overview.getRow(5).values=[forecast.isAll?'Einnahmen / Jahr':'Zuflüsse / Jahr',forecast.yearIncome];overview.getRow(6).values=[forecast.isAll?'Ausgaben / Jahr':'Abflüsse / Jahr',forecast.yearExpense];overview.getRow(7).values=[forecast.isAll?'Überschuss / Jahr':'Nettofluss / Jahr',forecast.yearIncome-forecast.yearExpense];styleHeader(overview.getRow(4));for(let r=5;r<=7;r++)euroCell(overview.getCell(r,2));
|
|
||||||
let row=10;overview.getCell(row,1).value='Kontenübersicht';overview.getCell(row,1).font={bold:true,size:14};row++;
|
|
||||||
overview.getRow(row).values=['Konto','Zuflüsse / Jahr','Abflüsse / Jahr','Nettofluss / Jahr'];styleHeader(overview.getRow(row));row++;
|
|
||||||
for(const account of scopeAccounts(data,accountId)){const f=buildAnnualForecast(data,reportYear,account.id);overview.getRow(row).values=[account.name,f.yearIncome,f.yearExpense,f.yearIncome-f.yearExpense];for(let c=2;c<=4;c++)euroCell(overview.getCell(row,c));row++;}
|
|
||||||
widths(overview,[28,20,20,20,14,14,14,14]);
|
|
||||||
|
|
||||||
const annual=wb.addWorksheet('Jahresplan',{views:[{state:'frozen',ySplit:4}]});addTitle(annual,`Jahresplan ${reportYear}`,forecast.isAll?'Wirtschaftliche Sicht: Transfers bleiben saldoneutral.':`Liquiditätssicht für ${scope}: Transfers werden als Zu- bzw. Abfluss berücksichtigt.`);
|
const statement=wb.addWorksheet('Kontoauszug',{views:[{state:'frozen',ySplit:4}]});
|
||||||
annual.getRow(4).values=['Monat',forecast.isAll?'Einnahmen':'Zuflüsse',forecast.isAll?'Ausgaben':'Abflüsse',forecast.isAll?'Überschuss':'Nettofluss','Echte Einnahmen','Echte Ausgaben','Transfers rein','Transfers raus'];styleHeader(annual.getRow(4));
|
addTitle(statement,`FixFin Kontoauszug ${reportYear}`,`${account.name} · Planungsdaten · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`);
|
||||||
forecast.rows.forEach((r,i)=>{const rr=i+5;annual.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance,r.income,r.expense,r.transferIn,r.transferOut];for(let c=2;c<=8;c++)euroCell(annual.getCell(rr,c));});widths(annual,[18,18,18,18,18,18,18,18]);
|
statement.getRow(4).values=['Monat','Art','Bezeichnung','Kategorie','Zufluss','Abfluss'];styleHeader(statement.getRow(4));
|
||||||
|
rows.forEach((r,i)=>{const rr=i+5;statement.getRow(rr).values=[r.month,kindLabel(r),r.name,r.category,isInflow(r)?r.amount:null,isInflow(r)?null:r.amount];euroCell(statement.getCell(rr,5));euroCell(statement.getCell(rr,6));if(isInflow(r))statement.getCell(rr,5).font={color:{argb:'FF067647'}};else statement.getCell(rr,6).font={color:{argb:'FFB42318'}};});
|
||||||
|
const totalRow=rows.length+6;statement.getCell(totalRow,1).value='Jahressumme';statement.getCell(totalRow,1).font={bold:true};statement.getCell(totalRow,5).value=forecast.yearIncome;statement.getCell(totalRow,6).value=forecast.yearExpense;euroCell(statement.getCell(totalRow,5));euroCell(statement.getCell(totalRow,6));statement.getCell(totalRow,5).font={bold:true,color:{argb:'FF067647'}};statement.getCell(totalRow,6).font={bold:true,color:{argb:'FFB42318'}};
|
||||||
|
statement.autoFilter={from:'A4',to:'F4'};widths(statement,[16,18,34,24,18,18]);
|
||||||
|
|
||||||
const warnings=reportWarnings(data,reportYear,accountId);
|
const summary=wb.addWorksheet('Monatsübersicht',{views:[{state:'frozen',ySplit:4}]});
|
||||||
const warningSheet=wb.addWorksheet('Hinweise');addTitle(warningSheet,`Planungshinweise ${reportYear}`,`Automatisch aus den hinterlegten Zu- und Abflüssen, Laufzeiten und Betragsänderungen abgeleitet.`);warningSheet.getRow(4).values=['Stufe','Hinweis','Details'];styleHeader(warningSheet.getRow(4));warnings.forEach((w,i)=>{warningSheet.getRow(i+5).values=[w.level,w.title,w.text];});if(!warnings.length)warningSheet.getRow(5).values=['OK','Keine auffälligen Planungshinweise','Für den gewählten Zeitraum wurden keine der definierten Warnbedingungen ausgelöst.'];widths(warningSheet,[14,42,74]);
|
addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · Liquiditätssicht inklusive Transfers`);
|
||||||
|
summary.getRow(4).values=['Monat','Zuflüsse','Abflüsse','Nettofluss'];styleHeader(summary.getRow(4));
|
||||||
|
forecast.rows.forEach((r,i)=>{const rr=i+5;summary.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance];for(let c=2;c<=4;c++)euroCell(summary.getCell(rr,c));summary.getCell(rr,4).font={color:{argb:r.balance<0?'FFB42318':'FF067647'}};});
|
||||||
|
summary.getRow(18).values=['Jahr',forecast.yearIncome,forecast.yearExpense,forecast.yearIncome-forecast.yearExpense];summary.getRow(18).font={bold:true};for(let c=2;c<=4;c++)euroCell(summary.getCell(18,c));summary.getCell(18,4).font={bold:true,color:{argb:forecast.yearIncome-forecast.yearExpense<0?'FFB42318':'FF067647'}};widths(summary,[20,20,20,20]);
|
||||||
|
|
||||||
const moves=wb.addWorksheet('Bewegungen',{views:[{state:'frozen',ySplit:4}]});addTitle(moves,`Geplante Bewegungen ${reportYear}`,`Alle im gewählten Umfang tatsächlich fälligen Positionen, aufbereitet wie ein Planungs-Kontoauszug.`);
|
|
||||||
moves.getRow(4).values=['Monat','Art','Bezeichnung','Kategorie','Konto / Route','Betrag','Intervall','Enddatum'];styleHeader(moves.getRow(4));
|
|
||||||
const kindLabel=r=>r.kind==='income'?'Eingang':r.kind==='expense'?'Ausgang':r.direction==='in'?'Transfer rein':r.direction==='out'?'Transfer raus':'Transfer';
|
|
||||||
occurrenceRows(data,reportYear,accountId).forEach((r,i)=>{const rr=i+5;moves.getRow(rr).values=[r.month,kindLabel(r),r.name,r.category,r.account,r.amount,r.interval,r.endDate];euroCell(moves.getCell(rr,6));});widths(moves,[14,16,30,22,34,16,18,14]);
|
|
||||||
|
|
||||||
const changes=wb.addWorksheet('Änderungen');addTitle(changes,'Geplante Betragsänderungen','Terminierte Änderungen an Eingängen und Ausgängen.');changes.getRow(4).values=['Art','Bezeichnung','Konto','Wirksam ab','Neuer Betrag','Enddatum'];styleHeader(changes.getRow(4));let cr=5;
|
|
||||||
for(const [kind,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]])for(const item of items)if(inScope(item,kind==='Eingang'?'income':'expense',accountId))for(const change of item.amountChanges||[]){changes.getRow(cr).values=[kind,item.name,accountName(data,item.accountId),change.effectiveFrom,Number(change.amount||0),item.endDate||''];euroCell(changes.getCell(cr,5));cr++;}widths(changes,[14,30,28,14,18,14]);
|
|
||||||
|
|
||||||
const master=wb.addWorksheet('Stammdaten');addTitle(master,'Stammdaten','Definitionen der geplanten Einnahmen, Ausgaben und Transfers.');master.getRow(4).values=['Art','Bezeichnung','Konto / Route','Kategorie','Basisbetrag','Intervall','Fälligkeitsmonat','Enddatum','Reminder'];styleHeader(master.getRow(4));let mr=5;
|
|
||||||
const addMaster=(kind,item,route)=>{master.getRow(mr).values=[kind,item.name,route,categoryName(data,item.categoryId),Number(item.amount||0),INTERVALS[item.interval||'monthly']?.label||item.interval,item.dueMonth?MONTHS[Number(item.dueMonth)-1]:'',item.endDate||'',item.pushoverReminder?'Ja':''];euroCell(master.getCell(mr,5));mr++;};
|
|
||||||
for(const x of data.incomes||[])if(inScope(x,'income',accountId))addMaster('Eingang',x,accountName(data,x.accountId));for(const x of data.expenses||[])if(inScope(x,'expense',accountId))addMaster('Ausgang',x,accountName(data,x.accountId));for(const x of data.transfers||[])if(inScope(x,'transfer',accountId))addMaster('Transfer',x,`${accountName(data,x.fromAccountId)} -> ${accountName(data,x.toAccountId)}`);widths(master,[14,30,34,22,16,18,18,14,12]);
|
|
||||||
|
|
||||||
const system=wb.addWorksheet('Konten & Kategorien');addTitle(system,'Konten, Kategorien & Einstellungen','Technische Zugangsdaten wie Pushover-Token werden bewusst nicht exportiert.');
|
|
||||||
system.getRow(4).values=['Einstellung','Wert'];styleHeader(system.getRow(4));system.getRow(5).values=['Periodische Posten im Monats-Saldo',data.settings?.includePeriodicInBalance===false?'Nein':'Ja'];
|
|
||||||
let sysRow=8;system.getCell(sysRow,1).value='Konten';system.getCell(sysRow,1).font={bold:true,size:14};sysRow++;system.getRow(sysRow).values=['Konto-ID','Kontoname'];styleHeader(system.getRow(sysRow));sysRow++;for(const account of scopeAccounts(data,accountId)){system.getRow(sysRow).values=[account.id,account.name];sysRow++;}
|
|
||||||
sysRow+=2;system.getCell(sysRow,1).value='Kategorien';system.getCell(sysRow,1).font={bold:true,size:14};sysRow++;system.getRow(sysRow).values=['Kategorie-ID','Kategoriename'];styleHeader(system.getRow(sysRow));sysRow++;for(const category of data.categories||[]){system.getRow(sysRow).values=[category.id,category.name];sysRow++;}widths(system,[42,42,18,18,18,18,18,18]);
|
|
||||||
|
|
||||||
const scenarios=wb.addWorksheet('Szenarien');addTitle(scenarios,'Szenarien','Was-wäre-wenn-Anpassungen aus FixFin.');scenarios.getRow(4).values=['Szenario','Art','Bezeichnung','Konto / Route','Betrag / Monat'];styleHeader(scenarios.getRow(4));let sr=5;for(const s of data.scenarios||[])for(const a of s.adjustments||[]){const relevant=accountId==='all'||(a.type==='transfer'?(a.fromAccountId===accountId||a.toAccountId===accountId):a.accountId===accountId);if(!relevant)continue;scenarios.getRow(sr).values=[s.name,a.type==='income'?'Eingang':a.type==='expense'?'Ausgang':'Transfer',a.name,a.type==='transfer'?`${accountName(data,a.fromAccountId)} -> ${accountName(data,a.toAccountId)}`:accountName(data,a.accountId),Number(a.amount||0)];euroCell(scenarios.getCell(sr,5));sr++;}widths(scenarios,[28,14,30,34,18]);
|
|
||||||
|
|
||||||
for(const [accountIndex,account] of scopeAccounts(data,accountId).entries()){
|
|
||||||
const f=buildAnnualForecast(data,reportYear,account.id);const sh=wb.addWorksheet(safeSheetName(`Konto ${account.name}`.slice(0,27)+` ${accountIndex+1}`),{views:[{state:'frozen',ySplit:4}]});addTitle(sh,account.name,`Liquiditäts-Auszug ${reportYear} · Zuflüsse inkl. Transfers rein, Abflüsse inkl. Transfers raus.`);sh.getRow(4).values=['Monat','Zuflüsse','Abflüsse','Nettofluss'];styleHeader(sh.getRow(4));f.rows.forEach((r,i)=>{const rr=i+5;sh.getRow(rr).values=[r.name,r.displayIncome,r.displayExpense,r.balance];for(let c=2;c<=4;c++)euroCell(sh.getCell(rr,c));});let rr=19;sh.getCell(rr,1).value='Einzelpositionen';sh.getCell(rr,1).font={bold:true,size:14};rr++;sh.getRow(rr).values=['Monat','Art','Bezeichnung','Kategorie','Betrag'];styleHeader(sh.getRow(rr));rr++;for(const r of occurrenceRows(data,reportYear,account.id)){sh.getRow(rr).values=[r.month,kindLabel(r),r.name,r.category,r.amount];euroCell(sh.getCell(rr,5));rr++;}widths(sh,[16,18,32,24,18]);
|
|
||||||
}
|
|
||||||
return wb.xlsx.writeBuffer();
|
return wb.xlsx.writeBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectPdfBuffer(doc){return new Promise((resolve,reject)=>{const chunks=[];doc.on('data',c=>chunks.push(c));doc.on('end',()=>resolve(Buffer.concat(chunks)));doc.on('error',reject);});}
|
function collectPdfBuffer(doc){return new Promise((resolve,reject)=>{const chunks=[];doc.on('data',c=>chunks.push(c));doc.on('end',()=>resolve(Buffer.concat(chunks)));doc.on('error',reject);});}
|
||||||
function pdfMoney(v){return money.format(Number(v||0));}
|
function pdfMoney(value){return money.format(Number(value||0));}
|
||||||
function drawPdfHeader(doc,title,subtitle){doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(20).text(title);doc.moveDown(.2).fillColor('#667085').font('Helvetica').fontSize(9).text(subtitle);doc.moveDown(.7);doc.strokeColor('#CCD5E1').moveTo(50,doc.y).lineTo(545,doc.y).stroke();doc.moveDown(.7);}
|
function drawPdfHeader(doc,title,subtitle){doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(20).text(title);doc.moveDown(.2).fillColor('#667085').font('Helvetica').fontSize(9).text(subtitle);doc.moveDown(.7);doc.strokeColor('#CCD5E1').moveTo(50,doc.y).lineTo(545,doc.y).stroke();doc.moveDown(.7);}
|
||||||
function ensurePdfSpace(doc,height=60){if(doc.y+height>770)doc.addPage();}
|
function addPageIfNeeded(doc,height=80){if(doc.y+height>760)doc.addPage();}
|
||||||
function pdfSection(doc,title,subtitle=''){ensurePdfSpace(doc,50);doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(14).text(title);if(subtitle)doc.fillColor('#667085').font('Helvetica').fontSize(8.5).text(subtitle);doc.moveDown(.45);}
|
function pdfMetricRow(doc,items){addPageIfNeeded(doc,48);const y=doc.y;const width=495/items.length;items.forEach((item,i)=>{const x=50+i*width;doc.roundedRect(x,y,width-8,40,6).fillAndStroke('#F4F6FA','#D6DCE7');doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(item.label,x+8,y+7,{width:width-24});doc.fillColor(item.negative?'#B42318':item.positive?'#067647':'#17233B').font('Helvetica-Bold').fontSize(11).text(item.value,x+8,y+20,{width:width-24});});doc.y=y+50;}
|
||||||
function pdfMetricRow(doc,items){ensurePdfSpace(doc,48);const y=doc.y;const width=495/items.length;items.forEach((x,i)=>{const x0=50+i*width;doc.roundedRect(x0,y,width-8,40,6).fillAndStroke('#F4F6FA','#D6DCE7');doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(x.label,x0+8,y+7,{width:width-24});doc.fillColor(x.negative?'#B42318':x.positive?'#067647':'#17233B').font('Helvetica-Bold').fontSize(11).text(x.value,x0+8,y+20,{width:width-24});});doc.y=y+50;}
|
function pdfTableHeader(doc,cols){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){ensurePdfSpace(doc,30);const y=doc.y;doc.rect(50,y,495,20).fill('#263A67');let x=50;for(const col of cols){doc.fillColor('#FFFFFF').font('Helvetica-Bold').fontSize(7.5).text(col.label,x+5,y+6,{width:col.width-10,align:col.align||'left'});x+=col.width;}doc.y=y+20;}
|
function 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 pdfTableRow(doc,cols,values,{bold=false,tone=null}={}){ensurePdfSpace(doc,24);const y=doc.y;doc.rect(50,y,495,21).fill('#FFFFFF');doc.strokeColor('#E4E7EC').moveTo(50,y+21).lineTo(545,y+21).stroke();let x=50;cols.forEach((col,i)=>{doc.fillColor(tone&&i===values.length-1?tone:'#344054').font(bold?'Helvetica-Bold':'Helvetica').fontSize(7.5).text(String(values[i]??''),x+5,y+6,{width:col.width-10,align:col.align||'left',ellipsis:true});x+=col.width;});doc.y=y+21;}
|
|
||||||
|
|
||||||
export async function createPdfReport(data,{year,accountId='all'}={}){
|
export async function createPdfReport(data,{year,accountId}={}){
|
||||||
const reportYear=Number(year)||new Date().getFullYear();const scope=accountId==='all'?'Alle Konten':accountName(data,accountId);const forecast=buildAnnualForecast(data,reportYear,accountId);
|
const reportYear=Number(year)||new Date().getFullYear();
|
||||||
const doc=new PDFDocument({size:'A4',margin:50,info:{Title:`FixFin Planungs-Auszug ${reportYear}`,Author:'FixFin'}});const done=collectPdfBuffer(doc);
|
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.');
|
||||||
drawPdfHeader(doc,`FixFin Planungs-Auszug ${reportYear}`,`Umfang: ${scope} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())} · Planungsdaten, kein Bankkontoauszug`);
|
const forecast=buildAnnualForecast(data,reportYear,accountId);const rows=occurrenceRows(data,reportYear,accountId);
|
||||||
pdfMetricRow(doc,[{label:forecast.isAll?'Einnahmen / Jahr':'Zuflüsse / Jahr',value:pdfMoney(forecast.yearIncome),positive:true},{label:forecast.isAll?'Ausgaben / Jahr':'Abflüsse / Jahr',value:pdfMoney(forecast.yearExpense),negative:true},{label:forecast.isAll?'Überschuss / Jahr':'Nettofluss / Jahr',value:pdfMoney(forecast.yearIncome-forecast.yearExpense),positive:forecast.yearIncome>=forecast.yearExpense,negative:forecast.yearIncome<forecast.yearExpense}]);
|
const doc=new PDFDocument({size:'A4',margin:50,info:{Title:`FixFin Kontoauszug ${reportYear} - ${account.name}`,Author:'FixFin'}});const done=collectPdfBuffer(doc);
|
||||||
const warnings=reportWarnings(data,reportYear,accountId);if(warnings.length){pdfSection(doc,'Planungshinweise');for(const warning of warnings.slice(0,8)){ensurePdfSpace(doc,30);doc.fillColor(warning.level==='Achtung'?'#B42318':'#17233B').font('Helvetica-Bold').fontSize(8.5).text(`${warning.level}: ${warning.title}`);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(warning.text);doc.moveDown(.3);}}
|
drawPdfHeader(doc,`FixFin Kontoauszug ${reportYear}`,`${account.name} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`);
|
||||||
pdfSection(doc,'Jahresübersicht',forecast.isAll?'Wirtschaftliche Sicht. Transfers werden nicht als Einnahmen oder Ausgaben gezählt.':'Liquiditätssicht. Transfers rein zählen als Zufluss, Transfers raus als Abfluss.');
|
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 mcols=[{label:'Monat',width:125},{label:forecast.isAll?'Einnahmen':'Zuflüsse',width:120,align:'right'},{label:forecast.isAll?'Ausgaben':'Abflüsse',width:120,align:'right'},{label:forecast.isAll?'Überschuss':'Nettofluss',width:130,align:'right'}];pdfTableHeader(doc,mcols);for(const r of forecast.rows)pdfTableRow(doc,mcols,[r.name,pdfMoney(r.displayIncome),pdfMoney(r.displayExpense),pdfMoney(r.balance)],{tone:r.balance<0?'#B42318':'#067647'});
|
|
||||||
|
|
||||||
for(const account of scopeAccounts(data,accountId)){
|
const cols=[{label:'Art',width:88},{label:'Bezeichnung',width:167},{label:'Kategorie',width:105},{label:'Zufluss',width:68,align:'right'},{label:'Abfluss',width:67,align:'right'}];
|
||||||
doc.addPage();const f=buildAnnualForecast(data,reportYear,account.id);drawPdfHeader(doc,account.name,`Liquiditäts-Auszug ${reportYear} · Geplante Kontobewegungen`);pdfMetricRow(doc,[{label:'Zuflüsse',value:pdfMoney(f.yearIncome),positive:true},{label:'Abflüsse',value:pdfMoney(f.yearExpense),negative:true},{label:'Nettofluss',value:pdfMoney(f.yearIncome-f.yearExpense),positive:f.yearIncome>=f.yearExpense,negative:f.yearIncome<f.yearExpense}]);
|
for(let monthIndex=0;monthIndex<12;monthIndex++){
|
||||||
const accountCols=[{label:'Monat',width:125},{label:'Zuflüsse',width:120,align:'right'},{label:'Abflüsse',width:120,align:'right'},{label:'Nettofluss',width:130,align:'right'}];pdfTableHeader(doc,accountCols);for(const r of f.rows)pdfTableRow(doc,accountCols,[r.name,pdfMoney(r.displayIncome),pdfMoney(r.displayExpense),pdfMoney(r.balance)],{tone:r.balance<0?'#B42318':'#067647'});
|
const monthRows=rows.filter(r=>r.monthIndex===monthIndex);const month=forecast.rows[monthIndex];
|
||||||
pdfSection(doc,'Einzelpositionen','Echte Ein-/Ausgaben und interne Transfers bleiben getrennt gekennzeichnet.');
|
addPageIfNeeded(doc,115);doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(14).text(month.name);doc.moveDown(.35);
|
||||||
const rows=occurrenceRows(data,reportYear,account.id);const dcols=[{label:'Monat',width:72},{label:'Art',width:82},{label:'Bezeichnung',width:166},{label:'Kategorie',width:100},{label:'Betrag',width:75,align:'right'}];pdfTableHeader(doc,dcols);const kindLabel=r=>r.kind==='income'?'Eingang':r.kind==='expense'?'Ausgang':r.direction==='in'?'Transfer rein':'Transfer raus';for(const r of rows)pdfTableRow(doc,dcols,[r.month.slice(0,3),kindLabel(r),r.name,r.category,pdfMoney(r.amount)],{tone:r.kind==='income'||r.direction==='in'?'#067647':'#B42318'});
|
pdfMetricRow(doc,[{label:'Zuflüsse',value:pdfMoney(month.displayIncome),positive:true},{label:'Abflüsse',value:pdfMoney(month.displayExpense),negative:true},{label:'Nettofluss',value:pdfMoney(month.balance),positive:month.balance>=0,negative:month.balance<0}]);
|
||||||
|
if(!monthRows.length){doc.fillColor('#667085').font('Helvetica').fontSize(8).text('Keine geplanten Kontobewegungen.');doc.moveDown(.8);continue;}
|
||||||
|
pdfTableHeader(doc,cols);
|
||||||
|
for(const row of monthRows){
|
||||||
|
if(doc.y+22>760){doc.addPage();drawPdfHeader(doc,`Kontoauszug ${reportYear}`,`${account.name} · ${month.name} · Fortsetzung`);pdfTableHeader(doc,cols);}
|
||||||
|
const inflow=isInflow(row);drawTableRow(doc,cols,[kindLabel(row),row.name,row.category,inflow?pdfMoney(row.amount):'',inflow?'':pdfMoney(row.amount)],{toneCols:inflow?{3:'#067647'}:{4:'#B42318'}});
|
||||||
}
|
}
|
||||||
doc.addPage();drawPdfHeader(doc,'Stammdaten & Termine',`${scope} - Definitionen, Laufzeiten und geplante Betragsänderungen`);
|
doc.moveDown(.8);
|
||||||
pdfSection(doc,'Konten & Einstellungen');doc.fillColor('#344054').font('Helvetica').fontSize(8).text(`Periodische Posten im Monats-Saldo: ${data.settings?.includePeriodicInBalance===false?'Nein':'Ja'}`);doc.moveDown(.35);for(const account of scopeAccounts(data,accountId)){doc.fillColor('#344054').font('Helvetica').fontSize(8).text(`Konto: ${account.name} (${account.id})`);}doc.moveDown(.5);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text('Pushover-Zugangsdaten werden aus Sicherheitsgründen nicht in Berichte exportiert.');doc.moveDown(.6);
|
|
||||||
const defs=[];
|
|
||||||
for(const x of data.incomes||[])if(inScope(x,'income',accountId))defs.push({kind:'Eingang',item:x,route:accountName(data,x.accountId)});
|
|
||||||
for(const x of data.expenses||[])if(inScope(x,'expense',accountId))defs.push({kind:'Ausgang',item:x,route:accountName(data,x.accountId)});
|
|
||||||
for(const x of data.transfers||[])if(inScope(x,'transfer',accountId))defs.push({kind:'Transfer',item:x,route:`${accountName(data,x.fromAccountId)} -> ${accountName(data,x.toAccountId)}`});
|
|
||||||
for(const def of defs){ensurePdfSpace(doc,46);doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(9).text(`${def.kind} - ${def.item.name}`);const due=def.item.interval==='monthly'?'monatlich':def.item.dueMonth?`${INTERVALS[def.item.interval]?.label||def.item.interval}, ab ${MONTHS[Number(def.item.dueMonth)-1]}`:(INTERVALS[def.item.interval]?.label||def.item.interval);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(`${def.route} | ${categoryName(data,def.item.categoryId)} | Basis ${pdfMoney(def.item.amount)} | ${due}${def.item.endDate?` | Ende ${def.item.endDate}`:''}${def.item.pushoverReminder?' | Reminder am 1.':''}`);for(const change of def.item.amountChanges||[]){doc.fillColor('#344054').font('Helvetica').fontSize(7.5).text(` Änderung ab ${change.effectiveFrom}: ${pdfMoney(change.amount)}`);}doc.moveDown(.45);}
|
|
||||||
if((data.categories||[]).length){pdfSection(doc,'Kategorien');doc.fillColor('#344054').font('Helvetica').fontSize(8).text(data.categories.map(c=>c.name).join(' | '));}
|
|
||||||
|
|
||||||
if((data.scenarios||[]).length){doc.addPage();drawPdfHeader(doc,'Szenarien',`Was-wäre-wenn-Planungen - ${scope}`);for(const s of data.scenarios||[]){const adjustments=(s.adjustments||[]).filter(a=>accountId==='all'||(a.type==='transfer'?(a.fromAccountId===accountId||a.toAccountId===accountId):a.accountId===accountId));if(!adjustments.length)continue;pdfSection(doc,s.name,`${adjustments.length} Anpassungen`);for(const a of adjustments){ensurePdfSpace(doc,28);const route=a.type==='transfer'?`${accountName(data,a.fromAccountId)} -> ${accountName(data,a.toAccountId)}`:accountName(data,a.accountId);doc.fillColor('#344054').font('Helvetica-Bold').fontSize(8.5).text(`${a.type==='income'?'Eingang':a.type==='expense'?'Ausgang':'Transfer'} · ${a.name}`);doc.fillColor('#667085').font('Helvetica').fontSize(7.5).text(`${route} · ${pdfMoney(a.amount)} / Monat`);doc.moveDown(.35);}}
|
|
||||||
}
|
}
|
||||||
|
addPageIfNeeded(doc,40);doc.strokeColor('#D6DCE7').moveTo(50,doc.y).lineTo(545,doc.y).stroke();doc.moveDown(.45);doc.fillColor('#667085').font('Helvetica').fontSize(7).text('Erstellt aus FixFin-Planungsdaten. Kein von einer Bank ausgestellter Kontoauszug.');
|
||||||
doc.end();return done;
|
doc.end();return done;
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-11
@@ -220,13 +220,54 @@ function Icon({ name }) {
|
|||||||
flask:<><path d="M9 3h6"/><path d="M10 3v6l-5 8a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-8V3"/><path d="M8 14h8"/></>,
|
flask:<><path d="M9 3h6"/><path d="M10 3v6l-5 8a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-8V3"/><path d="M8 14h8"/></>,
|
||||||
planning:<><path d="M4 19V9"/><path d="M10 19V5"/><path d="M16 19v-7"/><path d="M22 19H2"/><path d="m4 7 6-4 6 6 5-4"/></>,
|
planning:<><path d="M4 19V9"/><path d="M10 19V5"/><path d="M16 19v-7"/><path d="M22 19H2"/><path d="m4 7 6-4 6 6 5-4"/></>,
|
||||||
data:<><path d="M12 3c5 0 9 1.3 9 3s-4 3-9 3-9-1.3-9-3 4-3 9-3Z"/><path d="M3 6v6c0 1.7 4 3 9 3s9-1.3 9-3V6"/><path d="M3 12v6c0 1.7 4 3 9 3s9-1.3 9-3v-6"/></>,
|
data:<><path d="M12 3c5 0 9 1.3 9 3s-4 3-9 3-9-1.3-9-3 4-3 9-3Z"/><path d="M3 6v6c0 1.7 4 3 9 3s9-1.3 9-3V6"/><path d="M3 12v6c0 1.7 4 3 9 3s9-1.3 9-3v-6"/></>,
|
||||||
|
statement:<><path d="M6 2h9l4 4v16H6z"/><path d="M14 2v5h5"/><path d="M9 12h7M9 16h7"/></>,
|
||||||
|
more:<><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
|
||||||
};
|
};
|
||||||
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>;
|
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_META={
|
||||||
|
dashboard:{label:'Übersicht',description:'Monatliche Salden und die wichtigsten Hinweise auf einen Blick.'},
|
||||||
|
movements:{label:'Bewegungen',description:'Eingänge, Ausgänge und interne Transfers verwalten.'},
|
||||||
|
planning:{label:'Planung',description:'Kontobedarf und Rücklagen für die laufende Planung.'},
|
||||||
|
year:{label:'Jahr',description:'Jahresplanung zwischen wirtschaftlicher Sicht und Kontoliquidität.'},
|
||||||
|
stats:{label:'Statistik',description:'Kategorien, Verteilungen und Kennzahlen aus der Planung.'},
|
||||||
|
statements:{label:'Auszüge',description:'Schlanke Kontoauszüge aus den geplanten Kontobewegungen.'},
|
||||||
|
accounts:{label:'Konten',description:'Konten für Einnahmen, Ausgaben und Transfers verwalten.'},
|
||||||
|
scenarios:{label:'Szenarien',description:'Was-wäre-wenn-Varianten ohne Änderung der echten Planung.'},
|
||||||
|
data:{label:'Daten',description:'Backup, Import und Pushover-Einstellungen.'},
|
||||||
|
};
|
||||||
|
const NAV_GROUPS={
|
||||||
|
analytics:{label:'Auswertung',items:[
|
||||||
|
{tab:'year',icon:'calendar',label:'Jahr',description:'Jahresplan & Liquidität'},
|
||||||
|
{tab:'stats',icon:'chart',label:'Statistik',description:'Kategorien & Kennzahlen'},
|
||||||
|
{tab:'statements',icon:'statement',label:'Auszüge',description:'PDF- & Excel-Kontoauszüge'},
|
||||||
|
]},
|
||||||
|
more:{label:'Mehr',items:[
|
||||||
|
{tab:'accounts',icon:'wallet',label:'Konten',description:'Konten verwalten'},
|
||||||
|
{tab:'scenarios',icon:'flask',label:'Szenarien',description:'Was-wäre-wenn-Planungen'},
|
||||||
|
{tab:'data',icon:'data',label:'Daten',description:'Backup, Import & Pushover'},
|
||||||
|
]},
|
||||||
|
};
|
||||||
|
const MAIN_NAV=[
|
||||||
|
{tab:'dashboard',icon:'dashboard',label:'Übersicht'},
|
||||||
|
{tab:'movements',icon:'transfer',label:'Bewegungen'},
|
||||||
|
{tab:'planning',icon:'planning',label:'Planung'},
|
||||||
|
{menu:'analytics',icon:'chart',label:'Auswertung'},
|
||||||
|
{menu:'more',icon:'more',label:'Mehr'},
|
||||||
|
];
|
||||||
|
|
||||||
|
function NavGroupMenu({group,tab,onClose,onSelect}){
|
||||||
|
const config=NAV_GROUPS[group];
|
||||||
|
useEffect(()=>{const key=e=>e.key==='Escape'&&onClose();window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[onClose]);
|
||||||
|
if(!config)return null;
|
||||||
|
return <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>;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [data,setData]=useState(emptyData);
|
const [data,setData]=useState(emptyData);
|
||||||
const [tab,setTab]=useState('dashboard');
|
const [tab,setTab]=useState('dashboard');
|
||||||
|
const [navMenu,setNavMenu]=useState(null);
|
||||||
const [loading,setLoading]=useState(true);
|
const [loading,setLoading]=useState(true);
|
||||||
const [saving,setSaving]=useState(false);
|
const [saving,setSaving]=useState(false);
|
||||||
const [error,setError]=useState('');
|
const [error,setError]=useState('');
|
||||||
@@ -237,14 +278,17 @@ function App() {
|
|||||||
function removeAccount(id){const used=data.incomes.some(x=>x.accountId===id)||data.expenses.some(x=>x.accountId===id)||data.transfers.some(x=>x.fromAccountId===id||x.toAccountId===id)||(data.scenarios||[]).some(s=>(s.adjustments||[]).some(x=>x.accountId===id||x.fromAccountId===id||x.toAccountId===id));if(used)return setError('Das Konto wird noch verwendet. Einträge, Transfers oder Szenario-Anpassungen bitte zuerst löschen.');if(confirm('Konto wirklich löschen?'))persist({...data,accounts:data.accounts.filter(x=>x.id!==id)});}
|
function removeAccount(id){const used=data.incomes.some(x=>x.accountId===id)||data.expenses.some(x=>x.accountId===id)||data.transfers.some(x=>x.fromAccountId===id||x.toAccountId===id)||(data.scenarios||[]).some(s=>(s.adjustments||[]).some(x=>x.accountId===id||x.fromAccountId===id||x.toAccountId===id));if(used)return setError('Das Konto wird noch verwendet. Einträge, Transfers oder Szenario-Anpassungen bitte zuerst löschen.');if(confirm('Konto wirklich löschen?'))persist({...data,accounts:data.accounts.filter(x=>x.id!==id)});}
|
||||||
function removeItem(type,id){if(confirm('Eintrag wirklich löschen?'))persist({...data,[type]:data[type].filter(x=>x.id!==id)});}
|
function removeItem(type,id){if(confirm('Eintrag wirklich löschen?'))persist({...data,[type]:data[type].filter(x=>x.id!==id)});}
|
||||||
function removeCategory(id){const used=[...data.incomes,...data.expenses,...data.transfers].filter(x=>x.categoryId===id).length;const message=used?`Kategorie wird bei ${used} ${used===1?'Eintrag':'Einträgen'} entfernt. Kategorie wirklich löschen?`:'Kategorie wirklich löschen?';if(!confirm(message))return;persist({...data,categories:data.categories.filter(x=>x.id!==id),incomes:data.incomes.map(x=>x.categoryId===id?{...x,categoryId:null}:x),expenses:data.expenses.map(x=>x.categoryId===id?{...x,categoryId:null}:x),transfers:data.transfers.map(x=>x.categoryId===id?{...x,categoryId:null}:x),scenarios:(data.scenarios||[]).map(s=>({...s,adjustments:(s.adjustments||[]).map(x=>x.categoryId===id?{...x,categoryId:null}:x)}))});}
|
function removeCategory(id){const used=[...data.incomes,...data.expenses,...data.transfers].filter(x=>x.categoryId===id).length;const message=used?`Kategorie wird bei ${used} ${used===1?'Eintrag':'Einträgen'} entfernt. Kategorie wirklich löschen?`:'Kategorie wirklich löschen?';if(!confirm(message))return;persist({...data,categories:data.categories.filter(x=>x.id!==id),incomes:data.incomes.map(x=>x.categoryId===id?{...x,categoryId:null}:x),expenses:data.expenses.map(x=>x.categoryId===id?{...x,categoryId:null}:x),transfers:data.transfers.map(x=>x.categoryId===id?{...x,categoryId:null}:x),scenarios:(data.scenarios||[]).map(s=>({...s,adjustments:(s.adjustments||[]).map(x=>x.categoryId===id?{...x,categoryId:null}:x)}))});}
|
||||||
async function importFile(file){try{await persist(JSON.parse(await file.text()));setTab('dashboard');}catch(e){setError(`Import fehlgeschlagen: ${e.message}`);}}
|
async function importFile(file){try{await persist(JSON.parse(await file.text()));selectTab('dashboard');}catch(e){setError(`Import fehlgeschlagen: ${e.message}`);}}
|
||||||
async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});}
|
async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});}
|
||||||
|
function selectTab(next){setTab(next);setNavMenu(null);}
|
||||||
|
function mainActive(item){if(item.tab)return tab===item.tab;return NAV_GROUPS[item.menu]?.items.some(x=>x.tab===tab);}
|
||||||
|
function activateMain(item){if(item.tab)selectTab(item.tab);else setNavMenu(current=>current===item.menu?null:item.menu);}
|
||||||
if(loading)return <div className="loading">FixFin wird geladen …</div>;
|
if(loading)return <div className="loading">FixFin wird geladen …</div>;
|
||||||
|
const meta=PAGE_META[tab]||PAGE_META.dashboard;
|
||||||
|
|
||||||
const nav=[['dashboard','dashboard','Übersicht'],['accounts','wallet','Konten'],['movements','transfer','Bewegungen'],['year','calendar','Jahr'],['planning','planning','Planung'],['stats','chart','Statistik'],['scenarios','flask','Szenarien'],['data','data','Daten']];
|
|
||||||
return <div className="app-shell">
|
return <div className="app-shell">
|
||||||
<aside className="sidebar"><div className="brand"><div className="brand-mark">F</div><div><strong>FixFin</strong><span>Fixkostenplanung</span></div></div><nav>{nav.map(([id,icon,label])=><button key={id} className={tab===id?'active':''} onClick={()=>setTab(id)}><Icon name={icon}/><span>{label}</span></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>{nav.find(x=>x[0]===tab)?.[2]}</h1><p>Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.</p></div>{tab==='accounts'&&<button className="primary" onClick={()=>setDialog({type:'accounts'})}>+ Konto</button>}</header>
|
<main><header className="topbar"><div><h1>{meta.label}</h1><p>{meta.description}</p></div>{tab==='accounts'&&<button className="primary" onClick={()=>setDialog({type:'accounts'})}>+ Konto</button>}</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}/>}
|
||||||
{tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>}
|
{tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>}
|
||||||
@@ -252,10 +296,12 @@ function App() {
|
|||||||
{tab==='year'&&<YearPage data={data}/>}
|
{tab==='year'&&<YearPage data={data}/>}
|
||||||
{tab==='planning'&&<PlanningPage data={data} totals={totals}/>}
|
{tab==='planning'&&<PlanningPage data={data} totals={totals}/>}
|
||||||
{tab==='stats'&&<StatisticsPage data={data} onAddCategory={()=>setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>}
|
{tab==='stats'&&<StatisticsPage data={data} onAddCategory={()=>setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>}
|
||||||
|
{tab==='statements'&&<StatementsPage data={data}/>}
|
||||||
{tab==='scenarios'&&<ScenariosPage data={data} persist={persist}/>}
|
{tab==='scenarios'&&<ScenariosPage data={data} persist={persist}/>}
|
||||||
{tab==='data'&&<DataPage data={data} importFile={importFile}/>}
|
{tab==='data'&&<DataPage data={data} importFile={importFile}/>}
|
||||||
</main>
|
</main>
|
||||||
<nav className="mobile-nav">{nav.map(([id,icon,label])=><button key={id} className={tab===id?'active':''} onClick={()=>setTab(id)}><Icon name={icon}/><span>{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>
|
||||||
|
{navMenu&&<NavGroupMenu group={navMenu} tab={tab} onClose={()=>setNavMenu(null)} onSelect={selectTab}/>}
|
||||||
{dialog&&<EditorDialog dialog={dialog} data={data} onClose={()=>setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>}
|
{dialog&&<EditorDialog dialog={dialog} data={data} onClose={()=>setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
@@ -534,13 +580,14 @@ function PushoverSettings(){
|
|||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportExports({data}){
|
function StatementsPage({data}){
|
||||||
const currentYear=new Date().getFullYear();
|
const currentYear=new Date().getFullYear();
|
||||||
const [year,setYear]=useState(currentYear);
|
const [year,setYear]=useState(currentYear);
|
||||||
const [accountId,setAccountId]=useState('all');
|
const [accountId,setAccountId]=useState(data.accounts[0]?.id||'');
|
||||||
const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(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]);
|
||||||
const scope=accountId==='all'?'Alle Konten':accountName(data,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>;
|
||||||
return <section className="panel report-export-panel"><div className="section-head"><div><h2>Auszüge & Berichte</h2><p>Aufbereitete Planungsdaten als PDF oder Excel. Die Kontosicht enthält echte Ein-/Ausgänge und Transfers als getrennte Zahlungsströme.</p></div><span className="report-badge">PDF · XLSX</span></div><div className="report-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Umfang</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></div><div className="report-preview"><div><span>Auszug</span><strong>{scope} · {year}</strong></div><p>PDF: druckfertiger Planungs-Auszug im Kontoauszug-Stil. Excel: Übersicht, Planungshinweise, Jahresplan, Bewegungen, Änderungen, Stammdaten, Konten/Kategorien, Szenarien und Konto-Blätter.</p></div><div className="button-row report-actions"><a className="primary button-link" href={`/api/report.pdf?${query}`}>PDF herunterladen</a><a className="secondary button-link" href={`/api/report.xlsx?${query}`}>Excel herunterladen</a></div><p className="report-disclaimer">Planungs-Auszug aus FixFin, kein von einer Bank ausgestellter Kontoauszug.</p></section>;
|
const account=accountName(data,accountId);const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}`;
|
||||||
|
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></div><div className="report-preview"><div><span>Kontoauszug</span><strong>{account} · {year}</strong></div><p>PDF: Jahreswerte und monatlich gruppierte Kontobewegungen. 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}){
|
||||||
@@ -554,7 +601,6 @@ function DataPage({data,importFile}){
|
|||||||
];
|
];
|
||||||
const json=JSON.stringify(data,null,2);
|
const json=JSON.stringify(data,null,2);
|
||||||
return <div className="content-stack data-page">
|
return <div className="content-stack data-page">
|
||||||
<ReportExports data={data}/>
|
|
||||||
<PushoverSettings/>
|
<PushoverSettings/>
|
||||||
<section className="panel data-backup-panel"><div className="section-head"><div><h2>Daten sichern</h2><p>Alle Daten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row data-actions"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section>
|
<section className="panel data-backup-panel"><div className="section-head"><div><h2>Daten sichern</h2><p>Alle Daten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row data-actions"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section>
|
||||||
<section className="panel data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div>
|
<section className="panel data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div>
|
||||||
|
|||||||
@@ -963,3 +963,39 @@ code { color:#b8c3ff; }
|
|||||||
.amount-change-add { grid-template-columns:1fr; }
|
.amount-change-add { grid-template-columns:1fr; }
|
||||||
.amount-change-add button { grid-column:auto; }
|
.amount-change-add button { grid-column:auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v3.17: reduzierte Hauptnavigation + Kontoauszüge */
|
||||||
|
.sidebar nav button { position:relative; }
|
||||||
|
.nav-group-caret { margin-left:auto; color:#66758a; font-size:18px; line-height:1; }
|
||||||
|
.sidebar nav button.active .nav-group-caret { color:#a7b2ff; }
|
||||||
|
|
||||||
|
.nav-menu-backdrop { position:fixed; inset:0; z-index:70; background:#02050a80; backdrop-filter:blur(3px); }
|
||||||
|
.nav-menu-sheet { position:fixed; left:270px; top:132px; width:min(360px,calc(100vw - 300px)); background:#111722; border:1px solid #334158; border-radius:18px; padding:15px; box-shadow:0 28px 90px #000a; }
|
||||||
|
.nav-menu-head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:2px 3px 11px; border-bottom:1px solid #253145; }
|
||||||
|
.nav-menu-head div > span { display:block; color:#718096; text-transform:uppercase; letter-spacing:.08em; font-size:8px; font-weight:850; margin-bottom:2px; }
|
||||||
|
.nav-menu-head div > strong { display:block; font-size:18px; }
|
||||||
|
.nav-menu-items { display:grid; gap:7px; margin-top:10px; }
|
||||||
|
.nav-menu-items button { width:100%; display:grid; grid-template-columns:38px minmax(0,1fr) 20px; align-items:center; gap:10px; border:1px solid transparent; background:#0d131c; color:var(--text); border-radius:12px; padding:10px; text-align:left; }
|
||||||
|
.nav-menu-items button:hover { background:#151e2b; border-color:#30405a; }
|
||||||
|
.nav-menu-items button.active { background:#18213a; border-color:#4b5fa6; }
|
||||||
|
.nav-menu-icon { width:36px; height:36px; display:grid; place-items:center; border-radius:10px; background:#182130; color:#9ba9ff; }
|
||||||
|
.nav-menu-icon svg { width:19px; height:19px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }
|
||||||
|
.nav-menu-copy { min-width:0; }
|
||||||
|
.nav-menu-copy strong { display:block; font-size:13px; margin-bottom:2px; }
|
||||||
|
.nav-menu-copy small { display:block; color:var(--muted); font-size:9px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.nav-menu-arrow { color:#718096; font-size:20px; text-align:right; }
|
||||||
|
|
||||||
|
.statements-page { max-width:820px; }
|
||||||
|
.statements-page .report-export-panel { border-color:#35466a; }
|
||||||
|
|
||||||
|
@media (max-width:760px) {
|
||||||
|
.mobile-nav { grid-template-columns:repeat(5,minmax(0,1fr)) !important; overflow-x:hidden !important; }
|
||||||
|
.mobile-nav button { min-width:0 !important; font-size:9px !important; padding:6px 2px !important; }
|
||||||
|
.mobile-nav svg { width:19px !important; height:19px !important; }
|
||||||
|
.nav-menu-sheet { left:12px; right:12px; top:auto; bottom:calc(72px + env(safe-area-inset-bottom)); width:auto; padding:13px; border-radius:17px; }
|
||||||
|
.nav-menu-backdrop { background:#02050aa8; }
|
||||||
|
.nav-menu-items button { grid-template-columns:40px minmax(0,1fr) 20px; padding:11px; }
|
||||||
|
.nav-menu-copy strong { font-size:14px; }
|
||||||
|
.nav-menu-copy small { font-size:10px; }
|
||||||
|
.statements-page { max-width:none; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user