Compare commits

...

5 Commits

Author SHA1 Message Date
Horux Dev
8f420711ae docs: sesion 2026-05-23 asignaciones tareas admin ui 2026-05-23 23:42:25 +00:00
Horux Dev
be96ecc324 feat: invitaciones trial como pestaña en admin usuarios + sidebar
- Quitado Invitaciones Trial del sidebar (4 layouts)
- Agregado tab Invitaciones Trial dentro de /admin/usuarios
- Componente reutilizable invitaciones-trial-tab.tsx
- Agregada nueva opcion Tareas en el sidebar principal
2026-05-23 23:41:58 +00:00
Horux Dev
bba000d308 feat: pagina /tareas + quitar completar obligaciones fiscales
- Nueva pagina /tareas para ver y marcar tareas operativas
- Endpoint GET /tareas/mis-tareas con periodo actual
- Quitado boton de marcar completada de obligaciones fiscales en /pendientes
2026-05-23 23:41:28 +00:00
Horux Dev
e8b0733304 feat: seguimiento auxiliares UI con tabs Asignadas/Sin asignar
- Componente seguimiento-auxiliares.tsx con tabs Asignadas/Sin asignar
- Tabs internos Obligaciones/Tareas en cada vista
- API client y hooks para asignaciones
- Fix: invalidar query sin-asignar al asignar/desasignar
2026-05-23 23:40:39 +00:00
Horux Dev
f43cb165c6 feat: asignaciones obligaciones/tareas + fixes backend
- Migracion 046: tablas obligacion_asignaciones y tarea_asignaciones
- Servicio y controller de asignaciones (CRUD + listados)
- Fix: enviar correo welcome al invitar usuario nuevo
- Fix: quitar JOIN users de queries tenant (usar Prisma en BD central)
- Fix: req.params.obligacionId correcto en asignaciones controller
- Fix: orden rutas estaticas antes de dinamicas en cartera.routes
- Fix: owner/cfo ven todas las asignaciones en getAsignacionesPorSupervisor
- Fix: validar que entidad pertenezca a cartera padre en subcartera
- Nuevo endpoint GET /carteras/asignaciones/sin-asignar
- Nuevo endpoint GET /tareas/mis-tareas
2026-05-23 23:40:12 +00:00
26 changed files with 1880 additions and 87 deletions

View File

@@ -0,0 +1,137 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as asignacionesService from '../services/asignaciones.service.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { AppError } from '../middlewares/error.middleware.js';
/**
* Valida que el auxiliar pertenezca al supervisor (o que el caller sea owner).
* Owner puede asignar a cualquier auxiliar del tenant.
*/
async function validarAuxiliarDelSupervisor(
pool: import('pg').Pool,
supervisorUserId: string,
auxiliarUserId: string,
callerRole: string,
): Promise<void> {
if (callerRole === 'owner') return;
const { rows } = await pool.query(
`SELECT 1 FROM auxiliar_supervisores
WHERE auxiliar_user_id = $1 AND supervisor_user_id = $2
LIMIT 1`,
[auxiliarUserId, supervisorUserId],
);
if (rows.length === 0) {
throw new AppError(403, 'El auxiliar no pertenece a tu equipo');
}
}
// ── Obligaciones ──
export async function asignarObligacion(req: Request, res: Response, next: NextFunction) {
try {
const obligacionId = String(req.params.obligacionId);
const schema = z.object({ auxiliarUserId: z.string().uuid() });
const { auxiliarUserId } = schema.parse(req.body);
await validarAuxiliarDelSupervisor(
req.tenantPool!,
req.user!.userId,
auxiliarUserId,
req.user!.role,
);
await asignacionesService.asignarObligacion(
req.tenantPool!,
obligacionId,
auxiliarUserId,
req.user!.userId,
);
res.json({ message: 'Obligación asignada' });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
next(err);
}
}
export async function desasignarObligacion(req: Request, res: Response, next: NextFunction) {
try {
const obligacionId = String(req.params.obligacionId);
await asignacionesService.desasignarObligacion(req.tenantPool!, obligacionId);
res.json({ message: 'Asignación de obligación eliminada' });
} catch (err) { next(err); }
}
// ── Tareas ──
export async function asignarTarea(req: Request, res: Response, next: NextFunction) {
try {
const tareaId = String(req.params.id);
const schema = z.object({ auxiliarUserId: z.string().uuid() });
const { auxiliarUserId } = schema.parse(req.body);
await validarAuxiliarDelSupervisor(
req.tenantPool!,
req.user!.userId,
auxiliarUserId,
req.user!.role,
);
await asignacionesService.asignarTarea(
req.tenantPool!,
tareaId,
auxiliarUserId,
req.user!.userId,
);
res.json({ message: 'Tarea asignada' });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
next(err);
}
}
export async function desasignarTarea(req: Request, res: Response, next: NextFunction) {
try {
const tareaId = String(req.params.id);
await asignacionesService.desasignarTarea(req.tenantPool!, tareaId);
res.json({ message: 'Asignación de tarea eliminada' });
} catch (err) { next(err); }
}
// ── Listados ──
export async function listPorSupervisor(req: Request, res: Response, next: NextFunction) {
try {
const data = await asignacionesService.getAsignacionesPorSupervisor(
req.tenantPool!,
req.user!.userId,
req.user!.role,
);
res.json(data);
} catch (err) { next(err); }
}
export async function listPorAuxiliar(req: Request, res: Response, next: NextFunction) {
try {
const data = await asignacionesService.getAsignacionesPorAuxiliar(
req.tenantPool!,
req.user!.userId,
);
res.json(data);
} catch (err) { next(err); }
}
export async function listSinAsignar(req: Request, res: Response, next: NextFunction) {
try {
const entidadIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const [obligaciones, tareas] = await Promise.all([
asignacionesService.getObligacionesSinAsignar(req.tenantPool!, entidadIds),
asignacionesService.getTareasSinAsignar(req.tenantPool!, entidadIds),
]);
res.json({ obligaciones, tareas });
} catch (err) { next(err); }
}

View File

@@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import * as tareasService from '../services/tareas.service.js'; import * as tareasService from '../services/tareas.service.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { emailService } from '../services/email/email.service.js'; import { emailService } from '../services/email/email.service.js';
import { getUserEmailById } from '../utils/memberships.js'; import { getUserEmailById } from '../utils/memberships.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
@@ -164,6 +165,17 @@ export async function descompletarPeriodo(req: Request, res: Response, next: Nex
} }
} }
export async function listMisTareas(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const entidadIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const tareas = await tareasService.listTareasConPeriodoPorContribuyentes(req.tenantPool!, entidadIds);
res.json(tareas);
} catch (error) {
next(error);
}
}
export async function seedDefaults(req: Request, res: Response, next: NextFunction) { export async function seedDefaults(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req); rejectClienteRole(req);

View File

@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS obligacion_asignaciones (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
auxiliar_user_id uuid NOT NULL,
asignado_por uuid NOT NULL,
asignado_at timestamptz DEFAULT now(),
UNIQUE (obligacion_id)
);
CREATE TABLE IF NOT EXISTS tarea_asignaciones (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tarea_id uuid NOT NULL REFERENCES tareas_catalogo(id) ON DELETE CASCADE,
auxiliar_user_id uuid NOT NULL,
asignado_por uuid NOT NULL,
asignado_at timestamptz DEFAULT now(),
UNIQUE (tarea_id)
);
CREATE INDEX IF NOT EXISTS idx_obligacion_asignaciones_auxiliar ON obligacion_asignaciones(auxiliar_user_id);
CREATE INDEX IF NOT EXISTS idx_tarea_asignaciones_auxiliar ON tarea_asignaciones(auxiliar_user_id);

View File

@@ -2,6 +2,7 @@ import { Router, type IRouter } from 'express';
import { authenticate, authorize } from '../middlewares/auth.middleware.js'; import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as ctrl from '../controllers/cartera.controller.js'; import * as ctrl from '../controllers/cartera.controller.js';
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
const router: IRouter = Router(); const router: IRouter = Router();
@@ -11,6 +12,11 @@ router.use(tenantMiddleware);
// Static routes first // Static routes first
router.get('/supervisores', authorize('owner'), ctrl.getSupervisores); router.get('/supervisores', authorize('owner'), ctrl.getSupervisores);
// 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/mias', authorize('auxiliar'), asignacionesCtrl.listPorAuxiliar);
router.get('/asignaciones/sin-asignar', authorize('owner', 'supervisor'), asignacionesCtrl.listSinAsignar);
// Read: owner + supervisor + auxiliar // Read: owner + supervisor + auxiliar
router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list); router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list);
router.get('/:id', authorize('owner', 'supervisor', 'auxiliar'), ctrl.getById); router.get('/:id', authorize('owner', 'supervisor', 'auxiliar'), ctrl.getById);

View File

