excel preview & file handling
This commit is contained in:
@@ -132,6 +132,32 @@ export const getMonthlyTimeEntries = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate timesheet file for a month
|
||||
* POST /api/time-tracking/month/:year/:month/generate
|
||||
*/
|
||||
export const generateMonthlyTimesheet = async (req, res) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
const { year, month } = req.params;
|
||||
|
||||
const result = await timeTrackingService.generateMonthlyTimesheet(
|
||||
userId,
|
||||
parseInt(year),
|
||||
parseInt(month)
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: 'Timesheet bol vygenerovaný',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||
res.status(error.statusCode || 500).json(errorResponse);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get time entry by ID
|
||||
* GET /api/time-tracking/:entryId
|
||||
|
||||
@@ -63,6 +63,7 @@ export const uploadTimesheet = async (req, res) => {
|
||||
fileSize: file.size,
|
||||
year: parseInt(year),
|
||||
month: parseInt(month),
|
||||
isGenerated: false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -76,6 +77,7 @@ export const uploadTimesheet = async (req, res) => {
|
||||
fileSize: newTimesheet.fileSize,
|
||||
year: newTimesheet.year,
|
||||
month: newTimesheet.month,
|
||||
isGenerated: newTimesheet.isGenerated,
|
||||
uploadedAt: newTimesheet.uploadedAt,
|
||||
},
|
||||
},
|
||||
@@ -122,6 +124,7 @@ export const getMyTimesheets = async (req, res) => {
|
||||
fileSize: timesheets.fileSize,
|
||||
year: timesheets.year,
|
||||
month: timesheets.month,
|
||||
isGenerated: timesheets.isGenerated,
|
||||
uploadedAt: timesheets.uploadedAt,
|
||||
})
|
||||
.from(timesheets)
|
||||
@@ -171,6 +174,7 @@ export const getAllTimesheets = async (req, res) => {
|
||||
fileSize: timesheets.fileSize,
|
||||
year: timesheets.year,
|
||||
month: timesheets.month,
|
||||
isGenerated: timesheets.isGenerated,
|
||||
uploadedAt: timesheets.uploadedAt,
|
||||
userId: timesheets.userId,
|
||||
username: users.username,
|
||||
|
||||
3
src/db/migrations/0005_add_is_generated_timesheets.sql
Normal file
3
src/db/migrations/0005_add_is_generated_timesheets.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Add flag to mark system-generated timesheets
|
||||
ALTER TABLE timesheets
|
||||
ADD COLUMN IF NOT EXISTS is_generated BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -89,6 +89,17 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add is_generated flag to timesheets if not exists
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name='timesheets' AND column_name='is_generated'
|
||||
) THEN
|
||||
ALTER TABLE timesheets ADD COLUMN is_generated BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_companies_created_at ON companies(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_company_id ON projects(company_id);
|
||||
|
||||
@@ -186,6 +186,7 @@ export const timesheets = pgTable('timesheets', {
|
||||
fileSize: integer('file_size').notNull(), // veľkosť súboru v bytoch
|
||||
year: integer('year').notNull(), // rok (napr. 2024)
|
||||
month: integer('month').notNull(), // mesiac (1-12)
|
||||
isGenerated: boolean('is_generated').default(false).notNull(), // či bol súbor vygenerovaný systémom
|
||||
uploadedAt: timestamp('uploaded_at').defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
|
||||
@@ -47,6 +47,18 @@ router.get(
|
||||
timeTrackingController.getMonthlyTimeEntries
|
||||
);
|
||||
|
||||
// Generate monthly timesheet (XLSX)
|
||||
router.post(
|
||||
'/month/:year/:month/generate',
|
||||
validateParams(
|
||||
z.object({
|
||||
year: z.string().regex(/^\d{4}$/, 'Rok musí byť 4-ciferné číslo'),
|
||||
month: z.string().regex(/^(0?[1-9]|1[0-2])$/, 'Mesiac musí byť číslo 1-12'),
|
||||
})
|
||||
),
|
||||
timeTrackingController.generateMonthlyTimesheet
|
||||
);
|
||||
|
||||
// Get monthly statistics
|
||||
router.get(
|
||||
'/stats/monthly/:year/:month',
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { timeEntries, projects, todos, companies, users } from '../db/schema.js';
|
||||
import { eq, and, gte, lte, desc, sql } from 'drizzle-orm';
|
||||
import { timeEntries, projects, todos, companies, users, timesheets } from '../db/schema.js';
|
||||
import { eq, and, gte, lte, desc } from 'drizzle-orm';
|
||||
import { NotFoundError, BadRequestError } from '../utils/errors.js';
|
||||
import ExcelJS from 'exceljs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// Helpers to normalize optional payload fields
|
||||
const normalizeOptionalId = (value) => {
|
||||
@@ -18,6 +21,28 @@ const normalizeOptionalText = (value) => {
|
||||
return trimmed.length ? trimmed : null;
|
||||
};
|
||||
|
||||
const formatDate = (value) => {
|
||||
const date = new Date(value);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatTime = (value) => {
|
||||
const date = new Date(value);
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const formatDuration = (minutes) => {
|
||||
if (!Number.isFinite(minutes)) return '';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = Math.abs(minutes % 60);
|
||||
return `${hours}:${String(mins).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a new time entry
|
||||
*/
|
||||
@@ -274,6 +299,210 @@ export const getMonthlyTimeEntries = async (userId, year, month) => {
|
||||
.orderBy(desc(timeEntries.startTime));
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate an XLSX timesheet for a given month and user.
|
||||
*/
|
||||
export const generateMonthlyTimesheet = async (userId, year, month) => {
|
||||
const startDate = new Date(year, month - 1, 1);
|
||||
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,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('Používateľ nenájdený');
|
||||
}
|
||||
|
||||
const entries = await db
|
||||
.select({
|
||||
id: timeEntries.id,
|
||||
startTime: timeEntries.startTime,
|
||||
endTime: timeEntries.endTime,
|
||||
duration: timeEntries.duration,
|
||||
description: timeEntries.description,
|
||||
projectName: projects.name,
|
||||
todoTitle: todos.title,
|
||||
companyName: companies.name,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.leftJoin(projects, eq(timeEntries.projectId, projects.id))
|
||||
.leftJoin(todos, eq(timeEntries.todoId, todos.id))
|
||||
.leftJoin(companies, eq(timeEntries.companyId, companies.id))
|
||||
.where(
|
||||
and(
|
||||
eq(timeEntries.userId, userId),
|
||||
gte(timeEntries.startTime, startDate),
|
||||
lte(timeEntries.startTime, endDate)
|
||||
)
|
||||
)
|
||||
.orderBy(timeEntries.startTime);
|
||||
|
||||
const completedEntries = entries.filter((entry) => entry.endTime && entry.duration !== null);
|
||||
if (completedEntries.length === 0) {
|
||||
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 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 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({
|
||||
date: formatDate(entry.startTime),
|
||||
project: entry.projectName || '',
|
||||
todo: entry.todoTitle || '',
|
||||
company: entry.companyName || '',
|
||||
description: entry.description || '',
|
||||
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 };
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
const filename = `timesheet-${periodLabel}-${Date.now()}.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();
|
||||
|
||||
return {
|
||||
timesheet: {
|
||||
id: newTimesheet.id,
|
||||
fileName: newTimesheet.fileName,
|
||||
fileType: newTimesheet.fileType,
|
||||
fileSize: newTimesheet.fileSize,
|
||||
year: newTimesheet.year,
|
||||
month: newTimesheet.month,
|
||||
isGenerated: newTimesheet.isGenerated,
|
||||
uploadedAt: newTimesheet.uploadedAt,
|
||||
},
|
||||
totals: {
|
||||
totalMinutes,
|
||||
totalFormatted: formatDuration(totalMinutes),
|
||||
daily: dailyTotals,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (savedFilePath) {
|
||||
await fs.unlink(savedFilePath).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update time entry
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user