feat: recordatorios periódicos; supervisor invita auxiliares; owner edita usuarios; precios planes y MSI
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
75
apps/api/src/jobs/recordatorios-periodicos.job.ts
Normal file
75
apps/api/src/jobs/recordatorios-periodicos.job.ts
Normal file
@@ -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<typeof cron.schedule> | 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<RecurrenciaRecordatorio, number> = {
|
||||
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<RecordatorioRow | null> {
|
||||
const { rows } = await pool.query<RecordatorioRow>(
|
||||
`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<RecordatorioRow | null> {
|
||||
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<number> {
|
||||
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<EventoFiscal[]> {
|
||||
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<RecordatorioRow>(`
|
||||
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<EventoFiscal> {
|
||||
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<RecordatorioRow>(`
|
||||
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<EventoFiscal | null> {
|
||||
// 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<RecordatorioRow>(`
|
||||
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<boolean> {
|
||||
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<RecordatorioRow>(`
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!editingId && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recurrencia">Recurrencia</Label>
|
||||
<Select
|
||||
value={form.recurrencia}
|
||||
onValueChange={(v) => setForm({ ...form, recurrencia: v as RecordatorioForm['recurrencia'] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unica">Única</SelectItem>
|
||||
<SelectItem value="mensual">Mensual</SelectItem>
|
||||
<SelectItem value="bimestral">Bimestral</SelectItem>
|
||||
<SelectItem value="trimestral">Trimestral</SelectItem>
|
||||
<SelectItem value="anual">Anual</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{form.recurrencia !== 'unica' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fechaFin">Fecha fin (opcional)</Label>
|
||||
<Input
|
||||
id="fechaFin"
|
||||
type="date"
|
||||
value={form.fechaFin}
|
||||
onChange={e => setForm({ ...form, fechaFin: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{editingId && form.recurrencia !== 'unica' && (
|
||||
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
Este es un recordatorio periódico. Los cambios se aplicarán a todas las ocurrencias futuras.
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="descripcion">Descripción (opcional)</Label>
|
||||
<Input
|
||||
@@ -417,7 +470,7 @@ export default function CalendarioPage() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-7 w-7 text-destructive"
|
||||
onClick={() => evento.id && handleDelete(evento.id)}
|
||||
onClick={() => handleDelete(evento)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -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<string>('');
|
||||
|
||||
// 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() {
|
||||
<Label htmlFor="role">Rol</Label>
|
||||
<Select
|
||||
value={inviteForm.role}
|
||||
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as UserInvite['role'], supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
|
||||
onValueChange={(v) => {
|
||||
const isAuxiliar = v === 'auxiliar';
|
||||
const isSupervisor = currentUser?.role === 'supervisor';
|
||||
setInviteForm({
|
||||
...inviteForm,
|
||||
role: v as UserInvite['role'],
|
||||
supervisorUserId: isAuxiliar && isSupervisor ? currentUser?.id : undefined,
|
||||
});
|
||||
if (v !== 'cliente') setSelectedRfcIds([]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -393,6 +427,18 @@ export default function UsuariosPage() {
|
||||
<RoleIcon className="h-4 w-4" />
|
||||
<span className="text-sm">{roleInfo.label}</span>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditUser(usuario)}
|
||||
title="Editar nombre y rol"
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-1" /> Editar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && !isCurrentUser && (
|
||||
<div className="flex gap-1">
|
||||
{isDespacho && usuario.role === 'cliente' && (
|
||||
@@ -526,6 +572,67 @@ export default function UsuariosPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Edit User Modal */}
|
||||
{editingUser && (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) setEditingUser(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar usuario</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-email">Email</Label>
|
||||
<Input id="edit-email" value={editingUser.email} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-nombre">Nombre</Label>
|
||||
<Input
|
||||
id="edit-nombre"
|
||||
value={editForm.nombre}
|
||||
onChange={e => setEditForm({ ...editForm, nombre: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-role">Rol</Label>
|
||||
{editingUser.id === currentUser?.id ? (
|
||||
<div className="text-sm border rounded-md px-3 py-2 bg-muted text-muted-foreground">
|
||||
{getRoleInfo(editForm.role, isDespacho).label}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={editForm.role}
|
||||
onValueChange={(v) => setEditForm({ ...editForm, role: v as Role })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(isDespacho
|
||||
? (['owner', 'supervisor', 'auxiliar', 'cliente'] as Role[])
|
||||
: (['owner', 'cfo', 'contador', 'visor', 'auxiliar'] as Role[])
|
||||
).map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{getRoleInfo(r, isDespacho).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{editingUser.id === currentUser?.id && (
|
||||
<p className="text-xs text-muted-foreground">No puedes cambiar tu propio rol.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditingUser(null)}>Cancelar</Button>
|
||||
<Button onClick={handleSaveUser} disabled={savingUser}>
|
||||
{savingUser ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user