From 4965a4488a84eeb245fade1d4d1fa1a1c052ecac Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 18 Sep 2026 15:25:34 +0000 Subject: [PATCH] feat: rol manager, arreglos de reportes (cancelacion/afluencias), credito unico, cliente enkontrol, vendedor en bitacora y actualizar afluencia existente --- api-gateway/src/config/acciones.ts | 11 +++ api-gateway/src/config/roles.ts | 3 + api-gateway/src/controllers/ai.controller.ts | 5 +- .../src/controllers/borradores.controller.ts | 3 +- .../captura-departamento.controller.ts | 5 +- .../src/controllers/config.controller.ts | 23 ++++- .../src/controllers/mi-dia.controller.ts | 6 +- .../controllers/reportes-datos.controller.ts | 16 ++- api-gateway/src/routes/index.ts | 7 +- api-gateway/src/services/ai.service.ts | 4 +- .../services/captura-departamento.service.ts | 2 +- .../src/services/config.service.test.ts | 4 +- api-gateway/src/services/mi-dia.service.ts | 12 ++- api-gateway/src/utils/roles.ts | 15 +++ apps/agente-ia/src/lib/vocabulario.ts | 3 +- .../src/components/AltaAfluenciaForm.tsx | 99 ++++++++++++++----- .../src/forms/DatosGeneralesCampos.tsx | 2 +- .../src/forms/ViviendaCreditoForm.tsx | 18 ++-- apps/dashboards/src/components/Reportes.tsx | 18 +++- apps/dashboards/src/lib/mi-tablero.ts | 2 +- apps/dashboards/src/pages/DashboardPage.tsx | 2 +- apps/dashboards/src/services/reportes.ts | 13 ++- apps/landing/src/App.tsx | 2 +- apps/landing/src/components/Navbar.tsx | 2 +- .../src/components/config/ConfigPage.test.tsx | 14 +++ .../src/components/config/ConfigPage.tsx | 39 +++++--- .../src/components/config/Usuarios.tsx | 40 +++++--- apps/landing/src/pages/CapturaPage.tsx | 2 +- apps/landing/src/pages/SoportePage.tsx | 2 +- .../src/components/DetalleLeadModal.tsx | 5 +- .../src/components/NotasApartado.test.tsx | 1 + apps/mi-dia/src/lib/mi-dia.test.ts | 2 + apps/mi-dia/src/lib/mi-dia.ts | 6 ++ apps/mi-dia/src/pages/MiDiaPage.tsx | 2 +- apps/mi-dia/src/services/exportar.test.ts | 3 + 35 files changed, 303 insertions(+), 90 deletions(-) create mode 100644 api-gateway/src/utils/roles.ts diff --git a/api-gateway/src/config/acciones.ts b/api-gateway/src/config/acciones.ts index ee9606c..49aaf23 100644 --- a/api-gateway/src/config/acciones.ts +++ b/api-gateway/src/config/acciones.ts @@ -70,6 +70,17 @@ export const ACCIONES_POR_ROL_DEFAULT: Record = { director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'], escrituracion: [...MODULOS_VENDEDOR, 'cobranza'], direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'], + // manager: mismos módulos que admin, sin poder crear usuarios ni editar permisos + manager: MODULOS, admin: MODULOS, } diff --git a/api-gateway/src/controllers/ai.controller.ts b/api-gateway/src/controllers/ai.controller.ts index 8c70c8c..65aa5f8 100644 --- a/api-gateway/src/controllers/ai.controller.ts +++ b/api-gateway/src/controllers/ai.controller.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { prisma } from '../services/prisma.js' import { chat, indexarDocumento, type FuenteCitada } from '../services/ai.service.js' import { AppError, NotFoundError } from '../utils/errors.js' +import { esAdminOManager } from '../utils/roles.js' const chatSchema = z.object({ sessionId: z.string().uuid().nullish(), @@ -68,7 +69,7 @@ export async function chatIAStream(req: Request, res: Response): Promise { export function filtroConversaciones( token: { sub: string; rol: string } | undefined, ): { usuarioId: string } | undefined { - if (!token || token.rol === 'admin') return undefined + if (!token || esAdminOManager(token.rol)) return undefined return { usuarioId: token.sub } } @@ -132,7 +133,7 @@ export async function eliminarConversacion(req: Request, res: Response): Promise const conversacion = await prisma.aIConversacion.findUnique({ where: { id } }) if (!conversacion) throw new NotFoundError('Conversación no encontrada') const token = req.usuarioToken - if (token && token.rol !== 'admin' && conversacion.usuarioId !== token.sub) { + if (token && !esAdminOManager(token.rol) && conversacion.usuarioId !== token.sub) { throw new AppError(403, 'Solo puedes borrar tus conversaciones') } await prisma.aIConversacion.delete({ where: { id } }) // cascade: mensajes diff --git a/api-gateway/src/controllers/borradores.controller.ts b/api-gateway/src/controllers/borradores.controller.ts index c2c0904..a9fde2a 100644 --- a/api-gateway/src/controllers/borradores.controller.ts +++ b/api-gateway/src/controllers/borradores.controller.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { prisma } from '../services/prisma.js' +import { esAdminOManager } from '../utils/roles.js' import { AppError, NotFoundError } from '../utils/errors.js' // ---------- Borradores del alta de clientes ---------- @@ -15,7 +16,7 @@ const guardarBorradorSchema = z.object({ /** sub del JWT; con AUTH_ENABLED=false (tests) cae a un id fijo */ const usuarioDe = (req: Request): string => req.usuarioToken?.sub ?? 'anon' -const esAdmin = (req: Request): boolean => req.usuarioToken?.rol === 'admin' +const esAdmin = (req: Request): boolean => esAdminOManager(req.usuarioToken?.rol) /** 404 si no existe, 403 si es de otro usuario (salvo admin) */ async function borradorPropio(req: Request, id: string) { diff --git a/api-gateway/src/controllers/captura-departamento.controller.ts b/api-gateway/src/controllers/captura-departamento.controller.ts index a831d4a..57a6e16 100644 --- a/api-gateway/src/controllers/captura-departamento.controller.ts +++ b/api-gateway/src/controllers/captura-departamento.controller.ts @@ -3,12 +3,13 @@ import { z } from 'zod' import * as capturaService from '../services/captura-departamento.service.js' import { DEPARTAMENTOS } from '../config/campos-departamento.js' import { AppError, BadRequestError } from '../utils/errors.js' +import { esAdminOManager } from '../utils/roles.js' // ── Middleware admin ───────────────────────────────────────────────── function requireAdmin(req: Request, _res: Response, next: NextFunction): void { - if (req.usuarioToken?.rol !== 'admin') { - throw new AppError(403, 'Se requiere rol admin') + if (!esAdminOManager(req.usuarioToken?.rol)) { + throw new AppError(403, 'Se requiere rol admin o manager') } next() } diff --git a/api-gateway/src/controllers/config.controller.ts b/api-gateway/src/controllers/config.controller.ts index a1f40fb..412ed83 100644 --- a/api-gateway/src/controllers/config.controller.ts +++ b/api-gateway/src/controllers/config.controller.ts @@ -1,20 +1,31 @@ /** * Endpoints de administración de configuración. * - * Protegidos con requireAdmin (rol === 'admin') excepto /acciones que - * cualquiera autenticado puede consultar. + * Protegidos con requireAdmin (admin o manager) excepto /acciones que + * cualquiera autenticado puede consultar. Las operaciones sensibles —crear + * usuarios y editar permisos de roles— usan requireAdminEstricto (solo admin): + * el rol manager no las puede hacer. */ import type { NextFunction, Request, Response } from 'express' import { z } from 'zod' import * as configService from '../services/config.service.js' import { esRolValido } from '../config/roles.js' +import { esAdmin, esAdminOManager } from '../utils/roles.js' import { AppError, BadRequestError } from '../utils/errors.js' // ── Middleware admin ──────────────────────────────────────────────────── export function requireAdmin(req: Request, _res: Response, next: NextFunction): void { - if (req.usuarioToken?.rol !== 'admin') { - throw new AppError(403, 'Se requiere rol admin') + if (!esAdminOManager(req.usuarioToken?.rol)) { + throw new AppError(403, 'Se requiere rol admin o manager') + } + next() +} + +/** Solo admin: crear usuarios y editar permisos de roles (manager NO) */ +export function requireAdminEstricto(req: Request, _res: Response, next: NextFunction): void { + if (!esAdmin(req.usuarioToken?.rol)) { + throw new AppError(403, 'Solo un administrador puede hacer esta operación') } next() } @@ -86,6 +97,10 @@ export async function actualizarUsuarioEndpoint(req: Request, res: Response): Pr const id = req.params.id as string if (!id) throw new BadRequestError('Falta el parámetro :id') const parsed = actualizarUsuarioSchema.parse(req.body) + // El rol es un permiso: solo el administrador puede cambiarlo (manager no) + if (parsed.rol !== undefined && !esAdmin(req.usuarioToken?.rol)) { + throw new AppError(403, 'Solo un administrador puede cambiar el rol de un usuario') + } // Filtra undefined para enviar solo campos con valor const body: { rol?: string; asignacion?: string | null; activo?: boolean } = {} if (parsed.rol !== undefined) body.rol = parsed.rol diff --git a/api-gateway/src/controllers/mi-dia.controller.ts b/api-gateway/src/controllers/mi-dia.controller.ts index 632000b..8a80ab8 100644 --- a/api-gateway/src/controllers/mi-dia.controller.ts +++ b/api-gateway/src/controllers/mi-dia.controller.ts @@ -20,7 +20,11 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas') // Capturas de afluencia: admin ve todas, otros solo las suyas - const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas' + const esAdmin = + usuario.rol === 'admin' || + usuario.rol === 'manager' || + usuario.rol === 'direccion_general' || + usuario.rol === 'director_ventas' const capturas = await prisma.leadCaptura.findMany({ where: esAdmin ? {} : { usuarioId: usuario.id }, orderBy: { createdAt: 'desc' }, diff --git a/api-gateway/src/controllers/reportes-datos.controller.ts b/api-gateway/src/controllers/reportes-datos.controller.ts index 78f5492..4e3a857 100644 --- a/api-gateway/src/controllers/reportes-datos.controller.ts +++ b/api-gateway/src/controllers/reportes-datos.controller.ts @@ -16,7 +16,7 @@ import { VOCABULARIO } from '../config/vocabulario.js' export function requireRolReportes(req: Request, _res: Response, next: NextFunction): void { const rol = req.usuarioToken?.rol - if (rol !== 'admin' && rol !== 'director_ventas' && rol !== 'direccion_general') { + if (rol !== 'admin' && rol !== 'manager' && rol !== 'director_ventas' && rol !== 'direccion_general') { throw new AppError(403, 'No tienes acceso a los reportes') } next() @@ -145,8 +145,20 @@ export async function reporteAfluencias(req: Request, res: Response): Promise { - if (rol === 'admin') return + if (rol === 'admin' || rol === 'manager') return const bloqueados = CAMPOS_FECHA_BLOQUEADOS[departamento] if (!bloqueados) return const candidatos = registros.filter((r) => bloqueados.has(r.campo)) diff --git a/api-gateway/src/services/config.service.test.ts b/api-gateway/src/services/config.service.test.ts index a297c01..7a45f9c 100644 --- a/api-gateway/src/services/config.service.test.ts +++ b/api-gateway/src/services/config.service.test.ts @@ -162,8 +162,8 @@ describe('resetRoles', () => { prismaMock.rolConfiguracion.create.mockResolvedValue({}) await resetRoles() expect(prismaMock.rolConfiguracion.deleteMany).toHaveBeenCalled() - // MATRIZ tiene 6 roles - expect(prismaMock.rolConfiguracion.create).toHaveBeenCalledTimes(6) + // MATRIZ tiene 7 roles (incluye manager) + expect(prismaMock.rolConfiguracion.create).toHaveBeenCalledTimes(7) }) }) diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 8522326..a45867d 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -32,7 +32,7 @@ export const ESTADOS_FINALES_APARTADO = [ 'TITULADO', ] -const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin'] +const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin', 'manager'] const MS_DIA = 24 * 60 * 60 * 1000 @@ -70,6 +70,8 @@ export interface LeadMiDia { estado: string origen: string desarrollo?: string + /** Vendedor asignado (nombre); '' si no tiene */ + vendedor: string updatedAt: string /** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */ fuente: 'lead' | 'oportunidad' @@ -139,6 +141,8 @@ export interface ApartadoMiDia { email: string origen: string desarrollo: string + /** Vendedor asignado (nombre); '' si no tiene */ + vendedor: string unidadCodigo: string estado: string fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene) @@ -290,6 +294,7 @@ export function resumenMiDiaLocal( estado: c.etapa, origen: c.origen ?? '', desarrollo: c.desarrollo ?? '', + vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '', updatedAt: c.updatedAt.toISOString(), fuente: 'lead' as const, ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''), @@ -326,6 +331,7 @@ export function resumenMiDiaLocal( etapa: c.etapa, desarrollo: c.desarrollo ?? '', modeloInteres: c.modelo ?? '', + vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '', unidadCodigo, updatedAt: c.updatedAt.toISOString(), ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''), @@ -350,6 +356,7 @@ export function resumenMiDiaLocal( email: c.email ?? '', origen: c.origen ?? '', desarrollo: c.desarrollo ?? '', + vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '', unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '', estado: c.etapa, fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '', @@ -446,7 +453,7 @@ export function resumenMiDiaLocal( const actividadesAbiertas = actividades.filter((a) => a.estado.toUpperCase() === 'ABIERTA').length // --- Equipo (solo coordinador/admin/corporativo) --- - const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin'] + const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin', 'manager'] let equipo: NonNullable | undefined if (ROLES_CON_EQUIPO.includes(usuario.rol) && usuarios.length > 0) { @@ -507,6 +514,7 @@ export function resumenMiDiaLocal( email: c.email ?? '', origen: c.origen ?? '', desarrollo: c.desarrollo ?? '', + vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '', unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '', estado: c.etapa, fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '', diff --git a/api-gateway/src/utils/roles.ts b/api-gateway/src/utils/roles.ts new file mode 100644 index 0000000..de312a4 --- /dev/null +++ b/api-gateway/src/utils/roles.ts @@ -0,0 +1,15 @@ +/** + * Helpers de rol compartidos: el rol `manager` tiene los mismos permisos + * operativos que el administrador (puede todo lo del día a día) pero NO puede + * crear usuarios ni editar permisos de roles — eso queda solo para `admin`. + */ + +/** Administrador puro: único que gestiona usuarios y permisos */ +export function esAdmin(rol: string | undefined): boolean { + return rol === 'admin' +} + +/** Admin o manager: permisos operativos completos */ +export function esAdminOManager(rol: string | undefined): boolean { + return rol === 'admin' || rol === 'manager' +} diff --git a/apps/agente-ia/src/lib/vocabulario.ts b/apps/agente-ia/src/lib/vocabulario.ts index 324c660..f5b602e 100644 --- a/apps/agente-ia/src/lib/vocabulario.ts +++ b/apps/agente-ia/src/lib/vocabulario.ts @@ -55,7 +55,8 @@ const SUGERENCIAS_DIRECCION = [ /** Preguntas sugeridas según el rol de la sesión (vendedor vs dirección/administración) */ export function preguntasSugeridasPorRol(rol: string | undefined, fecha: Date = new Date()): string[] { - const esDireccion = rol === 'admin' || rol === 'director_ventas' || rol === 'direccion_general' || rol === 'administracion_venta' + const esDireccion = + rol === 'admin' || rol === 'manager' || rol === 'director_ventas' || rol === 'direccion_general' || rol === 'administracion_venta' const base = esDireccion ? SUGERENCIAS_DIRECCION : SUGERENCIAS_VENDEDOR const dia = diaDelAnio(fecha) // Rota el punto de partida para que no salgan siempre las mismas seis diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index 7758c9d..8509937 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -76,6 +76,8 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor const [asignacion, setAsignacion] = useState<'capturista' | 'round-robin' | null>(null) const [aviso, setAviso] = useState('') const [avisoTipo, setAvisoTipo] = useState<'error' | 'advertencia'>('error') + // Registro existente con ese teléfono: permite actualizarlo en vez de fallar + const [duplicado, setDuplicado] = useState<{ id: string; nombre: string } | null>(null) const cambiar = (campo: keyof typeof FORM_INICIAL) => (e: { target: { value: string } }) => { setForm((prev) => ({ ...prev, [campo]: e.target.value })) @@ -152,16 +154,49 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor const enviar = async (e: FormEvent) => { e.preventDefault() setAviso('') + setDuplicado(null) if (!validar()) return setEnviando(true) - // Teléfono con lada: " " (si ya trae +, se respeta tal cual) + const res = await crearAfluencia(construirPayload(oportunidadId)) + setEnviando(false) + procesarResultado(res, oportunidadId) + } + + /** Reenvía la captura como ACTUALIZACIÓN del registro existente (duplicado) */ + const actualizarExistente = async () => { + if (!duplicado) return + setAviso('') + setEnviando(true) + const res = await crearAfluencia(construirPayload(duplicado.id)) + setEnviando(false) + if (res.ok) { + setDuplicado(null) + procesarResultado(res, duplicado.id) + return + } + if (res.motivo === 'duplicado') { + setDuplicado({ id: res.leadId, nombre: res.nombre }) + setAvisoTipo('error') + setAviso(`No se pudo actualizar: el teléfono pertenece a ${res.nombre}.`) + return + } + setAvisoTipo('advertencia') + setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.') + } + + /** Teléfono con lada: " " (si ya trae +, se respeta tal cual) */ + const telefonoConLada = () => { const numeroTel = form.telefono.trim() - const telefono = - numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}` - const res = await crearAfluencia({ + return numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}` + } + + /** Payload del alta/actualización de afluencia (teléfono normalizado con lada) */ + const construirPayload = (oportunidadIdElegido?: string) => { + const telefono = telefonoConLada() + return { nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(), telefono, - ...(oportunidadId !== undefined ? { oportunidadId } : {}), + ...(oportunidadIdElegido !== undefined ? { oportunidadId: oportunidadIdElegido } : {}), ...(form.email.trim() ? { email: form.email.trim() } : {}), ...(form.origen ? { origen: form.origen } : {}), ...(form.desarrollo ? { desarrollo: form.desarrollo } : {}), @@ -177,31 +212,41 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor ...(form.califica ? { califica: form.califica } : {}), ...(form.loQuierePara.trim() ? { loQuierePara: form.loQuierePara.trim() } : {}), ...(form.notas.trim() ? { notas: form.notas.trim() } : {}), - }) - setEnviando(false) + } + } + + /** Resultado del API: éxito, duplicado (ofrece actualizar) o problema de red */ + const procesarResultado = ( + res: Awaited>, + oportunidadIdElegido?: string, + ) => { if (res.ok) { setLeadCreado({ id: res.lead.id, nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(), - telefono, + telefono: telefonoConLada(), email: form.email.trim(), estado: res.lead.estado, origen: form.origen, fuente: 'oportunidad', // la captura nueva vive en el tablero Oportunidades }) setAsignacion(res.lead.asignacion ?? 'round-robin') - } else if (res.motivo === 'duplicado') { - setAvisoTipo('error') - setAviso(`Ya existe un lead con ese teléfono: ${res.nombre}.`) - } else if (res.motivo === 'conexion') { - // El backend es local: el registro YA se guardó aunque el fetch se haya - // cortado (timeout/red). Aviso amarillo en vez de error rojo. - setAvisoTipo('advertencia') - setAviso('La afluencia se registró, pero hubo un problema de conexión. Verifica en Mi día.') - } else { - setAvisoTipo('error') - setAviso('No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.') + return } + if (res.motivo === 'duplicado') { + setDuplicado({ id: res.leadId, nombre: res.nombre }) + setAvisoTipo('error') + setAviso(`Ya existe un registro con ese teléfono: ${res.nombre}.`) + return + } + if (oportunidadIdElegido !== undefined) { + // El backend es local: la actualización YA se guardó aunque el fetch se cortara + setAvisoTipo('advertencia') + setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.') + return + } + setAvisoTipo('advertencia') + setAviso('La afluencia se registró, pero hubo un problema de conexión. Verifica en Mi día.') } const capturarOtro = () => { @@ -598,9 +643,19 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor )} {aviso && avisoTipo === 'error' && ( -