@@ -5,6 +5,7 @@ import * as ctrl from '../controllers/contribuyente.controller.js';
import * as configCtrl from '../controllers/contribuyente-config.controller.js'; import * as configCtrl from '../controllers/contribuyente-config.controller.js';
import * as facturacionCtrl from '../controllers/facturacion.controller.js'; import * as facturacionCtrl from '../controllers/facturacion.controller.js';
import * as obligacionesCtrl from '../controllers/obligaciones.controller.js'; import * as obligacionesCtrl from '../controllers/obligaciones.controller.js';
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
const router: IRouter = Router(); const router: IRouter = Router();
@@ -50,4 +51,8 @@ router.post('/:id/obligaciones/:obligacionId/uncomplete', authorize('owner', 'cf
router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo); router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo);
router.post('/:id/obligaciones/:obligacionId/uncomplete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompletePeriodo); router.post('/:id/obligaciones/:obligacionId/uncomplete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompletePeriodo);
// Asignación de obligaciones a auxiliares (supervisor/owner)
router.post('/:id/obligaciones/:obligacionId/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.asignarObligacion);
router.delete('/:id/obligaciones/:obligacionId/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.desasignarObligacion);
export default router; export default router;

View File

@@ -1,13 +1,15 @@
import { Router, type IRouter } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as ctrl from '../controllers/tareas.controller.js'; import * as ctrl from '../controllers/tareas.controller.js';
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
const router: IRouter = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);
router.get('/mis-tareas', ctrl.listMisTareas);
router.get('/', ctrl.listTareas); router.get('/', ctrl.listTareas);
router.post('/', ctrl.createTarea); router.post('/', ctrl.createTarea);
router.post('/seed', ctrl.seedDefaults); router.post('/seed', ctrl.seedDefaults);
@@ -17,4 +19,8 @@ router.delete('/:id', ctrl.deleteTarea);
router.post('/periodo/:id/completar', ctrl.completarPeriodo); router.post('/periodo/:id/completar', ctrl.completarPeriodo);
router.delete('/periodo/:id/completar', ctrl.descompletarPeriodo); router.delete('/periodo/:id/completar', ctrl.descompletarPeriodo);
// Asignación de tareas a auxiliares (supervisor/owner)
router.post('/:id/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.asignarTarea);
router.delete('/:id/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.desasignarTarea);
export { router as tareasRoutes }; export { router as tareasRoutes };

View File

@@ -0,0 +1,303 @@
import type { Pool } from 'pg';
import { prisma } from '../config/database.js';
// ── Asignación de obligaciones ──
export async function asignarObligacion(
pool: Pool,
obligacionId: string,
auxiliarUserId: string,
asignadoPor: string,
): Promise<void> {
await pool.query(
`INSERT INTO obligacion_asignaciones (obligacion_id, auxiliar_user_id, asignado_por)
VALUES ($1, $2, $3)
ON CONFLICT (obligacion_id)
DO UPDATE SET auxiliar_user_id = $2, asignado_por = $3, asignado_at = now()`,
[obligacionId, auxiliarUserId, asignadoPor],
);
}
export async function desasignarObligacion(pool: Pool, obligacionId: string): Promise<void> {
await pool.query('DELETE FROM obligacion_asignaciones WHERE obligacion_id = $1', [obligacionId]);
}
// ── Asignación de tareas ──
export async function asignarTarea(
pool: Pool,
tareaId: string,
auxiliarUserId: string,
asignadoPor: string,
): Promise<void> {
await pool.query(
`INSERT INTO tarea_asignaciones (tarea_id, auxiliar_user_id, asignado_por)
VALUES ($1, $2, $3)
ON CONFLICT (tarea_id)
DO UPDATE SET auxiliar_user_id = $2, asignado_por = $3, asignado_at = now()`,
[tareaId, auxiliarUserId, asignadoPor],
);
}
export async function desasignarTarea(pool: Pool, tareaId: string): Promise<void> {
await pool.query('DELETE FROM tarea_asignaciones WHERE tarea_id = $1', [tareaId]);
}
// ── Listados ──
export interface AsignacionObligacion {
id: string;
obligacionId: string;
obligacionNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
export interface AsignacionTarea {
id: string;
tareaId: string;
tareaNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
async function resolveUserNames(userIds: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>();
if (userIds.length === 0) return map;
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, nombre: true },
});
for (const u of users) {
map.set(u.id, u.nombre);
}
return map;
}
/**
* Devuelve todas las asignaciones de obligaciones y tareas de los auxiliares
* que pertenecen al supervisor indicado (vía auxiliar_supervisores).
* Owner ve todas las asignaciones del tenant.
*/
export async function getAsignacionesPorSupervisor(
pool: Pool,
supervisorUserId: string,
role: string,
): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> {
const isOwner = role === 'owner' || role === 'cfo' || role === 'contador';
const whereObl = isOwner
? 'WHERE 1=1'
: 'WHERE EXISTS (SELECT 1 FROM auxiliar_supervisores asp WHERE asp.auxiliar_user_id = oa.auxiliar_user_id AND asp.supervisor_user_id = $1)';
const whereTarea = isOwner
? 'WHERE 1=1'
: 'WHERE EXISTS (SELECT 1 FROM auxiliar_supervisores asp WHERE asp.auxiliar_user_id = ta.auxiliar_user_id AND asp.supervisor_user_id = $1)';
const params = isOwner ? [] : [supervisorUserId];
const { rows: obligaciones } = await pool.query<AsignacionObligacion>(
`SELECT
oa.id,
oa.obligacion_id AS "obligacionId",
oc.nombre AS "obligacionNombre",
oc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
oa.auxiliar_user_id AS "auxiliarUserId",
oa.asignado_por AS "asignadoPor",
oa.asignado_at AS "asignadoAt"
FROM obligacion_asignaciones oa
JOIN obligaciones_contribuyente oc ON oc.id = oa.obligacion_id
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
${whereObl}
ORDER BY oa.asignado_at DESC`,
params,
);
const { rows: tareas } = await pool.query<AsignacionTarea>(
`SELECT
ta.id,
ta.tarea_id AS "tareaId",
tc.nombre AS "tareaNombre",
tc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
ta.auxiliar_user_id AS "auxiliarUserId",
ta.asignado_por AS "asignadoPor",
ta.asignado_at AS "asignadoAt"
FROM tarea_asignaciones ta
JOIN tareas_catalogo tc ON tc.id = ta.tarea_id
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
${whereTarea}
ORDER BY ta.asignado_at DESC`,
params,
);
const allAuxIds = [...new Set([
...obligaciones.map(o => o.auxiliarUserId),
...tareas.map(t => t.auxiliarUserId),
])];
const names = await resolveUserNames(allAuxIds);
return {
obligaciones: obligaciones.map(o => ({ ...o, auxiliarNombre: names.get(o.auxiliarUserId) ?? null })),
tareas: tareas.map(t => ({ ...t, auxiliarNombre: names.get(t.auxiliarUserId) ?? null })),
};
}
/**
* Devuelve las asignaciones del auxiliar logueado.
*/
export async function getAsignacionesPorAuxiliar(
pool: Pool,
auxiliarUserId: string,
): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> {
const { rows: obligaciones } = await pool.query<AsignacionObligacion>(
`SELECT
oa.id,
oa.obligacion_id AS "obligacionId",
oc.nombre AS "obligacionNombre",
oc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
oa.auxiliar_user_id AS "auxiliarUserId",
oa.asignado_por AS "asignadoPor",
oa.asignado_at AS "asignadoAt"
FROM obligacion_asignaciones oa
JOIN obligaciones_contribuyente oc ON oc.id = oa.obligacion_id
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE oa.auxiliar_user_id = $1
ORDER BY oa.asignado_at DESC`,
[auxiliarUserId],
);
const { rows: tareas } = await pool.query<AsignacionTarea>(
`SELECT
ta.id,
ta.tarea_id AS "tareaId",
tc.nombre AS "tareaNombre",
tc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
ta.auxiliar_user_id AS "auxiliarUserId",
ta.asignado_por AS "asignadoPor",
ta.asignado_at AS "asignadoAt"
FROM tarea_asignaciones ta
JOIN tareas_catalogo tc ON tc.id = ta.tarea_id
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE ta.auxiliar_user_id = $1
ORDER BY ta.asignado_at DESC`,
[auxiliarUserId],
);
const names = await resolveUserNames([auxiliarUserId]);
const auxName = names.get(auxiliarUserId) ?? null;
return {
obligaciones: obligaciones.map(o => ({ ...o, auxiliarNombre: auxName })),
tareas: tareas.map(t => ({ ...t, auxiliarNombre: auxName })),
};
}
/**
* Devuelve obligaciones activas sin asignar para los contribuyentes indicados.
*/
export async function getObligacionesSinAsignar(
pool: Pool,
entidadIds: string[],
): Promise<Omit<AsignacionObligacion, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[]> {
if (entidadIds.length === 0) return [];
const { rows } = await pool.query(
`SELECT
oc.id AS "obligacionId",
oc.nombre AS "obligacionNombre",
oc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
FROM obligaciones_contribuyente oc
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id
WHERE oc.activa = true AND oa.id IS NULL AND oc.contribuyente_id = ANY($1)
ORDER BY c.rfc, oc.nombre`,
[entidadIds],
);
return rows;
}
/**
* Devuelve tareas activas sin asignar para los contribuyentes indicados.
*/
export async function getTareasSinAsignar(
pool: Pool,
entidadIds: string[],
): Promise<Omit<AsignacionTarea, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[]> {
if (entidadIds.length === 0) return [];
const { rows } = await pool.query(
`SELECT
tc.id AS "tareaId",
tc.nombre AS "tareaNombre",
tc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
FROM tareas_catalogo tc
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id
WHERE tc.active = true AND ta.id IS NULL AND tc.contribuyente_id = ANY($1)
ORDER BY c.rfc, tc.nombre`,
[entidadIds],
);
return rows;
}
/**
* Resuelve el auxiliar asignado a una obligación (o null).
*/
export async function getAuxiliarAsignadoObligacion(
pool: Pool,
obligacionId: string,
): Promise<{ auxiliarUserId: string; auxiliarNombre: string | null } | null> {
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
`SELECT oa.auxiliar_user_id
FROM obligacion_asignaciones oa
WHERE oa.obligacion_id = $1`,
[obligacionId],
);
if (rows.length === 0) return null;
const auxId = rows[0].auxiliar_user_id;
const names = await resolveUserNames([auxId]);
return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null };
}
/**
* Resuelve el auxiliar asignado a una tarea (o null).
*/
export async function getAuxiliarAsignadoTarea(
pool: Pool,
tareaId: string,
): Promise<{ auxiliarUserId: string; auxiliarNombre: string | null } | null> {
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
`SELECT ta.auxiliar_user_id
FROM tarea_asignaciones ta
WHERE ta.tarea_id = $1`,
[tareaId],
);
if (rows.length === 0) return null;
const auxId = rows[0].auxiliar_user_id;
const names = await resolveUserNames([auxId]);
return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null };
}

