- 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>
303 lines
8.7 KiB
TypeScript
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();
|