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:
@@ -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,80 +571,15 @@ 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 db
|
||||
.insert(timesheets)
|
||||
.values({
|
||||
userId,
|
||||
fileName: filename,
|
||||
filePath,
|
||||
fileType: 'xlsx',
|
||||
fileSize,
|
||||
year,
|
||||
month,
|
||||
isGenerated: true,
|
||||
})
|
||||
.returning();
|
||||
const newTimesheet = await saveTimesheetFile(workbook, { userId, year, month, filename });
|
||||
|
||||
return {
|
||||
timesheet: {
|
||||
@@ -604,12 +598,6 @@ export const generateMonthlyTimesheet = async (userId, year, month) => {
|
||||
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 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,81 +795,16 @@ 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 db
|
||||
.insert(timesheets)
|
||||
.values({
|
||||
userId,
|
||||
fileName: filename,
|
||||
filePath,
|
||||
fileType: 'xlsx',
|
||||
fileSize,
|
||||
year,
|
||||
month,
|
||||
isGenerated: true,
|
||||
})
|
||||
.returning();
|
||||
const newTimesheet = await saveTimesheetFile(workbook, { userId, year, month, filename });
|
||||
|
||||
return {
|
||||
timesheet: {
|
||||
@@ -984,12 +824,6 @@ export const generateCompanyTimesheet = async (userId, year, month, companyId) =
|
||||
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) => {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user