From 540c1719d3620bec266c610f1ef1cd2eb03edf15 Mon Sep 17 00:00:00 2001 From: richardtekula Date: Mon, 24 Nov 2025 06:41:39 +0100 Subject: [PATCH] Add Time Tracking backend API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementovaný kompletný backend pre time tracking: - Nová tabuľka time_entries s foreign keys na users, projects, todos, companies - Service layer s business logikou pre CRUD operácie - Controller pre všetky endpointy - Validačné schémy pomocou Zod - Routes s autentifikáciou a validáciou - Endpointy: * POST /api/time-tracking/start - Spustenie timeru * POST /api/time-tracking/:id/stop - Zastavenie timeru * GET /api/time-tracking/running - Získanie bežiaceho záznamu * GET /api/time-tracking/month/:year/:month - Mesačné záznamy * GET /api/time-tracking/stats/monthly/:year/:month - Mesačné štatistiky * PATCH /api/time-tracking/:id - Aktualizácia záznamu * DELETE /api/time-tracking/:id - Zmazanie záznamu - Podpora pre isEdited flag pri editácii - Kalkulácia duration v minútach 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app.js | 2 + src/controllers/time-tracking.controller.js | 246 ++++++++++ .../0004_add_time_entries_table.sql | 23 + src/db/schema.js | 17 + src/routes/time-tracking.routes.js | 91 ++++ src/services/time-tracking.service.js | 440 ++++++++++++++++++ src/validators/crm.validators.js | 24 + 7 files changed, 843 insertions(+) create mode 100644 src/controllers/time-tracking.controller.js create mode 100644 src/db/migrations/0004_add_time_entries_table.sql create mode 100644 src/routes/time-tracking.routes.js create mode 100644 src/services/time-tracking.service.js diff --git a/src/app.js b/src/app.js index 3005000..7555e26 100644 --- a/src/app.js +++ b/src/app.js @@ -22,6 +22,7 @@ import companyRoutes from './routes/company.routes.js'; import projectRoutes from './routes/project.routes.js'; import todoRoutes from './routes/todo.routes.js'; import noteRoutes from './routes/note.routes.js'; +import timeTrackingRoutes from './routes/time-tracking.routes.js'; const app = express(); @@ -82,6 +83,7 @@ app.use('/api/companies', companyRoutes); app.use('/api/projects', projectRoutes); app.use('/api/todos', todoRoutes); app.use('/api/notes', noteRoutes); +app.use('/api/time-tracking', timeTrackingRoutes); // Basic route app.get('/', (req, res) => { diff --git a/src/controllers/time-tracking.controller.js b/src/controllers/time-tracking.controller.js new file mode 100644 index 0000000..306253f --- /dev/null +++ b/src/controllers/time-tracking.controller.js @@ -0,0 +1,246 @@ +import * as timeTrackingService from '../services/time-tracking.service.js'; +import { formatErrorResponse } from '../utils/errors.js'; + +/** + * Start a new time entry + * POST /api/time-tracking/start + */ +export const startTimeEntry = async (req, res) => { + try { + const userId = req.userId; + const { projectId, todoId, companyId, description } = req.body; + + const entry = await timeTrackingService.startTimeEntry(userId, { + projectId, + todoId, + companyId, + description, + }); + + res.status(201).json({ + success: true, + data: entry, + message: 'Časovač bol spustený', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Stop a running time entry + * POST /api/time-tracking/:entryId/stop + */ +export const stopTimeEntry = async (req, res) => { + try { + const userId = req.userId; + const { entryId } = req.params; + const { projectId, todoId, companyId, description } = req.body; + + const entry = await timeTrackingService.stopTimeEntry(entryId, userId, { + projectId, + todoId, + companyId, + description, + }); + + res.status(200).json({ + success: true, + data: entry, + message: 'Časovač bol zastavený', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get running time entry for current user + * GET /api/time-tracking/running + */ +export const getRunningTimeEntry = async (req, res) => { + try { + const userId = req.userId; + + const entry = await timeTrackingService.getRunningTimeEntry(userId); + + res.status(200).json({ + success: true, + data: entry, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get all time entries for current user with filters + * GET /api/time-tracking?projectId=xxx&todoId=xxx&companyId=xxx&startDate=xxx&endDate=xxx + */ +export const getAllTimeEntries = async (req, res) => { + try { + const userId = req.userId; + const { projectId, todoId, companyId, startDate, endDate } = req.query; + + const filters = { + projectId, + todoId, + companyId, + startDate, + endDate, + }; + + const entries = await timeTrackingService.getAllTimeEntries(userId, filters); + + res.status(200).json({ + success: true, + count: entries.length, + data: entries, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get time entries for a specific month + * GET /api/time-tracking/month/:year/:month + */ +export const getMonthlyTimeEntries = async (req, res) => { + try { + const userId = req.userId; + const { year, month } = req.params; + + const entries = await timeTrackingService.getMonthlyTimeEntries( + userId, + parseInt(year), + parseInt(month) + ); + + res.status(200).json({ + success: true, + count: entries.length, + data: entries, + }); + } 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 + */ +export const getTimeEntryById = async (req, res) => { + try { + const { entryId } = req.params; + + const entry = await timeTrackingService.getTimeEntryById(entryId); + + res.status(200).json({ + success: true, + data: entry, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get time entry with related data + * GET /api/time-tracking/:entryId/details + */ +export const getTimeEntryWithRelations = async (req, res) => { + try { + const { entryId } = req.params; + + const entry = await timeTrackingService.getTimeEntryWithRelations(entryId); + + res.status(200).json({ + success: true, + data: entry, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Update time entry + * PATCH /api/time-tracking/:entryId + */ +export const updateTimeEntry = async (req, res) => { + try { + const userId = req.userId; + const { entryId } = req.params; + const { startTime, endTime, projectId, todoId, companyId, description } = req.body; + + const entry = await timeTrackingService.updateTimeEntry(entryId, userId, { + startTime, + endTime, + projectId, + todoId, + companyId, + description, + }); + + res.status(200).json({ + success: true, + data: entry, + message: 'Záznam bol aktualizovaný', + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Delete time entry + * DELETE /api/time-tracking/:entryId + */ +export const deleteTimeEntry = async (req, res) => { + try { + const userId = req.userId; + const { entryId } = req.params; + + const result = await timeTrackingService.deleteTimeEntry(entryId, userId); + + res.status(200).json(result); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; + +/** + * Get monthly statistics + * GET /api/time-tracking/stats/monthly/:year/:month + */ +export const getMonthlyStats = async (req, res) => { + try { + const userId = req.userId; + const { year, month } = req.params; + + const stats = await timeTrackingService.getMonthlyStats( + userId, + parseInt(year), + parseInt(month) + ); + + res.status(200).json({ + success: true, + data: stats, + }); + } catch (error) { + const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development'); + res.status(error.statusCode || 500).json(errorResponse); + } +}; diff --git a/src/db/migrations/0004_add_time_entries_table.sql b/src/db/migrations/0004_add_time_entries_table.sql new file mode 100644 index 0000000..c201213 --- /dev/null +++ b/src/db/migrations/0004_add_time_entries_table.sql @@ -0,0 +1,23 @@ +CREATE TABLE "time_entries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "project_id" uuid, + "todo_id" uuid, + "company_id" uuid, + "start_time" timestamp NOT NULL, + "end_time" timestamp, + "duration" integer, + "description" text, + "is_running" boolean DEFAULT false NOT NULL, + "is_edited" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_todo_id_todos_id_fk" FOREIGN KEY ("todo_id") REFERENCES "public"."todos"("id") ON DELETE set null ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE set null ON UPDATE no action; diff --git a/src/db/schema.js b/src/db/schema.js index f5c19ec..541ecf6 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -190,3 +190,20 @@ export const timesheets = pgTable('timesheets', { createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); + +// Time Entries table - sledovanie odpracovaného času používateľov +export const timeEntries = pgTable('time_entries', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), // kto trackuje čas + projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }), // na akom projekte (voliteľné) + todoId: uuid('todo_id').references(() => todos.id, { onDelete: 'set null' }), // na akom todo (voliteľné) + companyId: uuid('company_id').references(() => companies.id, { onDelete: 'set null' }), // pre akú firmu (voliteľné) + startTime: timestamp('start_time').notNull(), // kedy začal trackovať + endTime: timestamp('end_time'), // kedy skončil (null ak ešte beží) + duration: integer('duration'), // trvanie v minútach + description: text('description'), // popis práce + isRunning: boolean('is_running').default(false).notNull(), // či práve beží + isEdited: boolean('is_edited').default(false).notNull(), // či bol editovaný + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); diff --git a/src/routes/time-tracking.routes.js b/src/routes/time-tracking.routes.js new file mode 100644 index 0000000..b831b84 --- /dev/null +++ b/src/routes/time-tracking.routes.js @@ -0,0 +1,91 @@ +import express from 'express'; +import * as timeTrackingController from '../controllers/time-tracking.controller.js'; +import { authenticate } from '../middlewares/auth/authMiddleware.js'; +import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; +import { + startTimeEntrySchema, + stopTimeEntrySchema, + updateTimeEntrySchema, +} from '../validators/crm.validators.js'; +import { z } from 'zod'; + +const router = express.Router(); + +// All time tracking routes require authentication +router.use(authenticate); + +/** + * Time Tracking management + */ + +// Start new time entry +router.post('/start', validateBody(startTimeEntrySchema), timeTrackingController.startTimeEntry); + +// Stop running time entry +router.post( + '/:entryId/stop', + validateParams(z.object({ entryId: z.string().uuid() })), + validateBody(stopTimeEntrySchema), + timeTrackingController.stopTimeEntry +); + +// Get running time entry +router.get('/running', timeTrackingController.getRunningTimeEntry); + +// Get all time entries with filters +router.get('/', timeTrackingController.getAllTimeEntries); + +// Get monthly time entries +router.get( + '/month/:year/:month', + 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.getMonthlyTimeEntries +); + +// Get monthly statistics +router.get( + '/stats/monthly/:year/:month', + 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.getMonthlyStats +); + +// Get time entry by ID +router.get( + '/:entryId', + validateParams(z.object({ entryId: z.string().uuid() })), + timeTrackingController.getTimeEntryById +); + +// Get time entry with relations +router.get( + '/:entryId/details', + validateParams(z.object({ entryId: z.string().uuid() })), + timeTrackingController.getTimeEntryWithRelations +); + +// Update time entry +router.patch( + '/:entryId', + validateParams(z.object({ entryId: z.string().uuid() })), + validateBody(updateTimeEntrySchema), + timeTrackingController.updateTimeEntry +); + +// Delete time entry +router.delete( + '/:entryId', + validateParams(z.object({ entryId: z.string().uuid() })), + timeTrackingController.deleteTimeEntry +); + +export default router; diff --git a/src/services/time-tracking.service.js b/src/services/time-tracking.service.js new file mode 100644 index 0000000..a4f96b2 --- /dev/null +++ b/src/services/time-tracking.service.js @@ -0,0 +1,440 @@ +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 { NotFoundError, BadRequestError } from '../utils/errors.js'; + +/** + * Start a new time entry + */ +export const startTimeEntry = async (userId, data) => { + const { projectId, todoId, companyId, description } = data; + + // Check if user already has a running time entry + const [existingRunning] = await db + .select() + .from(timeEntries) + .where(and(eq(timeEntries.userId, userId), eq(timeEntries.isRunning, true))) + .limit(1); + + if (existingRunning) { + throw new BadRequestError('Máte už spustený časovač. Prosím zastavte ho pred spustením nového.'); + } + + // Verify project exists if provided + if (projectId) { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!project) { + throw new NotFoundError('Projekt nenájdený'); + } + } + + // Verify todo exists if provided + if (todoId) { + const [todo] = await db + .select() + .from(todos) + .where(eq(todos.id, todoId)) + .limit(1); + + if (!todo) { + throw new NotFoundError('Todo nenájdené'); + } + } + + // Verify company exists if provided + if (companyId) { + const [company] = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + + if (!company) { + throw new NotFoundError('Firma nenájdená'); + } + } + + const [newEntry] = await db + .insert(timeEntries) + .values({ + userId, + projectId: projectId || null, + todoId: todoId || null, + companyId: companyId || null, + description: description || null, + startTime: new Date(), + endTime: null, + duration: null, + isRunning: true, + isEdited: false, + }) + .returning(); + + return newEntry; +}; + +/** + * Stop a running time entry + */ +export const stopTimeEntry = async (entryId, userId, data = {}) => { + const { projectId, todoId, companyId, description } = data; + + const entry = await getTimeEntryById(entryId); + + // Verify ownership + if (entry.userId !== userId) { + throw new BadRequestError('Nemáte oprávnenie zastaviť tento časovač'); + } + + if (!entry.isRunning) { + throw new BadRequestError('Tento časovač už nie je spustený'); + } + + const endTime = new Date(); + const durationInMinutes = Math.round((endTime - new Date(entry.startTime)) / 60000); + + // Verify related entities if provided + if (projectId) { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!project) { + throw new NotFoundError('Projekt nenájdený'); + } + } + + 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 + .update(timeEntries) + .set({ + endTime, + duration: durationInMinutes, + isRunning: false, + projectId: projectId !== undefined ? projectId : entry.projectId, + todoId: todoId !== undefined ? todoId : entry.todoId, + companyId: companyId !== undefined ? companyId : entry.companyId, + description: description !== undefined ? description : entry.description, + updatedAt: new Date(), + }) + .where(eq(timeEntries.id, entryId)) + .returning(); + + return updated; +}; + +/** + * Get running time entry for a user + */ +export const getRunningTimeEntry = async (userId) => { + const [running] = await db + .select() + .from(timeEntries) + .where(and(eq(timeEntries.userId, userId), eq(timeEntries.isRunning, true))) + .limit(1); + + return running || null; +}; + +/** + * Get time entry by ID + */ +export const getTimeEntryById = async (entryId) => { + const [entry] = await db + .select() + .from(timeEntries) + .where(eq(timeEntries.id, entryId)) + .limit(1); + + if (!entry) { + throw new NotFoundError('Záznam nenájdený'); + } + + return entry; +}; + +/** + * Get all time entries for a user with optional filters + */ +export const getAllTimeEntries = async (userId, filters = {}) => { + const { projectId, todoId, companyId, startDate, endDate } = filters; + + let query = db.select().from(timeEntries).where(eq(timeEntries.userId, userId)); + + const conditions = [eq(timeEntries.userId, userId)]; + + if (projectId) { + conditions.push(eq(timeEntries.projectId, projectId)); + } + + if (todoId) { + conditions.push(eq(timeEntries.todoId, todoId)); + } + + if (companyId) { + conditions.push(eq(timeEntries.companyId, companyId)); + } + + if (startDate) { + conditions.push(gte(timeEntries.startTime, new Date(startDate))); + } + + if (endDate) { + conditions.push(lte(timeEntries.startTime, new Date(endDate))); + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)); + } + + const result = await query.orderBy(desc(timeEntries.startTime)); + return result; +}; + +/** + * Get time entries for a specific month + */ +export const getMonthlyTimeEntries = async (userId, year, month) => { + const startDate = new Date(year, month - 1, 1); + const endDate = new Date(year, month, 0, 23, 59, 59, 999); + + return await db + .select() + .from(timeEntries) + .where( + and( + eq(timeEntries.userId, userId), + gte(timeEntries.startTime, startDate), + lte(timeEntries.startTime, endDate) + ) + ) + .orderBy(desc(timeEntries.startTime)); +}; + +/** + * Update time entry + */ +export const updateTimeEntry = async (entryId, userId, data) => { + const entry = await getTimeEntryById(entryId); + + // Verify ownership + if (entry.userId !== userId) { + throw new BadRequestError('Nemáte oprávnenie upraviť tento záznam'); + } + + if (entry.isRunning) { + throw new BadRequestError('Nemôžete upraviť bežiaci časovač. Najprv ho zastavte.'); + } + + const { startTime, endTime, projectId, todoId, companyId, description } = data; + + // Verify related entities if being changed + if (projectId !== undefined && projectId !== null) { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!project) { + throw new NotFoundError('Projekt nenájdený'); + } + } + + if (todoId !== undefined && todoId !== null) { + const [todo] = await db + .select() + .from(todos) + .where(eq(todos.id, todoId)) + .limit(1); + + if (!todo) { + throw new NotFoundError('Todo nenájdené'); + } + } + + if (companyId !== undefined && companyId !== null) { + 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 + let newDuration = entry.duration; + const newStartTime = startTime ? new Date(startTime) : new Date(entry.startTime); + const newEndTime = endTime ? new Date(endTime) : (entry.endTime ? new Date(entry.endTime) : null); + + if (newEndTime) { + newDuration = Math.round((newEndTime - newStartTime) / 60000); + } + + const [updated] = await db + .update(timeEntries) + .set({ + startTime: newStartTime, + endTime: newEndTime, + duration: newDuration, + projectId: projectId !== undefined ? projectId : entry.projectId, + todoId: todoId !== undefined ? todoId : entry.todoId, + companyId: companyId !== undefined ? companyId : entry.companyId, + description: description !== undefined ? description : entry.description, + isEdited: true, + updatedAt: new Date(), + }) + .where(eq(timeEntries.id, entryId)) + .returning(); + + return updated; +}; + +/** + * Delete time entry + */ +export const deleteTimeEntry = async (entryId, userId) => { + const entry = await getTimeEntryById(entryId); + + // Verify ownership + if (entry.userId !== userId) { + throw new BadRequestError('Nemáte oprávnenie odstrániť tento záznam'); + } + + if (entry.isRunning) { + throw new BadRequestError('Nemôžete odstrániť bežiaci časovač. Najprv ho zastavte.'); + } + + await db.delete(timeEntries).where(eq(timeEntries.id, entryId)); + + return { success: true, message: 'Záznam bol odstránený' }; +}; + +/** + * Get time entry with related data (project, todo, company) + */ +export const getTimeEntryWithRelations = async (entryId) => { + const entry = await getTimeEntryById(entryId); + + // Get project if exists + let project = null; + if (entry.projectId) { + [project] = await db + .select() + .from(projects) + .where(eq(projects.id, entry.projectId)) + .limit(1); + } + + // Get todo if exists + let todo = null; + if (entry.todoId) { + [todo] = await db + .select() + .from(todos) + .where(eq(todos.id, entry.todoId)) + .limit(1); + } + + // Get company if exists + let company = null; + if (entry.companyId) { + [company] = await db + .select() + .from(companies) + .where(eq(companies.id, entry.companyId)) + .limit(1); + } + + return { + ...entry, + project, + todo, + company, + }; +}; + +/** + * Get monthly statistics for a user + */ +export const getMonthlyStats = async (userId, year, month) => { + const entries = await getMonthlyTimeEntries(userId, year, month); + + // Total time in minutes + const totalMinutes = entries.reduce((sum, entry) => { + return sum + (entry.duration || 0); + }, 0); + + // Count of days worked + const uniqueDays = new Set( + entries + .filter((e) => !e.isRunning) + .map((e) => new Date(e.startTime).toDateString()) + ).size; + + // Time by project + const byProject = {}; + entries.forEach((entry) => { + if (entry.projectId && entry.duration) { + if (!byProject[entry.projectId]) { + byProject[entry.projectId] = 0; + } + byProject[entry.projectId] += entry.duration; + } + }); + + // Time by company + const byCompany = {}; + entries.forEach((entry) => { + if (entry.companyId && entry.duration) { + if (!byCompany[entry.companyId]) { + byCompany[entry.companyId] = 0; + } + byCompany[entry.companyId] += entry.duration; + } + }); + + return { + totalMinutes, + totalHours: Math.floor(totalMinutes / 60), + remainingMinutes: totalMinutes % 60, + daysWorked: uniqueDays, + averagePerDay: uniqueDays > 0 ? Math.round(totalMinutes / uniqueDays) : 0, + entriesCount: entries.length, + byProject, + byCompany, + }; +}; diff --git a/src/validators/crm.validators.js b/src/validators/crm.validators.js index ba3e251..defc7b5 100644 --- a/src/validators/crm.validators.js +++ b/src/validators/crm.validators.js @@ -105,3 +105,27 @@ export const updateNoteSchema = z.object({ contactId: z.string().uuid('Neplatný formát contact ID').optional().or(z.literal('').or(z.null())), reminderDate: z.string().optional().or(z.literal('').or(z.null())), }); + +// Time Tracking validators +export const startTimeEntrySchema = z.object({ + projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('')), + todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('')), + companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')), + description: z.string().max(1000).optional(), +}); + +export const stopTimeEntrySchema = z.object({ + projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('')), + todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('')), + companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')), + description: z.string().max(1000).optional(), +}); + +export const updateTimeEntrySchema = z.object({ + startTime: z.string().optional(), + endTime: z.string().optional(), + projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('').or(z.null())), + todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('').or(z.null())), + companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('').or(z.null())), + description: z.string().max(1000).optional(), +});