diff --git a/apps/api/src/controllers/calendario.controller.ts b/apps/api/src/controllers/calendario.controller.ts index 84ea05d..498584e 100644 --- a/apps/api/src/controllers/calendario.controller.ts +++ b/apps/api/src/controllers/calendario.controller.ts @@ -13,6 +13,8 @@ const createRecordatorioSchema = z.object({ fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd notas: z.string().max(2000).optional(), privado: z.boolean().optional(), + recurrencia: z.enum(['unica', 'mensual', 'bimestral', 'trimestral', 'anual']).default('unica'), + fechaFin: z.string().min(8).optional(), }); const updateRecordatorioSchema = z.object({ @@ -107,7 +109,7 @@ export async function createRecordatorio(req: Request, res: Response, next: Next const evento = await recordatoriosService.createRecordatorio( req.tenantPool!, req.user!.userId, - { ...data, tipo: 'custom', recurrencia: 'unica' } + { ...data, tipo: 'custom' } ); res.status(201).json(evento); diff --git a/apps/api/src/controllers/cartera.controller.ts b/apps/api/src/controllers/cartera.controller.ts index 032d6e5..291d250 100644 --- a/apps/api/src/controllers/cartera.controller.ts +++ b/apps/api/src/controllers/cartera.controller.ts @@ -260,7 +260,11 @@ export async function removeAuxiliar(req: Request, res: Response, next: NextFunc // Supervisores available (for dropdown) export async function getSupervisores(req: Request, res: Response, next: NextFunction) { try { - const supervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId); + const allSupervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId); + // Un supervisor solo se ve a si mismo en el dropdown (no puede asignar a otro supervisor) + const supervisores = isSupervisor(req) + ? allSupervisores.filter(s => s.userId === req.user!.userId) + : allSupervisores; return res.json({ data: supervisores }); } catch (err) { return next(err); } } diff --git a/apps/api/src/controllers/usuarios.controller.ts b/apps/api/src/controllers/usuarios.controller.ts index 51d0137..b99e904 100644 --- a/apps/api/src/controllers/usuarios.controller.ts +++ b/apps/api/src/controllers/usuarios.controller.ts @@ -70,14 +70,24 @@ export async function inviteUsuario(req: Request, res: Response, next: NextFunct } const data = inviteSchema.parse(req.body); - // Los supervisores solo pueden invitar clientes - if (req.user!.role === 'supervisor' && data.role !== 'cliente') { - throw new AppError(403, 'Los supervisores solo pueden invitar clientes'); + // Los supervisores solo pueden invitar clientes y auxiliares + if (req.user!.role === 'supervisor' && !['cliente', 'auxiliar'].includes(data.role)) { + throw new AppError(403, 'Los supervisores solo pueden invitar clientes y auxiliares'); } // Validate: auxiliar requires a supervisor if (data.role === 'auxiliar' && !data.supervisorUserId) { - throw new AppError(400, 'Debes asignar un supervisor al auxiliar'); + // Un supervisor que invita un auxiliar se asigna a sí mismo por defecto + if (req.user!.role === 'supervisor') { + data.supervisorUserId = req.user!.userId; + } else { + throw new AppError(400, 'Debes asignar un supervisor al auxiliar'); + } + } + + // Un supervisor solo puede asignar auxiliares a sí mismo + if (req.user!.role === 'supervisor' && data.role === 'auxiliar' && data.supervisorUserId !== req.user!.userId) { + throw new AppError(403, 'Solo puedes asignar auxiliares a tu propia supervisión'); } const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, data); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6a6c1a1..b108ccf 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,6 +7,7 @@ import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js'; import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js'; import { startNotificationsJob } from './jobs/notifications.job.js'; import { startSatSyncMonitorJob } from './jobs/sat-sync-monitor.job.js'; +import { startRecordatoriosPeriodicosJob } from './jobs/recordatorios-periodicos.job.js'; const PORT = parseInt(env.PORT, 10); @@ -25,6 +26,7 @@ const server = app.listen(PORT, '0.0.0.0', () => { startSatSyncJob(); startMetricasInvalidationsJob(); startSatSyncMonitorJob(); + startRecordatoriosPeriodicosJob(); if (sendRealEmails) { startWeeklyUpdateJob(); startNotificationsJob(); diff --git a/apps/api/src/jobs/recordatorios-periodicos.job.ts b/apps/api/src/jobs/recordatorios-periodicos.job.ts new file mode 100644 index 0000000..01cdc39 --- /dev/null +++ b/apps/api/src/jobs/recordatorios-periodicos.job.ts @@ -0,0 +1,75 @@ +/** + * Extensión periódica de recordatorios recurrentes. + * + * Cada vez que corre, itera los tenants activos y para cada serie periódica + * activa genera nuevas instancias futuras hasta mantener un horizonte mínimo + * (default 24 meses). + * + * Programado diariamente a las 6:00 AM (America/Mexico_City) porque es una + * tarea liviana y nos asegura que siempre haya instancias disponibles. + */ +import cron from 'node-cron'; +import { prisma, tenantDb } from '../config/database.js'; +import { extenderSeriesActivas } from '../services/recordatorios.service.js'; + +const SCHEDULE = '0 6 * * *'; // 06:00 AM diario + +let task: ReturnType | null = null; + +export async function runRecordatoriosPeriodicosJob(): Promise<{ + tenants: number; + series: number; + instancias: number; +}> { + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true, rfc: true, databaseName: true }, + }); + + let seriesTotal = 0; + let instanciasTotal = 0; + + for (const tenant of tenants) { + if (!tenant.databaseName) continue; + try { + const pool = await tenantDb.getPool(tenant.id, tenant.databaseName); + const result = await extenderSeriesActivas(pool); + if (result.series > 0) { + console.log(`[Recordatorios Periodicos] ${tenant.rfc}: ${result.instancias} instancias en ${result.series} series`); + } + seriesTotal += result.series; + instanciasTotal += result.instancias; + } catch (err: any) { + console.error(`[Recordatorios Periodicos] Error en ${tenant.rfc}:`, err.message || err); + } + } + + return { tenants: tenants.length, series: seriesTotal, instancias: instanciasTotal }; +} + +export function startRecordatoriosPeriodicosJob(): void { + if (task) { + console.warn('[Recordatorios Periodicos Cron] Ya iniciado'); + return; + } + task = cron.schedule(SCHEDULE, async () => { + try { + const result = await runRecordatoriosPeriodicosJob(); + if (result.series > 0) { + console.log(`[Recordatorios Periodicos Cron] ${result.tenants} tenants — ${result.instancias} instancias en ${result.series} series`); + } + } catch (err: any) { + console.error('[Recordatorios Periodicos Cron] Error general:', err.message || err); + } + }, { + timezone: 'America/Mexico_City', + }); + console.log(`[Recordatorios Periodicos Cron] Programado: ${SCHEDULE} (06:00 AM diario America/Mexico_City)`); +} + +export function stopRecordatoriosPeriodicosJob(): void { + if (task) { + task.stop(); + task = null; + } +} diff --git a/apps/api/src/migrations/tenant/056_recordatorios_periodicos.sql b/apps/api/src/migrations/tenant/056_recordatorios_periodicos.sql new file mode 100644 index 0000000..b60ed90 --- /dev/null +++ b/apps/api/src/migrations/tenant/056_recordatorios_periodicos.sql @@ -0,0 +1,19 @@ +-- Recordatorios periódicos: maestro + instancias materializadas en la misma tabla + +ALTER TABLE recordatorios + ADD COLUMN IF NOT EXISTS recurrencia VARCHAR(20) DEFAULT 'unica' NOT NULL, + ADD COLUMN IF NOT EXISTS serie_id INTEGER REFERENCES recordatorios(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS activo BOOLEAN DEFAULT true NOT NULL, + ADD COLUMN IF NOT EXISTS fecha_inicio DATE, + ADD COLUMN IF NOT EXISTS fecha_fin DATE; + +-- Backfill seguro para filas existentes (el default ya cubre recurrencia/activo, pero dejamos explícito) +UPDATE recordatorios + SET recurrencia = 'unica', + activo = true + WHERE recurrencia IS NULL + OR activo IS NULL; + +-- Índices para consultas de serie y regeneración +CREATE INDEX IF NOT EXISTS recordatorios_serie_id_idx ON recordatorios(serie_id); +CREATE INDEX IF NOT EXISTS recordatorios_recurrencia_activo_idx ON recordatorios(recurrencia, activo); diff --git a/apps/api/src/routes/cartera.routes.ts b/apps/api/src/routes/cartera.routes.ts index 623adbe..afd59e1 100644 --- a/apps/api/src/routes/cartera.routes.ts +++ b/apps/api/src/routes/cartera.routes.ts @@ -10,7 +10,7 @@ router.use(authenticate); router.use(tenantMiddleware); // Static routes first -router.get('/supervisores', authorize('owner'), ctrl.getSupervisores); +router.get('/supervisores', authorize('owner', 'supervisor'), ctrl.getSupervisores); // Asignaciones de obligaciones/tareas a auxiliares (antes de /:id para evitar match dinámico) router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor); diff --git a/apps/api/src/services/notifications.service.ts b/apps/api/src/services/notifications.service.ts index d4439ab..983d67f 100644 --- a/apps/api/src/services/notifications.service.ts +++ b/apps/api/src/services/notifications.service.ts @@ -378,6 +378,7 @@ export async function processProximosRecordatorios( WHERE completado = false AND fecha_limite = (CURRENT_DATE + ${dias})::date AND ${col} IS NULL + AND (serie_id IS NOT NULL OR recurrencia = 'unica') `); for (const r of rows) { diff --git a/apps/api/src/services/recordatorios.service.ts b/apps/api/src/services/recordatorios.service.ts index 2d4d80f..f81ef9e 100644 --- a/apps/api/src/services/recordatorios.service.ts +++ b/apps/api/src/services/recordatorios.service.ts @@ -1,77 +1,221 @@ import type { Pool } from 'pg'; import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared'; +export type RecurrenciaRecordatorio = 'unica' | 'mensual' | 'bimestral' | 'trimestral' | 'anual'; + +const RECURRENCIA_DELTA_MESES: Record = { + unica: 0, + mensual: 1, + bimestral: 2, + trimestral: 3, + anual: 12, +}; + +interface RecordatorioRow { + id: number; + titulo: string; + descripcion: string | null; + fecha_limite: Date; + notas: string | null; + completado: boolean; + privado: boolean; + creado_por: string; + created_at: Date; + recurrencia: RecurrenciaRecordatorio; + serie_id: number | null; + activo: boolean; + fecha_inicio: Date | null; + fecha_fin: Date | null; +} + +function toISODate(d: Date): string { + return d.toISOString().split('T')[0]; +} + +function startOfDay(d: Date): Date { + const r = new Date(d); + r.setHours(0, 0, 0, 0); + return r; +} + +function addMonthsLocal(date: Date, months: number): Date { + const result = new Date(date); + const day = result.getDate(); + result.setMonth(result.getMonth() + months); + // Si el mes resultante no tiene el mismo día, volver al último día del mes anterior + if (result.getDate() !== day) { + result.setDate(0); + } + return startOfDay(result); +} + +function calcularSiguienteFecha(fecha: Date, recurrencia: RecurrenciaRecordatorio): Date { + return addMonthsLocal(fecha, RECURRENCIA_DELTA_MESES[recurrencia]); +} + +function generarFechasInstancias( + fechaInicio: Date, + recurrencia: RecurrenciaRecordatorio, + fechaFin: Date | null | undefined, + maxMesesHorizonte = 24, +): Date[] { + const delta = RECURRENCIA_DELTA_MESES[recurrencia]; + if (delta === 0) return []; + + const inicio = startOfDay(fechaInicio); + const limiteHorizonte = addMonthsLocal(new Date(), maxMesesHorizonte); + const limite = fechaFin ? startOfDay(fechaFin) : limiteHorizonte; + + const fechas: Date[] = []; + let current = inicio; + while (current <= limite) { + fechas.push(new Date(current)); + current = calcularSiguienteFecha(current, recurrencia); + } + return fechas; +} + +function rowToEventoFiscal(r: RecordatorioRow): EventoFiscal { + return { + id: r.id, + titulo: r.titulo, + descripcion: r.descripcion || '', + tipo: 'custom' as const, + fechaLimite: toISODate(r.fecha_limite), + recurrencia: r.recurrencia, + completado: r.completado, + notas: r.notas, + privado: r.privado, + creadoPor: r.creado_por, + createdAt: r.created_at?.toISOString(), + // metadata extra para el frontend + serieId: r.serie_id ?? undefined, + esPeriodico: r.recurrencia !== 'unica', + } as EventoFiscal; +} + +async function findById(pool: Pool, id: number): Promise { + const { rows } = await pool.query( + `SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado, + creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin + FROM recordatorios WHERE id = $1`, + [id] + ); + return rows[0] ?? null; +} + +async function getMaestroDesdeId(pool: Pool, id: number): Promise { + const row = await findById(pool, id); + if (!row) return null; + if (row.serie_id === null) return row; + return findById(pool, row.serie_id); +} + +async function generarInstancias( + pool: Pool, + maestro: RecordatorioRow, + maxMesesHorizonte = 24, +): Promise { + if (!maestro.fecha_inicio) return 0; + if (maestro.recurrencia === 'unica') return 0; + + const fechas = generarFechasInstancias( + maestro.fecha_inicio, + maestro.recurrencia, + maestro.fecha_fin ?? undefined, + maxMesesHorizonte, + ); + + if (fechas.length === 0) return 0; + + let insertadas = 0; + for (const fecha of fechas) { + const { rowCount } = await pool.query( + `INSERT INTO recordatorios ( + titulo, descripcion, fecha_limite, notas, completado, privado, + creado_por, recurrencia, serie_id, activo, fecha_inicio, fecha_fin + ) + VALUES ($1, $2, $3, $4, false, $5, $6, 'unica', $7, true, $8, $9) + ON CONFLICT DO NOTHING`, + [ + maestro.titulo, + maestro.descripcion, + toISODate(fecha), + maestro.notas, + maestro.privado, + maestro.creado_por, + maestro.id, + toISODate(maestro.fecha_inicio), + maestro.fecha_fin ? toISODate(maestro.fecha_fin) : null, + ] + ); + insertadas += rowCount ?? 0; + } + return insertadas; +} + /** * Obtiene recordatorios visibles para el usuario. * - Públicos: todos los del tenant * - Privados: solo los creados por el usuario + * Excluye los maestros periódicos (serie_id IS NULL AND recurrencia != 'unica') + * para no duplicar eventos en el calendario. */ export async function getRecordatorios( pool: Pool, userId: string, año: number ): Promise { - const { rows } = await pool.query(` - SELECT id, titulo, descripcion, fecha_limite as "fechaLimite", - notas, completado, privado, creado_por as "creadoPor", - created_at as "createdAt" + const { rows } = await pool.query(` + SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado, + creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin FROM recordatorios WHERE EXTRACT(YEAR FROM fecha_limite) = $1 + AND activo = true AND (privado = false OR creado_por = $2) + AND NOT (serie_id IS NULL AND recurrencia <> 'unica') ORDER BY fecha_limite `, [año, userId]); - return rows.map(r => ({ - id: r.id, - titulo: r.titulo, - descripcion: r.descripcion || '', - tipo: 'custom' as const, - fechaLimite: r.fechaLimite instanceof Date - ? r.fechaLimite.toISOString().split('T')[0] - : String(r.fechaLimite).split('T')[0], - recurrencia: 'unica' as const, - completado: r.completado, - notas: r.notas, - privado: r.privado, - creadoPor: r.creadoPor, - createdAt: r.createdAt?.toISOString(), - })); + return rows.map(rowToEventoFiscal); } export async function createRecordatorio( pool: Pool, userId: string, - data: EventoCreate & { privado?: boolean } + data: EventoCreate & { privado?: boolean; fechaFin?: string | null } ): Promise { - const { rows } = await pool.query(` - INSERT INTO recordatorios (titulo, descripcion, fecha_limite, notas, privado, creado_por) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite", - notas, completado, privado, creado_por as "creadoPor", - created_at as "createdAt" + const recurrencia = (data.recurrencia as RecurrenciaRecordatorio) || 'unica'; + const fechaFin = data.fechaFin ? new Date(data.fechaFin + 'T00:00:00') : null; + const fechaLimite = new Date(data.fechaLimite + 'T00:00:00'); + + const { rows } = await pool.query(` + INSERT INTO recordatorios ( + titulo, descripcion, fecha_limite, notas, completado, privado, + creado_por, recurrencia, serie_id, activo, fecha_inicio, fecha_fin + ) + VALUES ($1, $2, $3, $4, false, $5, $6, $7, NULL, true, $8, $9) + RETURNING id, titulo, descripcion, fecha_limite, notas, completado, privado, + creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin `, [ data.titulo, data.descripcion || null, - data.fechaLimite, + toISODate(fechaLimite), data.notas || null, data.privado ?? false, userId, + recurrencia, + toISODate(fechaLimite), + fechaFin ? toISODate(fechaFin) : null, ]); - const r = rows[0]; - return { - id: r.id, - titulo: r.titulo, - descripcion: r.descripcion || '', - tipo: 'custom', - fechaLimite: r.fechaLimite instanceof Date - ? r.fechaLimite.toISOString().split('T')[0] - : String(r.fechaLimite).split('T')[0], - recurrencia: 'unica', - completado: r.completado, - notas: r.notas, - createdAt: r.createdAt?.toISOString(), - }; + const maestro = rows[0]; + + if (recurrencia !== 'unica') { + await generarInstancias(pool, maestro); + } + + return rowToEventoFiscal(maestro); } export async function updateRecordatorio( @@ -80,54 +224,123 @@ export async function updateRecordatorio( id: number, data: EventoUpdate & { privado?: boolean } ): Promise { - // Verify ownership or public - const { rows: existing } = await pool.query( - `SELECT id, creado_por FROM recordatorios WHERE id = $1`, - [id] - ); + const row = await findById(pool, id); + if (!row) return null; - if (existing.length === 0) return null; + // Completar una instancia individual de una serie periódica + const soloCompletado = + data.completado !== undefined && + data.titulo === undefined && + data.descripcion === undefined && + data.fechaLimite === undefined && + data.notas === undefined && + data.privado === undefined; - const sets: string[] = []; - const params: any[] = []; + if (soloCompletado && row.serie_id !== null) { + await pool.query(`UPDATE recordatorios SET completado = $1, updated_at = NOW() WHERE id = $2`, [ + data.completado, + id, + ]); + const updated = await findById(pool, id); + return updated ? rowToEventoFiscal(updated) : null; + } + + const maestro = row.serie_id === null ? row : await findById(pool, row.serie_id); + if (!maestro) return null; + + // Recordatorio único: editar directamente + if (maestro.recurrencia === 'unica') { + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (data.titulo !== undefined) { sets.push(`titulo = $${idx++}`); params.push(data.titulo); } + if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx++}`); params.push(data.descripcion); } + if (data.fechaLimite !== undefined) { sets.push(`fecha_limite = $${idx++}`); params.push(data.fechaLimite); } + if (data.notas !== undefined) { sets.push(`notas = $${idx++}`); params.push(data.notas); } + if (data.privado !== undefined) { sets.push(`privado = $${idx++}`); params.push(data.privado); } + if (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); } + + if (sets.length === 0) return rowToEventoFiscal(maestro); + sets.push(`updated_at = NOW()`); + params.push(maestro.id); + + const { rows } = await pool.query(` + UPDATE recordatorios SET ${sets.join(', ')} + WHERE id = $${idx} + RETURNING id, titulo, descripcion, fecha_limite, notas, completado, privado, + creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin + `, params); + + return rows[0] ? rowToEventoFiscal(rows[0]) : null; + } + + // Serie periódica: editar maestro y propagar a instancias futuras no completadas + const updatesMaestro: string[] = []; + const paramsMaestro: any[] = []; let idx = 1; - if (data.titulo !== undefined) { sets.push(`titulo = $${idx++}`); params.push(data.titulo); } - if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx++}`); params.push(data.descripcion); } - if (data.fechaLimite !== undefined) { sets.push(`fecha_limite = $${idx++}`); params.push(data.fechaLimite); } - if (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); } - if (data.notas !== undefined) { sets.push(`notas = $${idx++}`); params.push(data.notas); } - if (data.privado !== undefined) { sets.push(`privado = $${idx++}`); params.push(data.privado); } + if (data.titulo !== undefined) { updatesMaestro.push(`titulo = $${idx++}`); paramsMaestro.push(data.titulo); } + if (data.descripcion !== undefined) { updatesMaestro.push(`descripcion = $${idx++}`); paramsMaestro.push(data.descripcion); } + if (data.notas !== undefined) { updatesMaestro.push(`notas = $${idx++}`); paramsMaestro.push(data.notas); } + if (data.privado !== undefined) { updatesMaestro.push(`privado = $${idx++}`); paramsMaestro.push(data.privado); } - if (sets.length === 0) return null; + const nuevaFechaInicio = data.fechaLimite ? new Date(data.fechaLimite + 'T00:00:00') : null; + if (nuevaFechaInicio) { + updatesMaestro.push(`fecha_limite = $${idx++}`); + paramsMaestro.push(toISODate(nuevaFechaInicio)); + updatesMaestro.push(`fecha_inicio = $${idx++}`); + paramsMaestro.push(toISODate(nuevaFechaInicio)); + } - sets.push(`updated_at = NOW()`); - params.push(id); + if (updatesMaestro.length > 0) { + updatesMaestro.push(`updated_at = NOW()`); + paramsMaestro.push(maestro.id); + await pool.query( + `UPDATE recordatorios SET ${updatesMaestro.join(', ')} WHERE id = $${idx}`, + paramsMaestro + ); + } - const { rows } = await pool.query(` - UPDATE recordatorios SET ${sets.join(', ')} - WHERE id = $${idx} - RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite", - notas, completado, privado, creado_por as "creadoPor", - created_at as "createdAt" - `, params); + // Propagar campos de contenido a instancias futuras no completadas + const updatesInstancias: string[] = []; + const paramsInstancias: any[] = []; + let iIdx = 1; - if (rows.length === 0) return null; + if (data.titulo !== undefined) { updatesInstancias.push(`titulo = $${iIdx++}`); paramsInstancias.push(data.titulo); } + if (data.descripcion !== undefined) { updatesInstancias.push(`descripcion = $${iIdx++}`); paramsInstancias.push(data.descripcion); } + if (data.notas !== undefined) { updatesInstancias.push(`notas = $${iIdx++}`); paramsInstancias.push(data.notas); } + if (data.privado !== undefined) { updatesInstancias.push(`privado = $${iIdx++}`); paramsInstancias.push(data.privado); } - const r = rows[0]; - return { - id: r.id, - titulo: r.titulo, - descripcion: r.descripcion || '', - tipo: 'custom', - fechaLimite: r.fechaLimite instanceof Date - ? r.fechaLimite.toISOString().split('T')[0] - : String(r.fechaLimite).split('T')[0], - recurrencia: 'unica', - completado: r.completado, - notas: r.notas, - createdAt: r.createdAt?.toISOString(), - }; + if (updatesInstancias.length > 0) { + paramsInstancias.push(maestro.id); + await pool.query( + `UPDATE recordatorios + SET ${updatesInstancias.join(', ')} + WHERE serie_id = $${iIdx} + AND fecha_limite >= CURRENT_DATE + AND completado = false`, + paramsInstancias + ); + } + + // Si cambió la fecha de inicio, regenerar instancias futuras + if (nuevaFechaInicio) { + await pool.query( + `DELETE FROM recordatorios + WHERE serie_id = $1 + AND fecha_limite >= CURRENT_DATE + AND completado = false`, + [maestro.id] + ); + const maestroActualizado = await findById(pool, maestro.id); + if (maestroActualizado) { + await generarInstancias(pool, maestroActualizado); + } + } + + const maestroFinal = await findById(pool, maestro.id); + return maestroFinal ? rowToEventoFiscal(maestroFinal) : null; } export async function deleteRecordatorio( @@ -135,9 +348,70 @@ export async function deleteRecordatorio( userId: string, id: number ): Promise { - const { rowCount } = await pool.query( - `DELETE FROM recordatorios WHERE id = $1`, - [id] - ); + const row = await findById(pool, id); + if (!row) return false; + + // Serie periódica: cancelar (desactivar maestro y borrar instancias futuras no completadas) + if (row.recurrencia !== 'unica' || row.serie_id !== null) { + const maestro = row.serie_id === null ? row : await findById(pool, row.serie_id); + if (!maestro) return false; + + await pool.query(`UPDATE recordatorios SET activo = false, updated_at = NOW() WHERE id = $1`, [maestro.id]); + await pool.query( + `DELETE FROM recordatorios + WHERE serie_id = $1 + AND fecha_limite >= CURRENT_DATE + AND completado = false`, + [maestro.id] + ); + return true; + } + + // Único: borrar directamente + const { rowCount } = await pool.query(`DELETE FROM recordatorios WHERE id = $1`, [id]); return (rowCount ?? 0) > 0; } + +/** + * Extiende las series activas para mantener un horizonte futuro de instancias. + * Útil para llamar desde un cron periódico. + */ +export async function extenderSeriesActivas( + pool: Pool, + mesesHorizonte = 24, +): Promise<{ series: number; instancias: number }> { + const { rows: maestros } = await pool.query(` + SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado, + creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin + FROM recordatorios + WHERE serie_id IS NULL + AND recurrencia <> 'unica' + AND activo = true + `); + + let seriesProcesadas = 0; + let instanciasCreadas = 0; + + const horizonte = addMonthsLocal(new Date(), mesesHorizonte); + + for (const maestro of maestros) { + const { rows: ultimas } = await pool.query<{ fecha_limite: Date }>(` + SELECT fecha_limite + FROM recordatorios + WHERE serie_id = $1 + ORDER BY fecha_limite DESC + LIMIT 1 + `, [maestro.id]); + + const ultima = ultimas[0]?.fecha_limite; + if (!ultima || startOfDay(ultima) < addMonthsLocal(new Date(), mesesHorizonte - 6)) { + const creadas = await generarInstancias(pool, maestro, mesesHorizonte); + if (creadas > 0) { + seriesProcesadas++; + instanciasCreadas += creadas; + } + } + } + + return { series: seriesProcesadas, instancias: instanciasCreadas }; +} diff --git a/apps/web/app/(dashboard)/calendario/page.tsx b/apps/web/app/(dashboard)/calendario/page.tsx index 1fa148e..19561bd 100644 --- a/apps/web/app/(dashboard)/calendario/page.tsx +++ b/apps/web/app/(dashboard)/calendario/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { DashboardShell } from '@/components/layouts/dashboard-shell'; -import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui'; +import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui'; import { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario'; import { useAuthStore } from '@/stores/auth-store'; import { @@ -42,6 +42,8 @@ interface RecordatorioForm { fechaLimite: string; notas: string; privado: boolean; + recurrencia: 'unica' | 'mensual' | 'bimestral' | 'trimestral' | 'anual'; + fechaFin: string; } const emptyForm: RecordatorioForm = { @@ -50,6 +52,8 @@ const emptyForm: RecordatorioForm = { fechaLimite: '', notas: '', privado: false, + recurrencia: 'unica', + fechaFin: '', }; export default function CalendarioPage() { @@ -100,6 +104,8 @@ export default function CalendarioPage() { fechaLimite: evento.fechaLimite, notas: evento.notas || '', privado: (evento as any).privado ?? false, + recurrencia: (evento.recurrencia as RecordatorioForm['recurrencia']) || 'unica', + fechaFin: '', // La fecha fin no se edita desde el calendario; se mantiene la original }); setShowForm(true); }; @@ -113,15 +119,19 @@ export default function CalendarioPage() { data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any, }); } else { - await createEvento.mutateAsync({ + const payload: any = { titulo: form.titulo, descripcion: form.descripcion, tipo: 'custom', fechaLimite: form.fechaLimite, - recurrencia: 'unica', + recurrencia: form.recurrencia, notas: form.notas, privado: form.privado, - } as any); + }; + if (form.recurrencia !== 'unica' && form.fechaFin) { + payload.fechaFin = form.fechaFin; + } + await createEvento.mutateAsync(payload); } setShowForm(false); setForm(emptyForm); @@ -131,10 +141,15 @@ export default function CalendarioPage() { } }; - const handleDelete = async (id: number) => { - if (!confirm('¿Eliminar este recordatorio?')) return; + const handleDelete = async (evento: EventoFiscal) => { + if (!evento.id) return; + const esPeriodico = evento.recurrencia && evento.recurrencia !== 'unica'; + const mensaje = esPeriodico + ? 'Esto cancelará todas las ocurrencias futuras de esta serie. ¿Continuar?' + : '¿Eliminar este recordatorio?'; + if (!confirm(mensaje)) return; try { - await deleteEvento.mutateAsync(id); + await deleteEvento.mutateAsync(evento.id); } catch { alert('Error al eliminar'); } @@ -206,6 +221,44 @@ export default function CalendarioPage() { /> + {!editingId && ( +
+
+ + +
+ {form.recurrencia !== 'unica' && ( +
+ + setForm({ ...form, fechaFin: e.target.value })} + /> +
+ )} +
+ )} + {editingId && form.recurrencia !== 'unica' && ( +
+ Este es un recordatorio periódico. Los cambios se aplicarán a todas las ocurrencias futuras. +
+ )}
diff --git a/apps/web/app/(dashboard)/usuarios/page.tsx b/apps/web/app/(dashboard)/usuarios/page.tsx index ce6aa38..ca99d68 100644 --- a/apps/web/app/(dashboard)/usuarios/page.tsx +++ b/apps/web/app/(dashboard)/usuarios/page.tsx @@ -9,7 +9,7 @@ import { addClienteAcceso } from '@/lib/api/contribuyentes'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api/client'; import { useAuthStore } from '@/stores/auth-store'; -import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound } from 'lucide-react'; +import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound, Pencil } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui'; import Link from 'next/link'; import { cn } from '@horux/shared-ui'; @@ -79,7 +79,7 @@ export default function UsuariosPage() { const isDespacho = isDespachoTenant(currentUser?.tenantRfc); const inviteRoles = isDespacho ? (currentUser?.role === 'supervisor' - ? despachoInviteRoles.filter(r => r.value === 'cliente') + ? despachoInviteRoles.filter(r => r.value === 'cliente' || r.value === 'auxiliar') : despachoInviteRoles) : legacyInviteRoles; const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor'; @@ -106,6 +106,13 @@ export default function UsuariosPage() { const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState(''); + // Edit user modal (owner only) + const [editingUser, setEditingUser] = useState<{ id: string; nombre: string; role: Role; email: string } | null>(null); + const [editForm, setEditForm] = useState<{ nombre: string; role: Role }>({ nombre: '', role: 'auxiliar' }); + const [savingUser, setSavingUser] = useState(false); + + const isOwner = currentUser?.role === 'owner'; + const openEditSupervisor = async (userId: string, nombre: string) => { try { const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`); @@ -132,6 +139,24 @@ export default function UsuariosPage() { } }; + const openEditUser = (usuario: { id: string; nombre: string; role: Role; email: string }) => { + setEditingUser(usuario); + setEditForm({ nombre: usuario.nombre, role: usuario.role }); + }; + + const handleSaveUser = async () => { + if (!editingUser) return; + setSavingUser(true); + try { + await updateUsuario.mutateAsync({ id: editingUser.id, data: editForm }); + setEditingUser(null); + } catch (error: any) { + alert(error.response?.data?.message || 'Error al guardar usuario'); + } finally { + setSavingUser(false); + } + }; + const openEditAccesos = async (userId: string, nombre: string) => { try { const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`); @@ -270,7 +295,16 @@ export default function UsuariosPage() { +
+
+ + setEditForm({ ...editForm, nombre: e.target.value })} + /> +
+
+ + {editingUser.id === currentUser?.id ? ( +
+ {getRoleInfo(editForm.role, isDespacho).label} +
+ ) : ( + + )} + {editingUser.id === currentUser?.id && ( +

No puedes cambiar tu propio rol.

+ )} +
+ + + + + + + + )} ); } diff --git a/docs/CAMBIOS-2026-05-04.md b/docs/CAMBIOS-2026-05-04.md index dd06b52..5798495 100644 --- a/docs/CAMBIOS-2026-05-04.md +++ b/docs/CAMBIOS-2026-05-04.md @@ -674,6 +674,127 @@ pm2 reload horux-web --- +## 30. Supervisor puede invitar Clientes y Auxiliares + +**Fecha:** 2026-06-25 + +### Cambio +Antes los supervisores solo podían invitar usuarios con rol **Cliente**. Ahora también pueden invitar **Auxiliares**, siempre asignados a su propia supervisión. + +### Backend + +| Archivo | Cambio | +|---|---| +| `apps/api/src/controllers/usuarios.controller.ts` | Permite a `supervisor` invitar `cliente` y `auxiliar`; al invitar un auxiliar se asigna a sí mismo por defecto y no permite asignar a otro supervisor | +| `apps/api/src/routes/cartera.routes.ts` | `/carteras/supervisores` ahora acepta `owner` y `supervisor` | +| `apps/api/src/controllers/cartera.controller.ts` | `getSupervisores` filtra para que un supervisor solo se vea a sí mismo en el dropdown | + +### Frontend + +| Archivo | Cambio | +|---|---| +| `apps/web/app/(dashboard)/usuarios/page.tsx` | El dropdown de roles para supervisor incluye **Auxiliar**; al seleccionar auxiliar se preselecciona al supervisor logueado como responsable | + +### Deploy + +```bash +cd /root/HoruxDespachosNuevo +pnpm --filter api build +pnpm --filter web build +pm2 reload horux-api +pm2 reload horux-web +``` + +**Estado:** ✅ Exitoso + +--- + +## 31. Owner puede editar nombre y rol de usuarios + +**Fecha:** 2026-06-25 + +### Cambio +En la página **Configuración › Usuarios**, el **owner** ahora puede editar el **nombre** y el **rol** de cualquier usuario del tenant desde un modal. + +### Detalles +- Se agregó el botón **Editar** en cada fila de usuario (solo visible para `owner`). +- El modal permite cambiar: + - **Nombre** + - **Rol** (los roles disponibles dependen de si es tenant despacho o legacy) +- Un owner **no puede cambiar su propio rol** (solo su nombre). +- El backend ya tenía el endpoint `PATCH /usuarios/:id`; se ajustó la UI para exponerlo. + +### Archivos modificados + +| Archivo | Cambio | +|---|---| +| `apps/web/app/(dashboard)/usuarios/page.tsx` | Botón Editar, modal de edición, validación de rol propio | + +### Deploy + +```bash +cd /root/HoruxDespachosNuevo +pnpm --filter web build +pm2 reload horux-web +``` + +**Estado:** ✅ Exitoso + +--- + +## 32. Recordatorios periódicos en calendario + +**Fecha:** 2026-06-25 + +### Cambio +Los recordatorios custom del calendario ahora pueden ser **periódicos**: mensual, bimestral, trimestral o anual. Se mantienen activos indefinidamente hasta que el usuario cancele la serie. + +### Modelo +Se reutiliza la tabla `recordatorios` con metadatos de serie: +- **Maestro**: `serie_id IS NULL` y `recurrencia <> 'unica'`. +- **Instancias**: filas con `serie_id = maestro.id`, una por cada ocurrencia generada. + +Esto permite completar una instancia sin afectar las demás, y el cron de emails de recordatorios próximos sigue funcionando sin cambios mayores. + +### Funcionalidad +- Al crear un recordatorio periódico se generan instancias para un horizonte de 24 meses. +- Al **editar** una instancia se actualizan el maestro y **todas las ocurrencias futuras no completadas**. +- Al **eliminar** una instancia periódica se **cancela toda la serie**: se desactiva el maestro y se borran las instancias futuras no completadas. +- Un cron diario a las 6:00 AM extiende automáticamente las series activas para mantener el horizonte futuro. + +### Frecuencias soportadas +- `mensual` +- `bimestral` +- `trimestral` +- `anual` + +### Archivos modificados + +| Archivo | Cambio | +|---|---| +| `apps/api/src/migrations/tenant/056_recordatorios_periodicos.sql` | Nuevas columnas `recurrencia`, `serie_id`, `activo`, `fecha_inicio`, `fecha_fin` e índices | +| `apps/api/src/services/recordatorios.service.ts` | Lógica de creación, edición, eliminación y regeneración de series periódicas | +| `apps/api/src/services/notifications.service.ts` | Excluir maestros del envío de recordatorios próximos | +| `apps/api/src/controllers/calendario.controller.ts` | Schemas aceptan `recurrencia` y `fechaFin` | +| `apps/api/src/jobs/recordatorios-periodicos.job.ts` | Nuevo cron de extensión de series | +| `apps/api/src/index.ts` | Registro del nuevo cron | +| `apps/web/app/(dashboard)/calendario/page.tsx` | Selector de recurrencia, fecha fin, indicador de serie y confirmación al cancelar | + +### Deploy + +```bash +cd /root/HoruxDespachosNuevo +pnpm --filter api build +pnpm --filter web build +pnpm --filter @horux/api db:migrate-tenants +pm2 reload horux-api +pm2 reload horux-web +``` + +**Estado:** ✅ Exitoso + +--- + ## Deploy histórico ### Preparación