feat: Add services, company documents, company timesheet export

- Add services table and CRUD endpoints (/api/services)
- Add company documents upload/download functionality
- Add company timesheet XLSX export endpoint
- Remove admin requirement from event routes (all authenticated users can manage events)
- Add service validators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
richardtekula
2026-01-17 18:45:01 +01:00
parent b542d1d635
commit 514b6c8a92
13 changed files with 866 additions and 11 deletions

View File

@@ -29,6 +29,7 @@ import auditRoutes from './routes/audit.routes.js';
import eventRoutes from './routes/event.routes.js'; import eventRoutes from './routes/event.routes.js';
import messageRoutes from './routes/message.routes.js'; import messageRoutes from './routes/message.routes.js';
import userRoutes from './routes/user.routes.js'; import userRoutes from './routes/user.routes.js';
import serviceRoutes from './routes/service.routes.js';
const app = express(); const app = express();
@@ -124,6 +125,7 @@ app.use('/api/audit-logs', auditRoutes);
app.use('/api/events', eventRoutes); app.use('/api/events', eventRoutes);
app.use('/api/messages', messageRoutes); app.use('/api/messages', messageRoutes);
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
app.use('/api/services', serviceRoutes);
// Basic route // Basic route
app.get('/', (req, res) => { app.get('/', (req, res) => {

View File

@@ -0,0 +1,88 @@
import * as companyDocumentService from '../services/company-document.service.js';
/**
* Get all documents for a company
* GET /api/companies/:companyId/documents
*/
export const getDocuments = async (req, res, next) => {
try {
const { companyId } = req.params;
const documents = await companyDocumentService.getDocumentsByCompanyId(companyId);
res.status(200).json({
success: true,
count: documents.length,
data: documents,
});
} catch (error) {
next(error);
}
};
/**
* Upload a document for a company
* POST /api/companies/:companyId/documents
*/
export const uploadDocument = async (req, res, next) => {
try {
const { companyId } = req.params;
const userId = req.userId;
const { description } = req.body;
const document = await companyDocumentService.uploadDocument({
companyId,
userId,
file: req.file,
description,
});
res.status(201).json({
success: true,
data: document,
message: 'Dokument bol nahraný',
});
} catch (error) {
next(error);
}
};
/**
* Download a document
* GET /api/companies/:companyId/documents/:docId/download
*/
export const downloadDocument = async (req, res, next) => {
try {
const { companyId, docId } = req.params;
const { filePath, fileName, fileType } = await companyDocumentService.getDocumentForDownload(
companyId,
docId
);
res.setHeader('Content-Type', fileType);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
res.sendFile(filePath);
} catch (error) {
next(error);
}
};
/**
* Delete a document
* DELETE /api/companies/:companyId/documents/:docId
*/
export const deleteDocument = async (req, res, next) => {
try {
const { companyId, docId } = req.params;
const result = await companyDocumentService.deleteDocument(companyId, docId);
res.status(200).json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};

View File

@@ -0,0 +1,101 @@
import * as serviceService from '../services/service.service.js';
/**
* Get all services
* GET /api/services
*/
export const getAllServices = async (req, res, next) => {
try {
const services = await serviceService.getAllServices();
res.status(200).json({
success: true,
count: services.length,
data: services,
});
} catch (error) {
next(error);
}
};
/**
* Get service by ID
* GET /api/services/:serviceId
*/
export const getServiceById = async (req, res, next) => {
try {
const { serviceId } = req.params;
const service = await serviceService.getServiceById(serviceId);
res.status(200).json({
success: true,
data: service,
});
} catch (error) {
next(error);
}
};
/**
* Create new service
* POST /api/services
* Body: { name, price, description }
*/
export const createService = async (req, res, next) => {
try {
const userId = req.userId;
const data = req.body;
const service = await serviceService.createService(userId, data);
res.status(201).json({
success: true,
data: service,
message: 'Služba bola vytvorená',
});
} catch (error) {
next(error);
}
};
/**
* Update service
* PUT /api/services/:serviceId
* Body: { name, price, description }
*/
export const updateService = async (req, res, next) => {
try {
const { serviceId } = req.params;
const data = req.body;
const service = await serviceService.updateService(serviceId, data);
res.status(200).json({
success: true,
data: service,
message: 'Služba bola aktualizovaná',
});
} catch (error) {
next(error);
}
};
/**
* Delete service
* DELETE /api/services/:serviceId
*/
export const deleteService = async (req, res, next) => {
try {
const { serviceId } = req.params;
const result = await serviceService.deleteService(serviceId);
res.status(200).json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
};

View File

@@ -325,3 +325,37 @@ export const getMonthlyStats = async (req, res, next) => {
next(error); next(error);
} }
}; };
/**
* Generate timesheet for a specific company
* POST /api/time-tracking/generate-company-timesheet
* Body: { year, month, companyId }
*/
export const generateCompanyTimesheet = async (req, res, next) => {
try {
const userId = req.userId;
const { year, month, companyId } = req.body;
if (!year || !month || !companyId) {
return res.status(400).json({
success: false,
message: 'Year, month and companyId are required',
});
}
const result = await timeTrackingService.generateCompanyTimesheet(
userId,
parseInt(year),
parseInt(month),
companyId
);
res.status(201).json({
success: true,
data: result,
message: `Timesheet pre firmu ${result.companyName} bol vygenerovaný`,
});
} catch (error) {
next(error);
}
};

View File

@@ -284,6 +284,32 @@ export const timeEntries = pgTable('time_entries', {
updatedAt: timestamp('updated_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(),
}); });
// Company Documents table - dokumenty nahrané k firme
export const companyDocuments = pgTable('company_documents', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id').references(() => companies.id, { onDelete: 'cascade' }).notNull(),
fileName: text('file_name').notNull(), // unikátny názov súboru na disku
originalName: text('original_name').notNull(), // pôvodný názov súboru
filePath: text('file_path').notNull(), // cesta k súboru
fileType: text('file_type').notNull(), // MIME typ
fileSize: integer('file_size').notNull(), // veľkosť v bytoch
description: text('description'),
uploadedBy: uuid('uploaded_by').references(() => users.id, { onDelete: 'set null' }),
uploadedAt: timestamp('uploaded_at').defaultNow().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Services table - služby ponúkané firmou
export const services = pgTable('services', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
price: text('price').notNull(), // stored as text for flexibility with decimal
description: text('description'),
createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Messages table - interná komunikácia medzi používateľmi // Messages table - interná komunikácia medzi používateľmi
export const messages = pgTable('messages', { export const messages = pgTable('messages', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),

View File

@@ -1,6 +1,8 @@
import express from 'express'; import express from 'express';
import multer from 'multer';
import * as companyController from '../controllers/company.controller.js'; import * as companyController from '../controllers/company.controller.js';
import * as personalContactController from '../controllers/personal-contact.controller.js'; import * as personalContactController from '../controllers/personal-contact.controller.js';
import * as companyDocumentController from '../controllers/company-document.controller.js';
import { authenticate } from '../middlewares/auth/authMiddleware.js'; import { authenticate } from '../middlewares/auth/authMiddleware.js';
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js'; import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
import { checkCompanyAccess } from '../middlewares/auth/resourceAccessMiddleware.js'; import { checkCompanyAccess } from '../middlewares/auth/resourceAccessMiddleware.js';
@@ -8,6 +10,14 @@ import { validateBody, validateParams } from '../middlewares/security/validateIn
import { createCompanySchema, updateCompanySchema, createCompanyReminderSchema, updateCompanyReminderSchema } from '../validators/crm.validators.js'; import { createCompanySchema, updateCompanySchema, createCompanyReminderSchema, updateCompanyReminderSchema } from '../validators/crm.validators.js';
import { z } from 'zod'; import { z } from 'zod';
// Configure multer for file uploads (memory storage)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB max
},
});
const router = express.Router(); const router = express.Router();
// All company routes require authentication // All company routes require authentication
@@ -198,4 +208,40 @@ router.get(
personalContactController.getContactsByCompany personalContactController.getContactsByCompany
); );
// Company Documents
router.get(
'/:companyId/documents',
validateParams(z.object({ companyId: z.string().uuid() })),
checkCompanyAccess,
companyDocumentController.getDocuments
);
router.post(
'/:companyId/documents',
validateParams(z.object({ companyId: z.string().uuid() })),
checkCompanyAccess,
upload.single('file'),
companyDocumentController.uploadDocument
);
router.get(
'/:companyId/documents/:docId/download',
validateParams(z.object({
companyId: z.string().uuid(),
docId: z.string().uuid()
})),
checkCompanyAccess,
companyDocumentController.downloadDocument
);
router.delete(
'/:companyId/documents/:docId',
requireAdmin,
validateParams(z.object({
companyId: z.string().uuid(),
docId: z.string().uuid()
})),
companyDocumentController.deleteDocument
);
export default router; export default router;

View File

@@ -1,7 +1,6 @@
import express from 'express'; import express from 'express';
import * as eventController from '../controllers/event.controller.js'; import * as eventController from '../controllers/event.controller.js';
import { authenticate } from '../middlewares/auth/authMiddleware.js'; import { authenticate } from '../middlewares/auth/authMiddleware.js';
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
import { validateBody, validateParams, validateQuery } from '../middlewares/security/validateInput.js'; import { validateBody, validateParams, validateQuery } from '../middlewares/security/validateInput.js';
import { createEventSchema, updateEventSchema } from '../validators/crm.validators.js'; import { createEventSchema, updateEventSchema } from '../validators/crm.validators.js';
import { z } from 'zod'; import { z } from 'zod';
@@ -39,42 +38,38 @@ router.get(
); );
/** /**
* POST /api/events - Vytvoriť event (iba admin) * POST /api/events - Vytvoriť event (všetci autentifikovaní používatelia)
*/ */
router.post( router.post(
'/', '/',
requireAdmin,
validateBody(createEventSchema), validateBody(createEventSchema),
eventController.createEvent eventController.createEvent
); );
/** /**
* PUT /api/events/:eventId - Upraviť event (iba admin) * PUT /api/events/:eventId - Upraviť event (všetci autentifikovaní používatelia)
*/ */
router.put( router.put(
'/:eventId', '/:eventId',
requireAdmin,
validateParams(eventIdSchema), validateParams(eventIdSchema),
validateBody(updateEventSchema), validateBody(updateEventSchema),
eventController.updateEvent eventController.updateEvent
); );
/** /**
* DELETE /api/events/:eventId - Zmazať event (iba admin) * DELETE /api/events/:eventId - Zmazať event (všetci autentifikovaní používatelia)
*/ */
router.delete( router.delete(
'/:eventId', '/:eventId',
requireAdmin,
validateParams(eventIdSchema), validateParams(eventIdSchema),
eventController.deleteEvent eventController.deleteEvent
); );
/** /**
* POST /api/events/:eventId/notify - Odoslať notifikácie priradeným používateľom (iba admin) * POST /api/events/:eventId/notify - Odoslať notifikácie priradeným používateľom (všetci autentifikovaní používatelia)
*/ */
router.post( router.post(
'/:eventId/notify', '/:eventId/notify',
requireAdmin,
validateParams(eventIdSchema), validateParams(eventIdSchema),
eventController.sendEventNotification eventController.sendEventNotification
); );

View File

@@ -0,0 +1,63 @@
import express from 'express';
import * as serviceController from '../controllers/service.controller.js';
import { authenticate } from '../middlewares/auth/authMiddleware.js';
import { requireAdmin } from '../middlewares/auth/roleMiddleware.js';
import { validateBody, validateParams } from '../middlewares/security/validateInput.js';
import { createServiceSchema, updateServiceSchema } from '../validators/crm.validators.js';
import { z } from 'zod';
const router = express.Router();
const serviceIdSchema = z.object({
serviceId: z.string().uuid(),
});
// All service routes require authentication
router.use(authenticate);
/**
* GET /api/services - Get all services (all authenticated users)
*/
router.get('/', serviceController.getAllServices);
/**
* GET /api/services/:serviceId - Get service by ID (all authenticated users)
*/
router.get(
'/:serviceId',
validateParams(serviceIdSchema),
serviceController.getServiceById
);
/**
* POST /api/services - Create new service (admin only)
*/
router.post(
'/',
requireAdmin,
validateBody(createServiceSchema),
serviceController.createService
);
/**
* PUT /api/services/:serviceId - Update service (admin only)
*/
router.put(
'/:serviceId',
requireAdmin,
validateParams(serviceIdSchema),
validateBody(updateServiceSchema),
serviceController.updateService
);
/**
* DELETE /api/services/:serviceId - Delete service (admin only)
*/
router.delete(
'/:serviceId',
requireAdmin,
validateParams(serviceIdSchema),
serviceController.deleteService
);
export default router;

View File

@@ -63,6 +63,19 @@ router.post(
timeTrackingController.generateMonthlyTimesheet timeTrackingController.generateMonthlyTimesheet
); );
// Generate company timesheet (XLSX)
router.post(
'/generate-company-timesheet',
validateBody(
z.object({
year: z.number().int().min(2000).max(2100),
month: z.number().int().min(1).max(12),
companyId: z.string().uuid('Neplatný formát company ID'),
})
),
timeTrackingController.generateCompanyTimesheet
);
// Get monthly statistics // Get monthly statistics
router.get( router.get(
'/stats/monthly/:year/:month', '/stats/monthly/:year/:month',

View File

@@ -0,0 +1,166 @@
import fs from 'fs/promises';
import path from 'path';
import { db } from '../config/database.js';
import { companyDocuments, companies, users } from '../db/schema.js';
import { and, desc, eq } from 'drizzle-orm';
import { BadRequestError, NotFoundError } from '../utils/errors.js';
import { logger } from '../utils/logger.js';
const BASE_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'documents');
const buildDestinationPath = (companyId, originalName) => {
const ext = path.extname(originalName);
const name = path.basename(originalName, ext);
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const filename = `${name}-${uniqueSuffix}${ext}`;
const folder = path.join(BASE_UPLOAD_DIR, companyId);
const filePath = path.join(folder, filename);
return { folder, filename, filePath };
};
const safeUnlink = async (filePath) => {
if (!filePath) return;
try {
await fs.unlink(filePath);
} catch (error) {
logger.error('Failed to delete file', error);
}
};
const ensureCompanyExists = 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;
};
/**
* Get all documents for a company
*/
export const getDocumentsByCompanyId = async (companyId) => {
await ensureCompanyExists(companyId);
return db
.select({
id: companyDocuments.id,
companyId: companyDocuments.companyId,
fileName: companyDocuments.fileName,
originalName: companyDocuments.originalName,
fileType: companyDocuments.fileType,
fileSize: companyDocuments.fileSize,
description: companyDocuments.description,
uploadedAt: companyDocuments.uploadedAt,
uploadedBy: companyDocuments.uploadedBy,
uploaderUsername: users.username,
uploaderFirstName: users.firstName,
uploaderLastName: users.lastName,
})
.from(companyDocuments)
.leftJoin(users, eq(companyDocuments.uploadedBy, users.id))
.where(eq(companyDocuments.companyId, companyId))
.orderBy(desc(companyDocuments.uploadedAt));
};
/**
* Upload a document for a company
*/
export const uploadDocument = async ({ companyId, userId, file, description }) => {
if (!file) {
throw new BadRequestError('Súbor nebol nahraný');
}
await ensureCompanyExists(companyId);
const { folder, filename, filePath } = buildDestinationPath(companyId, file.originalname);
await fs.mkdir(folder, { recursive: true });
try {
await fs.writeFile(filePath, file.buffer);
const [newDoc] = await db
.insert(companyDocuments)
.values({
companyId,
fileName: filename,
originalName: file.originalname,
filePath,
fileType: file.mimetype,
fileSize: file.size,
description: description || null,
uploadedBy: userId,
})
.returning();
return newDoc;
} catch (error) {
await safeUnlink(filePath);
throw error;
}
};
/**
* Get document by ID for download
*/
export const getDocumentForDownload = async (companyId, documentId) => {
await ensureCompanyExists(companyId);
const [doc] = await db
.select()
.from(companyDocuments)
.where(and(
eq(companyDocuments.id, documentId),
eq(companyDocuments.companyId, companyId)
))
.limit(1);
if (!doc) {
throw new NotFoundError('Dokument nenájdený');
}
try {
await fs.access(doc.filePath);
} catch {
throw new NotFoundError('Súbor nebol nájdený na serveri');
}
return {
document: doc,
filePath: doc.filePath,
fileName: doc.originalName,
fileType: doc.fileType,
};
};
/**
* Delete a document
*/
export const deleteDocument = async (companyId, documentId) => {
await ensureCompanyExists(companyId);
const [doc] = await db
.select()
.from(companyDocuments)
.where(and(
eq(companyDocuments.id, documentId),
eq(companyDocuments.companyId, companyId)
))
.limit(1);
if (!doc) {
throw new NotFoundError('Dokument nenájdený');
}
await safeUnlink(doc.filePath);
await db.delete(companyDocuments).where(eq(companyDocuments.id, documentId));
return { success: true, message: 'Dokument bol odstránený' };
};