- {aviso} -

+
+

{aviso}

+ {duplicado && ( + + )} +
)}
diff --git a/apps/alta-clientes/src/forms/DatosGeneralesCampos.tsx b/apps/alta-clientes/src/forms/DatosGeneralesCampos.tsx index 259fed2..130eb08 100644 --- a/apps/alta-clientes/src/forms/DatosGeneralesCampos.tsx +++ b/apps/alta-clientes/src/forms/DatosGeneralesCampos.tsx @@ -84,7 +84,7 @@ export default function DatosGeneralesCampos({ prefix, compacto = false }: Props /> )} {!compacto && ( - + )} ) diff --git a/apps/alta-clientes/src/forms/ViviendaCreditoForm.tsx b/apps/alta-clientes/src/forms/ViviendaCreditoForm.tsx index 7e4e3a0..6d4ab0b 100644 --- a/apps/alta-clientes/src/forms/ViviendaCreditoForm.tsx +++ b/apps/alta-clientes/src/forms/ViviendaCreditoForm.tsx @@ -30,13 +30,14 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente } // eslint-disable-next-line react-hooks/exhaustive-deps }, [proyecto]) - const alternarCredito = (valor: TipoCredito) => { + /** + * El tipo de crédito es de selección ÚNICA (Infonavit, Apoyo Infonavit, + * Cofinavit, Segundo crédito, etc.). Se guarda como arreglo de un elemento + * para no cambiar el contrato de datos; volver a tocar el elegido lo quita. + */ + const seleccionarCredito = (valor: TipoCredito) => { const actuales = metodos.getValues('tiposCredito') ?? [] - metodos.setValue( - 'tiposCredito', - actuales.includes(valor) ? actuales.filter((t) => t !== valor) : [...actuales, valor], - { shouldDirty: true }, - ) + metodos.setValue('tiposCredito', actuales.includes(valor) ? [] : [valor], { shouldDirty: true }) } return ( @@ -88,10 +89,11 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente } > alternarCredito(tc.valor)} + onChange={() => seleccionarCredito(tc.valor)} /> {tc.etiqueta} diff --git a/apps/dashboards/src/components/Reportes.tsx b/apps/dashboards/src/components/Reportes.tsx index c773991..c07a1cb 100644 --- a/apps/dashboards/src/components/Reportes.tsx +++ b/apps/dashboards/src/components/Reportes.tsx @@ -59,6 +59,8 @@ type Fila = interface DefReporte { id: string + /** Endpoint de datos del API (default: el mismo id) */ + endpoint?: string /** Número con el que el cliente pidió el reporte (1-10) */ numero: number titulo: string @@ -100,6 +102,7 @@ const REPORTES: DefReporte[] = [ }, { id: 'cancelaciones-motivo', + endpoint: 'cancelaciones', numero: 2, titulo: 'Cancelación (Cliente / Motivo)', descripcion: 'Cancelaciones de todas las etapas con el motivo y submotivo del descarte.', @@ -132,17 +135,28 @@ const REPORTES: DefReporte[] = [ { key: 'nombre', label: 'Nombre' }, { key: 'telefono', label: 'Teléfono' }, { key: 'email', label: 'Email' }, + { key: 'edad', label: 'Edad', tipo: 'numero' }, + { key: 'estadoCivil', label: 'Estado civil' }, + { key: 'tipoEmpleo', label: 'Tipo empleo' }, + { key: 'ocupacion', label: 'Ocupación' }, + { key: 'rangoSalarial', label: 'Rango salarial' }, + { key: 'credito', label: 'Crédito' }, + { key: 'tipoEconomia', label: 'Tipo economía' }, + { key: 'califica', label: 'Califica' }, + { key: 'numDependientes', label: 'Dependientes' }, + { key: 'loQuierePara', label: '¿Para qué?' }, { key: 'origen', label: 'Origen' }, { key: 'desarrollo', label: 'Desarrollo' }, { key: 'modelo', label: 'Modelo' }, - { key: 'califica', label: 'Califica' }, { key: 'etapa', label: 'Etapa' }, { key: 'vendedor', label: 'Vendedor' }, + { key: 'notas', label: 'Notas' }, { key: 'fecha', label: 'Fecha', tipo: 'fecha' }, ], }, { id: 'cancelaciones-todas', + endpoint: 'cancelaciones', numero: 4, titulo: 'Listado de cancelaciones', descripcion: 'Todas las cancelaciones del sistema: leads, oportunidades, apartados y escrituración.', @@ -356,7 +370,7 @@ export default function Reportes() { if (esCaptura) { setCaptura(await fetchReporteCaptura(desarrollo || undefined)) } else { - const respuesta = await fetchReporte(def.id, { + const respuesta = await fetchReporte(def.endpoint ?? def.id, { desde: desde || undefined, hasta: hasta || undefined, desarrollo: desarrollo || undefined, diff --git a/apps/dashboards/src/lib/mi-tablero.ts b/apps/dashboards/src/lib/mi-tablero.ts index 5e8d212..e05ebe5 100644 --- a/apps/dashboards/src/lib/mi-tablero.ts +++ b/apps/dashboards/src/lib/mi-tablero.ts @@ -52,7 +52,7 @@ export interface ResumenMiDia { } } -const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin'] +const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin', 'manager'] /** ¿El rol ve el tablero general por defecto y la sección de equipo? (misma regla que el API) */ export function esRolConEquipo(rol: string): boolean { diff --git a/apps/dashboards/src/pages/DashboardPage.tsx b/apps/dashboards/src/pages/DashboardPage.tsx index a7ad2e3..43b8907 100644 --- a/apps/dashboards/src/pages/DashboardPage.tsx +++ b/apps/dashboards/src/pages/DashboardPage.tsx @@ -16,7 +16,7 @@ const VISTAS: { id: Vista; etiqueta: string; clave: string }[] = [ ] /** Roles que ven el módulo de Reportes (datos de todos los vendedores) */ -const ROLES_REPORTES = ['admin', 'director_ventas', 'direccion_general'] +const ROLES_REPORTES = ['admin', 'manager', 'director_ventas', 'direccion_general'] /** * Tableros con vista por rol: cada tab se muestra solo si el módulo está diff --git a/apps/dashboards/src/services/reportes.ts b/apps/dashboards/src/services/reportes.ts index 9244133..f84ef8e 100644 --- a/apps/dashboards/src/services/reportes.ts +++ b/apps/dashboards/src/services/reportes.ts @@ -68,8 +68,19 @@ export interface FilaAfluenciaReporte { desarrollo: string modelo: string etapa: string - califica: string vendedor: string + numDependientes: string + edad: string + estadoCivil: string + tipoEmpleo: string + ocupacion: string + rangoSalarial: string + credito: string + tipoEconomia: string + califica: string + loQuierePara: string + medioContacto: string + notas: string fecha: string } diff --git a/apps/landing/src/App.tsx b/apps/landing/src/App.tsx index 51d52ea..a5db063 100644 --- a/apps/landing/src/App.tsx +++ b/apps/landing/src/App.tsx @@ -85,7 +85,7 @@ export default function App() { ) } - if (vista === 'configuraciones' && usuario?.rol === 'admin') { + if (vista === 'configuraciones' && (usuario?.rol === 'admin' || usuario?.rol === 'manager')) { return (
setVista('configuraciones')} /> diff --git a/apps/landing/src/components/Navbar.tsx b/apps/landing/src/components/Navbar.tsx index 10f5eed..58544d3 100644 --- a/apps/landing/src/components/Navbar.tsx +++ b/apps/landing/src/components/Navbar.tsx @@ -23,7 +23,7 @@ export function Navbar({ usuario, onConfiguraciones }: Props) { } }, []) - const esAdmin = usuario?.rol === 'admin' + const esAdmin = usuario?.rol === 'admin' || usuario?.rol === 'manager' return (
diff --git a/apps/landing/src/components/config/ConfigPage.test.tsx b/apps/landing/src/components/config/ConfigPage.test.tsx index a337049..1d7d59b 100644 --- a/apps/landing/src/components/config/ConfigPage.test.tsx +++ b/apps/landing/src/components/config/ConfigPage.test.tsx @@ -22,12 +22,18 @@ function crearStorageStub(): Storage { const respuestaOk = (data: unknown) => new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }) +/** Sesión simulada; rol 'admin' para las pruebas de administrador */ +function sesionDe(rol: string) { + return { token: 'jwt-prueba', usuario: { nombre: 'Prueba', rol, asignacion: null } } +} + let fetchMock: ReturnType> beforeEach(() => { fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('localStorage', crearStorageStub()) + localStorage.setItem('frasa_auth', JSON.stringify(sesionDe('admin'))) // Mockear las llamadas iniciales que los tabs pueden hacer fetchMock.mockImplementation((input) => { const url = String(input) @@ -63,6 +69,14 @@ describe('ConfigPage', () => { expect(screen.getByText('Restaurar valores por defecto')).toBeTruthy() }) + it('el rol manager NO ve "Roles y permisos" (no edita permisos)', () => { + localStorage.setItem('frasa_auth', JSON.stringify(sesionDe('manager'))) + render( {}} />) + expect(screen.queryByRole('button', { name: 'Roles y permisos' })).toBeNull() + expect(screen.getByRole('button', { name: 'Usuarios' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Auditoría' })).toBeTruthy() + }) + it('al hacer clic en "Volver", ejecuta el callback', () => { const volver = vi.fn() render() diff --git a/apps/landing/src/components/config/ConfigPage.tsx b/apps/landing/src/components/config/ConfigPage.tsx index 14f1cab..c6002e4 100644 --- a/apps/landing/src/components/config/ConfigPage.tsx +++ b/apps/landing/src/components/config/ConfigPage.tsx @@ -12,18 +12,27 @@ interface Props { type Tab = 'roles' | 'usuarios' | 'sistema' | 'asistente' | 'auditoria' -const TABS_BASE: { key: Tab; label: string }[] = [ - { key: 'roles', label: 'Roles y permisos' }, - { key: 'usuarios', label: 'Usuarios' }, - { key: 'sistema', label: 'Sistema' }, -] - export function ConfigPage({ onVolver }: Props) { - const [tab, setTab] = useState('roles') - const esAdmin = getSesion()?.usuario.rol === 'admin' - const tabs = esAdmin - ? [...TABS_BASE, { key: 'asistente' as const, label: 'Asistente IA' }, { key: 'auditoria' as const, label: 'Auditoría' }] - : TABS_BASE + const [tabElegida, setTab] = useState('roles') + const rol = getSesion()?.usuario.rol + const esAdmin = rol === 'admin' + // Roles y permisos: SOLO administrador (el manager no edita permisos ni crea usuarios). + const tabs: { key: Tab; label: string }[] = esAdmin + ? [ + { key: 'roles', label: 'Roles y permisos' }, + { key: 'usuarios', label: 'Usuarios' }, + { key: 'sistema', label: 'Sistema' }, + { key: 'asistente', label: 'Asistente IA' }, + { key: 'auditoria', label: 'Auditoría' }, + ] + : [ + { key: 'usuarios', label: 'Usuarios' }, + { key: 'sistema', label: 'Sistema' }, + { key: 'asistente', label: 'Asistente IA' }, + { key: 'auditoria', label: 'Auditoría' }, + ] + // Con un rol sin acceso a la tab pedida, se abre la primera disponible + const tab: Tab = tabs.some((t) => t.key === tabElegida) ? tabElegida : tabs[0]!.key return (
@@ -63,11 +72,11 @@ export function ConfigPage({ onVolver }: Props) { {/* Contenido del tab activo */}
- {tab === 'roles' && } - {tab === 'usuarios' && } + {tab === 'roles' && (esAdmin ? : null)} + {tab === 'usuarios' && } {tab === 'sistema' && } - {tab === 'asistente' && esAdmin && } - {tab === 'auditoria' && esAdmin && } + {tab === 'asistente' && } + {tab === 'auditoria' && }
) diff --git a/apps/landing/src/components/config/Usuarios.tsx b/apps/landing/src/components/config/Usuarios.tsx index 2d5192e..3192eba 100644 --- a/apps/landing/src/components/config/Usuarios.tsx +++ b/apps/landing/src/components/config/Usuarios.tsx @@ -12,6 +12,7 @@ const ROLES = [ 'director_ventas', 'escrituracion', 'direccion_general', + 'manager', 'admin', ] as const @@ -30,6 +31,7 @@ const ROL_LABELS: Record = { director_ventas: 'Director ventas', escrituracion: 'Escrituración', direccion_general: 'Dirección general', + manager: 'Manager', admin: 'Admin', } @@ -42,7 +44,17 @@ const ASIGNACION_LABELS: Record = { '': 'Sin asignación', } -export function Usuarios() { +/** + * El rol manager puede ver la lista y ajustar asignación/activo, pero NO crear + * usuarios ni cambiar roles (permisos) — eso queda para el administrador. + */ +export function Usuarios({ + puedeCrear = true, + puedeEditarRoles = true, +}: { + puedeCrear?: boolean + puedeEditarRoles?: boolean +} = {}) { const [usuarios, setUsuarios] = useState([]) const [cargando, setCargando] = useState(true) const [error, setError] = useState('') @@ -176,7 +188,8 @@ export function Usuarios() {

{error}

)} - {/* Alta de usuario */} + {/* Alta de usuario (solo administrador) */} + {puedeCrear && (

@@ -278,6 +291,7 @@ export function Usuarios() { )}

+ )} {/* Búsqueda */}
@@ -319,15 +333,19 @@ export function Usuarios() { {u.nombre} {u.username} - + {puedeEditarRoles ? ( + + ) : ( + {ROL_LABELS[u.rol] ?? u.rol} + )}