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

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

View File

@@ -55,7 +55,8 @@ const SUGERENCIAS_DIRECCION = [
/** Preguntas sugeridas según el rol de la sesión (vendedor vs dirección/administración) */
export function preguntasSugeridasPorRol(rol: string | undefined, fecha: Date = new Date()): string[] {
const esDireccion = rol === 'admin' || rol === 'director_ventas' || rol === 'direccion_general' || rol === 'administracion_venta'
const esDireccion =
rol === 'admin' || rol === 'manager' || rol === 'director_ventas' || rol === 'direccion_general' || rol === 'administracion_venta'
const base = esDireccion ? SUGERENCIAS_DIRECCION : SUGERENCIAS_VENDEDOR
const dia = diaDelAnio(fecha)
// Rota el punto de partida para que no salgan siempre las mismas seis

View File

@@ -76,6 +76,8 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
const [asignacion, setAsignacion] = useState<'capturista' | 'round-robin' | null>(null)
const [aviso, setAviso] = useState('')
const [avisoTipo, setAvisoTipo] = useState<'error' | 'advertencia'>('error')
// Registro existente con ese teléfono: permite actualizarlo en vez de fallar
const [duplicado, setDuplicado] = useState<{ id: string; nombre: string } | null>(null)
const cambiar = (campo: keyof typeof FORM_INICIAL) => (e: { target: { value: string } }) => {
setForm((prev) => ({ ...prev, [campo]: e.target.value }))
@@ -152,16 +154,49 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
const enviar = async (e: FormEvent) => {
e.preventDefault()
setAviso('')
setDuplicado(null)
if (!validar()) return
setEnviando(true)
// Teléfono con lada: "<prefijo> <número>" (si ya trae +, se respeta tal cual)
const res = await crearAfluencia(construirPayload(oportunidadId))
setEnviando(false)
procesarResultado(res, oportunidadId)
}
/** Reenvía la captura como ACTUALIZACIÓN del registro existente (duplicado) */
const actualizarExistente = async () => {
if (!duplicado) return
setAviso('')
setEnviando(true)
const res = await crearAfluencia(construirPayload(duplicado.id))
setEnviando(false)
if (res.ok) {
setDuplicado(null)
procesarResultado(res, duplicado.id)
return
}
if (res.motivo === 'duplicado') {
setDuplicado({ id: res.leadId, nombre: res.nombre })
setAvisoTipo('error')
setAviso(`No se pudo actualizar: el teléfono pertenece a ${res.nombre}.`)
return
}
setAvisoTipo('advertencia')
setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.')
}
/** Teléfono con lada: "<prefijo> <número>" (si ya trae +, se respeta tal cual) */
const telefonoConLada = () => {
const numeroTel = form.telefono.trim()
const telefono =
numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}`
const res = await crearAfluencia({
return numeroTel === '' ? '' : numeroTel.startsWith('+') ? numeroTel : `${lada === 'US' ? '+1' : '+52'} ${numeroTel}`
}
/** Payload del alta/actualización de afluencia (teléfono normalizado con lada) */
const construirPayload = (oportunidadIdElegido?: string) => {
const telefono = telefonoConLada()
return {
nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(),
telefono,
...(oportunidadId !== undefined ? { oportunidadId } : {}),
...(oportunidadIdElegido !== undefined ? { oportunidadId: oportunidadIdElegido } : {}),
...(form.email.trim() ? { email: form.email.trim() } : {}),
...(form.origen ? { origen: form.origen } : {}),
...(form.desarrollo ? { desarrollo: form.desarrollo } : {}),
@@ -177,31 +212,41 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
...(form.califica ? { califica: form.califica } : {}),
...(form.loQuierePara.trim() ? { loQuierePara: form.loQuierePara.trim() } : {}),
...(form.notas.trim() ? { notas: form.notas.trim() } : {}),
})
setEnviando(false)
}
}
/** Resultado del API: éxito, duplicado (ofrece actualizar) o problema de red */
const procesarResultado = (
res: Awaited<ReturnType<typeof crearAfluencia>>,
oportunidadIdElegido?: string,
) => {
if (res.ok) {
setLeadCreado({
id: res.lead.id,
nombre: [form.nombrePila, form.apellidoPaterno, form.apellidoMaterno].filter(Boolean).join(' ').trim() || form.nombre.trim(),
telefono,
telefono: telefonoConLada(),
email: form.email.trim(),
estado: res.lead.estado,
origen: form.origen,
fuente: 'oportunidad', // la captura nueva vive en el tablero Oportunidades
})
setAsignacion(res.lead.asignacion ?? 'round-robin')
} else if (res.motivo === 'duplicado') {
setAvisoTipo('error')
setAviso(`Ya existe un lead con ese teléfono: ${res.nombre}.`)
} else if (res.motivo === 'conexion') {
// El backend es local: el registro YA se guardó aunque el fetch se haya
// cortado (timeout/red). Aviso amarillo en vez de error rojo.
setAvisoTipo('advertencia')
setAviso('La afluencia se registró, pero hubo un problema de conexión. Verifica en Mi día.')
} else {
setAvisoTipo('error')
setAviso('No se pudo registrar el lead. Revisa tu conexión e intenta de nuevo.')
return
}
if (res.motivo === 'duplicado') {
setDuplicado({ id: res.leadId, nombre: res.nombre })
setAvisoTipo('error')
setAviso(`Ya existe un registro con ese teléfono: ${res.nombre}.`)
return
}
if (oportunidadIdElegido !== undefined) {
// El backend es local: la actualización YA se guardó aunque el fetch se cortara
setAvisoTipo('advertencia')
setAviso('La afluencia se actualizó, pero hubo un problema de conexión. Verifica en Mi día.')
return
}
setAvisoTipo('advertencia')
setAviso('La afluencia se registró, pero hubo un problema de conexión. Verifica en Mi día.')
}
const capturarOtro = () => {
@@ -598,9 +643,19 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
)}
{aviso && avisoTipo === 'error' && (
<p role="alert" className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
{aviso}
</p>
<div role="alert" className="mt-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
<p>{aviso}</p>
{duplicado && (
<button
type="button"
onClick={() => void actualizarExistente()}
disabled={enviando}
className="mt-3 rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{enviando ? 'Actualizando…' : 'Actualizar este registro con los datos capturados'}
</button>
)}
</div>
)}
<div className="mt-8 flex gap-3">

View File

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

View File

@@ -30,13 +30,14 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [proyecto])
const alternarCredito = (valor: TipoCredito) => {
/**
* El tipo de crédito es de selección ÚNICA (Infonavit, Apoyo Infonavit,
* Cofinavit, Segundo crédito, etc.). Se guarda como arreglo de un elemento
* para no cambiar el contrato de datos; volver a tocar el elegido lo quita.
*/
const seleccionarCredito = (valor: TipoCredito) => {
const actuales = metodos.getValues('tiposCredito') ?? []
metodos.setValue(
'tiposCredito',
actuales.includes(valor) ? actuales.filter((t) => t !== valor) : [...actuales, valor],
{ shouldDirty: true },
)
metodos.setValue('tiposCredito', actuales.includes(valor) ? [] : [valor], { shouldDirty: true })
}
return (
@@ -88,10 +89,11 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente }
>
<input
id={`tipoCredito-${tc.valor}`}
type="checkbox"
type="radio"
name="tipoCreditoUnico"
className="sr-only"
checked={tiposSeleccionados.includes(tc.valor)}
onChange={() => alternarCredito(tc.valor)}
onChange={() => seleccionarCredito(tc.valor)}
/>
{tc.etiqueta}
</label>

View File

@@ -59,6 +59,8 @@ type Fila =
interface DefReporte {
id: string
/** Endpoint de datos del API (default: el mismo id) */
endpoint?: string
/** Número con el que el cliente pidió el reporte (1-10) */
numero: number
titulo: string
@@ -100,6 +102,7 @@ const REPORTES: DefReporte[] = [
},
{
id: 'cancelaciones-motivo',
endpoint: 'cancelaciones',
numero: 2,
titulo: 'Cancelación (Cliente / Motivo)',
descripcion: 'Cancelaciones de todas las etapas con el motivo y submotivo del descarte.',
@@ -132,17 +135,28 @@ const REPORTES: DefReporte[] = [
{ key: 'nombre', label: 'Nombre' },
{ key: 'telefono', label: 'Teléfono' },
{ key: 'email', label: 'Email' },
{ key: 'edad', label: 'Edad', tipo: 'numero' },
{ key: 'estadoCivil', label: 'Estado civil' },
{ key: 'tipoEmpleo', label: 'Tipo empleo' },
{ key: 'ocupacion', label: 'Ocupación' },
{ key: 'rangoSalarial', label: 'Rango salarial' },
{ key: 'credito', label: 'Crédito' },
{ key: 'tipoEconomia', label: 'Tipo economía' },
{ key: 'califica', label: 'Califica' },
{ key: 'numDependientes', label: 'Dependientes' },
{ key: 'loQuierePara', label: '¿Para qué?' },
{ key: 'origen', label: 'Origen' },
{ key: 'desarrollo', label: 'Desarrollo' },
{ key: 'modelo', label: 'Modelo' },
{ key: 'califica', label: 'Califica' },
{ key: 'etapa', label: 'Etapa' },
{ key: 'vendedor', label: 'Vendedor' },
{ key: 'notas', label: 'Notas' },
{ key: 'fecha', label: 'Fecha', tipo: 'fecha' },
],
},
{
id: 'cancelaciones-todas',
endpoint: 'cancelaciones',
numero: 4,
titulo: 'Listado de cancelaciones',
descripcion: 'Todas las cancelaciones del sistema: leads, oportunidades, apartados y escrituración.',
@@ -356,7 +370,7 @@ export default function Reportes() {
if (esCaptura) {
setCaptura(await fetchReporteCaptura(desarrollo || undefined))
} else {
const respuesta = await fetchReporte<Fila>(def.id, {
const respuesta = await fetchReporte<Fila>(def.endpoint ?? def.id, {
desde: desde || undefined,
hasta: hasta || undefined,
desarrollo: desarrollo || undefined,

View File

@@ -52,7 +52,7 @@ export interface ResumenMiDia {
}
}
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin', 'manager']
/** ¿El rol ve el tablero general por defecto y la sección de equipo? (misma regla que el API) */
export function esRolConEquipo(rol: string): boolean {

View File

@@ -16,7 +16,7 @@ const VISTAS: { id: Vista; etiqueta: string; clave: string }[] = [
]
/** Roles que ven el módulo de Reportes (datos de todos los vendedores) */
const ROLES_REPORTES = ['admin', 'director_ventas', 'direccion_general']
const ROLES_REPORTES = ['admin', 'manager', 'director_ventas', 'direccion_general']
/**
* Tableros con vista por rol: cada tab se muestra solo si el módulo está

View File

@@ -68,8 +68,19 @@ export interface FilaAfluenciaReporte {
desarrollo: string
modelo: string
etapa: string
califica: string
vendedor: string
numDependientes: string
edad: string
estadoCivil: string
tipoEmpleo: string
ocupacion: string
rangoSalarial: string
credito: string
tipoEconomia: string
califica: string
loQuierePara: string
medioContacto: string
notas: string
fecha: string
}

View File

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

View File

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

View File

@@ -22,12 +22,18 @@ function crearStorageStub(): Storage {
const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
/** Sesión simulada; rol 'admin' para las pruebas de administrador */
function sesionDe(rol: string) {
return { token: 'jwt-prueba', usuario: { nombre: 'Prueba', rol, asignacion: null } }
}
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('localStorage', crearStorageStub())
localStorage.setItem('frasa_auth', JSON.stringify(sesionDe('admin')))
// Mockear las llamadas iniciales que los tabs pueden hacer
fetchMock.mockImplementation((input) => {
const url = String(input)
@@ -63,6 +69,14 @@ describe('ConfigPage', () => {
expect(screen.getByText('Restaurar valores por defecto')).toBeTruthy()
})
it('el rol manager NO ve "Roles y permisos" (no edita permisos)', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionDe('manager')))
render(<ConfigPage onVolver={() => {}} />)
expect(screen.queryByRole('button', { name: 'Roles y permisos' })).toBeNull()
expect(screen.getByRole('button', { name: 'Usuarios' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Auditoría' })).toBeTruthy()
})
it('al hacer clic en "Volver", ejecuta el callback', () => {
const volver = vi.fn()
render(<ConfigPage onVolver={volver} />)

View File

@@ -12,18 +12,27 @@ interface Props {
type Tab = 'roles' | 'usuarios' | 'sistema' | 'asistente' | 'auditoria'
const TABS_BASE: { key: Tab; label: string }[] = [
{ key: 'roles', label: 'Roles y permisos' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'sistema', label: 'Sistema' },
]
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
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' },
]
: [
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'sistema', label: 'Sistema' },
{ key: 'asistente', label: 'Asistente IA' },
{ key: 'auditoria', label: 'Auditoría' },
]
// Con un rol sin acceso a la tab pedida, se abre la primera disponible
const tab: Tab = tabs.some((t) => t.key === tabElegida) ? tabElegida : tabs[0]!.key
return (
<main className="w-full flex-1 px-6 py-12 sm:px-6 lg:px-8 sm:py-16">
@@ -63,11 +72,11 @@ export function ConfigPage({ onVolver }: Props) {
{/* Contenido del tab activo */}
<section className="mt-6">
{tab === 'roles' && <RolesPermisos />}
{tab === 'usuarios' && <Usuarios />}
{tab === 'roles' && (esAdmin ? <RolesPermisos /> : null)}
{tab === 'usuarios' && <Usuarios puedeCrear={esAdmin} puedeEditarRoles={esAdmin} />}
{tab === 'sistema' && <Sistema />}
{tab === 'asistente' && esAdmin && <AsistenteIA />}
{tab === 'auditoria' && esAdmin && <AuditoriaLog />}
{tab === 'asistente' && <AsistenteIA />}
{tab === 'auditoria' && <AuditoriaLog />}
</section>
</main>
)

View File

@@ -12,6 +12,7 @@ const ROLES = [
'director_ventas',
'escrituracion',
'direccion_general',
'manager',
'admin',
] as const
@@ -30,6 +31,7 @@ const ROL_LABELS: Record<string, string> = {
director_ventas: 'Director ventas',
escrituracion: 'Escrituración',
direccion_general: 'Dirección general',
manager: 'Manager',
admin: 'Admin',
}
@@ -42,7 +44,17 @@ const ASIGNACION_LABELS: Record<string, string> = {
'': 'Sin asignación',
}
export function Usuarios() {
/**
* El rol manager puede ver la lista y ajustar asignación/activo, pero NO crear
* usuarios ni cambiar roles (permisos) — eso queda para el administrador.
*/
export function Usuarios({
puedeCrear = true,
puedeEditarRoles = true,
}: {
puedeCrear?: boolean
puedeEditarRoles?: boolean
} = {}) {
const [usuarios, setUsuarios] = useState<UsuarioConfig[]>([])
const [cargando, setCargando] = useState(true)
const [error, setError] = useState('')
@@ -176,7 +188,8 @@ export function Usuarios() {
<p role="alert" className="text-sm text-red-600">{error}</p>
)}
{/* Alta de usuario */}
{/* Alta de usuario (solo administrador) */}
{puedeCrear && (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
@@ -278,6 +291,7 @@ export function Usuarios() {
</form>
)}
</div>
)}
{/* Búsqueda */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
@@ -319,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-[#64748B] dark:text-[#94A3B8]">{u.username}</td>
<td className="px-4 py-3">
<select
value={u.rol}
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>
))}
</select>
{puedeEditarRoles ? (
<select
value={u.rol}
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>
))}
</select>
) : (
<span className="text-xs text-[#374151] dark:text-[#CBD5E1]">{ROL_LABELS[u.rol] ?? u.rol}</span>
)}
</td>
<td className="px-4 py-3">
<select

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,8 @@ export interface LeadMiDia {
estado: string
origen: string
desarrollo?: string
/** Vendedor asignado (nombre); '' si no tiene */
vendedor: string
updatedAt: string
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
fuente: 'lead' | 'oportunidad'
@@ -61,6 +63,8 @@ export interface OportunidadMiDia {
etapa: string
desarrollo: string
modeloInteres: string
/** Vendedor asignado (nombre); '' si no tiene */
vendedor: string
unidadCodigo: string
updatedAt: string
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
@@ -76,6 +80,8 @@ export interface ApartadoMiDia {
email: string
origen: string
desarrollo: string
/** Vendedor asignado (nombre); '' si no tiene */
vendedor: string
unidadCodigo: string
estado: string
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)

View File

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

View File

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