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 { AppError } from '../middlewares/error.middleware.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 { getUserEmailById } from '../utils/memberships.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) {
|
||||
try {
|
||||
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 { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as ctrl from '../controllers/cartera.controller.js';
|
||||
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -11,6 +12,11 @@ router.use(tenantMiddleware);
|
||||
// Static routes first
|
||||
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
|
||||
router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list);
|
||||
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 facturacionCtrl from '../controllers/facturacion.controller.js';
|
||||
import * as obligacionesCtrl from '../controllers/obligaciones.controller.js';
|
||||
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
|
||||
|
||||
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/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;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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 * as ctrl from '../controllers/tareas.controller.js';
|
||||
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
router.get('/mis-tareas', ctrl.listMisTareas);
|
||||
router.get('/', ctrl.listTareas);
|
||||
router.post('/', ctrl.createTarea);
|
||||
router.post('/seed', ctrl.seedDefaults);
|
||||
@@ -17,4 +19,8 @@ router.delete('/:id', ctrl.deleteTarea);
|
||||
router.post('/periodo/:id/completar', ctrl.completarPeriodo);
|
||||
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 };
|
||||
|
||||
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
|
||||
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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ export interface ObligacionContribuyente {
|
||||
completadaPor: string | null;
|
||||
periodoCompletado: string | null;
|
||||
createdAt?: string;
|
||||
auxiliarAsignadoId?: string | null;
|
||||
auxiliarAsignadoNombre?: string | null;
|
||||
}
|
||||
|
||||
export function getCatalogo(): ObligacionFiscal[] {
|
||||
@@ -146,15 +148,18 @@ export function getCatalogo(): ObligacionFiscal[] {
|
||||
|
||||
export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId",
|
||||
nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria,
|
||||
activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom",
|
||||
completada, completada_at AS "completadaAt", completada_por AS "completadaPor",
|
||||
periodo_completado AS "periodoCompletado",
|
||||
created_at AS "createdAt"
|
||||
FROM obligaciones_contribuyente
|
||||
WHERE contribuyente_id = $1
|
||||
ORDER BY categoria, nombre
|
||||
SELECT
|
||||
oc.id, oc.contribuyente_id AS "contribuyenteId", oc.catalogo_id AS "catalogoId",
|
||||
oc.nombre, oc.fundamento, oc.frecuencia, oc.fecha_limite AS "fechaLimite", oc.categoria,
|
||||
oc.activa, oc.es_recomendada AS "esRecomendada", oc.es_custom AS "esCustom",
|
||||
oc.completada, oc.completada_at AS "completadaAt", oc.completada_por AS "completadaPor",
|
||||
oc.periodo_completado AS "periodoCompletado",
|
||||
oc.created_at AS "createdAt",
|
||||
oa.auxiliar_user_id AS "auxiliarAsignadoId"
|
||||
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]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface TareaCatalogo {
|
||||
active: boolean;
|
||||
orden: number;
|
||||
createdAt: Date;
|
||||
auxiliarAsignadoId?: string | null;
|
||||
auxiliarAsignadoNombre?: string | null;
|
||||
}
|
||||
|
||||
export interface TareaPeriodo {
|
||||
@@ -47,6 +49,8 @@ const ROW_TO_TAREA = (r: any): TareaCatalogo => ({
|
||||
active: r.active,
|
||||
orden: r.orden,
|
||||
createdAt: r.created_at,
|
||||
auxiliarAsignadoId: r.auxiliarAsignadoId ?? null,
|
||||
auxiliarAsignadoNombre: r.auxiliarAsignadoNombre ?? null,
|
||||
});
|
||||
|
||||
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[]> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM tareas_catalogo
|
||||
WHERE contribuyente_id = $1 AND active = true
|
||||
ORDER BY orden, nombre`,
|
||||
`SELECT
|
||||
tc.*,
|
||||
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)],
|
||||
);
|
||||
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 }));
|
||||
}
|
||||
|
||||
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 ───
|
||||
|
||||
const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from '../config/database.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getDespachoPlanLimits } from './plan-catalogo.service.js';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared';
|
||||
|
||||
/**
|
||||
@@ -99,6 +100,13 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
|
||||
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);
|
||||
@@ -224,8 +232,9 @@ export async function createUsuarioGlobal(
|
||||
// Si el email ya existe como user global, agregamos membership en este tenant
|
||||
let user = await prisma.user.findUnique({ where: { email: data.email } });
|
||||
|
||||
let tempPassword: string | null = null;
|
||||
if (!user) {
|
||||
const tempPassword = randomBytes(4).toString('hex');
|
||||
tempPassword = randomBytes(4).toString('hex');
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
@@ -235,6 +244,13 @@ export async function createUsuarioGlobal(
|
||||
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);
|
||||
|
||||
@@ -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 { 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 { getTenants, type Tenant } from '@/lib/api/tenants';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { isGlobalAdminRfc } from '@horux/shared';
|
||||
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 InvitacionesTrialTab from '../_components/invitaciones-trial-tab';
|
||||
|
||||
// Mapa de roles + fallback defensivo. El fork despacho introduce roles
|
||||
// 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 [filterTenant, setFilterTenant] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('usuarios');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createFormData, setCreateFormData] = useState({
|
||||
email: '',
|
||||
@@ -152,6 +154,13 @@ export default function AdminUsuariosPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Filtros */}
|
||||
<Card>
|
||||
@@ -425,6 +434,12 @@ export default function AdminUsuariosPage() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="invitaciones-trial">
|
||||
<InvitacionesTrialTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import {
|
||||
Button, Card, CardContent, CardHeader, CardTitle, Input, Label,
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
Tabs, TabsList, TabsTrigger, TabsContent,
|
||||
cn,
|
||||
} from '@horux/shared-ui';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
FolderOpen, Plus, Trash2, ChevronDown, ChevronUp, X,
|
||||
Users, Building2, FolderPlus, UserCog,
|
||||
Users, Building2, FolderPlus, UserCog, ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useCarteras, useCreateCartera, useDeleteCartera,
|
||||
@@ -25,14 +26,16 @@ import { useUsuarios } from '@/lib/hooks/use-usuarios';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import type { Cartera } from '@/lib/api/carteras';
|
||||
import SeguimientoAuxiliares from './seguimiento-auxiliares';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* SubcarteraCard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
|
||||
function SubcarteraCard({ sub, usuarios, contribuyentes, parentEntidadIds, onDelete }: {
|
||||
sub: Cartera;
|
||||
usuarios: any[];
|
||||
contribuyentes: any[];
|
||||
parentEntidadIds: string[];
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -47,7 +50,7 @@ function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -319,6 +322,7 @@ function CarteraDetail({ cartera, canEdit = true, canManageSubcarteras = true }:
|
||||
sub={sub}
|
||||
usuarios={usuarios ?? []}
|
||||
contribuyentes={contribuyentes ?? []}
|
||||
parentEntidadIds={entidadIds ?? []}
|
||||
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 canManageSubcarteras = userRole === 'owner' || userRole === 'supervisor'; // Create subcarteras
|
||||
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: supervisores } = useSupervisores();
|
||||
const { data: usuarios } = useUsuarios();
|
||||
@@ -440,24 +448,8 @@ export default function CarterasPage() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
const CarterasList = () => (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">Cargando...</p>
|
||||
) : !carteras || carteras.length === 0 ? (
|
||||
@@ -487,6 +479,48 @@ export default function CarterasPage() {
|
||||
))}
|
||||
</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 */}
|
||||
<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 {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Building2,
|
||||
@@ -75,7 +75,7 @@ export default function PendientesPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [singleObligaciones, setSingleObligaciones] = useState<ObligacionPeriodo[]>([]);
|
||||
const [filter, setFilter] = useState<'todos' | 'mis'>('todos');
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
|
||||
|
||||
// Single contribuyente view — fetch period-aware data
|
||||
useEffect(() => {
|
||||
@@ -132,31 +132,7 @@ export default function PendientesPage() {
|
||||
const pendientesCount = singleObligaciones.filter((o) => o.periodStatus === 'pendiente').length;
|
||||
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
|
||||
const statusBadge = (status: string) => {
|
||||
@@ -311,18 +287,15 @@ export default function PendientesPage() {
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
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'}
|
||||
>
|
||||
<span className="shrink-0">
|
||||
{ob.periodStatus === 'completada' ? (
|
||||
<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>
|
||||
<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">
|
||||
|
||||
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,
|
||||
CreditCard,
|
||||
Gift,
|
||||
CheckSquare2,
|
||||
UserCog,
|
||||
Shield,
|
||||
FileWarning,
|
||||
@@ -56,6 +57,7 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, 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: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
];
|
||||
@@ -64,7 +66,6 @@ const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ClipboardList,
|
||||
CreditCard,
|
||||
Gift,
|
||||
CheckSquare2,
|
||||
UserCog,
|
||||
Shield,
|
||||
FileWarning,
|
||||
@@ -55,6 +56,7 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, 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: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
];
|
||||
@@ -63,7 +65,6 @@ const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ClipboardList,
|
||||
ListChecks,
|
||||
Gift,
|
||||
CheckSquare2,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { logout } from '@/lib/api/auth';
|
||||
@@ -59,6 +60,7 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, 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: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
];
|
||||
@@ -67,7 +69,6 @@ const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ClipboardList,
|
||||
CreditCard,
|
||||
Gift,
|
||||
CheckSquare2,
|
||||
UserCog,
|
||||
Shield,
|
||||
FileWarning,
|
||||
@@ -56,6 +57,7 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, 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: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
];
|
||||
@@ -64,7 +66,6 @@ const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
|
||||
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