import { query, queryOne } from '../../../config/database.js'; import { NotFoundError, ValidationError } from '../../../shared/errors/index.js'; export interface Timesheet { id: string; tenant_id: string; company_id: string; project_id: string; project_name?: string; task_id?: string; task_name?: string; user_id: string; user_name?: string; date: Date; hours: number; description?: string; billable: boolean; status: 'draft' | 'submitted' | 'approved' | 'rejected'; created_at: Date; } export interface CreateTimesheetDto { company_id: string; project_id: string; task_id?: string; date: string; hours: number; description?: string; billable?: boolean; } export interface UpdateTimesheetDto { task_id?: string | null; date?: string; hours?: number; description?: string | null; billable?: boolean; } export interface TimesheetFilters { company_id?: string; project_id?: string; task_id?: string; user_id?: string; status?: string; date_from?: string; date_to?: string; page?: number; limit?: number; } class TimesheetsService { async findAll(tenantId: string, filters: TimesheetFilters = {}): Promise<{ data: Timesheet[]; total: number }> { const { company_id, project_id, task_id, user_id, status, date_from, date_to, page = 1, limit = 20 } = filters; const offset = (page - 1) * limit; let whereClause = 'WHERE ts.tenant_id = $1'; const params: any[] = [tenantId]; let paramIndex = 2; if (company_id) { whereClause += ` AND ts.company_id = $${paramIndex++}`; params.push(company_id); } if (project_id) { whereClause += ` AND ts.project_id = $${paramIndex++}`; params.push(project_id); } if (task_id) { whereClause += ` AND ts.task_id = $${paramIndex++}`; params.push(task_id); } if (user_id) { whereClause += ` AND ts.user_id = $${paramIndex++}`; params.push(user_id); } if (status) { whereClause += ` AND ts.status = $${paramIndex++}`; params.push(status); } if (date_from) { whereClause += ` AND ts.date >= $${paramIndex++}`; params.push(date_from); } if (date_to) { whereClause += ` AND ts.date <= $${paramIndex++}`; params.push(date_to); } const countResult = await queryOne<{ count: string }>( `SELECT COUNT(*) as count FROM projects.timesheets ts ${whereClause}`, params ); params.push(limit, offset); const data = await query( `SELECT ts.*, p.name as project_name, t.name as task_name, u.name as user_name FROM projects.timesheets ts LEFT JOIN projects.projects p ON ts.project_id = p.id LEFT JOIN projects.tasks t ON ts.task_id = t.id LEFT JOIN auth.users u ON ts.user_id = u.id ${whereClause} ORDER BY ts.date DESC, ts.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, params ); return { data, total: parseInt(countResult?.count || '0', 10), }; } async findById(id: string, tenantId: string): Promise { const timesheet = await queryOne( `SELECT ts.*, p.name as project_name, t.name as task_name, u.name as user_name FROM projects.timesheets ts LEFT JOIN projects.projects p ON ts.project_id = p.id LEFT JOIN projects.tasks t ON ts.task_id = t.id LEFT JOIN auth.users u ON ts.user_id = u.id WHERE ts.id = $1 AND ts.tenant_id = $2`, [id, tenantId] ); if (!timesheet) { throw new NotFoundError('Timesheet no encontrado'); } return timesheet; } async create(dto: CreateTimesheetDto, tenantId: string, userId: string): Promise { if (dto.hours <= 0 || dto.hours > 24) { throw new ValidationError('Las horas deben estar entre 0 y 24'); } const timesheet = await queryOne( `INSERT INTO projects.timesheets ( tenant_id, company_id, project_id, task_id, user_id, date, hours, description, billable, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, [ tenantId, dto.company_id, dto.project_id, dto.task_id, userId, dto.date, dto.hours, dto.description, dto.billable ?? true, userId ] ); return timesheet!; } async update(id: string, dto: UpdateTimesheetDto, tenantId: string, userId: string): Promise { const existing = await this.findById(id, tenantId); if (existing.status !== 'draft') { throw new ValidationError('Solo se pueden editar timesheets en estado borrador'); } if (existing.user_id !== userId) { throw new ValidationError('Solo puedes editar tus propios timesheets'); } const updateFields: string[] = []; const values: any[] = []; let paramIndex = 1; if (dto.task_id !== undefined) { updateFields.push(`task_id = $${paramIndex++}`); values.push(dto.task_id); } if (dto.date !== undefined) { updateFields.push(`date = $${paramIndex++}`); values.push(dto.date); } if (dto.hours !== undefined) { if (dto.hours <= 0 || dto.hours > 24) { throw new ValidationError('Las horas deben estar entre 0 y 24'); } updateFields.push(`hours = $${paramIndex++}`); values.push(dto.hours); } if (dto.description !== undefined) { updateFields.push(`description = $${paramIndex++}`); values.push(dto.description); } if (dto.billable !== undefined) { updateFields.push(`billable = $${paramIndex++}`); values.push(dto.billable); } if (updateFields.length === 0) { return existing; } updateFields.push(`updated_by = $${paramIndex++}`); values.push(userId); updateFields.push(`updated_at = CURRENT_TIMESTAMP`); values.push(id, tenantId); await query( `UPDATE projects.timesheets SET ${updateFields.join(', ')} WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, values ); return this.findById(id, tenantId); } async delete(id: string, tenantId: string, userId: string): Promise { const existing = await this.findById(id, tenantId); if (existing.status !== 'draft') { throw new ValidationError('Solo se pueden eliminar timesheets en estado borrador'); } if (existing.user_id !== userId) { throw new ValidationError('Solo puedes eliminar tus propios timesheets'); } await query( `DELETE FROM projects.timesheets WHERE id = $1 AND tenant_id = $2`, [id, tenantId] ); } async submit(id: string, tenantId: string, userId: string): Promise { const timesheet = await this.findById(id, tenantId); if (timesheet.status !== 'draft') { throw new ValidationError('Solo se pueden enviar timesheets en estado borrador'); } if (timesheet.user_id !== userId) { throw new ValidationError('Solo puedes enviar tus propios timesheets'); } await query( `UPDATE projects.timesheets SET status = 'submitted', updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND tenant_id = $3`, [userId, id, tenantId] ); return this.findById(id, tenantId); } async approve(id: string, tenantId: string, userId: string): Promise { const timesheet = await this.findById(id, tenantId); if (timesheet.status !== 'submitted') { throw new ValidationError('Solo se pueden aprobar timesheets enviados'); } await query( `UPDATE projects.timesheets SET status = 'approved', approved_by = $1, approved_at = CURRENT_TIMESTAMP, updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND tenant_id = $3`, [userId, id, tenantId] ); return this.findById(id, tenantId); } async reject(id: string, tenantId: string, userId: string): Promise { const timesheet = await this.findById(id, tenantId); if (timesheet.status !== 'submitted') { throw new ValidationError('Solo se pueden rechazar timesheets enviados'); } await query( `UPDATE projects.timesheets SET status = 'rejected', updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND tenant_id = $3`, [userId, id, tenantId] ); return this.findById(id, tenantId); } async getMyTimesheets(tenantId: string, userId: string, filters: TimesheetFilters = {}): Promise<{ data: Timesheet[]; total: number }> { return this.findAll(tenantId, { ...filters, user_id: userId }); } async getPendingApprovals(tenantId: string, filters: TimesheetFilters = {}): Promise<{ data: Timesheet[]; total: number }> { return this.findAll(tenantId, { ...filters, status: 'submitted' }); } } export const timesheetsService = new TimesheetsService();