View File

@@ -110,6 +110,17 @@ export async function deleteCartera(pool: Pool, id: string): Promise<boolean> {
// Entidades in cartera // Entidades in cartera
export async function addEntidadToCartera(pool: Pool, carteraId: string, entidadId: string): Promise<void> { export async function addEntidadToCartera(pool: Pool, carteraId: string, entidadId: string): Promise<void> {
// Si es subcartera, validar que la entidad pertenezca a la cartera padre
const cartera = await getCarteraById(pool, carteraId);
if (cartera?.parentId) {
const { rows } = await pool.query(
'SELECT 1 FROM cartera_entidades WHERE cartera_id = $1 AND entidad_id = $2',
[cartera.parentId, entidadId],
);
if (rows.length === 0) {
throw new Error('La entidad no pertenece a la cartera padre de esta subcartera');
}
}
await pool.query('INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, entidadId]); await pool.query('INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, entidadId]);
} }

View File

@@ -138,6 +138,8 @@ export interface ObligacionContribuyente {
completadaPor: string | null; completadaPor: string | null;
periodoCompletado: string | null; periodoCompletado: string | null;
createdAt?: string; createdAt?: string;
auxiliarAsignadoId?: string | null;
auxiliarAsignadoNombre?: string | null;
} }
export function getCatalogo(): ObligacionFiscal[] { export function getCatalogo(): ObligacionFiscal[] {
@@ -146,15 +148,18 @@ export function getCatalogo(): ObligacionFiscal[] {
export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> { export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> {
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId", SELECT
nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria, oc.id, oc.contribuyente_id AS "contribuyenteId", oc.catalogo_id AS "catalogoId",
activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom", oc.nombre, oc.fundamento, oc.frecuencia, oc.fecha_limite AS "fechaLimite", oc.categoria,
completada, completada_at AS "completadaAt", completada_por AS "completadaPor", oc.activa, oc.es_recomendada AS "esRecomendada", oc.es_custom AS "esCustom",
periodo_completado AS "periodoCompletado", oc.completada, oc.completada_at AS "completadaAt", oc.completada_por AS "completadaPor",
created_at AS "createdAt" oc.periodo_completado AS "periodoCompletado",
FROM obligaciones_contribuyente oc.created_at AS "createdAt",
WHERE contribuyente_id = $1 oa.auxiliar_user_id AS "auxiliarAsignadoId"
ORDER BY categoria, nombre FROM obligaciones_contribuyente oc
LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id
WHERE oc.contribuyente_id = $1
ORDER BY oc.categoria, oc.nombre
`, [contribuyenteId]); `, [contribuyenteId]);
return rows; return rows;
} }

View File

@@ -17,6 +17,8 @@ export interface TareaCatalogo {
active: boolean; active: boolean;
orden: number; orden: number;
createdAt: Date; createdAt: Date;
auxiliarAsignadoId?: string | null;
auxiliarAsignadoNombre?: string | null;
} }
export interface TareaPeriodo { export interface TareaPeriodo {
@@ -47,6 +49,8 @@ const ROW_TO_TAREA = (r: any): TareaCatalogo => ({
active: r.active, active: r.active,
orden: r.orden, orden: r.orden,
createdAt: r.created_at, createdAt: r.created_at,
auxiliarAsignadoId: r.auxiliarAsignadoId ?? null,
auxiliarAsignadoNombre: r.auxiliarAsignadoNombre ?? null,
}); });
const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({ const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({
@@ -68,9 +72,13 @@ function sanitizeUuid(id: string): string {
export async function listTareas(pool: Pool, contribuyenteId: string): Promise<TareaCatalogo[]> { export async function listTareas(pool: Pool, contribuyenteId: string): Promise<TareaCatalogo[]> {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT * FROM tareas_catalogo `SELECT
WHERE contribuyente_id = $1 AND active = true tc.*,
ORDER BY orden, nombre`, ta.auxiliar_user_id AS "auxiliarAsignadoId"
FROM tareas_catalogo tc
LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id
WHERE tc.contribuyente_id = $1 AND tc.active = true
ORDER BY tc.orden, tc.nombre`,
[sanitizeUuid(contribuyenteId)], [sanitizeUuid(contribuyenteId)],
); );
return rows.map(ROW_TO_TAREA); return rows.map(ROW_TO_TAREA);
@@ -272,6 +280,59 @@ export async function listTareasConPeriodoActual(
return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null })); return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null }));
} }
export interface TareaConContribuyente extends TareaConPeriodo {
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
}
/**
* Lee tareas activas con periodo actual para una lista de contribuyentes.
* Útil para la vista "Mis Tareas".
*/
export async function listTareasConPeriodoPorContribuyentes(
pool: Pool,
contribuyenteIds: string[],
): Promise<TareaConContribuyente[]> {
if (contribuyenteIds.length === 0) return [];
// Materializar periodos para cada contribuyente en paralelo
await Promise.all(contribuyenteIds.map(id => materializarPeriodos(pool, id)));
const { rows: tareasRows } = await pool.query(
`SELECT tc.*, c.entidad_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
FROM tareas_catalogo tc
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE tc.contribuyente_id = ANY($1::uuid[]) AND tc.active = true
ORDER BY c.rfc, tc.orden, tc.nombre`,
[contribuyenteIds],
);
if (tareasRows.length === 0) return [];
const tareaIds = tareasRows.map((r: any) => r.id);
const today = new Date().toISOString().split('T')[0];
const { rows: periodoRows } = await pool.query(
`SELECT DISTINCT ON (tarea_id) *
FROM tarea_periodos
WHERE tarea_id = ANY($1::uuid[])
AND (completada = false OR fecha_limite >= $2::date)
ORDER BY tarea_id, fecha_limite ASC`,
[tareaIds, today],
);
const periodos = new Map(periodoRows.map((r: any) => [r.tarea_id, ROW_TO_PERIODO(r)]));
return tareasRows.map((r: any) => ({
...ROW_TO_TAREA(r),
contribuyenteId: r.contribuyenteId,
contribuyenteRfc: r.contribuyenteRfc,
contribuyenteRazonSocial: r.contribuyenteRazonSocial,
periodoActual: periodos.get(r.id) ?? null,
}));
}
// ─── Completar / descompletar periodo ─── // ─── Completar / descompletar periodo ───
const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']); const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']);

View File

@@ -2,6 +2,7 @@ import { prisma } from '../config/database.js';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { getDespachoPlanLimits } from './plan-catalogo.service.js'; import { getDespachoPlanLimits } from './plan-catalogo.service.js';
import { emailService } from './email/email.service.js';
import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared'; import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared';
/** /**
@@ -99,6 +100,13 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
lastTenantId: tenantId, lastTenantId: tenantId,
}, },
}); });
// Enviar correo de bienvenida con credenciales (non-blocking)
emailService.sendWelcome(data.email, {
nombre: data.nombre,
email: data.email,
tempPassword,
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
} }
const rolId = await getRolId(data.role); const rolId = await getRolId(data.role);
@@ -224,8 +232,9 @@ export async function createUsuarioGlobal(
// Si el email ya existe como user global, agregamos membership en este tenant // Si el email ya existe como user global, agregamos membership en este tenant
let user = await prisma.user.findUnique({ where: { email: data.email } }); let user = await prisma.user.findUnique({ where: { email: data.email } });
let tempPassword: string | null = null;
if (!user) { if (!user) {
const tempPassword = randomBytes(4).toString('hex'); tempPassword = randomBytes(4).toString('hex');
const passwordHash = await bcrypt.hash(tempPassword, 12); const passwordHash = await bcrypt.hash(tempPassword, 12);
user = await prisma.user.create({ user = await prisma.user.create({
data: { data: {
@@ -235,6 +244,13 @@ export async function createUsuarioGlobal(
lastTenantId: tenantId, lastTenantId: tenantId,
}, },
}); });
// Enviar correo de bienvenida con credenciales (non-blocking)
emailService.sendWelcome(data.email, {
nombre: data.nombre,
email: data.email,
tempPassword,
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
} }
const rolId = await getRolId(data.role); const rolId = await getRolId(data.role);

View File

@@ -0,0 +1,252 @@
'use client';
import { useEffect, useState } from 'react';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { getAllInvitations, createInvitation, cancelInvitation } from '@/lib/api/trial-invitations';
import { getTenants } from '@/lib/api/tenants';
import { Gift, X, Clock, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
interface TenantOption {
id: string;
nombre: string;
rfc: string;
}
interface Invitation {
id: string;
tenantId: string;
plan: string;
durationDays: number;
status: string;
token: string;
sentAt: string;
expiresAt: string;
acceptedAt: string | null;
tenant: {
nombre: string;
rfc: string;
} | null;
}
export default function InvitacionesTrialTab() {
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [selectedTenantId, setSelectedTenantId] = useState('');
const [durationDays, setDurationDays] = useState('30');
const [plan, setPlan] = useState('business_control');
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
useEffect(() => {
loadData();
}, []);
async function loadData() {
setLoading(true);
try {
const [tenantsData, invitationsData] = await Promise.all([
getTenants(),
getAllInvitations(),
]);
setTenants(tenantsData);
setInvitations(invitationsData);
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cargar datos' });
} finally {
setLoading(false);
}
}
async function handleCreate() {
if (!selectedTenantId || !durationDays) {
setMessage({ kind: 'err', text: 'Selecciona un despacho y duración' });
return;
}
setCreating(true);
setMessage(null);
try {
await createInvitation({
tenantId: selectedTenantId,
plan,
durationDays: parseInt(durationDays, 10),
});
setMessage({ kind: 'ok', text: 'Invitación enviada correctamente' });
setSelectedTenantId('');
setDurationDays('30');
loadData();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al crear invitación' });
} finally {
setCreating(false);
}
}
async function handleCancel(id: string) {
if (!confirm('¿Seguro que quieres cancelar esta invitación?')) return;
try {
await cancelInvitation(id);
setMessage({ kind: 'ok', text: 'Invitación cancelada' });
loadData();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cancelar' });
}
}
function statusIcon(status: string) {
switch (status) {
case 'pending': return <Clock className="h-4 w-4 text-amber-500" />;
case 'accepted': return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case 'expired': return <AlertTriangle className="h-4 w-4 text-red-500" />;
case 'cancelled': return <X className="h-4 w-4 text-gray-500" />;
default: return null;
}
}
function statusLabel(status: string) {
switch (status) {
case 'pending': return 'Pendiente';
case 'accepted': return 'Aceptada';
case 'expired': return 'Expirada';
case 'cancelled': return 'Cancelada';
default: return status;
}
}
return (
<div className="space-y-6">
{/* Toast de resultado */}
{message && (
<div
className={`max-w-3xl rounded-lg px-4 py-3 text-sm ${
message.kind === 'ok'
? 'bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
: 'bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
}`}
>
{message.text}
</div>
)}
{/* Formulario de creación */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Nueva invitación</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Despacho</Label>
<Select value={selectedTenantId} onValueChange={setSelectedTenantId}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un despacho" />
</SelectTrigger>
<SelectContent>
{tenants.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.nombre} ({t.rfc})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Plan</Label>
<Select value={plan} onValueChange={setPlan}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="business_control">Business Control</SelectItem>
<SelectItem value="business_cloud">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Duración (días)</Label>
<Input
type="number"
min={1}
max={365}
value={durationDays}
onChange={(e) => setDurationDays(e.target.value)}
/>
</div>
</div>
<Button onClick={handleCreate} disabled={creating}>
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Gift className="h-4 w-4 mr-2" />}
Enviar invitación
</Button>
</CardContent>
</Card>
{/* Tabla de invitaciones */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Historial de invitaciones</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : invitations.length === 0 ? (
<p className="text-muted-foreground text-center py-8">No hay invitaciones enviadas</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3">Despacho</th>
<th className="text-left py-2 px-3">Plan</th>
<th className="text-left py-2 px-3">Días</th>
<th className="text-left py-2 px-3">Estado</th>
<th className="text-left py-2 px-3">Enviado</th>
<th className="text-left py-2 px-3">Expira</th>
<th className="text-left py-2 px-3"></th>
</tr>
</thead>
<tbody>
{invitations.map((inv) => (
<tr key={inv.id} className="border-b hover:bg-muted/50">
<td className="py-2 px-3">
<div className="font-medium">{inv.tenant?.nombre || '—'}</div>
<div className="text-xs text-muted-foreground">{inv.tenant?.rfc || '—'}</div>
</td>
<td className="py-2 px-3">
{inv.plan === 'business_control' ? 'Business Control' : inv.plan === 'business_cloud' ? 'Enterprise' : inv.plan}
</td>
<td className="py-2 px-3">{inv.durationDays}</td>
<td className="py-2 px-3">
<span className="flex items-center gap-1">
{statusIcon(inv.status)}
{statusLabel(inv.status)}
</span>
</td>
<td className="py-2 px-3">
{new Date(inv.sentAt).toLocaleDateString('es-MX')}
</td>
<td className="py-2 px-3">
{new Date(inv.expiresAt).toLocaleDateString('es-MX')}
</td>
<td className="py-2 px-3">
{inv.status === 'pending' && (
<button
onClick={() => handleCancel(inv.id)}
className="text-destructive hover:underline text-xs"
>
Cancelar
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -2,13 +2,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell'; import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui'; import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tabs, TabsList, TabsTrigger, TabsContent } from '@horux/shared-ui';
import { useAllUsuarios, useCreateUsuarioGlobal, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios'; import { useAllUsuarios, useCreateUsuarioGlobal, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
import { getTenants, type Tenant } from '@/lib/api/tenants'; import { getTenants, type Tenant } from '@/lib/api/tenants';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared'; import { isGlobalAdminRfc } from '@horux/shared';
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase, Plus } from 'lucide-react'; import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase, Plus } from 'lucide-react';
import { cn } from '@horux/shared-ui'; import { cn } from '@horux/shared-ui';
import InvitacionesTrialTab from '../_components/invitaciones-trial-tab';
// Mapa de roles + fallback defensivo. El fork despacho introduce roles // Mapa de roles + fallback defensivo. El fork despacho introduce roles
// adicionales (cfo, supervisor, auxiliar, cliente) que no estaban en // adicionales (cfo, supervisor, auxiliar, cliente) que no estaban en
@@ -43,6 +44,7 @@ export default function AdminUsuariosPage() {
const [editingUser, setEditingUser] = useState<EditingUser | null>(null); const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
const [filterTenant, setFilterTenant] = useState<string>('all'); const [filterTenant, setFilterTenant] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState('usuarios');
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [createFormData, setCreateFormData] = useState({ const [createFormData, setCreateFormData] = useState({
email: '', email: '',
@@ -152,6 +154,13 @@ export default function AdminUsuariosPage() {
return ( return (
<DashboardShell title="Administracion de Usuarios"> <DashboardShell title="Administracion de Usuarios">
<Tabs value={activeTab} onValueChange={setActiveTab} defaultValue="usuarios" className="space-y-4">
<TabsList>
<TabsTrigger value="usuarios">Usuarios</TabsTrigger>
<TabsTrigger value="invitaciones-trial">Invitaciones Trial</TabsTrigger>
</TabsList>
<TabsContent value="usuarios">
<div className="space-y-4"> <div className="space-y-4">
{/* Filtros */} {/* Filtros */}
<Card> <Card>
@@ -425,6 +434,12 @@ export default function AdminUsuariosPage() {
)) ))
)} )}
</div> </div>
</TabsContent>
<TabsContent value="invitaciones-trial">
<InvitacionesTrialTab />
</TabsContent>
</Tabs>
</DashboardShell> </DashboardShell>
); );
} }

View File

@@ -5,12 +5,13 @@ import {
Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Button, Card, CardContent, CardHeader, CardTitle, Input, Label,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Tabs, TabsList, TabsTrigger, TabsContent,
cn, cn,
} from '@horux/shared-ui'; } from '@horux/shared-ui';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { import {
FolderOpen, Plus, Trash2, ChevronDown, ChevronUp, X, FolderOpen, Plus, Trash2, ChevronDown, ChevronUp, X,
Users, Building2, FolderPlus, UserCog, Users, Building2, FolderPlus, UserCog, ClipboardList,
} from 'lucide-react'; } from 'lucide-react';
import { import {
useCarteras, useCreateCartera, useDeleteCartera, useCarteras, useCreateCartera, useDeleteCartera,
@@ -25,14 +26,16 @@ import { useUsuarios } from '@/lib/hooks/use-usuarios';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { DashboardShell } from '@/components/layouts/dashboard-shell'; import { DashboardShell } from '@/components/layouts/dashboard-shell';
import type { Cartera } from '@/lib/api/carteras'; import type { Cartera } from '@/lib/api/carteras';
import SeguimientoAuxiliares from './seguimiento-auxiliares';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* SubcarteraCard */ /* SubcarteraCard */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: { function SubcarteraCard({ sub, usuarios, contribuyentes, parentEntidadIds, onDelete }: {
sub: Cartera; sub: Cartera;
usuarios: any[]; usuarios: any[];
contribuyentes: any[]; contribuyentes: any[];
parentEntidadIds: string[];
onDelete: () => void; onDelete: () => void;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@@ -47,7 +50,7 @@ function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
); );
const available = (contribuyentes ?? []).filter( const available = (contribuyentes ?? []).filter(
(c: any) => !(entidadIds ?? []).includes(c.id) (c: any) => (parentEntidadIds ?? []).includes(c.id) && !(entidadIds ?? []).includes(c.id)
); );
const auxiliarUser = usuarios?.find((u: any) => u.id === sub.auxiliarUserId); const auxiliarUser = usuarios?.find((u: any) => u.id === sub.auxiliarUserId);
@@ -319,6 +322,7 @@ function CarteraDetail({ cartera, canEdit = true, canManageSubcarteras = true }:
sub={sub} sub={sub}
usuarios={usuarios ?? []} usuarios={usuarios ?? []}
contribuyentes={contribuyentes ?? []} contribuyentes={contribuyentes ?? []}
parentEntidadIds={entidadIds ?? []}
onDelete={() => handleDeleteSubcartera(sub.id)} onDelete={() => handleDeleteSubcartera(sub.id)}
/> />
))} ))}
@@ -396,6 +400,10 @@ export default function CarterasPage() {
const canEditCartera = userRole === 'owner'; // Edit/delete top-level carteras + add/remove RFCs const canEditCartera = userRole === 'owner'; // Edit/delete top-level carteras + add/remove RFCs
const canManageSubcarteras = userRole === 'owner' || userRole === 'supervisor'; // Create subcarteras const canManageSubcarteras = userRole === 'owner' || userRole === 'supervisor'; // Create subcarteras
const isAuxiliar = userRole === 'auxiliar'; const isAuxiliar = userRole === 'auxiliar';
const isSupervisor = userRole === 'supervisor';
const isOwner = userRole === 'owner';
const puedeVerSeguimiento = isOwner || isSupervisor;
const [activeTab, setActiveTab] = useState('carteras');
const { data: carteras, isLoading } = useCarteras(); const { data: carteras, isLoading } = useCarteras();
const { data: supervisores } = useSupervisores(); const { data: supervisores } = useSupervisores();
const { data: usuarios } = useUsuarios(); const { data: usuarios } = useUsuarios();
@@ -440,9 +448,43 @@ export default function CarterasPage() {
} }
}; };
const CarterasList = () => (
<>
{isLoading ? (
<p className="text-muted-foreground">Cargando...</p>
) : !carteras || carteras.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin carteras</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Crea la primera cartera para organizar tus contribuyentes.
</p>
<Button onClick={() => setShowCreate(true)}>Crear primera cartera</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{carteras.map(cartera => (
<CarteraCard
key={cartera.id}
cartera={cartera}
expanded={expandedId === cartera.id}
onToggle={() => setExpandedId(expandedId === cartera.id ? null : cartera.id)}
onDelete={() => handleDelete(cartera)}
usuarios={usuarios ?? []}
canEdit={canEditCartera}
canManageSubcarteras={canManageSubcarteras}
/>
))}
</div>
)}
</>
);
return ( return (
<DashboardShell title="Carteras"> <DashboardShell title="Carteras">
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -450,42 +492,34 @@ export default function CarterasPage() {
{isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'} {isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'}
</p> </p>
</div> </div>
{canCreate && ( {canCreate && activeTab === 'carteras' && (
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2"> <Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Nueva cartera <Plus className="h-4 w-4" /> Nueva cartera
</Button> </Button>
)} )}
</div> </div>
{/* List */} {puedeVerSeguimiento ? (
{isLoading ? ( <Tabs value={activeTab} onValueChange={setActiveTab} defaultValue="carteras" className="space-y-4">
<p className="text-muted-foreground">Cargando...</p> <TabsList>
) : !carteras || carteras.length === 0 ? ( <TabsTrigger value="carteras">
<Card> <FolderOpen className="h-4 w-4 mr-1.5" /> Carteras
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> </TabsTrigger>
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" /> <TabsTrigger value="seguimiento">
<h3 className="text-lg font-semibold">Sin carteras</h3> <ClipboardList className="h-4 w-4 mr-1.5" /> Seguimiento de Auxiliares
<p className="text-sm text-muted-foreground mt-1 mb-4"> </TabsTrigger>
Crea la primera cartera para organizar tus contribuyentes. </TabsList>
</p>
<Button onClick={() => setShowCreate(true)}>Crear primera cartera</Button> <TabsContent value="carteras">
</CardContent> <CarterasList />
</Card> </TabsContent>
<TabsContent value="seguimiento">
<SeguimientoAuxiliares />
</TabsContent>
</Tabs>
) : ( ) : (
<div className="space-y-3"> <CarterasList />
{carteras.map(cartera => (
<CarteraCard
key={cartera.id}
cartera={cartera}
expanded={expandedId === cartera.id}
onToggle={() => setExpandedId(expandedId === cartera.id ? null : cartera.id)}
onDelete={() => handleDelete(cartera)}
usuarios={usuarios ?? []}
canEdit={canEditCartera}
canManageSubcarteras={canManageSubcarteras}
/>
))}
</div>
)} )}
{/* Create dialog */} {/* Create dialog */}

View File

@@ -0,0 +1,332 @@
'use client';
import { useState } from 'react';
import {
Button, Card, CardContent, CardHeader, CardTitle,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Tabs, TabsList, TabsTrigger, TabsContent,
} from '@horux/shared-ui';
import {
useAsignacionesSupervisor,
useSinAsignar,
useAsignarObligacion,
useDesasignarObligacion,
useAsignarTarea,
useDesasignarTarea,
} from '@/lib/hooks/use-asignaciones';
import { useUsuarios } from '@/lib/hooks/use-usuarios';
import { useAuthStore } from '@/stores/auth-store';
import { UserCheck, UserX, UserCog, Plus } from 'lucide-react';
export default function SeguimientoAuxiliares() {
const { user } = useAuthStore();
const { data: asignaciones, isLoading: loadingAsignadas } = useAsignacionesSupervisor();
const { data: sinAsignar, isLoading: loadingSinAsignar } = useSinAsignar();
const { data: usuarios } = useUsuarios();
const asignarObligacionMut = useAsignarObligacion();
const desasignarObligacionMut = useDesasignarObligacion();
const asignarTareaMut = useAsignarTarea();
const desasignarTareaMut = useDesasignarTarea();
const [modalOpen, setModalOpen] = useState(false);
const [modalType, setModalType] = useState<'obligacion' | 'tarea'>('obligacion');
const [modalItem, setModalItem] = useState<any>(null);
const [selectedAuxiliar, setSelectedAuxiliar] = useState('');
const auxiliares = (usuarios ?? []).filter((u: any) => u.role === 'auxiliar');
const openAssignModal = (type: 'obligacion' | 'tarea', item: any) => {
setModalType(type);
setModalItem(item);
setSelectedAuxiliar(item.auxiliarUserId || '');
setModalOpen(true);
};
const handleAssign = async () => {
if (!selectedAuxiliar || !modalItem) return;
try {
if (modalType === 'obligacion') {
await asignarObligacionMut.mutateAsync({
contribuyenteId: modalItem.contribuyenteId,
obligacionId: modalItem.obligacionId,
auxiliarUserId: selectedAuxiliar,
});
} else {
await asignarTareaMut.mutateAsync({
tareaId: modalItem.tareaId,
auxiliarUserId: selectedAuxiliar,
});
}
setModalOpen(false);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al asignar');
}
};
const handleUnassign = async (type: 'obligacion' | 'tarea', item: any) => {
if (!confirm('¿Eliminar la asignación?')) return;
try {
if (type === 'obligacion') {
await desasignarObligacionMut.mutateAsync({
contribuyenteId: item.contribuyenteId,
obligacionId: item.obligacionId,
});
} else {
await desasignarTareaMut.mutateAsync({ tareaId: item.tareaId });
}
} catch (err: any) {
alert(err.response?.data?.message || 'Error al desasignar');
}
};
if (loadingAsignadas || loadingSinAsignar) {
return <p className="text-muted-foreground">Cargando asignaciones...</p>;
}
const obligacionesAsignadas = asignaciones?.obligaciones ?? [];
const tareasAsignadas = asignaciones?.tareas ?? [];
const obligacionesSinAsignar = sinAsignar?.obligaciones ?? [];
const tareasSinAsignar = sinAsignar?.tareas ?? [];
return (
<div className="space-y-4">
<Tabs defaultValue="asignadas" className="space-y-4">
<TabsList>
<TabsTrigger value="asignadas">Asignadas</TabsTrigger>
<TabsTrigger value="sin-asignar">Sin asignar</TabsTrigger>
</TabsList>
<TabsContent value="asignadas">
<Tabs defaultValue="obligaciones" className="space-y-4">
<TabsList>
<TabsTrigger value="obligaciones">Obligaciones ({obligacionesAsignadas.length})</TabsTrigger>
<TabsTrigger value="tareas">Tareas ({tareasAsignadas.length})</TabsTrigger>
</TabsList>
<TabsContent value="obligaciones">
<AsignacionesTable
items={obligacionesAsignadas}
tipo="obligacion"
modo="asignadas"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('obligacion', item)}
onUnassign={(item) => handleUnassign('obligacion', item)}
/>
</TabsContent>
<TabsContent value="tareas">
<AsignacionesTable
items={tareasAsignadas}
tipo="tarea"
modo="asignadas"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('tarea', item)}
onUnassign={(item) => handleUnassign('tarea', item)}
/>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="sin-asignar">
<Tabs defaultValue="obligaciones" className="space-y-4">
<TabsList>
<TabsTrigger value="obligaciones">Obligaciones ({obligacionesSinAsignar.length})</TabsTrigger>
<TabsTrigger value="tareas">Tareas ({tareasSinAsignar.length})</TabsTrigger>
</TabsList>
<TabsContent value="obligaciones">
<SinAsignarTable
items={obligacionesSinAsignar}
tipo="obligacion"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('obligacion', item)}
/>
</TabsContent>
<TabsContent value="tareas">
<SinAsignarTable
items={tareasSinAsignar}
tipo="tarea"
auxiliares={auxiliares}
onAssign={(item) => openAssignModal('tarea', item)}
/>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
{/* Modal de asignación */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{modalItem?.auxiliarUserId ? 'Reasignar' : 'Asignar'} {modalType === 'obligacion' ? 'obligación' : 'tarea'}</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground mb-2">
{modalType === 'obligacion' ? modalItem?.obligacionNombre : modalItem?.tareaNombre}
</p>
<p className="text-xs text-muted-foreground mb-4">
Contribuyente: {modalItem?.contribuyenteRazonSocial} ({modalItem?.contribuyenteRfc})
</p>
<Select value={selectedAuxiliar} onValueChange={setSelectedAuxiliar}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un auxiliar" />
</SelectTrigger>
<SelectContent>
{auxiliares.map((a: any) => (
<SelectItem key={a.id} value={a.id}>{a.nombre} ({a.email})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={handleAssign} disabled={!selectedAuxiliar}>
{modalItem?.auxiliarUserId ? 'Reasignar' : 'Asignar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function AsignacionesTable({
items,
tipo,
modo,
auxiliares,
onAssign,
onUnassign,
}: {
items: any[];
tipo: 'obligacion' | 'tarea';
modo: 'asignadas';
auxiliares: any[];
onAssign: (item: any) => void;
onUnassign: (item: any) => void;
}) {
if (items.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay {tipo === 'obligacion' ? 'obligaciones' : 'tareas'} asignadas.
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium">Auxiliar</th>
<th className="text-left px-4 py-3 font-medium">Contribuyente</th>
<th className="text-left px-4 py-3 font-medium">{tipo === 'obligacion' ? 'Obligación' : 'Tarea'}</th>
<th className="text-left px-4 py-3 font-medium">Asignado</th>
<th className="text-right px-4 py-3 font-medium">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr key={item.id} className="hover:bg-muted/30">
<td className="px-4 py-3">
{item.auxiliarNombre ? (
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 flex items-center gap-1 w-fit">
<UserCheck className="h-3 w-3" /> {item.auxiliarNombre}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-border text-muted-foreground hover:bg-secondary/80 flex items-center gap-1 w-fit">
<UserX className="h-3 w-3" /> Sin asignar
</span>
)}
</td>
<td className="px-4 py-3">
<div className="font-medium">{item.contribuyenteRazonSocial}</div>
<div className="text-xs text-muted-foreground">{item.contribuyenteRfc}</div>
</td>
<td className="px-4 py-3">{tipo === 'obligacion' ? item.obligacionNombre : item.tareaNombre}</td>
<td className="px-4 py-3 text-muted-foreground">
{item.asignadoAt ? new Date(item.asignadoAt).toLocaleDateString('es-MX') : '-'}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => onAssign(item)}>
<UserCog className="h-4 w-4" />
</Button>
{item.auxiliarUserId && (
<Button variant="ghost" size="sm" className="text-red-600" onClick={() => onUnassign(item)}>
<UserX className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function SinAsignarTable({
items,
tipo,
auxiliares,
onAssign,
}: {
items: any[];
tipo: 'obligacion' | 'tarea';
auxiliares: any[];
onAssign: (item: any) => void;
}) {
if (items.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay {tipo === 'obligacion' ? 'obligaciones' : 'tareas'} sin asignar.
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium">Contribuyente</th>
<th className="text-left px-4 py-3 font-medium">{tipo === 'obligacion' ? 'Obligación' : 'Tarea'}</th>
<th className="text-right px-4 py-3 font-medium">Acción</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item, idx) => (
<tr key={`${item.obligacionId || item.tareaId}-${idx}`} className="hover:bg-muted/30">
<td className="px-4 py-3">
<div className="font-medium">{item.contribuyenteRazonSocial}</div>
<div className="text-xs text-muted-foreground">{item.contribuyenteRfc}</div>
</td>
<td className="px-4 py-3">{tipo === 'obligacion' ? item.obligacionNombre : item.tareaNombre}</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => onAssign(item)}>
<Plus className="h-4 w-4 mr-1" /> Asignar
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -11,7 +11,7 @@ import { Header } from '@/components/layouts/header';
import { import {
ClipboardList, ClipboardList,
CheckCircle2, CheckCircle2,
Circle,
Clock, Clock,
AlertTriangle, AlertTriangle,
Building2, Building2,
@@ -75,7 +75,7 @@ export default function PendientesPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [singleObligaciones, setSingleObligaciones] = useState<ObligacionPeriodo[]>([]); const [singleObligaciones, setSingleObligaciones] = useState<ObligacionPeriodo[]>([]);
const [filter, setFilter] = useState<'todos' | 'mis'>('todos'); const [filter, setFilter] = useState<'todos' | 'mis'>('todos');
const [toggling, setToggling] = useState<string | null>(null);
// Single contribuyente view — fetch period-aware data // Single contribuyente view — fetch period-aware data
useEffect(() => { useEffect(() => {
@@ -132,31 +132,7 @@ export default function PendientesPage() {
const pendientesCount = singleObligaciones.filter((o) => o.periodStatus === 'pendiente').length; const pendientesCount = singleObligaciones.filter((o) => o.periodStatus === 'pendiente').length;
const categorias = [...new Set(singleObligaciones.map((o) => o.categoria || 'Sin categoría'))]; const categorias = [...new Set(singleObligaciones.map((o) => o.categoria || 'Sin categoría'))];
const toggleComplete = async (obligacionId: string, currentStatus: string, periodoAplica: string) => {
if (!selectedContribuyenteId) return;
const key = `${obligacionId}:${periodoAplica}`;
setToggling(key);
try {
if (currentStatus === 'completada') {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/uncomplete-periodo`,
{ periodo: periodoAplica }
);
} else {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/complete-periodo`,
{ periodo: periodoAplica }
);
}
// Refetch
const { data } = await apiClient.get(`/contribuyentes/${selectedContribuyenteId}/obligaciones/periodo?periodo=${periodo}&atrasados=true`);
setSingleObligaciones(data.data || []);
} catch {
// silent — state stays as-is
} finally {
setToggling(null);
}
};
// Status badge // Status badge
const statusBadge = (status: string) => { const statusBadge = (status: string) => {
@@ -311,18 +287,15 @@ export default function PendientesPage() {
)} )}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <span className="shrink-0">
onClick={() => toggleComplete(ob.id, ob.periodStatus, ob.periodoAplica)}
disabled={toggling === toggleKey}
className="shrink-0 focus:outline-none"
title={ob.periodStatus === 'completada' ? 'Marcar como pendiente' : 'Marcar como completada'}
>
{ob.periodStatus === 'completada' ? ( {ob.periodStatus === 'completada' ? (
<CheckCircle2 className="h-4 w-4 text-green-500" /> <CheckCircle2 className="h-4 w-4 text-green-500" />
) : ob.periodStatus === 'atrasada' ? (
<AlertTriangle className="h-4 w-4 text-red-400" />
) : ( ) : (
<Circle className={cn('h-4 w-4', ob.periodStatus === 'atrasada' ? 'text-red-400' : 'text-muted-foreground')} /> <Clock className="h-4 w-4 text-muted-foreground" />
)} )}
</button> </span>
<div> <div>
<p className={cn('text-sm font-medium', ob.periodStatus === 'completada' && 'line-through')}>{ob.nombre}</p> <p className={cn('text-sm font-medium', ob.periodStatus === 'completada' && 'line-through')}>{ob.nombre}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap"> <div className="flex items-center gap-2 mt-0.5 flex-wrap">

View File

@@ -0,0 +1,223 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, Button, cn } from '@horux/shared-ui';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { useMisTareas, useCompletarTareaPeriodo, useDescompletarTareaPeriodo } from '@/lib/hooks/use-tareas-mis';
import { CheckCircle2, Circle, AlertTriangle, Clock, Building2 } from 'lucide-react';
const RECURRENCIAS: Record<string, string> = {
semanal: 'Semanal',
quincenal: 'Quincenal',
mensual: 'Mensual',
bimestral: 'Bimestral',
trimestral: 'Trimestral',
semestral: 'Semestral',
anual: 'Anual',
};
const DIAS_SEMANA = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
interface TareaItem {
id: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
nombre: string;
descripcion: string | null;
recurrencia: string;
diaSemana: number | null;
diaMes: number | null;
soloSupervisorCompleta: boolean;
periodoActual: {
id: string;
fechaLimite: string;
completada: boolean;
completadaAt: string | null;
completadaPor: string | null;
notas: string | null;
} | null;
}
export default function TareasPage() {
const { data: tareas, isLoading } = useMisTareas();
const completarMut = useCompletarTareaPeriodo();
const descompletarMut = useDescompletarTareaPeriodo();
const [filter, setFilter] = useState<'todas' | 'pendientes' | 'completadas'>('todas');
if (isLoading) {
return (
<DashboardShell title="Mis Tareas">
<p className="text-muted-foreground">Cargando tareas...</p>
</DashboardShell>
);
}
const all = tareas ?? [];
const filtered = all.filter((t: TareaItem) => {
if (filter === 'pendientes') return !t.periodoActual?.completada;
if (filter === 'completadas') return t.periodoActual?.completada;
return true;
});
// Agrupar por contribuyente
const grouped = filtered.reduce((acc: Record<string, TareaItem[]>, t: TareaItem) => {
const key = t.contribuyenteId;
if (!acc[key]) acc[key] = [];
acc[key].push(t);
return acc;
}, {});
const contribuyenteMap = all.reduce((acc: Record<string, { rfc: string; razonSocial: string }>, t: TareaItem) => {
if (!acc[t.contribuyenteId]) {
acc[t.contribuyenteId] = { rfc: t.contribuyenteRfc, razonSocial: t.contribuyenteRazonSocial };
}
return acc;
}, {});
const pendingCount = all.filter((t: TareaItem) => !t.periodoActual?.completada).length;
const completedCount = all.filter((t: TareaItem) => t.periodoActual?.completada).length;
return (
<DashboardShell title="Mis Tareas">
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-amber-100 dark:bg-amber-900 rounded-full p-2">
<Clock className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold">{pendingCount}</p>
<p className="text-xs text-muted-foreground">Pendientes</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-green-100 dark:bg-green-900 rounded-full p-2">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-2xl font-bold">{completedCount}</p>
<p className="text-xs text-muted-foreground">Completadas</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex gap-2">
{(['todas', 'pendientes', 'completadas'] as const).map((f) => (
<Button
key={f}
variant={filter === f ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter(f)}
>
{f === 'todas' ? 'Todas' : f === 'pendientes' ? 'Pendientes' : 'Completadas'}
</Button>
))}
</div>
{/* Tareas por contribuyente */}
{Object.keys(grouped).length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay tareas {filter === 'pendientes' ? 'pendientes' : filter === 'completadas' ? 'completadas' : ''}.
</CardContent>
</Card>
) : (
Object.keys(grouped).map((contribuyenteId) => {
const info = contribuyenteMap[contribuyenteId];
const items = grouped[contribuyenteId];
return (
<Card key={contribuyenteId}>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span>{info.razonSocial}</span>
<span className="text-muted-foreground font-normal">({info.rfc})</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{items.map((t: TareaItem) => {
const p = t.periodoActual;
const fl = p ? new Date(p.fechaLimite) : null;
const today = new Date(); today.setHours(0, 0, 0, 0);
const atrasada = !!fl && !p?.completada && fl < today;
const recLabel = RECURRENCIAS[t.recurrencia] || t.recurrencia;
const cuando = (t.recurrencia === 'semanal' || t.recurrencia === 'quincenal')
? DIAS_SEMANA[t.diaSemana ?? 1]
: `día ${t.diaMes}`;
return (
<div
key={t.id}
className={cn(
'flex items-center gap-3 py-2 border-b last:border-0',
p?.completada && 'opacity-60'
)}
>
<button
onClick={() => {
if (!p) return;
if (p.completada) {
descompletarMut.mutate(p.id);
} else {
completarMut.mutate(p.id);
}
}}
disabled={!p || completarMut.isPending || descompletarMut.isPending}
title={p?.completada ? 'Marcar pendiente' : 'Marcar completada'}
className="flex-shrink-0 focus:outline-none"
>
{p?.completada ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : atrasada ? (
<AlertTriangle className="h-5 w-5 text-red-400" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={cn('text-sm font-medium', p?.completada && 'line-through text-muted-foreground')}>
{t.nombre}
</span>
{t.soloSupervisorCompleta && (
<span className="text-[10px] uppercase bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200 rounded px-1.5 py-0.5">
Supervisor
</span>
)}
{atrasada && (
<span className="text-[10px] uppercase bg-red-100 text-red-700 rounded px-1.5 py-0.5">
Atrasada
</span>
)}
</div>
{t.descripcion && (
<p className="text-xs text-muted-foreground truncate">{t.descripcion}</p>
)}
<p className="text-xs text-muted-foreground mt-0.5">
{recLabel} · {cuando}
{fl && ` · vence ${fl.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' })}`}
</p>
</div>
</div>
);
})}
</CardContent>
</Card>
);
})
)}
</div>
</DashboardShell>
);
}

