fix(mi-dia): admin reasigna/mueve leads, capturista real en cards, datos en apartados, INE obligatoria y bitácora persistente

This commit is contained in:
2026-09-17 06:54:57 +00:00
parent b99707689f
commit 31f83b0504
11 changed files with 162 additions and 14 deletions

View File

@@ -15,7 +15,7 @@ function calcularClienteClave(nombre?: string | null, telefono?: string | null):
}
function esLead(item: LeadItem): item is LeadMiDia {
return 'email' in item && 'origen' in item && !('modeloInteres' in item)
return 'fuente' in item && item.fuente === 'lead'
}
function esOportunidad(item: LeadItem): item is OportunidadMiDia {
return 'modeloInteres' in item

View File

@@ -26,6 +26,10 @@ const sesionFake = { token: 'jwt-prueba', usuario: { nombre: 'Test', rol: 'vende
const apartadoFake: ApartadoMiDia = {
id: 'apt-1',
nombre: 'Juan Pérez',
telefono: '6641234567',
email: 'juan@example.com',
origen: 'PASE',
desarrollo: 'DIAGONAL',
unidadCodigo: 'D-101',
estado: 'En proceso',
fechaLimite: '2026-09-15',

View File

@@ -72,6 +72,10 @@ export interface OportunidadMiDia {
export interface ApartadoMiDia {
id: string
nombre: string
telefono: string
email: string
origen: string
desarrollo: string
unidadCodigo: string
estado: string
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
@@ -236,11 +240,12 @@ export const ETIQUETA_FASE_OPORTUNIDAD: Record<FaseOportunidad, string> = {
}
// Slots de la precarga de documentos en "Mis apartados"; los obligatorios son
// los que pide el proceso de venta (captura del cotizador y de pago apartado)
// los que pide el proceso de venta (INE, captura del cotizador y de pago apartado)
export const SLOTS_DOCS_APARTADO = [
{ tipo: 'CAPTURA_COTIZADOR', etiqueta: 'Captura del cotizador', obligatorio: true },
{ tipo: 'CAPTURA_PAGO_APARTADO', etiqueta: 'Captura de pago apartado', obligatorio: true },
{ tipo: 'OTRO', etiqueta: 'Documentos generales (INE, CURP, comprobantes…)', obligatorio: false },
{ tipo: 'INE', etiqueta: 'INE (identificación oficial)', obligatorio: true },
{ tipo: 'OTRO', etiqueta: 'Documentos generales (CURP, comprobantes…)', obligatorio: false },
] as const
export type SlotDocApartado = (typeof SLOTS_DOCS_APARTADO)[number]['tipo']

View File

@@ -45,6 +45,8 @@ import {
fetchArchivoDocumentoApartado,
fetchResumenMiDia,
listarDocumentosApartado,
moverLead as moverLeadApi,
reasignarLead as reasignarLeadApi,
subirDocumentoApartado,
type DocumentoApartado,
} from '../services/mi-dia'
@@ -378,6 +380,12 @@ function FilaApartado({
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
) : null}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
<EnlacesContacto nombre={apartado.nombre} telefono={apartado.telefono ?? ''} />
{apartado.desarrollo ? ` · ${apartado.desarrollo}` : ''}
{apartado.origen ? ` · ${apartado.origen}` : ''}
{apartado.email ? ` · ${apartado.email}` : ''}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{apartado.fechaLimite !== '' ? `Fecha límite: ${formatoFechaCorta(apartado.fechaLimite)}` : 'Sin fecha límite'}
</p>
@@ -523,6 +531,15 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Expediente del cliente
</p>
{documentos !== null &&
SLOTS_DOCS_APARTADO.filter((s) => s.obligatorio && !(documentos ?? []).some((d) => d.tipo === s.tipo)).length > 0 && (
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
Documentos obligatorios pendientes:{' '}
{SLOTS_DOCS_APARTADO.filter((s) => s.obligatorio && !(documentos ?? []).some((d) => d.tipo === s.tipo))
.map((s) => s.etiqueta)
.join(', ')}
</p>
)}
{error && (
<p role="alert" className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
@@ -675,6 +692,10 @@ function FilaLead({
onVerDetalle,
enviando,
acciones,
esAdmin,
vendedores,
onReasignar,
onMover,
}: {
lead: LeadMiDia
onAccion: (estado: string, fecha?: string) => void
@@ -683,6 +704,11 @@ function FilaLead({
onVerDetalle?: () => void
enviando: boolean
acciones?: string[]
/** Solo admin: reasignar a cualquier vendedor y mover entre tableros */
esAdmin?: boolean
vendedores?: { id: string; nombre: string }[]
onReasignar?: (usuarioId: string) => void
onMover?: () => void
}) {
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
@@ -752,6 +778,30 @@ function FilaLead({
Descartar
</button>
</div>
{/* Admin: reasignar a cualquier vendedor y mover entre tableros */}
{esAdmin && (
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-dashed border-[#E5E7EB] pt-2 dark:border-[#334155]">
<span className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">Admin</span>
<select
value=""
disabled={enviando}
onChange={(e) => {
if (e.target.value !== '' && onReasignar) onReasignar(e.target.value)
e.target.value = ''
}}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs text-[#374151] transition-colors focus:border-accent focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
aria-label="Reasignar lead a vendedor"
>
<option value="">Reasignar a…</option>
{(vendedores ?? []).map((v) => (
<option key={v.id} value={v.id}>{v.nombre}</option>
))}
</select>
<button type="button" disabled={enviando} onClick={onMover} className={CLASE_BOTON_ACCION}>
{lead.fuente === 'lead' ? '→ Oportunidad' : '→ Leads'}
</button>
</div>
)}
</li>
)
}
@@ -994,6 +1044,23 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
window.open(urlCotizadorParaOportunidad(op), '_blank')
}
/** Reasignar lead a otro vendedor (solo admin) */
async function reasignar(lead: LeadMiDia, usuarioId: string) {
await ejecutar(async () => {
const nombre = await reasignarLeadApi(lead.id, usuarioId)
toast.success(`Lead reasignado a ${nombre || 'vendedor'}`)
}, 'No se pudo reasignar el lead')
}
/** Mover lead entre tableros Leads ↔ Oportunidades (solo admin) */
async function mover(lead: LeadMiDia) {
const destino = lead.fuente === 'lead' ? 'OPORTUNIDADES' : 'LEADS'
await ejecutar(async () => {
await moverLeadApi(lead.id, destino)
toast.success(destino === 'OPORTUNIDADES' ? 'Lead movido a Oportunidades' : 'Lead movido a Leads')
}, 'No se pudo mover el lead')
}
async function enviarCaptura(e: FormEvent) {
e.preventDefault()
setCapEnviando(true)
@@ -1246,6 +1313,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
onDescartar={() => setDescartando(lead)}
onVerDetalle={() => setLeadSeleccionado(lead)}
acciones={acciones}
esAdmin={rol === 'admin'}
vendedores={resumen.equipo?.porVendedor.map((v) => ({ id: v.usuarioId, nombre: v.nombre ?? '' }))}
onReasignar={(usuarioId) => void reasignar(lead, usuarioId)}
onMover={() => void mover(lead)}
/>
))}
</ul>

View File

@@ -54,6 +54,10 @@ const opFake: OportunidadMiDia = {
const apartadoFake: ApartadoMiDia = {
id: '3',
nombre: 'Carlos Ruiz',
telefono: '6641234567',
email: 'carlos@example.com',
origen: 'REFERIDO',
desarrollo: 'ESTANZA',
unidadCodigo: 'E-202',
estado: 'En proceso',
fechaLimite: '2026-09-15',

View File

@@ -107,6 +107,36 @@ export function cambiarEstadoApartado(
})
}
/** POST /leads/:itemId/mover — mover lead a otro tablero (solo admin) */
export async function moverLead(itemId: string, board: 'LEADS' | 'OPORTUNIDADES'): Promise<void> {
const res = await fetch(`${API_URL}/leads/${itemId}/mover`, conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ board }),
}))
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
}
/** PATCH /leads/:itemId/asignar — reasignar el lead a otro vendedor (solo admin) */
export async function reasignarLead(itemId: string, usuarioId: string): Promise<string> {
const res = await fetch(`${API_URL}/leads/${itemId}/asignar`, conAuth({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ usuarioId }),
}))
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
const data = (await res.json()) as { nombre?: string }
return data.nombre ?? ''
}
// ---------- Precarga de documentos del expediente (Mis apartados) ----------
export interface DocumentoApartado {