feat: rol manager, arreglos de reportes (cancelacion/afluencias), credito unico, cliente enkontrol, vendedor en bitacora y actualizar afluencia existente

This commit is contained in:
2026-09-18 15:25:34 +00:00
parent 9630893974
commit 4965a4488a
35 changed files with 303 additions and 90 deletions

View File

@@ -70,6 +70,17 @@ export const ACCIONES_POR_ROL_DEFAULT: Record<string, Record<AccionClave, boolea
ver_precios: true,
gestionar_clientes: true,
},
// manager: mismas acciones operativas que admin (no gestiona usuarios/permisos,
// eso se controla por endpoint, no por acción)
manager: {
descartar_lead: true,
editar_cotizacion: true,
asignar_vendedor: true,
ver_reportes: true,
exportar_pdf: true,
ver_precios: true,
gestionar_clientes: true,
},
}
/** Devuelve las acciones de un rol (o vacío si el rol no existe) */

View File

@@ -24,6 +24,7 @@ export const ROLES = [
'director_ventas',
'escrituracion',
'direccion_general',
'manager',
'admin',
] as const
export type Rol = (typeof ROLES)[number]
@@ -48,6 +49,8 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
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,
}

View File

@@ -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<void> {
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

View File

@@ -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) {

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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' },

View File

@@ -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<vo
desarrollo: c.desarrollo ?? '',
modelo: c.modelo ?? '',
etapa: c.etapa,
califica: perfil['califica'] ?? '',
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
// Perfilamiento completo capturado en el alta de afluencia
numDependientes: perfil['numDependientes'] ?? '',
edad: perfil['edad'] ?? '',
estadoCivil: perfil['estadoCivil'] ?? '',
tipoEmpleo: perfil['tipoEmpleo'] ?? '',
ocupacion: perfil['ocupacion'] ?? '',
rangoSalarial: perfil['rangoSalarial'] ?? '',
credito: perfil['credito'] ?? '',
tipoEconomia: perfil['tipoEconomia'] ?? '',
califica: perfil['califica'] ?? '',
loQuierePara: perfil['loQuierePara'] ?? '',
medioContacto: perfil['medioContacto'] ?? '',
notas: perfil['notas'] ?? '',
fecha: c.createdAt.toISOString(),
}
}),

View File

@@ -105,6 +105,7 @@ import {
import { estadoSistema } from '../controllers/sistema.controller.js'
import {
requireAdmin,
requireAdminEstricto,
obtenerConfigsEndpoint,
actualizarConfigsEndpoint,
obtenerRolesConfigEndpoint,
@@ -306,11 +307,11 @@ apiRouter.get('/auditoria', requireAdmin, listarAuditoriaEndpoint)
apiRouter.get('/config/sistema', requireAdmin, obtenerConfigsEndpoint)
apiRouter.patch('/config/sistema', requireAdmin, actualizarConfigsEndpoint)
apiRouter.get('/config/roles', requireAdmin, obtenerRolesConfigEndpoint)
apiRouter.patch('/config/roles/:rol', requireAdmin, actualizarRolConfigEndpoint)
apiRouter.patch('/config/roles/:rol', requireAdminEstricto, actualizarRolConfigEndpoint)
apiRouter.get('/config/usuarios', requireAdmin, obtenerUsuariosEndpoint)
apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint)
apiRouter.post('/config/usuarios', requireAdmin, crearUsuarioEndpoint)
apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint)
apiRouter.post('/config/usuarios', requireAdminEstricto, crearUsuarioEndpoint)
apiRouter.post('/config/reset-roles', requireAdminEstricto, resetRolesEndpoint)
apiRouter.get('/config/acciones', obtenerAccionesEndpoint)
// ── Captura por departamentos ────────────────────────────────────────

View File

@@ -450,7 +450,9 @@ export interface ContextoUsuario {
/** admin / director_ventas / direccion_general ven los datos de todos */
export function esDireccion(rol: string | undefined): boolean {
return rol === undefined || rol === 'admin' || rol === 'director_ventas' || rol === 'direccion_general'
return (
rol === undefined || rol === 'admin' || rol === 'manager' || rol === 'director_ventas' || rol === 'direccion_general'
)
}
const FASES_APARTADO: readonly string[] = VOCABULARIO.FASES_APARTADO

View File

@@ -16,7 +16,7 @@ async function validarFechasBloqueadas(
registros: { unidad: string; campo: string; valor: string | null }[],
rol: string,
): Promise<void> {
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))

View File

@@ -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)
})
})

View File

@@ -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<ResumenMiDia['equipo']> | 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) : '',

View File

@@ -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'
}

View File

@@ -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

View File

