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')}`; 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 * Start a new time entry
*/ */
@@ -62,7 +232,6 @@ export const startTimeEntry = async (userId, data) => {
// Automatically stop existing running timer // Automatically stop existing running timer
if (existingRunning) { if (existingRunning) {
const endTime = new Date(); const endTime = new Date();
// Account for any active pause and accumulated pause duration
let totalPausedSeconds = existingRunning.pausedDuration || 0; let totalPausedSeconds = existingRunning.pausedDuration || 0;
if (existingRunning.pausedAt) { if (existingRunning.pausedAt) {
totalPausedSeconds += Math.floor((endTime - new Date(existingRunning.pausedAt)) / 1000); 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)); .where(eq(timeEntries.id, existingRunning.id));
} }
// Verify project exists if provided await validateRelatedEntities(projectId, todoId, companyId);
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á');
}
}
const [newEntry] = await db const [newEntry] = await db
.insert(timeEntries) .insert(timeEntries)
@@ -150,7 +282,6 @@ export const stopTimeEntry = async (entryId, userId, data = {}) => {
const entry = await getTimeEntryById(entryId); const entry = await getTimeEntryById(entryId);
// Verify ownership
if (entry.userId !== userId) { if (entry.userId !== userId) {
throw new BadRequestError('Nemáte oprávnenie zastaviť tento časovač'); 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(); const endTime = new Date();
// Account for any active pause and accumulated pause duration
let totalPausedSeconds = entry.pausedDuration || 0; let totalPausedSeconds = entry.pausedDuration || 0;
if (entry.pausedAt) { if (entry.pausedAt) {
totalPausedSeconds += Math.floor((endTime - new Date(entry.pausedAt)) / 1000); totalPausedSeconds += Math.floor((endTime - new Date(entry.pausedAt)) / 1000);
} }
const durationInMinutes = Math.round(((endTime - new Date(entry.startTime)) / 60000) - (totalPausedSeconds / 60)); const durationInMinutes = Math.round(((endTime - new Date(entry.startTime)) / 60000) - (totalPausedSeconds / 60));
// Verify related entities if provided (skip validation for null/undefined) await validateRelatedEntities(projectId, todoId, companyId);
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á');
}
}
const [updated] = await db const [updated] = await db
.update(timeEntries) .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 endDate = new Date(year, month, 0, 23, 59, 59, 999);
const [user] = await db const [user] = await db
.select({ .select({ username: users.username, firstName: users.firstName, lastName: users.lastName })
username: users.username,
firstName: users.firstName,
lastName: users.lastName,
})
.from(users) .from(users)
.where(eq(users.id, userId)) .where(eq(users.id, userId))
.limit(1); .limit(1);
if (!user) { if (!user) throw new NotFoundError('Používateľ nenájdený');
throw new NotFoundError('Používateľ nenájdený');
}
const entries = await db const entries = await db
.select({ .select({
@@ -447,63 +536,33 @@ export const generateMonthlyTimesheet = async (userId, year, month) => {
throw new NotFoundError('Žiadne dokončené záznamy pre daný mesiac'); throw new NotFoundError('Žiadne dokončené záznamy pre daný mesiac');
} }
let totalMinutes = 0; const { totalMinutes, dailyTotals } = computeDailyTotals(completedEntries);
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 fullName = [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username;
const periodLabel = `${year}-${String(month).padStart(2, '0')}`; const periodLabel = `${year}-${String(month).padStart(2, '0')}`;
worksheet.getCell('A1').value = 'Timesheet'; const columns = [
worksheet.getCell('A1').font = { name: 'Calibri', size: 16, bold: true }; { key: 'date', header: 'Date', width: 12 },
worksheet.mergeCells('A1:D1'); { key: 'project', header: 'Project', width: 28 },
{ key: 'todo', header: 'Todo', width: 28 },
worksheet.getCell('A2').value = `Name: ${fullName}`; { key: 'company', header: 'Company', width: 24 },
worksheet.getCell('A3').value = `Period: ${periodLabel}`; { key: 'description', header: 'Description', width: 40 },
worksheet.getCell('A4').value = `Generated: ${new Date().toLocaleString()}`; { key: 'start', header: 'Start', width: 12 },
{ key: 'end', header: 'End', width: 12 },
worksheet.columns = [ { key: 'duration', header: 'Duration (h:mm)', width: 16 },
{ 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 { workbook, worksheet } = generateTimesheetWorkbook({
const headerRow = worksheet.getRow(headerRowNumber); title: 'Timesheet',
headerRow.values = [ headerMeta: [
'Date', `Name: ${fullName}`,
'Project', `Period: ${periodLabel}`,
'Todo', `Generated: ${new Date().toLocaleString()}`,
'Company', ],
'Description', columns,
'Start', headerRowNumber: 6,
'End', entries: completedEntries,
'Duration (h:mm)', formatRow: (entry) => ({
];
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), date: formatDate(entry.startTime),
project: entry.projectName || '', project: entry.projectName || '',
todo: entry.todoTitle || '', todo: entry.todoTitle || '',
@@ -512,80 +571,15 @@ export const generateMonthlyTimesheet = async (userId, year, month) => {
start: formatTime(entry.startTime), start: formatTime(entry.startTime),
end: formatTime(entry.endTime), end: formatTime(entry.endTime),
duration: formatDuration(entry.duration), duration: formatDuration(entry.duration),
}); }),
}); });
// Style body rows addDailySummary(worksheet, dailyTotals, totalMinutes);
worksheet.eachRow((row, rowNumber) => {
if (rowNumber >= headerRowNumber) {
row.alignment = { vertical: 'middle', wrapText: true };
}
});
let summaryStart = worksheet.lastRow.number + 2; const namePrefix = getUserNamePrefix(user);
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 filename = `${namePrefix}-vykazprace-${periodLabel}.xlsx`; const filename = `${namePrefix}-vykazprace-${periodLabel}.xlsx`;
const filePath = path.join(uploadsDir, filename);
let savedFilePath = null;
try { const newTimesheet = await saveTimesheetFile(workbook, { userId, year, month, filename });
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 { return {
timesheet: { timesheet: {
@@ -604,12 +598,6 @@ export const generateMonthlyTimesheet = async (userId, year, month) => {
daily: dailyTotals, daily: dailyTotals,
}, },
}; };
} catch (error) {
if (savedFilePath) {
await fs.unlink(savedFilePath).catch(() => {});
}
throw error;
}
}; };
/** /**
@@ -619,7 +607,6 @@ export const updateTimeEntry = async (entryId, actor, data) => {
const { userId, role } = actor; const { userId, role } = actor;
const entry = await getTimeEntryById(entryId); const entry = await getTimeEntryById(entryId);
// Verify ownership (admin can edit anyone)
if (entry.userId !== userId && role !== 'admin') { if (entry.userId !== userId && role !== 'admin') {
throw new ForbiddenError('Nemáte oprávnenie upraviť tento záznam'); 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 companyId = normalizeOptionalId(data.companyId);
const description = normalizeOptionalText(data.description); const description = normalizeOptionalText(data.description);
// Verify related entities if being changed await validateRelatedEntities(projectId, todoId, companyId);
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á');
}
}
// Calculate new duration if times are changed // Calculate new duration if times are changed
let newDuration = entry.duration; let newDuration = entry.duration;
@@ -710,7 +662,6 @@ export const deleteTimeEntry = async (entryId, actor) => {
const { userId, role } = actor; const { userId, role } = actor;
const entry = await getTimeEntryById(entryId); const entry = await getTimeEntryById(entryId);
// Verify ownership (admin can delete anyone)
if (entry.userId !== userId && role !== 'admin') { if (entry.userId !== userId && role !== 'admin') {
throw new ForbiddenError('Nemáte oprávnenie odstrániť tento záznam'); 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) => { export const getTimeEntryWithRelations = async (entryId) => {
const entry = await getTimeEntryById(entryId); const entry = await getTimeEntryById(entryId);
// Get project if exists
let project = null; let project = null;
if (entry.projectId) { if (entry.projectId) {
[project] = await db [project] = await db
@@ -740,7 +690,6 @@ export const getTimeEntryWithRelations = async (entryId) => {
.limit(1); .limit(1);
} }
// Get todo if exists
let todo = null; let todo = null;
if (entry.todoId) { if (entry.todoId) {
[todo] = await db [todo] = await db
@@ -750,7 +699,6 @@ export const getTimeEntryWithRelations = async (entryId) => {
.limit(1); .limit(1);
} }
// Get company if exists
let company = null; let company = null;
if (entry.companyId) { if (entry.companyId) {
[company] = await db [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 endDate = new Date(year, month, 0, 23, 59, 59, 999);
const [user] = await db const [user] = await db
.select({ .select({ username: users.username, firstName: users.firstName, lastName: users.lastName })
username: users.username,
firstName: users.firstName,
lastName: users.lastName,
})
.from(users) .from(users)
.where(eq(users.id, userId)) .where(eq(users.id, userId))
.limit(1); .limit(1);
if (!user) { if (!user) throw new NotFoundError('Používateľ nenájdený');
throw new NotFoundError('Používateľ nenájdený');
}
const [company] = await db const company = await validateCompanyExists(companyId);
.select()
.from(companies)
.where(eq(companies.id, companyId))
.limit(1);
if (!company) {
throw new NotFoundError('Firma nenájdená');
}
const entries = await db const entries = await db
.select({ .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'); throw new NotFoundError('Žiadne dokončené záznamy pre danú firmu a mesiac');
} }
let totalMinutes = 0; const { totalMinutes, dailyTotals } = computeDailyTotals(completedEntries);
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 fullName = [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username; const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username;
const periodLabel = `${year}-${String(month).padStart(2, '0')}`; const periodLabel = `${year}-${String(month).padStart(2, '0')}`;
worksheet.getCell('A1').value = 'Company Timesheet'; const columns = [
worksheet.getCell('A1').font = { name: 'Calibri', size: 16, bold: true }; { key: 'date', header: 'Date', width: 12 },
worksheet.mergeCells('A1:D1'); { key: 'project', header: 'Project', width: 28 },
{ key: 'todo', header: 'Todo', width: 28 },
worksheet.getCell('A2').value = `Company: ${company.name}`; { key: 'description', header: 'Description', width: 40 },
worksheet.getCell('A3').value = `Employee: ${fullName}`; { key: 'start', header: 'Start', width: 12 },
worksheet.getCell('A4').value = `Period: ${periodLabel}`; { key: 'end', header: 'End', width: 12 },
worksheet.getCell('A5').value = `Generated: ${new Date().toLocaleString()}`; { key: 'duration', header: 'Duration (h:mm)', width: 16 },
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 headerRowNumber = 7; const { workbook, worksheet } = generateTimesheetWorkbook({
const headerRow = worksheet.getRow(headerRowNumber); title: 'Company Timesheet',
headerRow.values = [ headerMeta: [
'Date', `Company: ${company.name}`,
'Project', `Employee: ${fullName}`,
'Todo', `Period: ${periodLabel}`,
'Description', `Generated: ${new Date().toLocaleString()}`,
'Start', ],
'End', columns,
'Duration (h:mm)', headerRowNumber: 7,
]; entries: completedEntries,
headerRow.font = { name: 'Calibri', size: 11, bold: true }; formatRow: (entry) => ({
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), date: formatDate(entry.startTime),
project: entry.projectName || '', project: entry.projectName || '',
todo: entry.todoTitle || '', todo: entry.todoTitle || '',
@@ -890,81 +795,16 @@ export const generateCompanyTimesheet = async (userId, year, month, companyId) =
start: formatTime(entry.startTime), start: formatTime(entry.startTime),
end: formatTime(entry.endTime), end: formatTime(entry.endTime),
duration: formatDuration(entry.duration), duration: formatDuration(entry.duration),
}); }),
}); });
// Style body rows addDailySummary(worksheet, dailyTotals, totalMinutes);
worksheet.eachRow((row, rowNumber) => {
if (rowNumber >= headerRowNumber) {
row.alignment = { vertical: 'middle', wrapText: true };
}
});
let summaryStart = worksheet.lastRow.number + 2; const namePrefix = getUserNamePrefix(user);
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 companySlug = company.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); const companySlug = company.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
const filename = `${namePrefix}-vykazprace-${companySlug}-${periodLabel}.xlsx`; const filename = `${namePrefix}-vykazprace-${companySlug}-${periodLabel}.xlsx`;
const filePath = path.join(uploadsDir, filename);
let savedFilePath = null;
try { const newTimesheet = await saveTimesheetFile(workbook, { userId, year, month, filename });
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 { return {
timesheet: { timesheet: {
@@ -984,12 +824,6 @@ export const generateCompanyTimesheet = async (userId, year, month, companyId) =
daily: dailyTotals, daily: dailyTotals,
}, },
}; };
} catch (error) {
if (savedFilePath) {
await fs.unlink(savedFilePath).catch(() => {});
}
throw error;
}
}; };
/** /**
@@ -998,19 +832,16 @@ export const generateCompanyTimesheet = async (userId, year, month, companyId) =
export const getMonthlyStats = async (userId, year, month) => { export const getMonthlyStats = async (userId, year, month) => {
const entries = await getMonthlyTimeEntries(userId, year, month); const entries = await getMonthlyTimeEntries(userId, year, month);
// Total time in minutes
const totalMinutes = entries.reduce((sum, entry) => { const totalMinutes = entries.reduce((sum, entry) => {
return sum + (entry.duration || 0); return sum + (entry.duration || 0);
}, 0); }, 0);
// Count of days worked
const uniqueDays = new Set( const uniqueDays = new Set(
entries entries
.filter((e) => !e.isRunning) .filter((e) => !e.isRunning)
.map((e) => new Date(e.startTime).toDateString()) .map((e) => new Date(e.startTime).toDateString())
).size; ).size;
// Time by project
const byProject = {}; const byProject = {};
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.projectId && entry.duration) { if (entry.projectId && entry.duration) {
@@ -1021,7 +852,6 @@ export const getMonthlyStats = async (userId, year, month) => {
} }
}); });
// Time by company
const byCompany = {}; const byCompany = {};
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.companyId && entry.duration) { if (entry.companyId && entry.duration) {