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
|
fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd
|
||||||
notas: z.string().max(2000).optional(),
|
notas: z.string().max(2000).optional(),
|
||||||
privado: z.boolean().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({
|
const updateRecordatorioSchema = z.object({
|
||||||
@@ -107,7 +109,7 @@ export async function createRecordatorio(req: Request, res: Response, next: Next
|
|||||||
const evento = await recordatoriosService.createRecordatorio(
|
const evento = await recordatoriosService.createRecordatorio(
|
||||||
req.tenantPool!,
|
req.tenantPool!,
|
||||||
req.user!.userId,
|
req.user!.userId,
|
||||||
{ ...data, tipo: 'custom', recurrencia: 'unica' }
|
{ ...data, tipo: 'custom' }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(201).json(evento);
|
res.status(201).json(evento);
|
||||||
|
|||||||
@@ -260,7 +260,11 @@ export async function removeAuxiliar(req: Request, res: Response, next: NextFunc
|
|||||||
// Supervisores available (for dropdown)
|
// Supervisores available (for dropdown)
|
||||||
export async function getSupervisores(req: Request, res: Response, next: NextFunction) {
|
export async function getSupervisores(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
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 });
|
return res.json({ data: supervisores });
|
||||||
} catch (err) { return next(err); }
|
} 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);
|
const data = inviteSchema.parse(req.body);
|
||||||
|
|
||||||
// Los supervisores solo pueden invitar clientes
|
// Los supervisores solo pueden invitar clientes y auxiliares
|
||||||
if (req.user!.role === 'supervisor' && data.role !== 'cliente') {
|
if (req.user!.role === 'supervisor' && !['cliente', 'auxiliar'].includes(data.role)) {
|
||||||
throw new AppError(403, 'Los supervisores solo pueden invitar clientes');
|
throw new AppError(403, 'Los supervisores solo pueden invitar clientes y auxiliares');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate: auxiliar requires a supervisor
|
// Validate: auxiliar requires a supervisor
|
||||||
if (data.role === 'auxiliar' && !data.supervisorUserId) {
|
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);
|
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 { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js';
|
||||||
import { startNotificationsJob } from './jobs/notifications.job.js';
|
import { startNotificationsJob } from './jobs/notifications.job.js';
|
||||||
import { startSatSyncMonitorJob } from './jobs/sat-sync-monitor.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);
|
const PORT = parseInt(env.PORT, 10);
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ const server = app.listen(PORT, '0.0.0.0', () => {
|
|||||||
startSatSyncJob();
|
startSatSyncJob();
|
||||||
startMetricasInvalidationsJob();
|
startMetricasInvalidationsJob();
|
||||||
startSatSyncMonitorJob();
|
startSatSyncMonitorJob();
|
||||||
|
startRecordatoriosPeriodicosJob();
|
||||||
if (sendRealEmails) {
|
if (sendRealEmails) {
|
||||||
startWeeklyUpdateJob();
|
startWeeklyUpdateJob();
|
||||||
startNotificationsJob();
|
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);
|
router.use(tenantMiddleware);
|
||||||
|
|
||||||
// Static routes first
|
// 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)
|
// Asignaciones de obligaciones/tareas a auxiliares (antes de /:id para evitar match dinámico)
|
||||||
router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor);
|
router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor);
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ export async function processProximosRecordatorios(
|
|||||||
WHERE completado = false
|
WHERE completado = false
|
||||||
AND fecha_limite = (CURRENT_DATE + ${dias})::date
|
AND fecha_limite = (CURRENT_DATE + ${dias})::date
|
||||||
AND ${col} IS NULL
|
AND ${col} IS NULL
|
||||||
|
AND (serie_id IS NOT NULL OR recurrencia = 'unica')
|
||||||
`);
|
`);
|
||||||
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
|||||||
@@ -1,77 +1,221 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
|
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.
|
* Obtiene recordatorios visibles para el usuario.
|
||||||
* - Públicos: todos los del tenant
|
* - Públicos: todos los del tenant
|
||||||
* - Privados: solo los creados por el usuario
|
* - 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(
|
export async function getRecordatorios(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
userId: string,
|
userId: string,
|
||||||
año: number
|
año: number
|
||||||
): Promise<EventoFiscal[]> {
|
): Promise<EventoFiscal[]> {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query<RecordatorioRow>(`
|
||||||
SELECT id, titulo, descripcion, fecha_limite as "fechaLimite",
|
SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||||
notas, completado, privado, creado_por as "creadoPor",
|
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||||
created_at as "createdAt"
|
|
||||||
FROM recordatorios
|
FROM recordatorios
|
||||||
WHERE EXTRACT(YEAR FROM fecha_limite) = $1
|
WHERE EXTRACT(YEAR FROM fecha_limite) = $1
|
||||||
|
AND activo = true
|
||||||
AND (privado = false OR creado_por = $2)
|
AND (privado = false OR creado_por = $2)
|
||||||
|
AND NOT (serie_id IS NULL AND recurrencia <> 'unica')
|
||||||
ORDER BY fecha_limite
|
ORDER BY fecha_limite
|
||||||
`, [año, userId]);
|
`, [año, userId]);
|
||||||
|
|
||||||
return rows.map(r => ({
|
return rows.map(rowToEventoFiscal);
|
||||||
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(),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRecordatorio(
|
export async function createRecordatorio(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
userId: string,
|
userId: string,
|
||||||
data: EventoCreate & { privado?: boolean }
|
data: EventoCreate & { privado?: boolean; fechaFin?: string | null }
|
||||||
): Promise<EventoFiscal> {
|
): Promise<EventoFiscal> {
|
||||||
const { rows } = await pool.query(`
|
const recurrencia = (data.recurrencia as RecurrenciaRecordatorio) || 'unica';
|
||||||
INSERT INTO recordatorios (titulo, descripcion, fecha_limite, notas, privado, creado_por)
|
const fechaFin = data.fechaFin ? new Date(data.fechaFin + 'T00:00:00') : null;
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
const fechaLimite = new Date(data.fechaLimite + 'T00:00:00');
|
||||||
RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite",
|
|
||||||
notas, completado, privado, creado_por as "creadoPor",
|
const { rows } = await pool.query<RecordatorioRow>(`
|
||||||
created_at as "createdAt"
|
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.titulo,
|
||||||
data.descripcion || null,
|
data.descripcion || null,
|
||||||
data.fechaLimite,
|
toISODate(fechaLimite),
|
||||||
data.notas || null,
|
data.notas || null,
|
||||||
data.privado ?? false,
|
data.privado ?? false,
|
||||||
userId,
|
userId,
|
||||||
|
recurrencia,
|
||||||
|
toISODate(fechaLimite),
|
||||||
|
fechaFin ? toISODate(fechaFin) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const r = rows[0];
|
const maestro = rows[0];
|
||||||
return {
|
|
||||||
id: r.id,
|
if (recurrencia !== 'unica') {
|
||||||
titulo: r.titulo,
|
await generarInstancias(pool, maestro);
|
||||||
descripcion: r.descripcion || '',
|
}
|
||||||
tipo: 'custom',
|
|
||||||
fechaLimite: r.fechaLimite instanceof Date
|
return rowToEventoFiscal(maestro);
|
||||||
? r.fechaLimite.toISOString().split('T')[0]
|
|
||||||
: String(r.fechaLimite).split('T')[0],
|
|
||||||
recurrencia: 'unica',
|
|
||||||
completado: r.completado,
|
|
||||||
notas: r.notas,
|
|
||||||
createdAt: r.createdAt?.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRecordatorio(
|
export async function updateRecordatorio(
|
||||||
@@ -80,54 +224,123 @@ export async function updateRecordatorio(
|
|||||||
id: number,
|
id: number,
|
||||||
data: EventoUpdate & { privado?: boolean }
|
data: EventoUpdate & { privado?: boolean }
|
||||||
): Promise<EventoFiscal | null> {
|
): Promise<EventoFiscal | null> {
|
||||||
// Verify ownership or public
|
const row = await findById(pool, id);
|
||||||
const { rows: existing } = await pool.query(
|
if (!row) return null;
|
||||||
`SELECT id, creado_por FROM recordatorios WHERE id = $1`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
|
|
||||||
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[] = [];
|
if (soloCompletado && row.serie_id !== null) {
|
||||||
const params: any[] = [];
|
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;
|
let idx = 1;
|
||||||
|
|
||||||
if (data.titulo !== undefined) { sets.push(`titulo = $${idx++}`); params.push(data.titulo); }
|
if (data.titulo !== undefined) { updatesMaestro.push(`titulo = $${idx++}`); paramsMaestro.push(data.titulo); }
|
||||||
if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx++}`); params.push(data.descripcion); }
|
if (data.descripcion !== undefined) { updatesMaestro.push(`descripcion = $${idx++}`); paramsMaestro.push(data.descripcion); }
|
||||||
if (data.fechaLimite !== undefined) { sets.push(`fecha_limite = $${idx++}`); params.push(data.fechaLimite); }
|
if (data.notas !== undefined) { updatesMaestro.push(`notas = $${idx++}`); paramsMaestro.push(data.notas); }
|
||||||
if (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); }
|
if (data.privado !== undefined) { updatesMaestro.push(`privado = $${idx++}`); paramsMaestro.push(data.privado); }
|
||||||
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 (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()`);
|
if (updatesMaestro.length > 0) {
|
||||||
params.push(id);
|
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(`
|
// Propagar campos de contenido a instancias futuras no completadas
|
||||||
UPDATE recordatorios SET ${sets.join(', ')}
|
const updatesInstancias: string[] = [];
|
||||||
WHERE id = $${idx}
|
const paramsInstancias: any[] = [];
|
||||||
RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite",
|
let iIdx = 1;
|
||||||
notas, completado, privado, creado_por as "creadoPor",
|
|
||||||
created_at as "createdAt"
|
|
||||||
`, params);
|
|
||||||
|
|
||||||
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];
|
if (updatesInstancias.length > 0) {
|
||||||
return {
|
paramsInstancias.push(maestro.id);
|
||||||
id: r.id,
|
await pool.query(
|
||||||
titulo: r.titulo,
|
`UPDATE recordatorios
|
||||||
descripcion: r.descripcion || '',
|
SET ${updatesInstancias.join(', ')}
|
||||||
tipo: 'custom',
|
WHERE serie_id = $${iIdx}
|
||||||
fechaLimite: r.fechaLimite instanceof Date
|
AND fecha_limite >= CURRENT_DATE
|
||||||
? r.fechaLimite.toISOString().split('T')[0]
|
AND completado = false`,
|
||||||
: String(r.fechaLimite).split('T')[0],
|
paramsInstancias
|
||||||
recurrencia: 'unica',
|
);
|
||||||
completado: r.completado,
|
}
|
||||||
notas: r.notas,
|
|
||||||
createdAt: r.createdAt?.toISOString(),
|
// 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(
|
export async function deleteRecordatorio(
|
||||||
@@ -135,9 +348,70 @@ export async function deleteRecordatorio(
|
|||||||
userId: string,
|
userId: string,
|
||||||
id: number
|
id: number
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { rowCount } = await pool.query(
|
const row = await findById(pool, id);
|
||||||
`DELETE FROM recordatorios WHERE id = $1`,
|
if (!row) return false;
|
||||||
[id]
|
|
||||||
);
|
// 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;
|
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 { useState } from 'react';
|
||||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
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 { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +42,8 @@ interface RecordatorioForm {
|
|||||||
fechaLimite: string;
|
fechaLimite: string;
|
||||||
notas: string;
|
notas: string;
|
||||||
privado: boolean;
|
privado: boolean;
|
||||||
|
recurrencia: 'unica' | 'mensual' | 'bimestral' | 'trimestral' | 'anual';
|
||||||
|
fechaFin: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: RecordatorioForm = {
|
const emptyForm: RecordatorioForm = {
|
||||||
@@ -50,6 +52,8 @@ const emptyForm: RecordatorioForm = {
|
|||||||
fechaLimite: '',
|
fechaLimite: '',
|
||||||
notas: '',
|
notas: '',
|
||||||
privado: false,
|
privado: false,
|
||||||
|
recurrencia: 'unica',
|
||||||
|
fechaFin: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CalendarioPage() {
|
export default function CalendarioPage() {
|
||||||
@@ -100,6 +104,8 @@ export default function CalendarioPage() {
|
|||||||
fechaLimite: evento.fechaLimite,
|
fechaLimite: evento.fechaLimite,
|
||||||
notas: evento.notas || '',
|
notas: evento.notas || '',
|
||||||
privado: (evento as any).privado ?? false,
|
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);
|
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,
|
data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await createEvento.mutateAsync({
|
const payload: any = {
|
||||||
titulo: form.titulo,
|
titulo: form.titulo,
|
||||||
descripcion: form.descripcion,
|
descripcion: form.descripcion,
|
||||||
tipo: 'custom',
|
tipo: 'custom',
|
||||||
fechaLimite: form.fechaLimite,
|
fechaLimite: form.fechaLimite,
|
||||||
recurrencia: 'unica',
|
recurrencia: form.recurrencia,
|
||||||
notas: form.notas,
|
notas: form.notas,
|
||||||
privado: form.privado,
|
privado: form.privado,
|
||||||
} as any);
|
};
|
||||||
|
if (form.recurrencia !== 'unica' && form.fechaFin) {
|
||||||
|
payload.fechaFin = form.fechaFin;
|
||||||
|
}
|
||||||
|
await createEvento.mutateAsync(payload);
|
||||||
}
|
}
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
@@ -131,10 +141,15 @@ export default function CalendarioPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (evento: EventoFiscal) => {
|
||||||
if (!confirm('¿Eliminar este recordatorio?')) return;
|
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 {
|
try {
|
||||||
await deleteEvento.mutateAsync(id);
|
await deleteEvento.mutateAsync(evento.id);
|
||||||
} catch {
|
} catch {
|
||||||
alert('Error al eliminar');
|
alert('Error al eliminar');
|
||||||
}
|
}
|
||||||
@@ -206,6 +221,44 @@ export default function CalendarioPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="descripcion">Descripción (opcional)</Label>
|
<Label htmlFor="descripcion">Descripción (opcional)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -417,7 +470,7 @@ export default function CalendarioPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost" size="icon" className="h-7 w-7 text-destructive"
|
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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { addClienteAcceso } from '@/lib/api/contribuyentes';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { cn } from '@horux/shared-ui';
|
import { cn } from '@horux/shared-ui';
|
||||||
@@ -79,7 +79,7 @@ export default function UsuariosPage() {
|
|||||||
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
|
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
|
||||||
const inviteRoles = isDespacho
|
const inviteRoles = isDespacho
|
||||||
? (currentUser?.role === 'supervisor'
|
? (currentUser?.role === 'supervisor'
|
||||||
? despachoInviteRoles.filter(r => r.value === 'cliente')
|
? despachoInviteRoles.filter(r => r.value === 'cliente' || r.value === 'auxiliar')
|
||||||
: despachoInviteRoles)
|
: despachoInviteRoles)
|
||||||
: legacyInviteRoles;
|
: legacyInviteRoles;
|
||||||
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
|
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
|
||||||
@@ -106,6 +106,13 @@ export default function UsuariosPage() {
|
|||||||
|
|
||||||
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
|
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) => {
|
const openEditSupervisor = async (userId: string, nombre: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`);
|
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) => {
|
const openEditAccesos = async (userId: string, nombre: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`);
|
const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`);
|
||||||
@@ -270,7 +295,16 @@ export default function UsuariosPage() {
|
|||||||
<Label htmlFor="role">Rol</Label>
|
<Label htmlFor="role">Rol</Label>
|
||||||
<Select
|
<Select
|
||||||
value={inviteForm.role}
|
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>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -393,6 +427,18 @@ export default function UsuariosPage() {
|
|||||||
<RoleIcon className="h-4 w-4" />
|
<RoleIcon className="h-4 w-4" />
|
||||||
<span className="text-sm">{roleInfo.label}</span>
|
<span className="text-sm">{roleInfo.label}</span>
|
||||||
</div>
|
</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 && (
|
{isAdmin && !isCurrentUser && (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{isDespacho && usuario.role === 'cliente' && (
|
{isDespacho && usuario.role === 'cliente' && (
|
||||||
@@ -526,6 +572,67 @@ export default function UsuariosPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</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
|
## Deploy histórico
|
||||||
|
|
||||||
### Preparación
|
### Preparación
|
||||||
|
|||||||
Reference in New Issue
Block a user