zmiany
This commit is contained in:
@@ -1,25 +1,36 @@
|
||||
import type { Response } from 'express';
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import { getStatistics } from '../services/statistics.service.js';
|
||||
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
frequency: z.enum(['monthly', 'yearly', 'threshold']),
|
||||
thresholdAmount: z.number().min(0).default(0),
|
||||
sendToEmail: z.email().nullable().optional(),
|
||||
sendToEmail: z.string().email().nullable().optional(),
|
||||
categoryIds: z.array(z.string().uuid()).default([])
|
||||
});
|
||||
|
||||
const previewOverrideSchema = preferencesSchema.partial();
|
||||
const exportQuerySchema = z.object({
|
||||
format: z.enum(['csv', 'json', 'html', 'pdf']).default('csv'),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
categoryIds: z.string().optional(),
|
||||
status: z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']).optional(),
|
||||
tag: z.string().optional()
|
||||
});
|
||||
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
const require = createRequire(import.meta.url);
|
||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||
|
||||
const defaultPrefs = (email: string) => ({
|
||||
enabled: false,
|
||||
@@ -59,6 +70,11 @@ const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getSt
|
||||
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.label}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
|
||||
.join('');
|
||||
|
||||
const tagRows = (summary.byTag ?? [])
|
||||
.slice(0, 6)
|
||||
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.tag}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div style="font-family:Arial,sans-serif;color:#111827;max-width:760px;margin:0 auto">
|
||||
<h1 style="margin-bottom:8px">${title}</h1>
|
||||
@@ -67,10 +83,112 @@ const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getSt
|
||||
<table style="width:100%;border-collapse:collapse">${categoryRows || '<tr><td>No data</td></tr>'}</table>
|
||||
<h2 style="margin-top:32px">Timeline</h2>
|
||||
<table style="width:100%;border-collapse:collapse">${timelineRows || '<tr><td>No data</td></tr>'}</table>
|
||||
<h2 style="margin-top:32px">Top tags</h2>
|
||||
<table style="width:100%;border-collapse:collapse">${tagRows || '<tr><td>No data</td></tr>'}</table>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const collectExportItems = async (userId: string, filters: { startDate?: string; endDate?: string; categoryIds?: string[]; status?: string; tag?: string }) => {
|
||||
const items = await expenseRepo().find({ where: { user: { id: userId } }, relations: { category: true, proofs: true }, order: { expenseDate: 'DESC', createdAt: 'DESC' } });
|
||||
return items.filter((item) => {
|
||||
if (filters.startDate && item.expenseDate < filters.startDate) return false;
|
||||
if (filters.endDate && item.expenseDate > filters.endDate) return false;
|
||||
if (filters.categoryIds?.length && !filters.categoryIds.includes(item.category.id)) return false;
|
||||
if (filters.status && item.status !== filters.status) return false;
|
||||
if (filters.tag && !(item.tags ?? []).some((tag) => tag.toLowerCase() === filters.tag!.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const escapeCsv = (value: unknown) => {
|
||||
const text = String(value ?? '');
|
||||
if (/[",\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
|
||||
return text;
|
||||
};
|
||||
|
||||
const escapePdfText = (value: string) => value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)').replace(/\r/g, '').replace(/\n/g, ' ');
|
||||
|
||||
const buildPdfBuffer = (title: string, summary: Awaited<ReturnType<typeof getStatistics>>, items: Expense[]) => {
|
||||
const lines = [
|
||||
title,
|
||||
`Generated: ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`,
|
||||
`Total: ${summary.total.toFixed(2)} | Count: ${summary.count} | Average: ${summary.average.toFixed(2)}`,
|
||||
'',
|
||||
'Top categories:'
|
||||
];
|
||||
|
||||
if (summary.byCategory.length) {
|
||||
summary.byCategory.slice(0, 8).forEach((item) => lines.push(`- ${item.categoryName}: ${item.total.toFixed(2)} (${item.count})`));
|
||||
} else {
|
||||
lines.push('- No data');
|
||||
}
|
||||
|
||||
lines.push('', 'Timeline:');
|
||||
if (summary.timeline.length) {
|
||||
summary.timeline.slice(-8).forEach((item) => lines.push(`- ${item.label}: ${item.total.toFixed(2)}`));
|
||||
} else {
|
||||
lines.push('- No data');
|
||||
}
|
||||
|
||||
lines.push('', 'Expenses:');
|
||||
if (items.length) {
|
||||
items.slice(0, 30).forEach((item) => lines.push(`- ${item.expenseDate} | ${item.title} | ${item.category.name} | ${item.amount.toFixed(2)} ${item.currency} | ${item.status}`));
|
||||
} else {
|
||||
lines.push('- No data');
|
||||
}
|
||||
|
||||
const pageHeight = 792;
|
||||
const lineHeight = 16;
|
||||
const startY = 750;
|
||||
const linesPerPage = 40;
|
||||
const chunks = [] as string[][];
|
||||
for (let index = 0; index < lines.length; index += linesPerPage) chunks.push(lines.slice(index, index + linesPerPage));
|
||||
|
||||
const objects: string[] = [];
|
||||
const kids: string[] = [];
|
||||
const fontObjectNumber = 3 + chunks.length * 2;
|
||||
|
||||
objects[1] = '<< /Type /Catalog /Pages 2 0 R >>';
|
||||
|
||||
chunks.forEach((chunk, pageIndex) => {
|
||||
const pageObjectNumber = 3 + pageIndex * 2;
|
||||
const contentObjectNumber = pageObjectNumber + 1;
|
||||
kids.push(`${pageObjectNumber} 0 R`);
|
||||
|
||||
const contentLines = ['BT', '/F1 12 Tf', `50 ${startY} Td`];
|
||||
chunk.forEach((line, index) => {
|
||||
if (index > 0) contentLines.push(`0 -${lineHeight} Td`);
|
||||
contentLines.push(`(${escapePdfText(line)}) Tj`);
|
||||
});
|
||||
contentLines.push('ET');
|
||||
const stream = contentLines.join('\n');
|
||||
|
||||
objects[pageObjectNumber] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 ${pageHeight}] /Resources << /Font << /F1 ${fontObjectNumber} 0 R >> >> /Contents ${contentObjectNumber} 0 R >>`;
|
||||
objects[contentObjectNumber] = `<< /Length ${Buffer.byteLength(stream, 'utf8')} >>\nstream\n${stream}\nendstream`;
|
||||
});
|
||||
|
||||
objects[2] = `<< /Type /Pages /Count ${kids.length} /Kids [${kids.join(' ')}] >>`;
|
||||
objects[fontObjectNumber] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
|
||||
|
||||
let pdf = '%PDF-1.4\n';
|
||||
const offsets: number[] = [0];
|
||||
for (let index = 1; index < objects.length; index += 1) {
|
||||
if (!objects[index]) continue;
|
||||
offsets[index] = Buffer.byteLength(pdf, 'utf8');
|
||||
pdf += `${index} 0 obj\n${objects[index]}\nendobj\n`;
|
||||
}
|
||||
|
||||
const xrefOffset = Buffer.byteLength(pdf, 'utf8');
|
||||
pdf += `xref\n0 ${objects.length}\n`;
|
||||
pdf += '0000000000 65535 f \n';
|
||||
for (let index = 1; index < objects.length; index += 1) {
|
||||
pdf += `${String(offsets[index] ?? 0).padStart(10, '0')} 00000 n \n`;
|
||||
}
|
||||
pdf += `trailer\n<< /Size ${objects.length} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
||||
return Buffer.from(pdf, 'utf8');
|
||||
};
|
||||
|
||||
export const getPreferences = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
@@ -92,6 +210,7 @@ export const updatePreferences = async (req: AuthenticatedRequest, res: Response
|
||||
};
|
||||
|
||||
export const previewReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
@@ -120,6 +239,7 @@ export const previewReport = async (req: AuthenticatedRequest, res: Response) =>
|
||||
};
|
||||
|
||||
export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
@@ -151,9 +271,7 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
|
||||
const to = prefs.sendToEmail || user.email;
|
||||
await transport.sendMail({
|
||||
from: appSettings.smtpFromName
|
||||
? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>`
|
||||
: appSettings.smtpFromEmail,
|
||||
from: appSettings.smtpFromName ? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>` : appSettings.smtpFromEmail,
|
||||
to,
|
||||
subject: `${appSettings.appName} - ${range.label} report`,
|
||||
html: buildReportHtml(`${range.label} report`, summary)
|
||||
@@ -161,3 +279,92 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
|
||||
return res.json({ message: 'Report email was sent', sentTo: to });
|
||||
};
|
||||
|
||||
export const exportReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
await processDueRecurringExpenses(req.user!.id);
|
||||
const parsed = exportQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid report export filters', issues: parsed.error.issues });
|
||||
|
||||
const categoryIds = parsed.data.categoryIds?.split(',').filter(Boolean) ?? [];
|
||||
const items = await collectExportItems(req.user!.id, {
|
||||
startDate: parsed.data.startDate,
|
||||
endDate: parsed.data.endDate,
|
||||
categoryIds,
|
||||
status: parsed.data.status,
|
||||
tag: parsed.data.tag
|
||||
});
|
||||
const summary = await getStatistics(
|
||||
{
|
||||
userId: req.user!.id,
|
||||
startDate: parsed.data.startDate,
|
||||
endDate: parsed.data.endDate,
|
||||
categoryIds,
|
||||
status: parsed.data.status,
|
||||
tag: parsed.data.tag
|
||||
},
|
||||
'month'
|
||||
);
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
if (parsed.data.format === 'json') {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.json"`);
|
||||
return res.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
filters: parsed.data,
|
||||
summary,
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
date: item.expenseDate,
|
||||
amount: item.amount,
|
||||
currency: item.currency,
|
||||
status: item.status,
|
||||
category: item.category.name,
|
||||
merchant: item.merchant,
|
||||
tags: item.tags ?? [],
|
||||
customFields: item.customFields ?? {},
|
||||
attachments: item.proofs.map((proof) => proof.originalName || proof.label || 'Attachment')
|
||||
}))
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.format === 'html') {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.html"`);
|
||||
return res.send(buildReportHtml('Expense export', summary));
|
||||
}
|
||||
|
||||
if (parsed.data.format === 'pdf') {
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.pdf"`);
|
||||
return res.send(buildPdfBuffer('Expense export', summary, items));
|
||||
}
|
||||
|
||||
const rows = [
|
||||
['Date', 'Title', 'Category', 'Merchant', 'Amount', 'Currency', 'Status', 'Tags', 'Custom fields', 'Attachments'],
|
||||
...items.map((item) => [
|
||||
item.expenseDate,
|
||||
item.title,
|
||||
item.category.name,
|
||||
item.merchant ?? '',
|
||||
item.amount.toFixed(2),
|
||||
item.currency,
|
||||
item.status,
|
||||
(item.tags ?? []).join('|'),
|
||||
Object.entries(item.customFields ?? {})
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(' | '),
|
||||
item.proofs.map((proof) => proof.originalName || proof.label || 'Attachment').join(' | ')
|
||||
])
|
||||
];
|
||||
const csv = rows.map((row) => row.map(escapeCsv).join(',')).join('\n');
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="expense-report-${stamp}.csv"`);
|
||||
return res.send(csv);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user