@@ -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: "<prefijo> <número>" (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: "<prefijo> <número>" (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<ReturnType<typeof crearAfluencia>>,
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') {
return
}
if (res.motivo === 'duplicado') {
setDuplicado({ id: res.leadId, nombre: res.nombre })
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.
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.')
} else {
setAvisoTipo('error')
setAviso('No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.')
}
}
const capturarOtro = () => {
@@ -598,9 +643,19 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
)}
{aviso && avisoTipo === 'error' && (
<p role="alert" className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
{aviso}
</p>
<div role="alert" className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
<p>{aviso}</p>
{duplicado && (
<button
type="button"
onClick={() => void actualizarExistente()}
disabled={enviando}
className="mt-3 rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{enviando ? 'Actualizando…' : 'Actualizar este registro con los datos capturados'}
</button>
)}
</div>
)}
<div className="mt-8 flex gap-3">

View File

@@ -84,7 +84,7 @@ export default function DatosGeneralesCampos({ prefix, compacto = false }: Props
/>
)}
{!compacto && (
<CampoTexto nombre={p('usuarioEnkontrol')} etiqueta="Usuario Enkontrol" />
<CampoTexto nombre={p('usuarioEnkontrol')} etiqueta="Cliente Enkontrol" />
)}
</SectionCard>
)

View File

@@ -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 }
>
<input
id={`tipoCredito-${tc.valor}`}
type="checkbox"
type="radio"
name="tipoCreditoUnico"
className="sr-only"
checked={tiposSeleccionados.includes(tc.valor)}
onChange={() => alternarCredito(tc.valor)}
onChange={() => seleccionarCredito(tc.valor)}
/>
{tc.etiqueta}
</label>

View File