View File

@@ -22,6 +22,7 @@ import {
ClipboardList, ClipboardList,
CreditCard, CreditCard,
Gift, Gift,
CheckSquare2,
UserCog, UserCog,
Shield, Shield,
FileWarning, FileWarning,
@@ -56,6 +57,7 @@ const navigation: NavItem[] = [
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] }, { name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] }, { name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] }, { name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] }, { name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] }, { name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
]; ];
@@ -64,7 +66,6 @@ const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 }, { name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog }, { name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield }, { name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning }, { name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
]; ];

View File

@@ -22,6 +22,7 @@ import {
ClipboardList, ClipboardList,
CreditCard, CreditCard,
Gift, Gift,
CheckSquare2,
UserCog, UserCog,
Shield, Shield,
FileWarning, FileWarning,
@@ -55,6 +56,7 @@ const navigation: NavItem[] = [
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] }, { name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] }, { name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] }, { name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] }, { name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] }, { name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
]; ];
@@ -63,7 +65,6 @@ const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 }, { name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog }, { name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield }, { name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning }, { name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
]; ];

View File

@@ -27,6 +27,7 @@ import {
ClipboardList, ClipboardList,
ListChecks, ListChecks,
Gift, Gift,
CheckSquare2,
} from 'lucide-react'; } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth'; import { logout } from '@/lib/api/auth';
@@ -59,6 +60,7 @@ const navigation: NavItem[] = [
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] }, { name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] }, { name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] }, { name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] }, { name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] }, { name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
]; ];
@@ -67,7 +69,6 @@ const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 }, { name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog }, { name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield }, { name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning }, { name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
]; ];

