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')}`;
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user