This commit is contained in:
2026-08-19 13:30:10 +02:00
parent 4012170172
commit 51d61c1255
6 changed files with 140 additions and 88 deletions
+32 -27
View File
@@ -21,12 +21,14 @@ function effectiveAmount(item,year,monthIndex){
}
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 personName(data,id){return data.persons?.find(p=>p.id===id)?.name||'Ohne Person';}
export function buildAnnualForecast(data,year,accountId){
export function buildAnnualForecast(data,year,accountId,personId='all'){
const personMatches=item=>personId==='all'||item.personId===personId;
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(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(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(item.fromAccountId===accountId||item.toAccountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){
for(const item of data.incomes||[]) if(personMatches(item)&&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(personMatches(item)&&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(personMatches(item)&&(item.fromAccountId===accountId||item.toAccountId===accountId)) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){
const amount=Number(item.amount||0);
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});}
@@ -35,15 +37,16 @@ export function buildAnnualForecast(data,year,accountId){
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 occurrenceRows(data,year,accountId,personId='all'){
const personMatches=item=>personId==='all'||item.personId===personId;
const result=[];
const push=(item,kind,monthIndex,direction='')=>{
const amount=kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex);
result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,category:categoryName(data,item.categoryId),amount});
result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,person:personName(data,item.personId),category:categoryName(data,item.categoryId),amount});
};
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(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(item.fromAccountId===accountId||item.toAccountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
for(const item of data.incomes||[])if(personMatches(item)&&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(personMatches(item)&&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(personMatches(item)&&(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');
if(item.fromAccountId===accountId)push(item,'transfer',m,'out');
}
@@ -55,28 +58,29 @@ function kindLabel(row){return row.kind==='income'?'Eingang':row.kind==='expense
function isInflow(row){return row.kind==='income'||row.direction==='in';}
function styleHeader(row){row.font={bold:true,color:{argb:'FFFFFFFF'}};row.fill={type:'pattern',pattern:'solid',fgColor:{argb:'FF263A67'}};row.alignment={vertical:'middle'};row.height=22;}
function addTitle(sheet,title,subtitle=''){
sheet.mergeCells('A1: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:F2');sheet.getCell('A2').value=subtitle;sheet.getCell('A2').font={italic:true,color:{argb:'FF667085'}};}
function addTitle(sheet,title,subtitle='',lastColumn='F'){
sheet.mergeCells(`A1:${lastColumn}1`);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:${lastColumn}2`);sheet.getCell('A2').value=subtitle;sheet.getCell('A2').font={italic:true,color:{argb:'FF667085'}};}
}
function widths(sheet,values){values.forEach((w,i)=>sheet.getColumn(i+1).width=w);}
function euroCell(cell){cell.numFmt='#,##0.00 [$€-de-DE]';}
export async function createExcelReport(data,{year,accountId}={}){
export async function createExcelReport(data,{year,accountId,personId='all'}={}){
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 selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);
const forecast=buildAnnualForecast(data,reportYear,accountId,personId); const rows=occurrenceRows(data,reportYear,accountId,personId);
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}]});
addTitle(statement,`FixFin Kontoauszug ${reportYear}`,`${account.name} · Planungsdaten · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`);
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]);
addTitle(statement,`FixFin Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · Planungsdaten · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`,'G');
statement.getRow(4).values=['Monat','Art','Bezeichnung','Person','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.person,r.category,isInflow(r)?r.amount:null,isInflow(r)?null:r.amount];euroCell(statement.getCell(rr,6));euroCell(statement.getCell(rr,7));if(isInflow(r))statement.getCell(rr,6).font={color:{argb:'FF067647'}};else statement.getCell(rr,7).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,6).value=forecast.yearIncome;statement.getCell(totalRow,7).value=forecast.yearExpense;euroCell(statement.getCell(totalRow,6));euroCell(statement.getCell(totalRow,7));statement.getCell(totalRow,6).font={bold:true,color:{argb:'FF067647'}};statement.getCell(totalRow,7).font={bold:true,color:{argb:'FFB42318'}};
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}]});
addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · Liquiditätssicht inklusive Transfers`);
addTitle(summary,`Monatsübersicht ${reportYear}`,`${account.name} · ${selectedPerson} · Liquiditätssicht inklusive Transfers`,'D');
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]);
@@ -92,22 +96,23 @@ 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 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}={}){
export async function createPdfReport(data,{year,accountId,personId='all'}={}){
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 selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);
const forecast=buildAnnualForecast(data,reportYear,accountId,personId);const rows=occurrenceRows(data,reportYear,accountId,personId);
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} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`);
drawPdfHeader(doc,`FixFin Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · 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}]);
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'}];
const cols=[{label:'Art',width:70},{label:'Bezeichnung',width:130},{label:'Person',width:80},{label:'Kategorie',width:90},{label:'Zufluss',width:63,align:'right'},{label:'Abfluss',width:62,align:'right'}];
for(let monthIndex=0;monthIndex<12;monthIndex++){
const monthRows=rows.filter(r=>r.monthIndex===monthIndex);const month=forecast.rows[monthIndex];
// Jeder Monat beginnt auf einer eigenen Seite. Der Monatsname steht immer
// links und nutzt die volle Inhaltsbreite, damit er niemals umbrechen kann.
doc.addPage();
drawPdfHeader(doc,`Kontoauszug ${reportYear}`,account.name);
drawPdfHeader(doc,`Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson}`);
const monthY=doc.y;
doc.fillColor('#17233B').font('Helvetica-Bold').fontSize(16)
.text(month.name,50,monthY,{width:495,align:'left',lineBreak:false});
@@ -117,8 +122,8 @@ export async function createPdfReport(data,{year,accountId}={}){
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'}});
if(doc.y+22>760){doc.addPage();drawPdfHeader(doc,`Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · ${month.name} · Fortsetzung`);pdfTableHeader(doc,cols);}
const inflow=isInflow(row);drawTableRow(doc,cols,[kindLabel(row),row.name,row.person,row.category,inflow?pdfMoney(row.amount):'',inflow?'':pdfMoney(row.amount)],{toneCols:inflow?{4:'#067647'}:{5:'#B42318'}});
}
doc.moveDown(.8);
}