refactor: Deduplicate time-tracking.service.js (1045 -> 876 lines)

Extract shared helpers:
- validateProjectExists, validateTodoExists, validateCompanyExists,
  validateRelatedEntities (replaces 4x copy-pasted validation blocks)
- generateTimesheetWorkbook (shared workbook creation logic)
- addDailySummary (shared daily totals section)
- saveTimesheetFile (shared file save + DB insert)
- computeDailyTotals, getUserNamePrefix

generateMonthlyTimesheet and generateCompanyTimesheet now use shared
helpers instead of duplicating ~370 lines each.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
richardtekula
2026-01-28 07:24:23 +01:00
parent 4629f1903b
commit d4883480b2

View File

@@ -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) {