feat: rol manager, arreglos de reportes (cancelacion/afluencias), credito unico, cliente enkontrol, vendedor en bitacora y actualizar afluencia existente
This commit is contained in:
@@ -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) */
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) : '',
|
||||
|
||||
15
api-gateway/src/utils/roles.ts
Normal file
15
api-gateway/src/utils/roles.ts
Normal 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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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á
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -219,7 +219,7 @@ export function CapturaPage({ onVolver }: Props) {
|
||||
onGuardar={handleGuardar}
|
||||
guardando={guardando}
|
||||
filtroEstatus={filtroEstatus}
|
||||
puedeEditarFechas={rol === 'admin'}
|
||||
puedeEditarFechas={rol === 'admin' || rol === 'manager'}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user