@@ -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<Fila>(def.id, {
const respuesta = await fetchReporte<Fila>(def.endpoint ?? def.id, {
desde: desde || undefined,
hasta: hasta || undefined,
desarrollo: desarrollo || undefined,

View File

@@ -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 {

View File

@@ -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á

View File

@@ -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
}

View File

@@ -85,7 +85,7 @@ export default function App() {
)
}
if (vista === 'configuraciones' && usuario?.rol === 'admin') {
if (vista === 'configuraciones' && (usuario?.rol === 'admin' || usuario?.rol === 'manager')) {
return (
<div className="flex min-h-screen flex-col">
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />

View File

@@ -23,7 +23,7 @@ export function Navbar({ usuario, onConfiguraciones }: Props) {
}
}, [])
const esAdmin = usuario?.rol === 'admin'
const esAdmin = usuario?.rol === 'admin' || usuario?.rol === 'manager'
return (
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">

View File

@@ -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<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
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(<ConfigPage onVolver={() => {}} />)
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(<ConfigPage onVolver={volver} />)

View File

@@ -12,18 +12,27 @@ interface Props {
type Tab = 'roles' | 'usuarios' | 'sistema' | 'asistente' | 'auditoria'
const TABS_BASE: { key: Tab; label: string }[] = [
export function ConfigPage({ onVolver }: Props) {
const [tabElegida, setTab] = useState<Tab>('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' },
]
export function ConfigPage({ onVolver }: Props) {
const [tab, setTab] = useState<Tab>('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
: [
{ 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 (
<main className="w-full flex-1 px-6 py-12 sm:px-6 lg:px-8 sm:py-16">
@@ -63,11 +72,11 @@ export function ConfigPage({ onVolver }: Props) {
{/* Contenido del tab activo */}
<section className="mt-6">
{tab === 'roles' && <RolesPermisos />}
{tab === 'usuarios' && <Usuarios />}
{tab === 'roles' && (esAdmin ? <RolesPermisos /> : null)}
{tab === 'usuarios' && <Usuarios puedeCrear={esAdmin} puedeEditarRoles={esAdmin} />}
{tab === 'sistema' && <Sistema />}
{tab === 'asistente' && esAdmin && <AsistenteIA />}
{tab === 'auditoria' && esAdmin && <AuditoriaLog />}
{tab === 'asistente' && <AsistenteIA />}
{tab === 'auditoria' && <AuditoriaLog />}
</section>
</main>
)

View File

@@ -12,6 +12,7 @@ const ROLES = [
'director_ventas',
'escrituracion',
'direccion_general',
'manager',
'admin',
] as const
@@ -30,6 +31,7 @@ const ROL_LABELS: Record<string, string> = {
director_ventas: 'Director ventas',
escrituracion: 'Escrituración',
direccion_general: 'Dirección general',
manager: 'Manager',
admin: 'Admin',
}
@@ -42,7 +44,17 @@ const ASIGNACION_LABELS: Record<string, string> = {
'': '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<UsuarioConfig[]>([])
const [cargando, setCargando] = useState(true)
const [error, setError] = useState('')
@@ -176,7 +188,8 @@ export function Usuarios() {
<p role="alert" className="text-sm text-red-600">{error}</p>
)}
{/* Alta de usuario */}
{/* Alta de usuario (solo administrador) */}
{puedeCrear && (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
@@ -278,6 +291,7 @@ export function Usuarios() {
</form>
)}
</div>
)}
{/* Búsqueda */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
@@ -319,6 +333,7 @@ export function Usuarios() {
<td className="whitespace-nowrap px-4 py-3 text-[#111827] dark:text-[#E2E8F0]">{u.nombre}</td>
<td className="whitespace-nowrap px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{u.username}</td>
<td className="px-4 py-3">
{puedeEditarRoles ? (
<select
value={u.rol}
onChange={(e) => void cambiarRol(u.id, e.target.value)}
@@ -328,6 +343,9 @@ export function Usuarios() {
<option key={r} value={r}>{ROL_LABELS[r]}</option>
))}
</select>
) : (
<span className="text-xs text-[#374151] dark:text-[#CBD5E1]">{ROL_LABELS[u.rol] ?? u.rol}</span>
)}
</td>
<td className="px-4 py-3">
<select

View File

@@ -219,7 +219,7 @@ export function CapturaPage({ onVolver }: Props) {
onGuardar={handleGuardar}
guardando={guardando}
filtroEstatus={filtroEstatus}
puedeEditarFechas={rol === 'admin'}
puedeEditarFechas={rol === 'admin' || rol === 'manager'}
/>
)}
</section>

View File

@@ -284,7 +284,7 @@ export function SoportePage({ onVolver }: Props) {
{/* Listado de solicitudes */}
<section aria-label="Mis solicitudes" className="mt-12 max-w-3xl">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
{sesion?.usuario.rol === 'admin' ? 'Todas las solicitudes' : 'Mis solicitudes'}
{sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager' ? 'Todas las solicitudes' : 'Mis solicitudes'}
</h2>
{cargando ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>

View File

@@ -259,8 +259,11 @@ export default function DetalleLeadModal({
{tab === 'notas' && (
<div className="space-y-4">
{/* Barra de herramientas */}
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<h3 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Notas</h3>
<span className="rounded-full bg-[#F1F5F9] px-2.5 py-0.5 text-xs font-medium text-[#374151] dark:bg-[#0F172A] dark:text-[#CBD5E1]">
Vendedor asignado: <strong className="font-semibold">{item.vendedor?.trim() !== '' ? item.vendedor : 'sin asignar'}</strong>
</span>
<div className="ml-auto flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#94A3B8]" />

View File

@@ -29,6 +29,7 @@ const apartadoFake: ApartadoMiDia = {
telefono: '6641234567',
email: 'juan@example.com',
origen: 'PASE',
vendedor: 'Martin Zavala',
desarrollo: 'DIAGONAL',
unidadCodigo: 'D-101',
estado: 'En proceso',

View File

@@ -80,6 +80,7 @@ describe('urlCotizadorParaOportunidad', () => {
etapa: 'Afluencia',
desarrollo: 'SENDA',
modeloInteres: 'SOHO',
vendedor: 'Martin Zavala',
unidadCodigo: 'U-9',
updatedAt: '2026-09-04T10:00:00Z',
ultimoCambio: null,
@@ -118,6 +119,7 @@ describe('urlAfluenciaParaOportunidad', () => {
etapa: 'Agendo Cita',
desarrollo: 'SENDA',
modeloInteres: 'SOHO',
vendedor: 'Martin Zavala',
unidadCodigo: '',
updatedAt: '2026-09-04T10:00:00Z',
ultimoCambio: null,

View File

@@ -8,6 +8,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'
@@ -61,6 +63,8 @@ export interface OportunidadMiDia {
etapa: string
desarrollo: string
modeloInteres: string
/** Vendedor asignado (nombre); '' si no tiene */
vendedor: string
unidadCodigo: string
updatedAt: string
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
@@ -76,6 +80,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)

View File

@@ -1313,7 +1313,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
onDescartar={() => setDescartando(lead)}
onVerDetalle={() => setLeadSeleccionado(lead)}
acciones={acciones}
esAdmin={rol === 'admin'}
esAdmin={rol === 'admin' || rol === 'manager'}
vendedores={resumen.equipo?.porVendedor.map((v) => ({ id: v.usuarioId, nombre: v.nombre ?? '' }))}
onReasignar={(usuarioId) => void reasignar(lead, usuarioId)}
onMover={() => void mover(lead)}

View File

@@ -33,6 +33,7 @@ const leadFake: LeadMiDia = {
email: 'juan@test.com',
estado: 'CONTACTADO',
origen: 'PASE',
vendedor: 'Martin Zavala',
updatedAt: '2026-09-01T10:00:00Z',
fuente: 'lead',
ultimoCambio: { fechaISO: '2026-09-01T10:00:00Z', usuario: 'Admin', detalle: 'Lead creado' },
@@ -46,6 +47,7 @@ const opFake: OportunidadMiDia = {
etapa: 'Agendo Cita',
desarrollo: 'DIAGONAL',
modeloInteres: 'Modelo A',
vendedor: 'Martin Zavala',
unidadCodigo: 'D-101',
updatedAt: '2026-09-02T12:00:00Z',
ultimoCambio: { fechaISO: '2026-09-02T12:00:00Z', usuario: 'Admin', detalle: 'Etapa cambiada' },
@@ -57,6 +59,7 @@ const apartadoFake: ApartadoMiDia = {
telefono: '6641234567',
email: 'carlos@example.com',
origen: 'REFERIDO',
vendedor: 'Martin Zavala',
desarrollo: 'ESTANZA',
unidadCodigo: 'E-202',
estado: 'En proceso',