diff --git a/src/services/time-tracking.service.js b/src/services/time-tracking.service.js index afded26..cae4763 100644 --- a/src/services/time-tracking.service.js +++ b/src/services/time-tracking.service.js @@ -43,6 +43,176 @@ const formatDuration = (minutes) => { return `${hours}:${String(mins).padStart(2, '0')}`; }; +// --- Shared entity validation helpers --- + +const validateProjectExists = async (projectId) => { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + if (!project) throw new NotFoundError('Projekt nenájdený'); +}; + +const validateTodoExists = async (todoId) => { + const [todo] = await db + .select() + .from(todos) + .where(eq(todos.id, todoId)) + .limit(1); + if (!todo) throw new NotFoundError('Todo nenájdené'); +}; + +const validateCompanyExists = async (companyId) => { + const [company] = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + if (!company) throw new NotFoundError('Firma nenájdená'); + return company; +}; + +const validateRelatedEntities = async (projectId, todoId, companyId) => { + if (projectId) await validateProjectExists(projectId); + if (todoId) await validateTodoExists(todoId); + if (companyId) await validateCompanyExists(companyId); +}; + +// --- Shared timesheet generation helpers --- + +const getUserNamePrefix = (user) => { + if (user.firstName && user.lastName) { + return `${user.firstName}-${user.lastName}`.toLowerCase().replace(/\s+/g, '-'); + } + if (user.firstName) { + return user.firstName.toLowerCase().replace(/\s+/g, '-'); + } + if (user.username) { + return user.username.toLowerCase().replace(/\s+/g, '-'); + } + return 'timesheet'; +}; + +const computeDailyTotals = (entries) => { + let totalMinutes = 0; + const dailyTotals = {}; + entries.forEach((entry) => { + const minutes = entry.duration || 0; + totalMinutes += minutes; + const day = formatDate(entry.startTime); + dailyTotals[day] = (dailyTotals[day] || 0) + minutes; + }); + return { totalMinutes, dailyTotals }; +}; + +const generateTimesheetWorkbook = ({ title, headerMeta, columns, headerRowNumber, entries, formatRow }) => { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'crm-server'; + const worksheet = workbook.addWorksheet(title, { + views: [{ state: 'frozen', ySplit: headerRowNumber }], + }); + + // Title + worksheet.getCell('A1').value = title; + worksheet.getCell('A1').font = { name: 'Calibri', size: 16, bold: true }; + worksheet.mergeCells('A1:D1'); + + // Meta rows (Name, Period, etc.) + headerMeta.forEach((line, i) => { + worksheet.getCell(`A${i + 2}`).value = line; + }); + + worksheet.columns = columns; + + const headerRow = worksheet.getRow(headerRowNumber); + headerRow.values = columns.map((c) => c.header); + 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; + + entries.forEach((entry) => { + worksheet.addRow(formatRow(entry)); + }); + + // Style body rows + worksheet.eachRow((row, rowNumber) => { + if (rowNumber >= headerRowNumber) { + row.alignment = { vertical: 'middle', wrapText: true }; + } + }); + + return { workbook, worksheet }; +}; + +const addDailySummary = (worksheet, dailyTotals, totalMinutes) => { + const 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 saveTimesheetFile = async (workbook, { userId, year, month, filename }) => { + const uploadsDir = path.join( + process.cwd(), + 'uploads', + 'timesheets', + userId.toString(), + year.toString(), + month.toString() + ); + await fs.mkdir(uploadsDir, { recursive: true }); + + 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 newTimesheet; + } catch (error) { + if (savedFilePath) { + await fs.unlink(savedFilePath).catch(() => {}); + } + throw error; + } +}; + +// --- Public API --- + /** * Start a new time entry */ @@ -62,7 +232,6 @@ export const startTimeEntry = async (userId, data) => { // Automatically stop existing running timer if (existingRunning) { const endTime = new Date(); - // Account for any active pause and accumulated pause duration let totalPausedSeconds = existingRunning.pausedDuration || 0; if (existingRunning.pausedAt) { totalPausedSeconds += Math.floor((endTime - new Date(existingRunning.pausedAt)) / 1000); @@ -81,44 +250,7 @@ export const startTimeEntry = async (userId, data) => { .where(eq(timeEntries.id, existingRunning.id)); } - // Verify project exists if provided - if (projectId) { - const [project] = await db - .select() - .from(projects) - .where(eq(projects.id, projectId)) - .limit(1); - - if (!project) { - throw new NotFoundError('Projekt nenájdený'); - } - } - - // Verify todo exists if provided - if (todoId) { - const [todo] = await db - .select() - .from(todos) - .where(eq(todos.id, todoId)) - .limit(1); - - if (!todo) { - throw new NotFoundError('Todo nenájdené'); - } - } - - // Verify company exists if provided - if (companyId) { - const [company] = await db - .select() - .from(companies) - .where(eq(companies.id, companyId)) - .limit(1); - - if (!company) { - throw new NotFoundError('Firma nenájdená'); - } - } + await validateRelatedEntities(projectId, todoId, companyId); const [newEntry] = await db .insert(timeEntries) @@ -150,7 +282,6 @@ export const stopTimeEntry = async (entryId, userId, data = {}) => { const entry = await getTimeEntryById(entryId); - // Verify ownership if (entry.userId !== userId) { throw new BadRequestError('Nemáte oprávnenie zastaviť tento časovač'); } @@ -160,49 +291,13 @@ export const stopTimeEntry = async (entryId, userId, data = {}) => { } const endTime = new Date(); - // Account for any active pause and accumulated pause duration let totalPausedSeconds = entry.pausedDuration || 0; if (entry.pausedAt) { totalPausedSeconds += Math.floor((endTime - new Date(entry.pausedAt)) / 1000); } const durationInMinutes = Math.round(((endTime - new Date(entry.startTime)) / 60000) - (totalPausedSeconds / 60)); - // Verify related entities if provided (skip validation for null/undefined) - if (projectId) { - const [project] = await db - .select() - .from(projects) - .where(eq(projects.id, projectId)) - .limit(1); - - if (!project) { - throw new NotFoundError('Projekt nenájdený'); - } - } - - if (todoId) { - const [todo] = await db - .select() - .from(todos) - .where(eq(todos.id, todoId)) - .limit(1); - - if (!todo) { - throw new NotFoundError('Todo nenájdené'); - } - } - - if (companyId) { - const [company] = await db - .select() - .from(companies) - .where(eq(companies.id, companyId)) - .limit(1); - - if (!company) { - throw new NotFoundError('Firma nenájdená'); - } - } + await validateRelatedEntities(projectId, todoId, companyId); const [updated] = await db .update(timeEntries) @@ -405,18 +500,12 @@ export const generateMonthlyTimesheet = async (userId, year, month) => { 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, - }) + .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ý'); - } + if (!user) throw new NotFoundError('Používateľ nenájdený'); const entries = await db .select({ @@ -447,63 +536,33 @@ export const generateMonthlyTimesheet = async (userId, year, month) => { 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 { totalMinutes, dailyTotals } = computeDailyTotals(completedEntries); 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 columns = [ + { key: 'date', header: 'Date', width: 12 }, + { key: 'project', header: 'Project', width: 28 }, + { key: 'todo', header: 'Todo', width: 28 }, + { key: 'company', header: 'Company', width: 24 }, + { key: 'description', header: 'Description', width: 40 }, + { key: 'start', header: 'Start', width: 12 }, + { key: 'end', header: 'End', width: 12 }, + { key: 'duration', header: 'Duration (h:mm)', 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({ + const { workbook, worksheet } = generateTimesheetWorkbook({ + title: 'Timesheet', + headerMeta: [ + `Name: ${fullName}`, + `Period: ${periodLabel}`, + `Generated: ${new Date().toLocaleString()}`, + ], + columns, + headerRowNumber: 6, + entries: completedEntries, + formatRow: (entry) => ({ date: formatDate(entry.startTime), project: entry.projectName || '', todo: entry.todoTitle || '', @@ -512,104 +571,33 @@ export const generateMonthlyTimesheet = async (userId, year, month) => { 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 }; - } - }); + addDailySummary(worksheet, dailyTotals, totalMinutes); - 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 }); - - // Generate user-friendly filename - let namePrefix; - if (user.firstName && user.lastName) { - namePrefix = `${user.firstName}-${user.lastName}`.toLowerCase().replace(/\s+/g, '-'); - } else if (user.firstName) { - namePrefix = user.firstName.toLowerCase().replace(/\s+/g, '-'); - } else if (user.username) { - namePrefix = user.username.toLowerCase().replace(/\s+/g, '-'); - } else { - namePrefix = 'timesheet'; - } + const namePrefix = getUserNamePrefix(user); const filename = `${namePrefix}-vykazprace-${periodLabel}.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 saveTimesheetFile(workbook, { userId, year, month, filename }); - 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; - } + 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, + }, + }; }; /** @@ -619,7 +607,6 @@ export const updateTimeEntry = async (entryId, actor, data) => { const { userId, role } = actor; const entry = await getTimeEntryById(entryId); - // Verify ownership (admin can edit anyone) if (entry.userId !== userId && role !== 'admin') { throw new ForbiddenError('Nemáte oprávnenie upraviť tento záznam'); } @@ -634,42 +621,7 @@ export const updateTimeEntry = async (entryId, actor, data) => { const companyId = normalizeOptionalId(data.companyId); const description = normalizeOptionalText(data.description); - // Verify related entities if being changed - if (projectId) { - const [project] = await db - .select() - .from(projects) - .where(eq(projects.id, projectId)) - .limit(1); - - if (!project) { - throw new NotFoundError('Projekt nenájdený'); - } - } - - if (todoId) { - const [todo] = await db - .select() - .from(todos) - .where(eq(todos.id, todoId)) - .limit(1); - - if (!todo) { - throw new NotFoundError('Todo nenájdené'); - } - } - - if (companyId) { - const [company] = await db - .select() - .from(companies) - .where(eq(companies.id, companyId)) - .limit(1); - - if (!company) { - throw new NotFoundError('Firma nenájdená'); - } - } + await validateRelatedEntities(projectId, todoId, companyId); // Calculate new duration if times are changed let newDuration = entry.duration; @@ -710,7 +662,6 @@ export const deleteTimeEntry = async (entryId, actor) => { const { userId, role } = actor; const entry = await getTimeEntryById(entryId); - // Verify ownership (admin can delete anyone) if (entry.userId !== userId && role !== 'admin') { throw new ForbiddenError('Nemáte oprávnenie odstrániť tento záznam'); } @@ -730,7 +681,6 @@ export const deleteTimeEntry = async (entryId, actor) => { export const getTimeEntryWithRelations = async (entryId) => { const entry = await getTimeEntryById(entryId); - // Get project if exists let project = null; if (entry.projectId) { [project] = await db @@ -740,7 +690,6 @@ export const getTimeEntryWithRelations = async (entryId) => { .limit(1); } - // Get todo if exists let todo = null; if (entry.todoId) { [todo] = await db @@ -750,7 +699,6 @@ export const getTimeEntryWithRelations = async (entryId) => { .limit(1); } - // Get company if exists let company = null; if (entry.companyId) { [company] = await db @@ -776,28 +724,14 @@ export const generateCompanyTimesheet = async (userId, year, month, companyId) = 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, - }) + .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ý'); - } + if (!user) throw new NotFoundError('Používateľ nenájdený'); - const [company] = await db - .select() - .from(companies) - .where(eq(companies.id, companyId)) - .limit(1); - - if (!company) { - throw new NotFoundError('Firma nenájdená'); - } + const company = await validateCompanyExists(companyId); const entries = await db .select({ @@ -827,62 +761,33 @@ export const generateCompanyTimesheet = async (userId, year, month, companyId) = throw new NotFoundError('Žiadne dokončené záznamy pre danú firmu a 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('Company Timesheet', { - views: [{ state: 'frozen', ySplit: 7 }], - }); + const { totalMinutes, dailyTotals } = computeDailyTotals(completedEntries); const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username; const periodLabel = `${year}-${String(month).padStart(2, '0')}`; - worksheet.getCell('A1').value = 'Company Timesheet'; - worksheet.getCell('A1').font = { name: 'Calibri', size: 16, bold: true }; - worksheet.mergeCells('A1:D1'); - - worksheet.getCell('A2').value = `Company: ${company.name}`; - worksheet.getCell('A3').value = `Employee: ${fullName}`; - worksheet.getCell('A4').value = `Period: ${periodLabel}`; - worksheet.getCell('A5').value = `Generated: ${new Date().toLocaleString()}`; - - worksheet.columns = [ - { key: 'date', width: 12 }, - { key: 'project', width: 28 }, - { key: 'todo', width: 28 }, - { key: 'description', width: 40 }, - { key: 'start', width: 12 }, - { key: 'end', width: 12 }, - { key: 'duration', width: 16 }, + const columns = [ + { key: 'date', header: 'Date', width: 12 }, + { key: 'project', header: 'Project', width: 28 }, + { key: 'todo', header: 'Todo', width: 28 }, + { key: 'description', header: 'Description', width: 40 }, + { key: 'start', header: 'Start', width: 12 }, + { key: 'end', header: 'End', width: 12 }, + { key: 'duration', header: 'Duration (h:mm)', width: 16 }, ]; - const headerRowNumber = 7; - const headerRow = worksheet.getRow(headerRowNumber); - headerRow.values = [ - 'Date', - 'Project', - 'Todo', - '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({ + const { workbook, worksheet } = generateTimesheetWorkbook({ + title: 'Company Timesheet', + headerMeta: [ + `Company: ${company.name}`, + `Employee: ${fullName}`, + `Period: ${periodLabel}`, + `Generated: ${new Date().toLocaleString()}`, + ], + columns, + headerRowNumber: 7, + entries: completedEntries, + formatRow: (entry) => ({ date: formatDate(entry.startTime), project: entry.projectName || '', todo: entry.todoTitle || '', @@ -890,106 +795,35 @@ export const generateCompanyTimesheet = async (userId, year, month, companyId) = 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 }; - } - }); + addDailySummary(worksheet, dailyTotals, totalMinutes); - 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 }); - - // Generate user-friendly filename - let namePrefix; - if (user.firstName && user.lastName) { - namePrefix = `${user.firstName}-${user.lastName}`.toLowerCase().replace(/\s+/g, '-'); - } else if (user.firstName) { - namePrefix = user.firstName.toLowerCase().replace(/\s+/g, '-'); - } else if (user.username) { - namePrefix = user.username.toLowerCase().replace(/\s+/g, '-'); - } else { - namePrefix = 'timesheet'; - } + const namePrefix = getUserNamePrefix(user); const companySlug = company.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); const filename = `${namePrefix}-vykazprace-${companySlug}-${periodLabel}.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 saveTimesheetFile(workbook, { userId, year, month, filename }); - 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, - }, - companyName: company.name, - totals: { - totalMinutes, - totalFormatted: formatDuration(totalMinutes), - daily: dailyTotals, - }, - }; - } catch (error) { - if (savedFilePath) { - await fs.unlink(savedFilePath).catch(() => {}); - } - throw error; - } + 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, + }, + companyName: company.name, + totals: { + totalMinutes, + totalFormatted: formatDuration(totalMinutes), + daily: dailyTotals, + }, + }; }; /** @@ -998,19 +832,16 @@ export const generateCompanyTimesheet = async (userId, year, month, companyId) = export const getMonthlyStats = async (userId, year, month) => { const entries = await getMonthlyTimeEntries(userId, year, month); - // Total time in minutes const totalMinutes = entries.reduce((sum, entry) => { return sum + (entry.duration || 0); }, 0); - // Count of days worked const uniqueDays = new Set( entries .filter((e) => !e.isRunning) .map((e) => new Date(e.startTime).toDateString()) ).size; - // Time by project const byProject = {}; entries.forEach((entry) => { if (entry.projectId && entry.duration) { @@ -1021,7 +852,6 @@ export const getMonthlyStats = async (userId, year, month) => { } }); - // Time by company const byCompany = {}; entries.forEach((entry) => { if (entry.companyId && entry.duration) {