View File

@@ -22,6 +22,7 @@ import {
ClipboardList, ClipboardList,
CreditCard, CreditCard,
Gift, Gift,
CheckSquare2,
UserCog, UserCog,
Shield, Shield,
FileWarning, FileWarning,
@@ -56,6 +57,7 @@ const navigation: NavItem[] = [
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] }, { name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] }, { name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] }, { name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] }, { name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] }, { name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
]; ];
@@ -64,7 +66,6 @@ const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 }, { name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog }, { name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield }, { name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning }, { name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
]; ];

View File

@@ -0,0 +1,58 @@
import { apiClient } from './client';
export interface AsignacionObligacion {
id: string;
obligacionId: string;
obligacionNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
export interface AsignacionTarea {
id: string;
tareaId: string;
tareaNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
export interface AsignacionesResponse {
obligaciones: AsignacionObligacion[];
tareas: AsignacionTarea[];
}
export const getAsignacionesPorSupervisor = () =>
apiClient.get<AsignacionesResponse>('/carteras/asignaciones').then(r => r.data);
export const getAsignacionesPorAuxiliar = () =>
apiClient.get<AsignacionesResponse>('/carteras/asignaciones/mias').then(r => r.data);
export interface SinAsignarResponse {
obligaciones: Omit<AsignacionObligacion, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[];
tareas: Omit<AsignacionTarea, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[];
}
export const getSinAsignar = () =>
apiClient.get<SinAsignarResponse>('/carteras/asignaciones/sin-asignar').then(r => r.data);
export const asignarObligacion = (contribuyenteId: string, obligacionId: string, auxiliarUserId: string) =>
apiClient.post(`/contribuyentes/${contribuyenteId}/obligaciones/${obligacionId}/asignar`, { auxiliarUserId }).then(r => r.data);
export const desasignarObligacion = (contribuyenteId: string, obligacionId: string) =>
apiClient.delete(`/contribuyentes/${contribuyenteId}/obligaciones/${obligacionId}/asignar`).then(r => r.data);
export const asignarTarea = (tareaId: string, auxiliarUserId: string) =>
apiClient.post(`/tareas/${tareaId}/asignar`, { auxiliarUserId }).then(r => r.data);
export const desasignarTarea = (tareaId: string) =>
apiClient.delete(`/tareas/${tareaId}/asignar`).then(r => r.data);

View File

@@ -0,0 +1,31 @@
import { apiClient } from './client';
export interface TareaConContribuyente {
id: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
nombre: string;
descripcion: string | null;
recurrencia: string;
diaSemana: number | null;
diaMes: number | null;
soloSupervisorCompleta: boolean;
esDefault: boolean;
active: boolean;
orden: number;
createdAt: string;
auxiliarAsignadoId?: string | null;
periodoActual: {
id: string;
periodo: string;
fechaLimite: string;
completada: boolean;
completadaAt: string | null;
completadaPor: string | null;
notas: string | null;
} | null;
}
export const getMisTareas = () =>
apiClient.get<TareaConContribuyente[]>('/tareas/mis-tareas').then(r => r.data);

View File

@@ -0,0 +1,89 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getAsignacionesPorSupervisor,
getAsignacionesPorAuxiliar,
getSinAsignar,
asignarObligacion,
desasignarObligacion,
asignarTarea,
desasignarTarea,
} from '../api/asignaciones';
export function useAsignacionesSupervisor() {
return useQuery({
queryKey: ['asignaciones-supervisor'],
queryFn: getAsignacionesPorSupervisor,
});
}
export function useAsignacionesAuxiliar() {
return useQuery({
queryKey: ['asignaciones-auxiliar'],
queryFn: getAsignacionesPorAuxiliar,
});
}
export function useSinAsignar() {
return useQuery({
queryKey: ['asignaciones-sin-asignar'],
queryFn: getSinAsignar,
});
}
export function useAsignarObligacion() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ contribuyenteId, obligacionId, auxiliarUserId }: {
contribuyenteId: string;
obligacionId: string;
auxiliarUserId: string;
}) => asignarObligacion(contribuyenteId, obligacionId, auxiliarUserId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['obligaciones'] });
},
});
}
export function useDesasignarObligacion() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ contribuyenteId, obligacionId }: {
contribuyenteId: string;
obligacionId: string;
}) => desasignarObligacion(contribuyenteId, obligacionId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['obligaciones'] });
},
});
}
export function useAsignarTarea() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ tareaId, auxiliarUserId }: {
tareaId: string;
auxiliarUserId: string;
}) => asignarTarea(tareaId, auxiliarUserId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
});
}
export function useDesasignarTarea() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ tareaId }: { tareaId: string }) => desasignarTarea(tareaId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['asignaciones-supervisor'] });
qc.invalidateQueries({ queryKey: ['asignaciones-sin-asignar'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
});
}