View File

@@ -0,0 +1,87 @@
import { db } from '../config/database.js';
import { services } from '../db/schema.js';
import { eq, desc } from 'drizzle-orm';
import { NotFoundError } from '../utils/errors.js';
/**
* Get all services
*/
export const getAllServices = async () => {
return await db
.select()
.from(services)
.orderBy(desc(services.createdAt));
};
/**
* Get service by ID
*/
export const getServiceById = async (serviceId) => {
const [service] = await db
.select()
.from(services)
.where(eq(services.id, serviceId))
.limit(1);
if (!service) {
throw new NotFoundError('Služba nenájdená');
}
return service;
};
/**
* Create new service
* @param {string} userId - ID of user creating the service
* @param {object} data - Service data
*/
export const createService = async (userId, data) => {
const { name, price, description } = data;
const [newService] = await db
.insert(services)
.values({
name,
price,
description: description || null,
createdBy: userId,
})
.returning();
return newService;
};
/**
* Update service
* @param {string} serviceId - ID of service to update
* @param {object} data - Updated data
*/
export const updateService = async (serviceId, data) => {
const service = await getServiceById(serviceId);
const { name, price, description } = data;
const [updated] = await db
.update(services)
.set({
name: name !== undefined ? name : service.name,
price: price !== undefined ? price : service.price,
description: description !== undefined ? description : service.description,
updatedAt: new Date(),
})
.where(eq(services.id, serviceId))
.returning();
return updated;
};
/**
* Delete service
*/
export const deleteService = async (serviceId) => {
await getServiceById(serviceId); // Check if exists
await db.delete(services).where(eq(services.id, serviceId));
return { success: true, message: 'Služba bola odstránená' };
};

