Compare commits
5 Commits
0c7580aa44
...
8f420711ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f420711ae | ||
|
|
be96ecc324 | ||
|
|
bba000d308 | ||
|
|
e8b0733304 | ||
|
|
f43cb165c6 |
137
apps/api/src/controllers/asignaciones.controller.ts
Normal file
137
apps/api/src/controllers/asignaciones.controller.ts
Normal 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); }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
303
apps/api/src/services/asignaciones.service.ts
Normal file
303
apps/api/src/services/asignaciones.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,24 +448,8 @@ export default function CarterasPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const CarterasList = () => (
|
||||||
<DashboardShell title="Carteras">
|
<>
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canCreate && (
|
|
||||||
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
|
|
||||||
<Plus className="h-4 w-4" /> Nueva cartera
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* List */}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-muted-foreground">Cargando...</p>
|
<p className="text-muted-foreground">Cargando...</p>
|
||||||
) : !carteras || carteras.length === 0 ? (
|
) : !carteras || carteras.length === 0 ? (
|
||||||
@@ -487,6 +479,48 @@ export default function CarterasPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardShell title="Carteras">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canCreate && activeTab === 'carteras' && (
|
||||||
|
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4" /> Nueva cartera
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{puedeVerSeguimiento ? (
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} defaultValue="carteras" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="carteras">
|
||||||
|
<FolderOpen className="h-4 w-4 mr-1.5" /> Carteras
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="seguimiento">
|
||||||
|
<ClipboardList className="h-4 w-4 mr-1.5" /> Seguimiento de Auxiliares
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="carteras">
|
||||||
|
<CarterasList />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="seguimiento">
|
||||||
|
<SeguimientoAuxiliares />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<CarterasList />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create dialog */}
|
{/* Create dialog */}
|
||||||
<Dialog open={showCreate} onOpenChange={open => { if (!open) resetForm(); }}>
|
<Dialog open={showCreate} onOpenChange={open => { if (!open) resetForm(); }}>
|
||||||
|
|||||||
332
apps/web/app/(dashboard)/carteras/seguimiento-auxiliares.tsx
Normal file
332
apps/web/app/(dashboard)/carteras/seguimiento-auxiliares.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
223
apps/web/app/(dashboard)/tareas/page.tsx
Normal file
223
apps/web/app/(dashboard)/tareas/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
58
apps/web/lib/api/asignaciones.ts
Normal file
58
apps/web/lib/api/asignaciones.ts
Normal 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);
|
||||||
31
apps/web/lib/api/tareas-mis.ts
Normal file
31
apps/web/lib/api/tareas-mis.ts
Normal 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);
|
||||||
89
apps/web/lib/hooks/use-asignaciones.ts
Normal file
89
apps/web/lib/hooks/use-asignaciones.ts
Normal 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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
37
apps/web/lib/hooks/use-tareas-mis.ts
Normal file
37
apps/web/lib/hooks/use-tareas-mis.ts
Normal 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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
163
docs/sessions/2026-05-23-asignaciones-tareas-admin-ui.md
Normal file
163
docs/sessions/2026-05-23-asignaciones-tareas-admin-ui.md
Normal 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`
|
||||||
Reference in New Issue
Block a user