excel preview & file handling

This commit is contained in:
richardtekula
2025-11-24 10:18:28 +01:00
parent dfcf8056f3
commit 7fd6b9e742
12 changed files with 1336 additions and 18 deletions

View File

@@ -1,7 +1,10 @@
import { db } from '../config/database.js';
import { timeEntries, projects, todos, companies, users } from '../db/schema.js';
import { eq, and, gte, lte, desc, sql } from 'drizzle-orm';
import { timeEntries, projects, todos, companies, users, timesheets } from '../db/schema.js';
import { eq, and, gte, lte, desc } from 'drizzle-orm';
import { NotFoundError, BadRequestError } from '../utils/errors.js';
import ExcelJS from 'exceljs';
import fs from 'fs/promises';
import path from 'path';
// Helpers to normalize optional payload fields
const normalizeOptionalId = (value) => {
@@ -18,6 +21,28 @@ const normalizeOptionalText = (value) => {
return trimmed.length ? trimmed : null;
};
const formatDate = (value) => {
const date = new Date(value);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatTime = (value) => {
const date = new Date(value);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
};
const formatDuration = (minutes) => {
if (!Number.isFinite(minutes)) return '';
const hours = Math.floor(minutes / 60);
const mins = Math.abs(minutes % 60);
return `${hours}:${String(mins).padStart(2, '0')}`;
};
/**
* Start a new time entry
*/
@@ -274,6 +299,210 @@ export const getMonthlyTimeEntries = async (userId, year, month) => {
.orderBy(desc(timeEntries.startTime));
};
/**
* Generate an XLSX timesheet for a given month and user.
*/
export const generateMonthlyTimesheet = async (userId, year, month) => {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0, 23, 59, 59, 999);
const [user] = await db
.select({
username: users.username,
firstName: users.firstName,
lastName: users.lastName,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
throw new NotFoundError('Používateľ nenájdený');
}
const entries = await db
.select({
id: timeEntries.id,
startTime: timeEntries.startTime,
endTime: timeEntries.endTime,
duration: timeEntries.duration,
description: timeEntries.description,
projectName: projects.name,
todoTitle: todos.title,
companyName: companies.name,
})
.from(timeEntries)
.leftJoin(projects, eq(timeEntries.projectId, projects.id))
.leftJoin(todos, eq(timeEntries.todoId, todos.id))
.leftJoin(companies, eq(timeEntries.companyId, companies.id))
.where(
and(
eq(timeEntries.userId, userId),
gte(timeEntries.startTime, startDate),
lte(timeEntries.startTime, endDate)
)
)
.orderBy(timeEntries.startTime);
const completedEntries = entries.filter((entry) => entry.endTime && entry.duration !== null);
if (completedEntries.length === 0) {
throw new NotFoundError('Žiadne dokončené záznamy pre daný mesiac');
}
let totalMinutes = 0;
const dailyTotals = {};
completedEntries.forEach((entry) => {
const minutes = entry.duration || 0;
totalMinutes += minutes;
const day = formatDate(entry.startTime);
dailyTotals[day] = (dailyTotals[day] || 0) + minutes;
});
const workbook = new ExcelJS.Workbook();
workbook.creator = 'crm-server';
const worksheet = workbook.addWorksheet('Timesheet', {
views: [{ state: 'frozen', ySplit: 6 }],
});
const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username;
const periodLabel = `${year}-${String(month).padStart(2, '0')}`;
worksheet.getCell('A1').value = 'Timesheet';
worksheet.getCell('A1').font = { name: 'Calibri', size: 16, bold: true };
worksheet.mergeCells('A1:D1');
worksheet.getCell('A2').value = `Name: ${fullName}`;
worksheet.getCell('A3').value = `Period: ${periodLabel}`;
worksheet.getCell('A4').value = `Generated: ${new Date().toLocaleString()}`;
worksheet.columns = [
{ key: 'date', width: 12 },
{ key: 'project', width: 28 },
{ key: 'todo', width: 28 },
{ key: 'company', width: 24 },
{ key: 'description', width: 40 },
{ key: 'start', width: 12 },
{ key: 'end', width: 12 },
{ key: 'duration', width: 16 },
];
const headerRowNumber = 6;
const headerRow = worksheet.getRow(headerRowNumber);
headerRow.values = [
'Date',
'Project',
'Todo',
'Company',
'Description',
'Start',
'End',
'Duration (h:mm)',
];
headerRow.font = { name: 'Calibri', size: 11, bold: true };
headerRow.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8EEF5' } };
headerRow.height = 20;
completedEntries.forEach((entry) => {
worksheet.addRow({
date: formatDate(entry.startTime),
project: entry.projectName || '',
todo: entry.todoTitle || '',
company: entry.companyName || '',
description: entry.description || '',
start: formatTime(entry.startTime),
end: formatTime(entry.endTime),
duration: formatDuration(entry.duration),
});
});
// Style body rows
worksheet.eachRow((row, rowNumber) => {
if (rowNumber >= headerRowNumber) {
row.alignment = { vertical: 'middle', wrapText: true };
}
});
let summaryStart = worksheet.lastRow.number + 2;
worksheet.getCell(`A${summaryStart}`).value = 'Daily totals';
worksheet.getCell(`A${summaryStart}`).font = { name: 'Calibri', size: 12, bold: true };
worksheet.mergeCells(`A${summaryStart}:B${summaryStart}`);
const dailyHeaderRowNumber = summaryStart + 1;
const dailyHeaderRow = worksheet.getRow(dailyHeaderRowNumber);
dailyHeaderRow.values = ['Date', 'Total (h:mm)'];
dailyHeaderRow.font = { name: 'Calibri', size: 11, bold: true };
dailyHeaderRow.alignment = { vertical: 'middle', horizontal: 'center' };
dailyHeaderRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8EEF5' } };
Object.keys(dailyTotals)
.sort()
.forEach((day) => {
worksheet.addRow([day, formatDuration(dailyTotals[day])]);
});
const overallRow = worksheet.addRow(['Overall total', formatDuration(totalMinutes)]);
overallRow.font = { name: 'Calibri', size: 11, bold: true };
const uploadsDir = path.join(
process.cwd(),
'uploads',
'timesheets',
userId.toString(),
year.toString(),
month.toString()
);
await fs.mkdir(uploadsDir, { recursive: true });
const filename = `timesheet-${periodLabel}-${Date.now()}.xlsx`;
const filePath = path.join(uploadsDir, filename);
let savedFilePath = null;
try {
await workbook.xlsx.writeFile(filePath);
savedFilePath = filePath;
const { size: fileSize } = await fs.stat(filePath);
const [newTimesheet] = await db
.insert(timesheets)
.values({
userId,
fileName: filename,
filePath,
fileType: 'xlsx',
fileSize,
year,
month,
isGenerated: true,
})
.returning();
return {
timesheet: {
id: newTimesheet.id,
fileName: newTimesheet.fileName,
fileType: newTimesheet.fileType,
fileSize: newTimesheet.fileSize,
year: newTimesheet.year,
month: newTimesheet.month,
isGenerated: newTimesheet.isGenerated,
uploadedAt: newTimesheet.uploadedAt,
},
totals: {
totalMinutes,
totalFormatted: formatDuration(totalMinutes),
daily: dailyTotals,
},
};
} catch (error) {
if (savedFilePath) {
await fs.unlink(savedFilePath).catch(() => {});
}
throw error;
}
};
/**
* Update time entry
*/