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,
|
ver_precios: true,
|
||||||
gestionar_clientes: 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) */
|
/** Devuelve las acciones de un rol (o vacío si el rol no existe) */
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const ROLES = [
|
|||||||
'director_ventas',
|
'director_ventas',
|
||||||
'escrituracion',
|
'escrituracion',
|
||||||
'direccion_general',
|
'direccion_general',
|
||||||
|
'manager',
|
||||||
'admin',
|
'admin',
|
||||||
] as const
|
] as const
|
||||||
export type Rol = (typeof ROLES)[number]
|
export type Rol = (typeof ROLES)[number]
|
||||||
@@ -48,6 +49,8 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
|||||||
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
||||||
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
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,
|
admin: MODULOS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from 'zod'
|
|||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
import { chat, indexarDocumento, type FuenteCitada } from '../services/ai.service.js'
|
import { chat, indexarDocumento, type FuenteCitada } from '../services/ai.service.js'
|
||||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||||
|
import { esAdminOManager } from '../utils/roles.js'
|
||||||
|
|
||||||
const chatSchema = z.object({
|
const chatSchema = z.object({
|
||||||
sessionId: z.string().uuid().nullish(),
|
sessionId: z.string().uuid().nullish(),
|
||||||
@@ -68,7 +69,7 @@ export async function chatIAStream(req: Request, res: Response): Promise<void> {
|
|||||||
export function filtroConversaciones(
|
export function filtroConversaciones(
|
||||||
token: { sub: string; rol: string } | undefined,
|
token: { sub: string; rol: string } | undefined,
|
||||||
): { usuarioId: string } | undefined {
|
): { usuarioId: string } | undefined {
|
||||||
if (!token || token.rol === 'admin') return undefined
|
if (!token || esAdminOManager(token.rol)) return undefined
|
||||||
return { usuarioId: token.sub }
|
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 } })
|
const conversacion = await prisma.aIConversacion.findUnique({ where: { id } })
|
||||||
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
|
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||||
const token = req.usuarioToken
|
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')
|
throw new AppError(403, 'Solo puedes borrar tus conversaciones')
|
||||||
}
|
}
|
||||||
await prisma.aIConversacion.delete({ where: { id } }) // cascade: mensajes
|
await prisma.aIConversacion.delete({ where: { id } }) // cascade: mensajes
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
|
import { esAdminOManager } from '../utils/roles.js'
|
||||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||||
|
|
||||||
// ---------- Borradores del alta de clientes ----------
|
// ---------- 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 */
|
/** sub del JWT; con AUTH_ENABLED=false (tests) cae a un id fijo */
|
||||||
const usuarioDe = (req: Request): string => req.usuarioToken?.sub ?? 'anon'
|
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) */
|
/** 404 si no existe, 403 si es de otro usuario (salvo admin) */
|
||||||
async function borradorPropio(req: Request, id: string) {
|
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 * as capturaService from '../services/captura-departamento.service.js'
|
||||||
import { DEPARTAMENTOS } from '../config/campos-departamento.js'
|
import { DEPARTAMENTOS } from '../config/campos-departamento.js'
|
||||||
import { AppError, BadRequestError } from '../utils/errors.js'
|
import { AppError, BadRequestError } from '../utils/errors.js'
|
||||||
|
import { esAdminOManager } from '../utils/roles.js'
|
||||||
|
|
||||||
// ── Middleware admin ─────────────────────────────────────────────────
|
// ── Middleware admin ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
|
function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
|
||||||
if (req.usuarioToken?.rol !== 'admin') {
|
if (!esAdminOManager(req.usuarioToken?.rol)) {
|
||||||
throw new AppError(403, 'Se requiere rol admin')
|
throw new AppError(403, 'Se requiere rol admin o manager')
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* Endpoints de administración de configuración.
|
* Endpoints de administración de configuración.
|
||||||
*
|
*
|
||||||
* Protegidos con requireAdmin (rol === 'admin') excepto /acciones que
|
* Protegidos con requireAdmin (admin o manager) excepto /acciones que
|
||||||
* cualquiera autenticado puede consultar.
|
* 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 type { NextFunction, Request, Response } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import * as configService from '../services/config.service.js'
|
import * as configService from '../services/config.service.js'
|
||||||
import { esRolValido } from '../config/roles.js'
|
import { esRolValido } from '../config/roles.js'
|
||||||
|
import { esAdmin, esAdminOManager } from '../utils/roles.js'
|
||||||
import { AppError, BadRequestError } from '../utils/errors.js'
|
import { AppError, BadRequestError } from '../utils/errors.js'
|
||||||
|
|
||||||
// ── Middleware admin ────────────────────────────────────────────────────
|
// ── Middleware admin ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
|
export function requireAdmin(req: Request, _res: Response, next: NextFunction): void {
|
||||||
if (req.usuarioToken?.rol !== 'admin') {
|
if (!esAdminOManager(req.usuarioToken?.rol)) {
|
||||||
throw new AppError(403, 'Se requiere rol admin')
|
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()
|
next()
|
||||||
}
|
}
|
||||||
@@ -86,6 +97,10 @@ export async function actualizarUsuarioEndpoint(req: Request, res: Response): Pr
|
|||||||
const id = req.params.id as string
|
const id = req.params.id as string
|
||||||
if (!id) throw new BadRequestError('Falta el parámetro :id')
|
if (!id) throw new BadRequestError('Falta el parámetro :id')
|
||||||
const parsed = actualizarUsuarioSchema.parse(req.body)
|
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
|
// Filtra undefined para enviar solo campos con valor
|
||||||
const body: { rol?: string; asignacion?: string | null; activo?: boolean } = {}
|
const body: { rol?: string; asignacion?: string | null; activo?: boolean } = {}
|
||||||
if (parsed.rol !== undefined) body.rol = parsed.rol
|
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')
|
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
|
||||||
|
|
||||||
// Capturas de afluencia: admin ve todas, otros solo las suyas
|
// 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({
|
const capturas = await prisma.leadCaptura.findMany({
|
||||||
where: esAdmin ? {} : { usuarioId: usuario.id },
|
where: esAdmin ? {} : { usuarioId: usuario.id },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { VOCABULARIO } from '../config/vocabulario.js'
|
|||||||
|
|
||||||
export function requireRolReportes(req: Request, _res: Response, next: NextFunction): void {
|
export function requireRolReportes(req: Request, _res: Response, next: NextFunction): void {
|
||||||
const rol = req.usuarioToken?.rol
|
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')
|
throw new AppError(403, 'No tienes acceso a los reportes')
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
@@ -145,8 +145,20 @@ export async function reporteAfluencias(req: Request, res: Response): Promise<vo
|
|||||||
desarrollo: c.desarrollo ?? '',
|
desarrollo: c.desarrollo ?? '',
|
||||||
modelo: c.modelo ?? '',
|
modelo: c.modelo ?? '',
|
||||||
etapa: c.etapa,
|
etapa: c.etapa,
|
||||||
califica: perfil['califica'] ?? '',
|
|
||||||
vendedor: nombrePorId.get(c.usuarioId) ?? '(sin asignar)',
|
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(),
|
fecha: c.createdAt.toISOString(),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ import {
|
|||||||
import { estadoSistema } from '../controllers/sistema.controller.js'
|
import { estadoSistema } from '../controllers/sistema.controller.js'
|
||||||
import {
|
import {
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
|
requireAdminEstricto,
|
||||||
obtenerConfigsEndpoint,
|
obtenerConfigsEndpoint,
|
||||||
actualizarConfigsEndpoint,
|
actualizarConfigsEndpoint,
|
||||||
obtenerRolesConfigEndpoint,
|
obtenerRolesConfigEndpoint,
|
||||||
@@ -306,11 +307,11 @@ apiRouter.get('/auditoria', requireAdmin, listarAuditoriaEndpoint)
|
|||||||
apiRouter.get('/config/sistema', requireAdmin, obtenerConfigsEndpoint)
|
apiRouter.get('/config/sistema', requireAdmin, obtenerConfigsEndpoint)
|
||||||
apiRouter.patch('/config/sistema', requireAdmin, actualizarConfigsEndpoint)
|
apiRouter.patch('/config/sistema', requireAdmin, actualizarConfigsEndpoint)
|
||||||
apiRouter.get('/config/roles', requireAdmin, obtenerRolesConfigEndpoint)
|
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.get('/config/usuarios', requireAdmin, obtenerUsuariosEndpoint)
|
||||||
apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint)
|
apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint)
|
||||||
apiRouter.post('/config/usuarios', requireAdmin, crearUsuarioEndpoint)
|
apiRouter.post('/config/usuarios', requireAdminEstricto, crearUsuarioEndpoint)
|
||||||
apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint)
|
apiRouter.post('/config/reset-roles', requireAdminEstricto, resetRolesEndpoint)
|
||||||
apiRouter.get('/config/acciones', obtenerAccionesEndpoint)
|
apiRouter.get('/config/acciones', obtenerAccionesEndpoint)
|
||||||
|
|
||||||
// ── Captura por departamentos ────────────────────────────────────────
|
// ── Captura por departamentos ────────────────────────────────────────
|
||||||
|
|||||||
@@ -450,7 +450,9 @@ export interface ContextoUsuario {
|
|||||||
|
|
||||||
/** admin / director_ventas / direccion_general ven los datos de todos */
|
/** admin / director_ventas / direccion_general ven los datos de todos */
|
||||||
export function esDireccion(rol: string | undefined): boolean {
|
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
|
const FASES_APARTADO: readonly string[] = VOCABULARIO.FASES_APARTADO
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ async function validarFechasBloqueadas(
|
|||||||
registros: { unidad: string; campo: string; valor: string | null }[],
|
registros: { unidad: string; campo: string; valor: string | null }[],
|
||||||
rol: string,
|
rol: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (rol === 'admin') return
|
if (rol === 'admin' || rol === 'manager') return
|
||||||
const bloqueados = CAMPOS_FECHA_BLOQUEADOS[departamento]
|
const bloqueados = CAMPOS_FECHA_BLOQUEADOS[departamento]
|
||||||
if (!bloqueados) return
|
if (!bloqueados) return
|
||||||
const candidatos = registros.filter((r) => bloqueados.has(r.campo))
|
const candidatos = registros.filter((r) => bloqueados.has(r.campo))
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ describe('resetRoles', () => {
|
|||||||
prismaMock.rolConfiguracion.create.mockResolvedValue({})
|
prismaMock.rolConfiguracion.create.mockResolvedValue({})
|
||||||
await resetRoles()
|
await resetRoles()
|
||||||
expect(prismaMock.rolConfiguracion.deleteMany).toHaveBeenCalled()
|
expect(prismaMock.rolConfiguracion.deleteMany).toHaveBeenCalled()
|
||||||
// MATRIZ tiene 6 roles
|
// MATRIZ tiene 7 roles (incluye manager)
|
||||||
expect(prismaMock.rolConfiguracion.create).toHaveBeenCalledTimes(6)
|
expect(prismaMock.rolConfiguracion.create).toHaveBeenCalledTimes(7)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const ESTADOS_FINALES_APARTADO = [
|
|||||||
'TITULADO',
|
'TITULADO',
|
||||||
]
|
]
|
||||||
|
|
||||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin', 'manager']
|
||||||
|
|
||||||
const MS_DIA = 24 * 60 * 60 * 1000
|
const MS_DIA = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
@@ -70,6 +70,8 @@ export interface LeadMiDia {
|
|||||||
estado: string
|
estado: string
|
||||||
origen: string
|
origen: string
|
||||||
desarrollo?: string
|
desarrollo?: string
|
||||||
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
|
vendedor: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
||||||
fuente: 'lead' | 'oportunidad'
|
fuente: 'lead' | 'oportunidad'
|
||||||
@@ -139,6 +141,8 @@ export interface ApartadoMiDia {
|
|||||||
email: string
|
email: string
|
||||||
origen: string
|
origen: string
|
||||||
desarrollo: string
|
desarrollo: string
|
||||||
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
|
vendedor: string
|
||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
estado: string
|
estado: string
|
||||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||||
@@ -290,6 +294,7 @@ export function resumenMiDiaLocal(
|
|||||||
estado: c.etapa,
|
estado: c.etapa,
|
||||||
origen: c.origen ?? '',
|
origen: c.origen ?? '',
|
||||||
desarrollo: c.desarrollo ?? '',
|
desarrollo: c.desarrollo ?? '',
|
||||||
|
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||||
updatedAt: c.updatedAt.toISOString(),
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
fuente: 'lead' as const,
|
fuente: 'lead' as const,
|
||||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
@@ -326,6 +331,7 @@ export function resumenMiDiaLocal(
|
|||||||
etapa: c.etapa,
|
etapa: c.etapa,
|
||||||
desarrollo: c.desarrollo ?? '',
|
desarrollo: c.desarrollo ?? '',
|
||||||
modeloInteres: c.modelo ?? '',
|
modeloInteres: c.modelo ?? '',
|
||||||
|
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||||
unidadCodigo,
|
unidadCodigo,
|
||||||
updatedAt: c.updatedAt.toISOString(),
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
@@ -350,6 +356,7 @@ export function resumenMiDiaLocal(
|
|||||||
email: c.email ?? '',
|
email: c.email ?? '',
|
||||||
origen: c.origen ?? '',
|
origen: c.origen ?? '',
|
||||||
desarrollo: c.desarrollo ?? '',
|
desarrollo: c.desarrollo ?? '',
|
||||||
|
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||||
estado: c.etapa,
|
estado: c.etapa,
|
||||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
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
|
const actividadesAbiertas = actividades.filter((a) => a.estado.toUpperCase() === 'ABIERTA').length
|
||||||
|
|
||||||
// --- Equipo (solo coordinador/admin/corporativo) ---
|
// --- 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
|
let equipo: NonNullable<ResumenMiDia['equipo']> | undefined
|
||||||
|
|
||||||
if (ROLES_CON_EQUIPO.includes(usuario.rol) && usuarios.length > 0) {
|
if (ROLES_CON_EQUIPO.includes(usuario.rol) && usuarios.length > 0) {
|
||||||
@@ -507,6 +514,7 @@ export function resumenMiDiaLocal(
|
|||||||
email: c.email ?? '',
|
email: c.email ?? '',
|
||||||
origen: c.origen ?? '',
|
origen: c.origen ?? '',
|
||||||
desarrollo: c.desarrollo ?? '',
|
desarrollo: c.desarrollo ?? '',
|
||||||
|
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||||
estado: c.etapa,
|
estado: c.etapa,
|
||||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
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) */
|
/** 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[] {
|
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 base = esDireccion ? SUGERENCIAS_DIRECCION : SUGERENCIAS_VENDEDOR
|
||||||
const dia = diaDelAnio(fecha)
|
const dia = diaDelAnio(fecha)
|
||||||
// Rota el punto de partida para que no salgan siempre las mismas seis
|
// 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 [asignacion, setAsignacion] = useState<'capturista' | 'round-robin' | null>(null)
|
||||||
const [aviso, setAviso] = useState('')
|
const [aviso, setAviso] = useState('')
|
||||||
const [avisoTipo, setAvisoTipo] = useState<'error' | 'advertencia'>('error')
|
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 } }) => {
|
const cambiar = (campo: keyof typeof FORM_INICIAL) => (e: { target: { value: string } }) => {
|
||||||
setForm((prev) => ({ ...prev, [campo]: e.target.value }))
|
setForm((prev) => ({ ...prev, [campo]: e.target.value }))
|
||||||
@@ -152,16 +154,49 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
const enviar = async (e: FormEvent) => {
|
const enviar = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setAviso('')
|
setAviso('')
|
||||||
|
setDuplicado(null)
|
||||||
if (!validar()) return
|
if (!validar()) return
|
||||||
setEnviando(true)
|
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 numeroTel = form.telefono.trim()
|
||||||
const telefono =
|
return numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}`
|
||||||
numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}`
|
}
|
||||||
const res = await crearAfluencia({
|
|
||||||
|
/** 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(),
|
nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(),
|
||||||
telefono,
|
telefono,
|
||||||
...(oportunidadId !== undefined ? { oportunidadId } : {}),
|
...(oportunidadIdElegido !== undefined ? { oportunidadId: oportunidadIdElegido } : {}),
|
||||||
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
||||||
...(form.origen ? { origen: form.origen } : {}),
|
...(form.origen ? { origen: form.origen } : {}),
|
||||||
...(form.desarrollo ? { desarrollo: form.desarrollo } : {}),
|
...(form.desarrollo ? { desarrollo: form.desarrollo } : {}),
|
||||||
@@ -177,31 +212,41 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
...(form.califica ? { califica: form.califica } : {}),
|
...(form.califica ? { califica: form.califica } : {}),
|
||||||
...(form.loQuierePara.trim() ? { loQuierePara: form.loQuierePara.trim() } : {}),
|
...(form.loQuierePara.trim() ? { loQuierePara: form.loQuierePara.trim() } : {}),
|
||||||
...(form.notas.trim() ? { notas: form.notas.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) {
|
if (res.ok) {
|
||||||
setLeadCreado({
|
setLeadCreado({
|
||||||
id: res.lead.id,
|
id: res.lead.id,
|
||||||
nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(),
|
nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(),
|
||||||
telefono,
|
telefono: telefonoConLada(),
|
||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
estado: res.lead.estado,
|
estado: res.lead.estado,
|
||||||
origen: form.origen,
|
origen: form.origen,
|
||||||
fuente: 'oportunidad', // la captura nueva vive en el tablero Oportunidades
|
fuente: 'oportunidad', // la captura nueva vive en el tablero Oportunidades
|
||||||
})
|
})
|
||||||
setAsignacion(res.lead.asignacion ?? 'round-robin')
|
setAsignacion(res.lead.asignacion ?? 'round-robin')
|
||||||
} else if (res.motivo === 'duplicado') {
|
return
|
||||||
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.')
|
|
||||||
}
|
}
|
||||||
|
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 = () => {
|
const capturarOtro = () => {
|
||||||
@@ -598,9 +643,19 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{aviso && avisoTipo === 'error' && (
|
{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">
|
<div 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>{aviso}</p>
|
||||||
</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">
|
<div className="mt-8 flex gap-3">
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export default function DatosGeneralesCampos({ prefix, compacto = false }: Props
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!compacto && (
|
{!compacto && (
|
||||||
<CampoTexto nombre={p('usuarioEnkontrol')} etiqueta="Usuario Enkontrol" />
|
<CampoTexto nombre={p('usuarioEnkontrol')} etiqueta="Cliente Enkontrol" />
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,13 +30,14 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente }
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [proyecto])
|
}, [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') ?? []
|
const actuales = metodos.getValues('tiposCredito') ?? []
|
||||||
metodos.setValue(
|
metodos.setValue('tiposCredito', actuales.includes(valor) ? [] : [valor], { shouldDirty: true })
|
||||||
'tiposCredito',
|
|
||||||
actuales.includes(valor) ? actuales.filter((t) => t !== valor) : [...actuales, valor],
|
|
||||||
{ shouldDirty: true },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,10 +89,11 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente }
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id={`tipoCredito-${tc.valor}`}
|
id={`tipoCredito-${tc.valor}`}
|
||||||
type="checkbox"
|
type="radio"
|
||||||
|
name="tipoCreditoUnico"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
checked={tiposSeleccionados.includes(tc.valor)}
|
checked={tiposSeleccionados.includes(tc.valor)}
|
||||||
onChange={() => alternarCredito(tc.valor)}
|
onChange={() => seleccionarCredito(tc.valor)}
|
||||||
/>
|
/>
|
||||||
{tc.etiqueta}
|
{tc.etiqueta}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ type Fila =
|
|||||||
|
|
||||||
interface DefReporte {
|
interface DefReporte {
|
||||||
id: string
|
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) */
|
/** Número con el que el cliente pidió el reporte (1-10) */
|
||||||
numero: number
|
numero: number
|
||||||
titulo: string
|
titulo: string
|
||||||
@@ -100,6 +102,7 @@ const REPORTES: DefReporte[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cancelaciones-motivo',
|
id: 'cancelaciones-motivo',
|
||||||
|
endpoint: 'cancelaciones',
|
||||||
numero: 2,
|
numero: 2,
|
||||||
titulo: 'Cancelación (Cliente / Motivo)',
|
titulo: 'Cancelación (Cliente / Motivo)',
|
||||||
descripcion: 'Cancelaciones de todas las etapas con el motivo y submotivo del descarte.',
|
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: 'nombre', label: 'Nombre' },
|
||||||
{ key: 'telefono', label: 'Teléfono' },
|
{ key: 'telefono', label: 'Teléfono' },
|
||||||
{ key: 'email', label: 'Email' },
|
{ 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: 'origen', label: 'Origen' },
|
||||||
{ key: 'desarrollo', label: 'Desarrollo' },
|
{ key: 'desarrollo', label: 'Desarrollo' },
|
||||||
{ key: 'modelo', label: 'Modelo' },
|
{ key: 'modelo', label: 'Modelo' },
|
||||||
{ key: 'califica', label: 'Califica' },
|
|
||||||
{ key: 'etapa', label: 'Etapa' },
|
{ key: 'etapa', label: 'Etapa' },
|
||||||
{ key: 'vendedor', label: 'Vendedor' },
|
{ key: 'vendedor', label: 'Vendedor' },
|
||||||
|
{ key: 'notas', label: 'Notas' },
|
||||||
{ key: 'fecha', label: 'Fecha', tipo: 'fecha' },
|
{ key: 'fecha', label: 'Fecha', tipo: 'fecha' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cancelaciones-todas',
|
id: 'cancelaciones-todas',
|
||||||
|
endpoint: 'cancelaciones',
|
||||||
numero: 4,
|
numero: 4,
|
||||||
titulo: 'Listado de cancelaciones',
|
titulo: 'Listado de cancelaciones',
|
||||||
descripcion: 'Todas las cancelaciones del sistema: leads, oportunidades, apartados y escrituración.',
|
descripcion: 'Todas las cancelaciones del sistema: leads, oportunidades, apartados y escrituración.',
|
||||||
@@ -356,7 +370,7 @@ export default function Reportes() {
|
|||||||
if (esCaptura) {
|
if (esCaptura) {
|
||||||
setCaptura(await fetchReporteCaptura(desarrollo || undefined))
|
setCaptura(await fetchReporteCaptura(desarrollo || undefined))
|
||||||
} else {
|
} else {
|
||||||
const respuesta = await fetchReporte<Fila>(def.id, {
|
const respuesta = await fetchReporte<Fila>(def.endpoint ?? def.id, {
|
||||||
desde: desde || undefined,
|
desde: desde || undefined,
|
||||||
hasta: hasta || undefined,
|
hasta: hasta || undefined,
|
||||||
desarrollo: desarrollo || 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) */
|
/** ¿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 {
|
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) */
|
/** 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á
|
* Tableros con vista por rol: cada tab se muestra solo si el módulo está
|
||||||
|
|||||||
@@ -68,8 +68,19 @@ export interface FilaAfluenciaReporte {
|
|||||||
desarrollo: string
|
desarrollo: string
|
||||||
modelo: string
|
modelo: string
|
||||||
etapa: string
|
etapa: string
|
||||||
califica: string
|
|
||||||
vendedor: 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
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
<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 (
|
return (
|
||||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
|
<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) =>
|
const respuestaOk = (data: unknown) =>
|
||||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
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>>
|
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchMock = vi.fn<FetchFn>()
|
fetchMock = vi.fn<FetchFn>()
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
vi.stubGlobal('localStorage', crearStorageStub())
|
vi.stubGlobal('localStorage', crearStorageStub())
|
||||||
|
localStorage.setItem('frasa_auth', JSON.stringify(sesionDe('admin')))
|
||||||
// Mockear las llamadas iniciales que los tabs pueden hacer
|
// Mockear las llamadas iniciales que los tabs pueden hacer
|
||||||
fetchMock.mockImplementation((input) => {
|
fetchMock.mockImplementation((input) => {
|
||||||
const url = String(input)
|
const url = String(input)
|
||||||
@@ -63,6 +69,14 @@ describe('ConfigPage', () => {
|
|||||||
expect(screen.getByText('Restaurar valores por defecto')).toBeTruthy()
|
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', () => {
|
it('al hacer clic en "Volver", ejecuta el callback', () => {
|
||||||
const volver = vi.fn()
|
const volver = vi.fn()
|
||||||
render(<ConfigPage onVolver={volver} />)
|
render(<ConfigPage onVolver={volver} />)
|
||||||
|
|||||||
@@ -12,18 +12,27 @@ interface Props {
|
|||||||
|
|
||||||
type Tab = 'roles' | 'usuarios' | 'sistema' | 'asistente' | 'auditoria'
|
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) {
|
export function ConfigPage({ onVolver }: Props) {
|
||||||
const [tab, setTab] = useState<Tab>('roles')
|
const [tabElegida, setTab] = useState<Tab>('roles')
|
||||||
const esAdmin = getSesion()?.usuario.rol === 'admin'
|
const rol = getSesion()?.usuario.rol
|
||||||
const tabs = esAdmin
|
const esAdmin = rol === 'admin'
|
||||||
? [...TABS_BASE, { key: 'asistente' as const, label: 'Asistente IA' }, { key: 'auditoria' as const, label: 'Auditoría' }]
|
// Roles y permisos: SOLO administrador (el manager no edita permisos ni crea usuarios).
|
||||||
: TABS_BASE
|
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 (
|
return (
|
||||||
<main className="w-full flex-1 px-6 py-12 sm:px-6 lg:px-8 sm:py-16">
|
<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 */}
|
{/* Contenido del tab activo */}
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
{tab === 'roles' && <RolesPermisos />}
|
{tab === 'roles' && (esAdmin ? <RolesPermisos /> : null)}
|
||||||
{tab === 'usuarios' && <Usuarios />}
|
{tab === 'usuarios' && <Usuarios puedeCrear={esAdmin} puedeEditarRoles={esAdmin} />}
|
||||||
{tab === 'sistema' && <Sistema />}
|
{tab === 'sistema' && <Sistema />}
|
||||||
{tab === 'asistente' && esAdmin && <AsistenteIA />}
|
{tab === 'asistente' && <AsistenteIA />}
|
||||||
{tab === 'auditoria' && esAdmin && <AuditoriaLog />}
|
{tab === 'auditoria' && <AuditoriaLog />}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const ROLES = [
|
|||||||
'director_ventas',
|
'director_ventas',
|
||||||
'escrituracion',
|
'escrituracion',
|
||||||
'direccion_general',
|
'direccion_general',
|
||||||
|
'manager',
|
||||||
'admin',
|
'admin',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ const ROL_LABELS: Record<string, string> = {
|
|||||||
director_ventas: 'Director ventas',
|
director_ventas: 'Director ventas',
|
||||||
escrituracion: 'Escrituración',
|
escrituracion: 'Escrituración',
|
||||||
direccion_general: 'Dirección general',
|
direccion_general: 'Dirección general',
|
||||||
|
manager: 'Manager',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +44,17 @@ const ASIGNACION_LABELS: Record<string, string> = {
|
|||||||
'': 'Sin asignación',
|
'': '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 [usuarios, setUsuarios] = useState<UsuarioConfig[]>([])
|
||||||
const [cargando, setCargando] = useState(true)
|
const [cargando, setCargando] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -176,7 +188,8 @@ export function Usuarios() {
|
|||||||
<p role="alert" className="text-sm text-red-600">{error}</p>
|
<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="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
@@ -278,6 +291,7 @@ export function Usuarios() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Búsqueda */}
|
{/* Búsqueda */}
|
||||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
@@ -319,15 +333,19 @@ 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-[#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="whitespace-nowrap px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{u.username}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<select
|
{puedeEditarRoles ? (
|
||||||
value={u.rol}
|
<select
|
||||||
onChange={(e) => void cambiarRol(u.id, e.target.value)}
|
value={u.rol}
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#111827] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
|
onChange={(e) => void cambiarRol(u.id, e.target.value)}
|
||||||
>
|
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#111827] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
|
||||||
{ROLES.map((r) => (
|
>
|
||||||
<option key={r} value={r}>{ROL_LABELS[r]}</option>
|
{ROLES.map((r) => (
|
||||||
))}
|
<option key={r} value={r}>{ROL_LABELS[r]}</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[#374151] dark:text-[#CBD5E1]">{ROL_LABELS[u.rol] ?? u.rol}</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export function CapturaPage({ onVolver }: Props) {
|
|||||||
onGuardar={handleGuardar}
|
onGuardar={handleGuardar}
|
||||||
guardando={guardando}
|
guardando={guardando}
|
||||||
filtroEstatus={filtroEstatus}
|
filtroEstatus={filtroEstatus}
|
||||||
puedeEditarFechas={rol === 'admin'}
|
puedeEditarFechas={rol === 'admin' || rol === 'manager'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export function SoportePage({ onVolver }: Props) {
|
|||||||
{/* Listado de solicitudes */}
|
{/* Listado de solicitudes */}
|
||||||
<section aria-label="Mis solicitudes" className="mt-12 max-w-3xl">
|
<section aria-label="Mis solicitudes" className="mt-12 max-w-3xl">
|
||||||
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
<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>
|
</h2>
|
||||||
{cargando ? (
|
{cargando ? (
|
||||||
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>
|
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>
|
||||||
|
|||||||
@@ -259,8 +259,11 @@ export default function DetalleLeadModal({
|
|||||||
{tab === 'notas' && (
|
{tab === 'notas' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Barra de herramientas */}
|
{/* 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>
|
<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="ml-auto flex items-center gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#94A3B8]" />
|
<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',
|
telefono: '6641234567',
|
||||||
email: 'juan@example.com',
|
email: 'juan@example.com',
|
||||||
origen: 'PASE',
|
origen: 'PASE',
|
||||||
|
vendedor: 'Martin Zavala',
|
||||||
desarrollo: 'DIAGONAL',
|
desarrollo: 'DIAGONAL',
|
||||||
unidadCodigo: 'D-101',
|
unidadCodigo: 'D-101',
|
||||||
estado: 'En proceso',
|
estado: 'En proceso',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ describe('urlCotizadorParaOportunidad', () => {
|
|||||||
etapa: 'Afluencia',
|
etapa: 'Afluencia',
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modeloInteres: 'SOHO',
|
modeloInteres: 'SOHO',
|
||||||
|
vendedor: 'Martin Zavala',
|
||||||
unidadCodigo: 'U-9',
|
unidadCodigo: 'U-9',
|
||||||
updatedAt: '2026-09-04T10:00:00Z',
|
updatedAt: '2026-09-04T10:00:00Z',
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
@@ -118,6 +119,7 @@ describe('urlAfluenciaParaOportunidad', () => {
|
|||||||
etapa: 'Agendo Cita',
|
etapa: 'Agendo Cita',
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modeloInteres: 'SOHO',
|
modeloInteres: 'SOHO',
|
||||||
|
vendedor: 'Martin Zavala',
|
||||||
unidadCodigo: '',
|
unidadCodigo: '',
|
||||||
updatedAt: '2026-09-04T10:00:00Z',
|
updatedAt: '2026-09-04T10:00:00Z',
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface LeadMiDia {
|
|||||||
estado: string
|
estado: string
|
||||||
origen: string
|
origen: string
|
||||||
desarrollo?: string
|
desarrollo?: string
|
||||||
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
|
vendedor: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
||||||
fuente: 'lead' | 'oportunidad'
|
fuente: 'lead' | 'oportunidad'
|
||||||
@@ -61,6 +63,8 @@ export interface OportunidadMiDia {
|
|||||||
etapa: string
|
etapa: string
|
||||||
desarrollo: string
|
desarrollo: string
|
||||||
modeloInteres: string
|
modeloInteres: string
|
||||||
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
|
vendedor: string
|
||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||||
@@ -76,6 +80,8 @@ export interface ApartadoMiDia {
|
|||||||
email: string
|
email: string
|
||||||
origen: string
|
origen: string
|
||||||
desarrollo: string
|
desarrollo: string
|
||||||
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
|
vendedor: string
|
||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
estado: string
|
estado: string
|
||||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||||
|
|||||||
@@ -1313,7 +1313,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
onDescartar={() => setDescartando(lead)}
|
onDescartar={() => setDescartando(lead)}
|
||||||
onVerDetalle={() => setLeadSeleccionado(lead)}
|
onVerDetalle={() => setLeadSeleccionado(lead)}
|
||||||
acciones={acciones}
|
acciones={acciones}
|
||||||
esAdmin={rol === 'admin'}
|
esAdmin={rol === 'admin' || rol === 'manager'}
|
||||||
vendedores={resumen.equipo?.porVendedor.map((v) => ({ id: v.usuarioId, nombre: v.nombre ?? '' }))}
|
vendedores={resumen.equipo?.porVendedor.map((v) => ({ id: v.usuarioId, nombre: v.nombre ?? '' }))}
|
||||||
onReasignar={(usuarioId) => void reasignar(lead, usuarioId)}
|
onReasignar={(usuarioId) => void reasignar(lead, usuarioId)}
|
||||||
onMover={() => void mover(lead)}
|
onMover={() => void mover(lead)}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const leadFake: LeadMiDia = {
|
|||||||
email: 'juan@test.com',
|
email: 'juan@test.com',
|
||||||
estado: 'CONTACTADO',
|
estado: 'CONTACTADO',
|
||||||
origen: 'PASE',
|
origen: 'PASE',
|
||||||
|
vendedor: 'Martin Zavala',
|
||||||
updatedAt: '2026-09-01T10:00:00Z',
|
updatedAt: '2026-09-01T10:00:00Z',
|
||||||
fuente: 'lead',
|
fuente: 'lead',
|
||||||
ultimoCambio: { fechaISO: '2026-09-01T10:00:00Z', usuario: 'Admin', detalle: 'Lead creado' },
|
ultimoCambio: { fechaISO: '2026-09-01T10:00:00Z', usuario: 'Admin', detalle: 'Lead creado' },
|
||||||
@@ -46,6 +47,7 @@ const opFake: OportunidadMiDia = {
|
|||||||
etapa: 'Agendo Cita',
|
etapa: 'Agendo Cita',
|
||||||
desarrollo: 'DIAGONAL',
|
desarrollo: 'DIAGONAL',
|
||||||
modeloInteres: 'Modelo A',
|
modeloInteres: 'Modelo A',
|
||||||
|
vendedor: 'Martin Zavala',
|
||||||
unidadCodigo: 'D-101',
|
unidadCodigo: 'D-101',
|
||||||
updatedAt: '2026-09-02T12:00:00Z',
|
updatedAt: '2026-09-02T12:00:00Z',
|
||||||
ultimoCambio: { fechaISO: '2026-09-02T12:00:00Z', usuario: 'Admin', detalle: 'Etapa cambiada' },
|
ultimoCambio: { fechaISO: '2026-09-02T12:00:00Z', usuario: 'Admin', detalle: 'Etapa cambiada' },
|
||||||
@@ -57,6 +59,7 @@ const apartadoFake: ApartadoMiDia = {
|
|||||||
telefono: '6641234567',
|
telefono: '6641234567',
|
||||||
email: 'carlos@example.com',
|
email: 'carlos@example.com',
|
||||||
origen: 'REFERIDO',
|
origen: 'REFERIDO',
|
||||||
|
vendedor: 'Martin Zavala',
|
||||||
desarrollo: 'ESTANZA',
|
desarrollo: 'ESTANZA',
|
||||||
unidadCodigo: 'E-202',
|
unidadCodigo: 'E-202',
|
||||||
estado: 'En proceso',
|
estado: 'En proceso',
|
||||||
|
|||||||
Reference in New Issue
Block a user