View File

@@ -677,6 +677,218 @@ export const getTimeEntryWithRelations = async (entryId) => {
}; };
}; };
/**
* Generate an XLSX timesheet for a specific company for a given month and user.
*/
export const generateCompanyTimesheet = async (userId, year, month, companyId) => {
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 [company] = await db
.select()
.from(companies)
.where(eq(companies.id, companyId))
.limit(1);
if (!company) {
throw new NotFoundError('Firma 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,
})
.from(timeEntries)
.leftJoin(projects, eq(timeEntries.projectId, projects.id))
.leftJoin(todos, eq(timeEntries.todoId, todos.id))
.where(
and(
eq(timeEntries.userId, userId),
eq(timeEntries.companyId, companyId),
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ú 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 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 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({
date: formatDate(entry.startTime),
project: entry.projectName || '',
todo: entry.todoTitle || '',
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 = `company-timesheet-${company.name.replace(/[^a-zA-Z0-9]/g, '_')}-${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,
},
companyName: company.name,
totals: {
totalMinutes,
totalFormatted: formatDuration(totalMinutes),
daily: dailyTotals,
},
};
} catch (error) {
if (savedFilePath) {
await fs.unlink(savedFilePath).catch(() => {});
}
throw error;
}
};
/** /**
* Get monthly statistics for a user * Get monthly statistics for a user
*/ */

View File

@@ -177,7 +177,7 @@ export const updateTimeEntrySchema = z.object({
export const createEventSchema = z.object({ export const createEventSchema = z.object({
title: z.string().min(1, 'Názov je povinný'), title: z.string().min(1, 'Názov je povinný'),
description: z.string().optional(), description: z.string().optional(),
type: z.enum(['meeting', 'event', 'important']).default('meeting'), type: z.enum(['meeting', 'event', 'important', 'dostupnost', 'ine']).default('meeting'),
start: z.string().min(1, 'Začiatok je povinný'), start: z.string().min(1, 'Začiatok je povinný'),
end: z.string().min(1, 'Koniec je povinný'), end: z.string().min(1, 'Koniec je povinný'),
assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(), assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(),
@@ -186,8 +186,30 @@ export const createEventSchema = z.object({
export const updateEventSchema = z.object({ export const updateEventSchema = z.object({
title: z.string().min(1).optional(), title: z.string().min(1).optional(),
description: z.string().optional(), description: z.string().optional(),
type: z.enum(['meeting', 'event', 'important']).optional(), type: z.enum(['meeting', 'event', 'important', 'dostupnost', 'ine']).optional(),
start: z.string().optional(), start: z.string().optional(),
end: z.string().optional(), end: z.string().optional(),
assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(), assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(),
}); });
// Service validators
export const createServiceSchema = z.object({
name: z
.string({
required_error: 'Názov služby je povinný',
})
.min(1, 'Názov služby nemôže byť prázdny')
.max(255, 'Názov služby môže mať maximálne 255 znakov'),
price: z
.string({
required_error: 'Cena je povinná',
})
.min(1, 'Cena nemôže byť prázdna'),
description: z.string().max(1000).optional(),
});
export const updateServiceSchema = z.object({
name: z.string().min(1).max(255).optional(),
price: z.string().min(1).optional(),
description: z.string().max(1000).optional().or(z.literal('').or(z.null())),
});