View File

@@ -0,0 +1,37 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getMisTareas } from '../api/tareas-mis';
import { apiClient } from '../api/client';
export function useMisTareas() {
return useQuery({
queryKey: ['tareas-mis-tareas'],
queryFn: getMisTareas,
});
}
export function useCompletarTareaPeriodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: (periodoId: string) =>
apiClient.post(`/tareas/periodo/${periodoId}/completar`).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tareas-mis-tareas'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
onError: (err: any) => {
alert(err.response?.data?.message || 'No se pudo marcar como completada');
},
});
}
export function useDescompletarTareaPeriodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: (periodoId: string) =>
apiClient.delete(`/tareas/periodo/${periodoId}/completar`).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tareas-mis-tareas'] });
qc.invalidateQueries({ queryKey: ['tareas'] });
},
});
}

View File

@@ -0,0 +1,163 @@
# Sesión 2026-05-23: Asignaciones, Tareas, Admin UI y Fixes
## Resumen
Sesión extensa con múltiples fixes de bugs críticos descubiertos tras deploy de la feature de asignaciones, más nuevas funcionalidades de UX solicitadas por el usuario.
---
## 1. Fixes de build y deploy inicial de Asignaciones
### Problemas de build de Next.js
- **`Badge` no exportado desde `@horux/shared-ui`**: Reemplazado por `<span>` con clases CSS equivalentes en `seguimiento-auxiliares.tsx`.
- **`Tabs` requería `defaultValue`**: Agregado `defaultValue="carteras"` al componente `<Tabs>` en `carteras/page.tsx`.
### Migración tenant 046 aplicada
- Archivo: `apps/api/src/migrations/tenant/046_asignaciones_obligaciones_tareas.sql`
- Tablas creadas: `obligacion_asignaciones`, `tarea_asignaciones`
- Aplicada a 7 tenants activos.
---
## 2. Fix: Invitación de usuarios no enviaba correo
**Root cause:** `usuarios.service.ts``inviteUsuario` generaba `tempPassword` pero nunca llamaba `emailService.sendWelcome()`.
**Fix:**
- Importado `emailService` en `usuarios.service.ts`
- Agregada llamada `emailService.sendWelcome()` cuando se crea un usuario nuevo (`tempPassword !== null`)
- Aplicado también a `createUsuarioGlobal`
### Reenvío de correos a usuarios invitados previos
- Identificados 9 usuarios creados entre jueves 21/05 y hoy sin `lastLogin`
- Generadas nuevas contraseñas temporales, hasheadas en BD, y enviados correos de bienvenida
---
## 3. Fix: Subcartera mostraba contribuyentes de todo el tenant
**Root cause:** `SubcarteraCard` filtraba `available` solo excluyendo los ya asignados a la subcartera, sin verificar que pertenecieran a la cartera padre.
**Fixes:**
- **Frontend:** `SubcarteraCard` ahora recibe `parentEntidadIds` y filtra: `(parentEntidadIds ?? []).includes(c.id)`
- **Backend:** `cartera.service.ts``addEntidadToCartera` valida que si es subcartera (`parentId !== null`), la entidad previamente exista en la cartera padre.
---
## 4. Fix: Obligaciones y Tareas no se mostraban (error 500)
**Root cause:** Las queries de `obligaciones.service.ts`, `tareas.service.ts` y `asignaciones.service.ts` hacían `LEFT JOIN users` en la BD de tenant, pero la tabla `users` solo existe en la BD central (`horux360`).
**Error:** `relation "users" does not exist`
**Fixes:**
- Quitados todos los `LEFT JOIN users` de queries de tenant
- En `obligaciones.service.ts` y `tareas.service.ts`: se mantiene solo `auxiliarAsignadoId` (sin nombre)
- En `asignaciones.service.ts`: creada función `resolveUserNames()` que consulta Prisma (BD central) para obtener nombres de usuarios y mapearlos en los resultados
- **Fix adicional:** `asignaciones.controller.ts` usaba `req.params.id` como `obligacionId` cuando en realidad `req.params.id` era `contribuyenteId`. Corregido a `req.params.obligacionId`.
---
## 5. Fix: Endpoint `/carteras/asignaciones` devolvía 500
**Root cause:** Ruta dinámica `GET /:id` estaba definida antes de `GET /asignaciones` en `cartera.routes.ts`. Express interpretaba `"asignaciones"` como parámetro `:id`.
**Error:** `invalid input syntax for type uuid: "asignaciones"`
**Fix:** Reordenadas las rutas estáticas (`/asignaciones`, `/asignaciones/mias`, `/asignaciones/sin-asignar`) antes de las rutas dinámicas (`/:id`).
---
## 6. Fix: Owner no veía asignaciones en "Asignadas"
**Root cause:** `getAsignacionesPorSupervisor` filtraba por `asp.supervisor_user_id = $1`. Un owner no aparece en `auxiliar_supervisores`, por lo que no veía sus propias asignaciones.
**Fix:** La función ahora recibe `role` como parámetro. Si es `owner/cfo/contador`, no filtra por supervisor. Si es `supervisor`, filtra por `auxiliar_supervisores`.
---
## 7. Fix: Lista "Sin asignar" no se refrescaba tras asignar
**Root cause:** Las mutations `useAsignarObligacion`, `useDesasignarObligacion`, `useAsignarTarea`, `useDesasignarTarea` invalidaban `['asignaciones-supervisor']` pero no `['asignaciones-sin-asignar']`.
**Fix:** Agregada invalidación de `['asignaciones-sin-asignar']` a las 4 mutations.
---
## 8. Feature: Seguimiento de Auxiliares — pestaña "Sin asignar"
**Nuevo endpoint:** `GET /carteras/asignaciones/sin-asignar`
- Devuelve obligaciones y tareas activas que NO están en las tablas de asignaciones
- Respeta permisos: owner/cfo ven todo, supervisor solo sus contribuyentes
**Frontend:**
- Reestructurado `SeguimientoAuxiliares` con tabs principales: **"Asignadas"** | **"Sin asignar"**
- Dentro de cada uno, subtabs: **Obligaciones** | **Tareas**
- El modal de asignación funciona en ambas vistas
---
## 9. Feature: Nueva página `/tareas` (Tareas Operativas)
**Nuevo endpoint:** `GET /tareas/mis-tareas`
- Devuelve todas las tareas activas con su periodo actual para los contribuyentes visibles del usuario
- Usa `materializarPeriodos` para cada contribuyente y luego `listTareasConPeriodoPorContribuyentes` para traer todo en batch
**Frontend:**
- Nueva ruta: `/tareas`
- Agregada al sidebar principal en todos los layouts (icono `CheckSquare2`)
- Muestra tareas agrupadas por contribuyente
- Filtros: Todas / Pendientes / Completadas
- Permite marcar/desmarcar tareas como completadas
- Muestra indicadores: atrasada, supervisor-only, recurrencia, fecha límite
---
## 10. Feature: Obligaciones Fiscales ya no se pueden marcar como completadas
En `/pendientes`, se quitó el botón interactivo de check de las obligaciones fiscales. Ahora solo muestra un ícono visual del estado sin acción.
---
## 11. Feature: Invitaciones Trial movido a Admin Usuarios
**Sidebar:** Quitado "Invitaciones Trial" de `adminNavigation` en los 4 layouts (`sidebar.tsx`, `sidebar-compact.tsx`, `sidebar-floating.tsx`, `topnav.tsx`).
**Admin Usuarios:** Agregados tabs con `<Tabs>`:
- **"Usuarios"** — gestión de usuarios global existente
- **"Invitaciones Trial"** — formulario de envío + historial (extraído a componente `admin/_components/invitaciones-trial-tab.tsx`)
---
## Archivos modificados
### Backend
- `apps/api/src/services/usuarios.service.ts`
- `apps/api/src/services/cartera.service.ts`
- `apps/api/src/services/obligaciones.service.ts`
- `apps/api/src/services/tareas.service.ts`
- `apps/api/src/services/asignaciones.service.ts`
- `apps/api/src/controllers/asignaciones.controller.ts`
- `apps/api/src/controllers/tareas.controller.ts`
- `apps/api/src/routes/cartera.routes.ts`
- `apps/api/src/routes/contribuyente.routes.ts`
- `apps/api/src/routes/tareas.routes.ts`
### Frontend
- `apps/web/app/(dashboard)/carteras/page.tsx`
- `apps/web/app/(dashboard)/carteras/seguimiento-auxiliares.tsx`
- `apps/web/app/(dashboard)/pendientes/page.tsx`
- `apps/web/app/(dashboard)/admin/usuarios/page.tsx`
- `apps/web/app/(dashboard)/admin/_components/invitaciones-trial-tab.tsx`
- `apps/web/app/(dashboard)/tareas/page.tsx`
- `apps/web/components/layouts/sidebar.tsx`
- `apps/web/components/layouts/sidebar-compact.tsx`
- `apps/web/components/layouts/sidebar-floating.tsx`
- `apps/web/components/layouts/topnav.tsx`
- `apps/web/lib/api/asignaciones.ts`
- `apps/web/lib/api/tareas-mis.ts`
- `apps/web/lib/hooks/use-asignaciones.ts`
- `apps/web/lib/hooks/use-tareas-mis.ts`
### Migraciones
- `apps/api/src/migrations/tenant/046_asignaciones_obligaciones_tareas.sql`