diff --git a/src/app.js b/src/app.js index 30a334c..ff893c0 100644 --- a/src/app.js +++ b/src/app.js @@ -29,6 +29,7 @@ import auditRoutes from './routes/audit.routes.js'; import eventRoutes from './routes/event.routes.js'; import messageRoutes from './routes/message.routes.js'; import userRoutes from './routes/user.routes.js'; +import serviceRoutes from './routes/service.routes.js'; const app = express(); @@ -124,6 +125,7 @@ app.use('/api/audit-logs', auditRoutes); app.use('/api/events', eventRoutes); app.use('/api/messages', messageRoutes); app.use('/api/users', userRoutes); +app.use('/api/services', serviceRoutes); // Basic route app.get('/', (req, res) => { diff --git a/src/controllers/company-document.controller.js b/src/controllers/company-document.controller.js new file mode 100644 index 0000000..3c2c807 --- /dev/null +++ b/src/controllers/company-document.controller.js @@ -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); + } +}; diff --git a/src/controllers/service.controller.js b/src/controllers/service.controller.js new file mode 100644 index 0000000..1ba0195 --- /dev/null +++ b/src/controllers/service.controller.js @@ -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); + } +}; diff --git a/src/controllers/time-tracking.controller.js b/src/controllers/time-tracking.controller.js index 61e80cf..d107d53 100644 --- a/src/controllers/time-tracking.controller.js +++ b/src/controllers/time-tracking.controller.js @@ -325,3 +325,37 @@ export const getMonthlyStats = async (req, res, next) => { 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); + } +}; diff --git a/src/db/schema.js b/src/db/schema.js index fabaaa7..01273ff 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -284,6 +284,32 @@ export const timeEntries = pgTable('time_entries', { 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 export const messages = pgTable('messages', { id: uuid('id').primaryKey().defaultRandom(), diff --git a/src/routes/company.routes.js b/src/routes/company.routes.js index 89c35b5..8c8c035 100644 --- a/src/routes/company.routes.js +++ b/src/routes/company.routes.js @@ -1,6 +1,8 @@ import express from 'express'; +import multer from 'multer'; import * as companyController from '../controllers/company.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 { requireAdmin } from '../middlewares/auth/roleMiddleware.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 { 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(); // All company routes require authentication @@ -198,4 +208,40 @@ router.get( 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; diff --git a/src/routes/event.routes.js b/src/routes/event.routes.js index f9805a7..c7ed654 100644 --- a/src/routes/event.routes.js +++ b/src/routes/event.routes.js @@ -1,7 +1,6 @@ import express from 'express'; import * as eventController from '../controllers/event.controller.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 { createEventSchema, updateEventSchema } from '../validators/crm.validators.js'; 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( '/', - requireAdmin, validateBody(createEventSchema), eventController.createEvent ); /** - * PUT /api/events/:eventId - Upraviť event (iba admin) + * PUT /api/events/:eventId - Upraviť event (všetci autentifikovaní používatelia) */ router.put( '/:eventId', - requireAdmin, validateParams(eventIdSchema), validateBody(updateEventSchema), eventController.updateEvent ); /** - * DELETE /api/events/:eventId - Zmazať event (iba admin) + * DELETE /api/events/:eventId - Zmazať event (všetci autentifikovaní používatelia) */ router.delete( '/:eventId', - requireAdmin, validateParams(eventIdSchema), 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( '/:eventId/notify', - requireAdmin, validateParams(eventIdSchema), eventController.sendEventNotification ); diff --git a/src/routes/service.routes.js b/src/routes/service.routes.js new file mode 100644 index 0000000..4db0129 --- /dev/null +++ b/src/routes/service.routes.js @@ -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; diff --git a/src/routes/time-tracking.routes.js b/src/routes/time-tracking.routes.js index 62bda77..acbc32a 100644 --- a/src/routes/time-tracking.routes.js +++ b/src/routes/time-tracking.routes.js @@ -63,6 +63,19 @@ router.post( 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 router.get( '/stats/monthly/:year/:month', diff --git a/src/services/company-document.service.js b/src/services/company-document.service.js new file mode 100644 index 0000000..c5a33db --- /dev/null +++ b/src/services/company-document.service.js @@ -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ý' }; +}; diff --git a/src/services/service.service.js b/src/services/service.service.js new file mode 100644 index 0000000..ca56ea1 --- /dev/null +++ b/src/services/service.service.js @@ -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á' }; +}; diff --git a/src/services/time-tracking.service.js b/src/services/time-tracking.service.js index ff7b40f..8974bb4 100644 --- a/src/services/time-tracking.service.js +++ b/src/services/time-tracking.service.js @@ -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 */ diff --git a/src/validators/crm.validators.js b/src/validators/crm.validators.js index 35cc101..a51f76a 100644 --- a/src/validators/crm.validators.js +++ b/src/validators/crm.validators.js @@ -177,7 +177,7 @@ export const updateTimeEntrySchema = z.object({ export const createEventSchema = z.object({ title: z.string().min(1, 'Názov je povinný'), 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ý'), end: z.string().min(1, 'Koniec je povinný'), 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({ title: z.string().min(1).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(), end: z.string().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())), +});