first commit

This commit is contained in:
Vadim Sobinin
2026-02-02 16:14:57 +03:00
commit fc886320e3
48 changed files with 5569 additions and 0 deletions

196
backend/dist/routes/reports.js vendored Normal file
View File

@@ -0,0 +1,196 @@
import { z } from 'zod';
import { prisma } from '../index.js';
import PdfPrinter from 'pdfmake';
import ExcelJS from 'exceljs';
const reportSchema = z.object({
startDate: z.string(),
endDate: z.string(),
format: z.enum(['pdf', 'excel', 'csv']),
});
function formatDate(date) {
const d = new Date(date);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}.${month}.${year}`;
}
function formatTime(minutes) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}:${String(mins).padStart(2, '0')}`;
}
export async function reportsRoutes(fastify) {
fastify.addHook('onRequest', fastify.authenticate);
fastify.post('/generate', async (request, reply) => {
const user = request.user;
const result = reportSchema.safeParse(request.body);
if (!result.success) {
return reply.status(400).send({ error: 'Invalid input', details: result.error.errors });
}
const { startDate, endDate, format } = result.data;
const logs = await prisma.timeLog.findMany({
where: {
userId: user.id,
date: {
gte: new Date(startDate),
lte: new Date(endDate + 'T23:59:59'),
},
},
orderBy: { date: 'asc' },
});
// Calculate totals
const uniqueDates = new Set(logs.map(log => formatDate(log.date)));
const totalMinutes = logs.reduce((sum, log) => sum + log.minutes, 0);
const reportData = logs.map(log => ({
date: formatDate(log.date),
description: log.description,
time: formatTime(log.minutes),
}));
if (format === 'csv') {
const csvContent = generateCSV(reportData, uniqueDates.size, totalMinutes);
reply.header('Content-Type', 'text/csv; charset=utf-8');
reply.header('Content-Disposition', `attachment; filename="report_${startDate}_${endDate}.csv"`);
return reply.send(csvContent);
}
if (format === 'excel') {
const buffer = await generateExcel(reportData, uniqueDates.size, totalMinutes);
reply.header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
reply.header('Content-Disposition', `attachment; filename="report_${startDate}_${endDate}.xlsx"`);
return reply.send(buffer);
}
if (format === 'pdf') {
const buffer = await generatePDF(reportData, uniqueDates.size, totalMinutes, startDate, endDate);
reply.header('Content-Type', 'application/pdf');
reply.header('Content-Disposition', `attachment; filename="report_${startDate}_${endDate}.pdf"`);
return reply.send(buffer);
}
});
}
function generateCSV(data, uniqueDays, totalMinutes) {
const BOM = '\uFEFF';
const lines = [];
lines.push('Дата,Описание,Часы');
for (const row of data) {
const escapedDescription = `"${row.description.replace(/"/g, '""')}"`;
lines.push(`${row.date},${escapedDescription},${row.time}`);
}
lines.push('');
lines.push(`Total,${uniqueDays} дней,${formatTime(totalMinutes)}`);
return BOM + lines.join('\n');
}
async function generateExcel(data, uniqueDays, totalMinutes) {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Отчёт');
// Headers
sheet.columns = [
{ header: 'Дата', key: 'date', width: 15 },
{ header: 'Описание', key: 'description', width: 50 },
{ header: 'Часы', key: 'time', width: 10 },
];
// Style headers
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE0E0E0' },
};
// Data
for (const row of data) {
sheet.addRow(row);
}
// Empty row
sheet.addRow({});
// Total row
const totalRow = sheet.addRow({
date: 'Total',
description: `${uniqueDays} дней`,
time: formatTime(totalMinutes),
});
// Style total row
totalRow.font = { bold: true };
totalRow.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF90EE90' },
};
const arrayBuffer = await workbook.xlsx.writeBuffer();
return Buffer.from(arrayBuffer);
}
async function generatePDF(data, uniqueDays, totalMinutes, startDate, endDate) {
const fonts = {
Roboto: {
normal: 'node_modules/pdfmake/build/vfs_fonts.js',
bold: 'node_modules/pdfmake/build/vfs_fonts.js',
},
};
const printer = new PdfPrinter(fonts);
const tableBody = [
[
{ text: 'Дата', style: 'tableHeader' },
{ text: 'Описание', style: 'tableHeader' },
{ text: 'Часы', style: 'tableHeader' },
],
];
for (const row of data) {
tableBody.push([row.date, row.description, row.time]);
}
// Total row
tableBody.push([
{ text: 'Total', style: 'total' },
{ text: `${uniqueDays} дней`, style: 'total' },
{ text: formatTime(totalMinutes), style: 'total' },
]);
const docDefinition = {
content: [
{ text: 'Отчёт по времени', style: 'header' },
{ text: `Период: ${startDate} - ${endDate}`, style: 'subheader' },
{ text: ' ' },
{
table: {
headerRows: 1,
widths: ['auto', '*', 'auto'],
body: tableBody,
},
layout: {
fillColor: (rowIndex) => {
if (rowIndex === 0)
return '#EEEEEE';
if (rowIndex === tableBody.length - 1)
return '#90EE90';
return null;
},
},
},
],
styles: {
header: {
fontSize: 18,
bold: true,
margin: [0, 0, 0, 10],
},
subheader: {
fontSize: 12,
margin: [0, 0, 0, 10],
},
tableHeader: {
bold: true,
fontSize: 11,
},
total: {
bold: true,
fontSize: 11,
},
},
defaultStyle: {
fontSize: 10,
},
};
return new Promise((resolve, reject) => {
const pdfDoc = printer.createPdfKitDocument(docDefinition);
const chunks = [];
pdfDoc.on('data', (chunk) => chunks.push(chunk));
pdfDoc.on('end', () => resolve(Buffer.concat(chunks)));
pdfDoc.on('error', reject);
pdfDoc.end();
});
}