- Circuit breaker saludMonday (3 fallos → modo local; ping cada 60s)
- Endpoint GET /api/sistema/estado {mondayOk, pendientesSync}
- LeadCaptura extendida (board, etapa, origen, motivo, pendienteSync)
- Tablas Unidad (espejo inventario, 288 seed) + SyncPendiente
- crmLocal: crearLead, afluencia, cambio etapa, descarte, conversión, apartar
- Ruteo en controllers: fallback local ante 502 o Monday caído
- Mi día local: resumen completo desde SQLite + unión con Monday
- Fallbacks: unidades, contactos, reportes, prefill, buscador
- Sync worker cada 2min + handlers 15 tipos (con DependenciaPendienteError)
- Gate automatizaciones: skip si !mondayOk
- Banner 'Modo local' en 5 apps + badge pendiente en mi-dia
- 597 API tests + 263 front tests verdes
Env MODO_LOCAL_FORZADO=true para testing sin Monday.
1196 lines
48 KiB
TypeScript
1196 lines
48 KiB
TypeScript
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
|
import KpiCard from '../components/KpiCard'
|
|
import BannerModoLocal from '../components/BannerModoLocal'
|
|
import ChipPendienteSync from '../components/ChipPendienteSync'
|
|
import type { AccionesSesion } from '../components/AuthGate'
|
|
import {
|
|
esActividadVencida,
|
|
esRolConEquipo,
|
|
formatoFechaCorta,
|
|
hoyLocal,
|
|
DESARROLLOS_LEAD,
|
|
ETIQUETA_FASE_APARTADO,
|
|
ETIQUETA_FASE_OPORTUNIDAD,
|
|
FASES_APARTADO,
|
|
MOTIVOS_NO_COMPRA,
|
|
ORIGENES_LEAD,
|
|
SLOTS_DOCS_APARTADO,
|
|
SUBCATEGORIAS_NO_COMPRA,
|
|
componerTelefono,
|
|
LADAS,
|
|
textoDiasRestantes,
|
|
urlAfluenciaParaOportunidad,
|
|
urlCotizadorParaOportunidad,
|
|
type ApartadoMiDia,
|
|
type ClaveLada,
|
|
type FaseApartado,
|
|
type LeadMiDia,
|
|
type MotivoNoCompra,
|
|
type OportunidadMiDia,
|
|
type ResumenMiDia,
|
|
type SlotDocApartado,
|
|
} from '../lib/mi-dia'
|
|
import {
|
|
cambiarEstadoApartado,
|
|
cambiarEstadoLead,
|
|
cambiarEtapaOportunidad,
|
|
convertirLead,
|
|
crearLead,
|
|
descartarLead,
|
|
fetchResumenMiDia,
|
|
listarDocumentosApartado,
|
|
subirDocumentoApartado,
|
|
type DocumentoApartado,
|
|
} from '../services/mi-dia'
|
|
import { getSesion } from '../services/auth'
|
|
|
|
function TituloSeccion({ children }: { children: string }) {
|
|
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{children}</h2>
|
|
}
|
|
|
|
function EstadoVacio({ mensaje }: { mensaje: string }) {
|
|
return (
|
|
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#94A3B8]">{mensaje}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const CLASE_BOTON_ACCION =
|
|
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50'
|
|
|
|
const CLASE_INPUT =
|
|
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
|
|
|
|
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563]'
|
|
|
|
/** Modal de descarte: motivo + subcategoría (catálogo Monday) + comentario opcional */
|
|
function ModalDescartar({
|
|
lead,
|
|
onCancelar,
|
|
onConfirmar,
|
|
enviando,
|
|
}: {
|
|
lead: { id: string; nombre: string }
|
|
onCancelar: () => void
|
|
onConfirmar: (motivo: MotivoNoCompra, subcategoria: string | undefined, comentario: string) => void
|
|
enviando: boolean
|
|
}) {
|
|
const [motivo, setMotivo] = useState<MotivoNoCompra>(MOTIVOS_NO_COMPRA[0])
|
|
const [subcategoria, setSubcategoria] = useState('')
|
|
const [comentario, setComentario] = useState('')
|
|
const subcategorias = SUBCATEGORIAS_NO_COMPRA[motivo]
|
|
return (
|
|
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
|
|
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">Descartar lead</p>
|
|
<p className="mt-2 text-lg font-medium text-[#111827]">{lead.nombre}</p>
|
|
<form
|
|
className="mt-6 space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
onConfirmar(motivo, subcategoria === '' ? undefined : subcategoria, comentario.trim())
|
|
}}
|
|
>
|
|
<div>
|
|
<label htmlFor="motivo-descarte" className={CLASE_LABEL}>
|
|
Motivo
|
|
</label>
|
|
<select
|
|
id="motivo-descarte"
|
|
value={motivo}
|
|
onChange={(e) => {
|
|
setMotivo(e.target.value as MotivoNoCompra)
|
|
setSubcategoria('') // las subcategorías dependen de la categoría
|
|
}}
|
|
className={CLASE_INPUT}
|
|
>
|
|
{MOTIVOS_NO_COMPRA.map((m) => (
|
|
<option key={m} value={m}>
|
|
{m}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="subcategoria-descarte" className={CLASE_LABEL}>
|
|
Subcategoría
|
|
</label>
|
|
<select
|
|
id="subcategoria-descarte"
|
|
value={subcategoria}
|
|
onChange={(e) => setSubcategoria(e.target.value)}
|
|
className={CLASE_INPUT}
|
|
>
|
|
<option value="">— Selecciona —</option>
|
|
{subcategorias.map((s) => (
|
|
<option key={s} value={s}>
|
|
{s}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="comentario-descarte" className={CLASE_LABEL}>
|
|
Comentario (opcional)
|
|
</label>
|
|
<textarea
|
|
id="comentario-descarte"
|
|
value={comentario}
|
|
onChange={(e) => setComentario(e.target.value)}
|
|
rows={3}
|
|
className={CLASE_INPUT}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onCancelar}
|
|
disabled={enviando}
|
|
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={enviando}
|
|
className="rounded-lg bg-red-600 px-5 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-red-700 disabled:opacity-50"
|
|
>
|
|
{enviando ? 'Descartando…' : 'Descartar'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */
|
|
function FilaOportunidad({
|
|
op,
|
|
onEtapa,
|
|
onCotizar,
|
|
onAfluencia,
|
|
onDescartar,
|
|
enviando,
|
|
}: {
|
|
op: OportunidadMiDia
|
|
onEtapa: (etapa: string, fecha?: string) => void
|
|
onCotizar: () => void
|
|
onAfluencia: () => void
|
|
onDescartar: () => void
|
|
enviando: boolean
|
|
}) {
|
|
const [conCita, setConCita] = useState(false)
|
|
const [fechaCita, setFechaCita] = useState('')
|
|
// Sin 'No Asistio' ni 'Abandono': son redundantes con Descartar
|
|
const fases = ['Agendo Cita', 'Aparto'] as const
|
|
return (
|
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="flex items-center gap-2 text-sm font-medium">
|
|
{op.nombre}
|
|
{op.pendienteSync === true && <ChipPendienteSync />}
|
|
</p>
|
|
<p className="mt-1 text-xs text-[#64748B]">
|
|
{op.telefono || 'Sin teléfono'}
|
|
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
|
|
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
|
|
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
|
|
</p>
|
|
{op.ultimoCambio && (
|
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
|
|
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
|
|
{formatoFechaCorta(op.ultimoCambio.fechaISO.slice(0, 10))}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
|
{op.etapa || 'Sin etapa'}
|
|
</span>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
{/* Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
|
|
<button
|
|
type="button"
|
|
disabled={enviando}
|
|
onClick={onCotizar}
|
|
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
|
>
|
|
Cotizar →
|
|
</button>
|
|
{/* Afluencia: abre el formulario de alta afluencia prellenado */}
|
|
<button
|
|
type="button"
|
|
disabled={enviando}
|
|
onClick={onAfluencia}
|
|
className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:opacity-50"
|
|
>
|
|
Afluencia →
|
|
</button>
|
|
{fases.map((f) =>
|
|
f === 'Agendo Cita' ? (
|
|
<span key={f} className="contents">
|
|
<button
|
|
type="button"
|
|
disabled={enviando}
|
|
onClick={() => (conCita ? onEtapa(f, fechaCita || undefined) : setConCita(true))}
|
|
className={CLASE_BOTON_ACCION}
|
|
>
|
|
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD[f]}
|
|
</button>
|
|
{conCita && (
|
|
<input
|
|
type="date"
|
|
aria-label="Fecha de la cita"
|
|
value={fechaCita}
|
|
onChange={(e) => setFechaCita(e.target.value)}
|
|
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
|
/>
|
|
)}
|
|
</span>
|
|
) : (
|
|
<button
|
|
key={f}
|
|
type="button"
|
|
disabled={enviando}
|
|
onClick={() => onEtapa(f)}
|
|
className={CLASE_BOTON_ACCION}
|
|
>
|
|
{ETIQUETA_FASE_OPORTUNIDAD[f]}
|
|
</button>
|
|
),
|
|
)}
|
|
<button
|
|
type="button"
|
|
disabled={enviando}
|
|
onClick={onDescartar}
|
|
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50"
|
|
>
|
|
Descartar
|
|
</button>
|
|
</div>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
/** Fila de apartado con botones de fase del pipeline Enkontrol */
|
|
function FilaApartado({
|
|
apartado,
|
|
onFase,
|
|
enviando,
|
|
}: {
|
|
apartado: ApartadoMiDia
|
|
onFase: (fase: FaseApartado) => void
|
|
enviando: boolean
|
|
}) {
|
|
const [docsAbiertos, setDocsAbiertos] = useState(false)
|
|
return (
|
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="flex items-center gap-2 text-sm font-medium">
|
|
{apartado.nombre}
|
|
{apartado.pendienteSync === true && <ChipPendienteSync />}
|
|
{apartado.unidadCodigo ? (
|
|
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
|
) : null}
|
|
</p>
|
|
<p className="mt-1 text-xs text-[#64748B]">
|
|
{apartado.fechaLimite !== '' ? `Fecha límite: ${formatoFechaCorta(apartado.fechaLimite)}` : 'Sin fecha límite'}
|
|
</p>
|
|
{apartado.ultimoCambio && (
|
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
|
|
Último cambio: {apartado.ultimoCambio.detalle} · {apartado.ultimoCambio.usuario ?? '—'} ·{' '}
|
|
{formatoFechaCorta(apartado.ultimoCambio.fechaISO.slice(0, 10))}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
|
{apartado.estado || 'Sin estado'}
|
|
</span>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
{FASES_APARTADO.map((fase) => (
|
|
<button
|
|
key={fase}
|
|
type="button"
|
|
disabled={enviando}
|
|
onClick={() => {
|
|
// Firma Cliente (Venta): abre el alta de cliente prellenada con
|
|
// todo lo recolectado del apartado (ligada, sin duplicar)
|
|
if (fase === 'Firma Cliente (Venta)') {
|
|
window.location.href = `/client/?apartado=${apartado.id}`
|
|
return
|
|
}
|
|
onFase(fase)
|
|
}}
|
|
className={
|
|
fase === 'Cancelacion Apartado'
|
|
? 'rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50'
|
|
: fase === 'Firma Cliente (Venta)'
|
|
? 'rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50'
|
|
: fase === apartado.estado
|
|
? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50'
|
|
: CLASE_BOTON_ACCION
|
|
}
|
|
>
|
|
{fase === 'Firma Cliente (Venta)' ? 'Firma cliente (venta) →' : ETIQUETA_FASE_APARTADO[fase]}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => setDocsAbiertos((v) => !v)}
|
|
aria-expanded={docsAbiertos}
|
|
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
>
|
|
{docsAbiertos ? 'Cerrar documentos' : 'Documentos'}
|
|
</button>
|
|
</div>
|
|
{docsAbiertos && <PanelDocumentosApartado itemId={apartado.id} />}
|
|
</li>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Precarga de documentos del expediente desde "Mis apartados": captura del
|
|
* cotizador, captura de pago apartado (ambos obligatorios) y documentos
|
|
* generales. Los archivos se guardan en el cliente ligado al apartado.
|
|
*/
|
|
function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
|
const [documentos, setDocumentos] = useState<DocumentoApartado[] | null>(null)
|
|
const [error, setError] = useState('')
|
|
const [subiendo, setSubiendo] = useState<SlotDocApartado | null>(null)
|
|
|
|
const cargar = useCallback(async () => {
|
|
try {
|
|
setDocumentos(await listarDocumentosApartado(itemId))
|
|
setError('')
|
|
} catch {
|
|
setError('No se pudieron cargar los documentos.')
|
|
}
|
|
}, [itemId])
|
|
|
|
useEffect(() => {
|
|
void cargar()
|
|
}, [cargar])
|
|
|
|
const subir = async (tipo: SlotDocApartado, archivo: File | undefined) => {
|
|
if (!archivo) return
|
|
setSubiendo(tipo)
|
|
setError('')
|
|
try {
|
|
await subirDocumentoApartado(itemId, tipo, archivo)
|
|
await cargar()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'No se pudo subir el archivo.')
|
|
} finally {
|
|
setSubiendo(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mt-3 rounded-lg border border-dashed border-[#D1D5DB] bg-[#F8FAFC] p-4">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
|
Expediente del cliente
|
|
</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}
|
|
</p>
|
|
)}
|
|
<ul className="mt-3 space-y-3">
|
|
{SLOTS_DOCS_APARTADO.map((slot) => {
|
|
const delSlot = (documentos ?? []).filter((d) => d.tipo === slot.tipo)
|
|
return (
|
|
<li key={slot.tipo}>
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<p className="text-xs font-medium text-[#374151]">
|
|
{slot.etiqueta}
|
|
{slot.obligatorio && <span className="ml-1 text-red-600">*</span>}
|
|
</p>
|
|
<label
|
|
className={`cursor-pointer rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white ${
|
|
subiendo !== null ? 'pointer-events-none opacity-50' : ''
|
|
}`}
|
|
>
|
|
{subiendo === slot.tipo ? 'Subiendo…' : 'Subir archivo'}
|
|
<input
|
|
type="file"
|
|
accept=".pdf,.jpg,.jpeg,.png"
|
|
className="sr-only"
|
|
disabled={subiendo !== null}
|
|
onChange={(e) => {
|
|
void subir(slot.tipo, e.target.files?.[0])
|
|
e.target.value = ''
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
{documentos === null ? (
|
|
<p className="mt-1 text-[11px] text-[#94A3B8]">Cargando…</p>
|
|
) : delSlot.length === 0 ? (
|
|
<p className="mt-1 text-[11px] text-[#94A3B8]">Sin archivos</p>
|
|
) : (
|
|
<ul className="mt-1 space-y-0.5">
|
|
{delSlot.map((d) => (
|
|
<li key={d.id} className="text-[11px] text-[#64748B]">
|
|
{d.nombreArchivo} · {formatoFechaCorta(d.createdAt.slice(0, 10))}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
<p className="mt-3 text-[11px] text-[#94A3B8]">
|
|
* Obligatorios para el proceso de venta. PDF, JPG o PNG, máximo 10 MB.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados */
|
|
type TabCrm = 'leads' | 'oportunidades' | 'apartados'
|
|
|
|
function TabsCrm({
|
|
activa,
|
|
conteos,
|
|
onCambiar,
|
|
}: {
|
|
activa: TabCrm
|
|
conteos: Record<TabCrm, number>
|
|
onCambiar: (tab: TabCrm) => void
|
|
}) {
|
|
const tabs: { id: TabCrm; titulo: string }[] = [
|
|
{ id: 'leads', titulo: 'Mis leads' },
|
|
{ id: 'oportunidades', titulo: 'Mis oportunidades' },
|
|
{ id: 'apartados', titulo: 'Mis apartados' },
|
|
]
|
|
return (
|
|
<div className="mb-4 flex flex-wrap gap-1 rounded-xl border border-[#E5E7EB] bg-white p-1 shadow-sm" role="tablist">
|
|
{tabs.map((tab) => {
|
|
const activo = tab.id === activa
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activo}
|
|
onClick={() => onCambiar(tab.id)}
|
|
className={`rounded-lg px-4 py-2 text-xs font-semibold uppercase tracking-wider transition-colors duration-100 ${
|
|
activo ? 'bg-accent text-white' : 'text-[#64748B] hover:bg-[#F9FAFB] hover:text-accent'
|
|
}`}
|
|
>
|
|
{tab.titulo}
|
|
<span className={`ml-1.5 font-normal ${activo ? 'text-blue-100' : 'text-[#94A3B8]'}`}>
|
|
{conteos[tab.id]}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Fila de lead con acciones rápidas (contactado / intento / interesado / descartar).
|
|
* Agendar cita ya no está aquí: la cita se agenda desde Mis oportunidades. */
|
|
function FilaLead({
|
|
lead,
|
|
onAccion,
|
|
onConvertir,
|
|
onDescartar,
|
|
enviando,
|
|
}: {
|
|
lead: LeadMiDia
|
|
onAccion: (estado: string, fecha?: string) => void
|
|
onConvertir: () => void
|
|
onDescartar: () => void
|
|
enviando: boolean
|
|
}) {
|
|
return (
|
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="flex items-center gap-2 text-sm font-medium">
|
|
{lead.nombre}
|
|
{lead.pendienteSync === true && <ChipPendienteSync />}
|
|
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
|
<span
|
|
className={
|
|
lead.fuente === 'oportunidad'
|
|
? 'rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-800'
|
|
: 'rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600'
|
|
}
|
|
>
|
|
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
|
|
</span>
|
|
</p>
|
|
<p className="mt-1 text-xs text-[#64748B]">
|
|
{lead.telefono || 'Sin teléfono'}
|
|
{lead.origen ? ` · ${lead.origen}` : ''}
|
|
</p>
|
|
{lead.ultimoCambio && (
|
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
|
|
Último cambio: {lead.ultimoCambio.detalle} · {lead.ultimoCambio.usuario ?? '—'} ·{' '}
|
|
{formatoFechaCorta(lead.ultimoCambio.fechaISO.slice(0, 10))}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
|
{lead.estado || 'Sin estado'}
|
|
</span>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
<button type="button" disabled={enviando} onClick={() => onAccion('CONTACTADO')} className={CLASE_BOTON_ACCION}>
|
|
Contactado
|
|
</button>
|
|
<button type="button" disabled={enviando} onClick={() => onAccion('Intento de contacto')} className={CLASE_BOTON_ACCION}>
|
|
Intento
|
|
</button>
|
|
<button type="button" disabled={enviando} onClick={() => onAccion('INTERESADO')} className={CLASE_BOTON_ACCION}>
|
|
Interesado
|
|
</button>
|
|
{/* Agendo: convierte el lead en oportunidad (etapa Agendo Cita) */}
|
|
<button
|
|
type="button"
|
|
disabled={enviando}
|
|
onClick={onConvertir}
|
|
className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:opacity-50"
|
|
>
|
|
Agendo →
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={enviando}
|
|
onClick={onDescartar}
|
|
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50"
|
|
>
|
|
Descartar
|
|
</button>
|
|
</div>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
export default function MiDiaPage({ accionesSesion }: { accionesSesion: AccionesSesion }) {
|
|
const nombreVendedor = getSesion()?.usuario.nombre ?? ''
|
|
const rol = getSesion()?.usuario.rol ?? 'vendedor'
|
|
const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [cargando, setCargando] = useState(true)
|
|
const [enviando, setEnviando] = useState(false)
|
|
const [aviso, setAviso] = useState<string | null>(null)
|
|
const [descartando, setDescartando] = useState<{
|
|
id: string
|
|
nombre: string
|
|
fuente: 'lead' | 'oportunidad'
|
|
} | null>(null)
|
|
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
|
|
const [nota, setNota] = useState<string | null>(null)
|
|
|
|
// Captura rápida
|
|
const [capNombre, setCapNombre] = useState('')
|
|
const [capLada, setCapLada] = useState<ClaveLada>('MX')
|
|
const [capTelefono, setCapTelefono] = useState('')
|
|
const [capEmail, setCapEmail] = useState('')
|
|
const [capOrigen, setCapOrigen] = useState<string>(ORIGENES_LEAD[0])
|
|
const [capDesarrollo, setCapDesarrollo] = useState<string>('')
|
|
const [capNotas, setCapNotas] = useState('')
|
|
const [capEnviando, setCapEnviando] = useState(false)
|
|
const [capMensaje, setCapMensaje] = useState<{ tipo: 'ok' | 'error'; texto: string } | null>(null)
|
|
|
|
const cargar = useCallback(async () => {
|
|
setCargando(true)
|
|
try {
|
|
setResumen(await fetchResumenMiDia())
|
|
setError(null)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'No se pudo cargar tu día')
|
|
} finally {
|
|
setCargando(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
void cargar()
|
|
}, [cargar])
|
|
|
|
/** Ejecuta una acción sobre un lead y refresca el resumen */
|
|
async function ejecutar(accion: () => Promise<unknown>, mensajeError: string) {
|
|
setEnviando(true)
|
|
setAviso(null)
|
|
try {
|
|
await accion()
|
|
await cargar()
|
|
} catch (err) {
|
|
setAviso(err instanceof Error ? err.message : mensajeError)
|
|
} finally {
|
|
setEnviando(false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Botón "Agendo" de Mis leads: convierte el lead en oportunidad (etapa
|
|
* Agendo Cita); el lead sale de la lista y aparece en Mis oportunidades.
|
|
*/
|
|
async function convertir(lead: LeadMiDia) {
|
|
setEnviando(true)
|
|
setAviso(null)
|
|
setNota(null)
|
|
try {
|
|
await convertirLead(lead.id)
|
|
await cargar()
|
|
setNota(`${lead.nombre} pasó a Mis oportunidades (Agendo Cita).`)
|
|
} catch (err) {
|
|
setAviso(err instanceof Error ? err.message : 'No se pudo convertir el lead')
|
|
} finally {
|
|
setEnviando(false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Botón "Cotizar": pasa la oportunidad a etapa Cotizador y abre el cotizador
|
|
* con los datos del cliente prellenados y la oportunidad vinculada (al
|
|
* guardar, la cotización actualiza ESE item en Monday). Si el cambio de etapa
|
|
* falla, el cotizador se abre igual: el guardado reintenta la actualización.
|
|
*/
|
|
async function cotizarOportunidad(op: OportunidadMiDia) {
|
|
setEnviando(true)
|
|
setAviso(null)
|
|
try {
|
|
await cambiarEtapaOportunidad(op.id, 'Cotizador')
|
|
} catch {
|
|
setAviso('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.')
|
|
}
|
|
window.location.href = urlCotizadorParaOportunidad(op)
|
|
}
|
|
|
|
async function enviarCaptura(e: FormEvent) {
|
|
e.preventDefault()
|
|
setCapEnviando(true)
|
|
setCapMensaje(null)
|
|
try {
|
|
const telefono = componerTelefono(capLada, capTelefono)
|
|
const creado = await crearLead({
|
|
nombre: capNombre.trim(),
|
|
...(telefono !== '' ? { telefono } : {}),
|
|
...(capEmail.trim() !== '' ? { email: capEmail.trim() } : {}),
|
|
...(capOrigen !== '' ? { origen: capOrigen } : {}),
|
|
...(capDesarrollo !== '' ? { desarrollo: capDesarrollo } : {}),
|
|
...(capNotas.trim() !== '' ? { notas: capNotas.trim() } : {}),
|
|
})
|
|
setCapMensaje({
|
|
tipo: 'ok',
|
|
texto:
|
|
creado.asignacion === 'capturista'
|
|
? 'Lead registrado y asignado a ti'
|
|
: 'Lead registrado, se asignará por round-robin',
|
|
})
|
|
setCapNombre('')
|
|
setCapTelefono('')
|
|
setCapEmail('')
|
|
setCapDesarrollo('')
|
|
setCapNotas('')
|
|
// Refresca el resumen para que la captura aparezca al momento en "Mis capturas"
|
|
await cargar()
|
|
} catch (err) {
|
|
setCapMensaje({ tipo: 'error', texto: err instanceof Error ? err.message : 'No se pudo registrar el lead' })
|
|
} finally {
|
|
setCapEnviando(false)
|
|
}
|
|
}
|
|
|
|
const hoy = hoyLocal()
|
|
const iniciales = nombreVendedor
|
|
.split(' ')
|
|
.filter((p) => p !== '')
|
|
.map((p) => p.charAt(0))
|
|
.slice(0, 2)
|
|
.join('')
|
|
.toUpperCase()
|
|
|
|
return (
|
|
<>
|
|
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
|
<div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<a
|
|
href="/"
|
|
className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-[#4B5563] transition-colors hover:bg-[#F9FAFB] hover:text-accent"
|
|
>
|
|
← Menú
|
|
</a>
|
|
<p className="shrink-0 text-xs font-semibold uppercase tracking-wider text-accent">
|
|
FRASA <span aria-hidden="true" className="text-[#F97316]">·</span> Mi día
|
|
</p>
|
|
<span aria-hidden="true" className="hidden h-4 w-px bg-[#E5E7EB] sm:block" />
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span
|
|
aria-hidden="true"
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent/10 text-xs font-semibold text-accent"
|
|
>
|
|
{iniciales}
|
|
</span>
|
|
<span className="hidden truncate text-sm font-medium text-[#111827] sm:block">{nombreVendedor}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={accionesSesion.onCambiarPassword}
|
|
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#4B5563] transition-all duration-100 ease-out hover:text-[#111827]"
|
|
>
|
|
Contraseña
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={accionesSesion.onLogout}
|
|
className="rounded-md px-2 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:text-accent-hover"
|
|
>
|
|
Salir
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void cargar()}
|
|
disabled={cargando}
|
|
className="rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50 sm:text-sm"
|
|
>
|
|
{cargando ? 'Actualizando…' : 'Actualizar'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<BannerModoLocal />
|
|
|
|
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
|
|
<div className="mb-6">
|
|
<h1 className="text-xl font-semibold text-[#111827]">{nombreVendedor}</h1>
|
|
<p className="mt-1 text-sm text-[#64748B]">{formatoFechaCorta(hoy)}</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
|
|
Sin datos del API — {error}
|
|
</p>
|
|
)}
|
|
{aviso && (
|
|
<p role="alert" className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
|
|
{aviso}
|
|
</p>
|
|
)}
|
|
{nota && (
|
|
<p role="status" className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
|
|
{nota}
|
|
</p>
|
|
)}
|
|
{resumen?.sinMapeoMonday && (
|
|
<p className="mb-6 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-sm font-medium text-[#64748B] shadow-sm">
|
|
Tu usuario aún no está vinculado a Monday; las acciones siguen funcionando
|
|
</p>
|
|
)}
|
|
|
|
{!resumen && cargando && <EstadoVacio mensaje="Cargando…" />}
|
|
|
|
{resumen && (
|
|
<>
|
|
<section aria-label="Indicadores del día" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<KpiCard titulo="Leads asignados" valor={resumen.misLeads.length} />
|
|
<KpiCard
|
|
titulo="Actividades de hoy"
|
|
valor={resumen.misActividadesHoy.length}
|
|
detalle={`${resumen.conteos.actividadesAbiertas} abiertas en total`}
|
|
/>
|
|
<KpiCard titulo="Apartados por vencer" valor={resumen.apartadosPorVencer.length} detalle="próximos 7 días" />
|
|
<KpiCard titulo="Leads nuevos esta semana" valor={resumen.conteos.leadsNuevosSemana} />
|
|
</section>
|
|
|
|
<div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-3 lg:gap-6">
|
|
<div className="order-2 flex flex-col gap-6 lg:order-1 lg:col-span-2">
|
|
<section aria-label="CRM personal">
|
|
<TabsCrm
|
|
activa={tabCrm}
|
|
conteos={{
|
|
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
|
|
oportunidades: resumen.misOportunidades.items.length,
|
|
apartados: resumen.misApartados.length,
|
|
}}
|
|
onCambiar={setTabCrm}
|
|
/>
|
|
|
|
{tabCrm === 'leads' &&
|
|
(resumen.misLeads.filter((l) => l.fuente === 'lead').length === 0 ? (
|
|
<EstadoVacio mensaje="Sin leads asignados" />
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{resumen.misLeads
|
|
.filter((l) => l.fuente === 'lead')
|
|
.map((lead) => (
|
|
<FilaLead
|
|
key={`${lead.fuente}-${lead.id}`}
|
|
lead={lead}
|
|
enviando={enviando}
|
|
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
|
onConvertir={() => void convertir(lead)}
|
|
onDescartar={() => setDescartando(lead)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
))}
|
|
|
|
{tabCrm === 'oportunidades' &&
|
|
(resumen.misOportunidades.items.length === 0 ? (
|
|
<EstadoVacio mensaje="Sin oportunidades asignadas" />
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{resumen.misOportunidades.items.map((op) => (
|
|
<FilaOportunidad
|
|
key={op.id}
|
|
op={op}
|
|
enviando={enviando}
|
|
onEtapa={(etapa, fecha) =>
|
|
void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad')
|
|
}
|
|
onCotizar={() => void cotizarOportunidad(op)}
|
|
onAfluencia={() => {
|
|
window.location.href = urlAfluenciaParaOportunidad(op)
|
|
}}
|
|
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
|
|
/>
|
|
))}
|
|
</ul>
|
|
))}
|
|
|
|
{tabCrm === 'apartados' &&
|
|
(resumen.misApartados.length === 0 ? (
|
|
<EstadoVacio mensaje="Sin apartados asignados" />
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{resumen.misApartados.map((a) => (
|
|
<FilaApartado
|
|
key={a.id}
|
|
apartado={a}
|
|
enviando={enviando}
|
|
onFase={(fase) => void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')}
|
|
/>
|
|
))}
|
|
</ul>
|
|
))}
|
|
</section>
|
|
|
|
<section aria-label="Mis capturas de afluencia">
|
|
<TituloSeccion>Mis capturas de afluencia</TituloSeccion>
|
|
{resumen.misCapturas.length === 0 ? (
|
|
<EstadoVacio mensaje="Aún no registras afluencias" />
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{resumen.misCapturas.map((c) => (
|
|
<li
|
|
key={c.leadId}
|
|
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
|
>
|
|
<div>
|
|
<p className="flex items-center gap-2 text-sm font-medium">
|
|
{c.nombre}
|
|
{c.pendienteSync === true && <ChipPendienteSync />}
|
|
</p>
|
|
<p className="mt-1 text-xs text-[#64748B]">
|
|
{c.telefono || 'Sin teléfono'}
|
|
{c.origen ? ` · ${c.origen}` : ''}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
|
|
{c.estado || '—'}
|
|
</span>
|
|
<span className="text-xs font-medium text-[#64748B]">
|
|
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section aria-label="Mis clientes">
|
|
<TituloSeccion>Mis clientes</TituloSeccion>
|
|
{resumen.misClientes.length === 0 ? (
|
|
<EstadoVacio mensaje="Aún no registras clientes" />
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{resumen.misClientes.map((c) => (
|
|
<li
|
|
key={c.id}
|
|
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-medium">{c.nombre}</p>
|
|
<p className="mt-1 text-xs text-[#64748B]">
|
|
{[c.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{/* Estado del apartado en Monday (bidireccional); solo si hay apartado ligado */}
|
|
{c.estadoApartado !== null && (
|
|
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
|
{c.estadoApartado}
|
|
</span>
|
|
)}
|
|
<span
|
|
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
|
c.completado
|
|
? 'bg-gray-100 text-gray-600'
|
|
: 'bg-amber-50 text-amber-700'
|
|
}`}
|
|
>
|
|
{c.completado ? 'Cliente' : 'En captura'}
|
|
</span>
|
|
<span className="text-xs font-medium text-[#64748B]">
|
|
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
<aside className="order-1 flex flex-col gap-6 lg:order-2 lg:sticky lg:top-20 lg:self-start">
|
|
<section aria-label="Mis actividades">
|
|
<TituloSeccion>Mis actividades</TituloSeccion>
|
|
{resumen.misActividadesHoy.length === 0 ? (
|
|
<EstadoVacio mensaje="Sin actividades para hoy" />
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{resumen.misActividadesHoy.map((a) => (
|
|
<li
|
|
key={a.id}
|
|
className="flex items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
|
>
|
|
<p className="text-sm">
|
|
{a.nombre}
|
|
{a.tipo ? <span className="ml-2 text-xs uppercase tracking-wider text-[#94A3B8]">{a.tipo}</span> : null}
|
|
</p>
|
|
<span
|
|
className={`text-xs font-medium ${
|
|
esActividadVencida(a.fecha, hoy) ? 'text-red-600' : 'text-[#64748B]'
|
|
}`}
|
|
>
|
|
{formatoFechaCorta(a.fecha)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{resumen.apartadosPorVencer.length > 0 && (
|
|
<section aria-label="Apartados por vencer">
|
|
<TituloSeccion>Apartados por vencer</TituloSeccion>
|
|
<ul className="space-y-2">
|
|
{resumen.apartadosPorVencer.map((a) => (
|
|
<li
|
|
key={a.id}
|
|
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
|
>
|
|
<p className="text-sm">
|
|
{a.nombre}
|
|
{a.unidadCodigo ? <span className="ml-2 text-xs text-[#94A3B8]">{a.unidadCodigo}</span> : null}
|
|
</p>
|
|
<span
|
|
className={`text-xs font-medium ${
|
|
a.diasRestantes <= 1 ? 'text-red-600' : 'text-amber-600'
|
|
}`}
|
|
>
|
|
{textoDiasRestantes(a.diasRestantes)} · {formatoFechaCorta(a.fechaLimite)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
<section aria-label="Captura rápida de lead">
|
|
<TituloSeccion>Captura rápida de lead</TituloSeccion>
|
|
<form
|
|
onSubmit={enviarCaptura}
|
|
className="grid gap-4 rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
|
|
>
|
|
<div>
|
|
<label htmlFor="cap-nombre" className={CLASE_LABEL}>
|
|
Nombre
|
|
</label>
|
|
<input
|
|
id="cap-nombre"
|
|
type="text"
|
|
value={capNombre}
|
|
onChange={(e) => setCapNombre(e.target.value)}
|
|
required
|
|
className={CLASE_INPUT}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="cap-telefono" className={CLASE_LABEL}>
|
|
Teléfono
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<select
|
|
aria-label="Lada"
|
|
value={capLada}
|
|
onChange={(e) => setCapLada(e.target.value as ClaveLada)}
|
|
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
|
>
|
|
{LADAS.map((l) => (
|
|
<option key={l.clave} value={l.clave}>
|
|
{l.etiqueta}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
id="cap-telefono"
|
|
type="tel"
|
|
placeholder="664 123 4567"
|
|
value={capTelefono}
|
|
onChange={(e) => setCapTelefono(e.target.value)}
|
|
className={CLASE_INPUT}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="cap-email" className={CLASE_LABEL}>
|
|
Email
|
|
</label>
|
|
<input
|
|
id="cap-email"
|
|
type="email"
|
|
value={capEmail}
|
|
onChange={(e) => setCapEmail(e.target.value)}
|
|
className={CLASE_INPUT}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="cap-origen" className={CLASE_LABEL}>
|
|
Origen
|
|
</label>
|
|
<select
|
|
id="cap-origen"
|
|
value={capOrigen}
|
|
onChange={(e) => setCapOrigen(e.target.value)}
|
|
className={CLASE_INPUT}
|
|
>
|
|
{ORIGENES_LEAD.map((o) => (
|
|
<option key={o} value={o}>
|
|
{o}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="cap-desarrollo" className={CLASE_LABEL}>
|
|
Desarrollo de interés
|
|
</label>
|
|
<select
|
|
id="cap-desarrollo"
|
|
value={capDesarrollo}
|
|
onChange={(e) => setCapDesarrollo(e.target.value)}
|
|
className={CLASE_INPUT}
|
|
>
|
|
<option value="">Sin especificar</option>
|
|
{DESARROLLOS_LEAD.map((d) => (
|
|
<option key={d} value={d}>
|
|
{d}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="cap-notas" className={CLASE_LABEL}>
|
|
Notas
|
|
</label>
|
|
<input
|
|
id="cap-notas"
|
|
type="text"
|
|
value={capNotas}
|
|
onChange={(e) => setCapNotas(e.target.value)}
|
|
className={CLASE_INPUT}
|
|
/>
|
|
</div>
|
|
{capMensaje && (
|
|
<p
|
|
role="status"
|
|
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
|
|
capMensaje.tipo === 'ok'
|
|
? 'border-green-200 bg-green-50 text-green-700'
|
|
: 'border-red-200 bg-red-50 text-red-700'
|
|
}`}
|
|
>
|
|
{capMensaje.texto}
|
|
</p>
|
|
)}
|
|
<div>
|
|
<button
|
|
type="submit"
|
|
disabled={capEnviando}
|
|
className="w-full rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
|
>
|
|
{capEnviando ? 'Registrando…' : 'Registrar lead'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
|
|
{esRolConEquipo(rol) && resumen.equipo && (
|
|
<section aria-label="Equipo" className="mt-6">
|
|
<TituloSeccion>Equipo</TituloSeccion>
|
|
<p className="mb-4 text-sm text-[#64748B]">
|
|
Leads nuevos sin asignar: <strong>{resumen.equipo.leadsNuevosSinAsignar}</strong>
|
|
</p>
|
|
{resumen.equipo.porVendedor.length === 0 ? (
|
|
<EstadoVacio mensaje="Sin leads asignados en el equipo" />
|
|
) : (
|
|
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
{resumen.equipo.porVendedor.map((v) => (
|
|
<li
|
|
key={v.mondayUserId}
|
|
className="flex items-center justify-between rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
|
>
|
|
<p className="text-sm">{v.nombre ?? `Monday #${v.mondayUserId}`}</p>
|
|
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600">
|
|
{v.leadsAsignados} lead(s)
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
)}
|
|
</>
|
|
)}
|
|
</main>
|
|
|
|
{descartando && (
|
|
<ModalDescartar
|
|
lead={descartando}
|
|
enviando={enviando}
|
|
onCancelar={() => setDescartando(null)}
|
|
onConfirmar={(motivo, subcategoria, comentario) => {
|
|
const lead = descartando
|
|
setDescartando(null)
|
|
void ejecutar(
|
|
() => descartarLead(lead.id, motivo, comentario === '' ? undefined : comentario, lead.fuente, subcategoria),
|
|
'No se pudo descartar el lead',
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|