erp-core-backend-v2/src/modules/projects/services/timesheets.service.ts
Adrian Flores Cortes 7a957a69c7 refactor: Consolidate duplicate services and normalize module structure
- Partners: Moved services to services/ directory, consolidated duplicates,
  kept singleton pattern version with ranking service
- Products: Moved service to services/, removed duplicate class-based version,
  kept singleton with deletedAt filtering
- Reports: Moved service to services/, kept raw SQL version for active controller
- Warehouses: Moved service to services/, removed duplicate class-based version,
  kept singleton with proper tenant isolation

All modules now follow consistent structure:
- services/*.service.ts for business logic
- services/index.ts for exports
- Controllers import from ./services/index.js

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 04:40:16 -06:00

303 lines
8.7 KiB
TypeScript

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<Timesheet>(
`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<Timesheet> {
const timesheet = await queryOne<Timesheet>(
`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<Timesheet> {
if (dto.hours <= 0 || dto.hours > 24) {
throw new ValidationError('Las horas deben estar entre 0 y 24');
}
const timesheet = await queryOne<Timesheet>(
`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<Timesheet> {
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<void> {
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<Timesheet> {
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<Timesheet> {
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<Timesheet> {
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();