14 servicios eliminados, 7 controllers local-only, crons locales, config sin MONDAY_*, frontend sin banners/avisos Monday. 552 tests API + 7 apps construidas.
1688 lines
74 KiB
TypeScript
1688 lines
74 KiB
TypeScript
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
|
import KpiCard from '../components/KpiCard'
|
|
import DarkModeToggle from '../components/DarkModeToggle'
|
|
import { SkeletonList } from "../components/Skeleton"
|
|
import toast from "react-hot-toast"
|
|
import type { AccionesSesion } from '../components/AuthGate'
|
|
import {
|
|
esActividadVencida,
|
|
esRolConEquipo,
|
|
formatoFechaCorta,
|
|
hoyLocal,
|
|
DESARROLLOS_LEAD,
|
|
ESTADOS_LEAD,
|
|
ETIQUETA_FASE_APARTADO,
|
|
ETIQUETA_FASE_ESCRITURACION,
|
|
ETIQUETA_FASE_OPORTUNIDAD,
|
|
FASES_APARTADO,
|
|
FASES_ESCRITURACION,
|
|
FASES_OPORTUNIDAD,
|
|
ORIGENES_LEAD,
|
|
SLOTS_DOCS_APARTADO,
|
|
componerTelefono,
|
|
LADAS,
|
|
textoDiasRestantes,
|
|
urlAfluenciaParaOportunidad,
|
|
urlCotizadorParaOportunidad,
|
|
urlWhatsApp,
|
|
type ApartadoMiDia,
|
|
type ClaveLada,
|
|
type FaseApartado,
|
|
type LeadMiDia,
|
|
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 { guardarResumen, cargarResumenLocal } from '../services/offline-db'
|
|
import { suscribirPush } from '../services/push'
|
|
import { exportarLeads, exportarOportunidades, exportarApartados } from '../services/exportar'
|
|
import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, type FiltrosCRM } from '../components/FiltrosAvanzados'
|
|
import { getSesion } from '../services/auth'
|
|
import DashboardVendedor from '../components/DashboardVendedor'
|
|
import KanbanView from '../components/KanbanView'
|
|
import CalendarioCitas from '../components/CalendarioCitas'
|
|
import DetalleLeadModal from '../components/DetalleLeadModal'
|
|
|
|
function TituloSeccion({ children }: { children: string }) {
|
|
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
|
|
}
|
|
|
|
/** Banner de resumen matutino: saludo + KPIs del día */
|
|
function ResumenMatutino({
|
|
nombre,
|
|
leadsNuevos,
|
|
oportunidadesActivas,
|
|
apartadosPorVencer,
|
|
}: {
|
|
nombre: string
|
|
leadsNuevos: number
|
|
oportunidadesActivas: number
|
|
apartadosPorVencer: number
|
|
}) {
|
|
const hora = new Date().getHours()
|
|
const esManana = hora < 12
|
|
const saludo = esManana ? 'Buenos días' : 'Buenas tardes'
|
|
const icono = esManana ? '☀️' : '🌙'
|
|
return (
|
|
<div className="mb-6 rounded-xl bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-950/40 dark:to-blue-900/30 border border-blue-200 dark:border-blue-800/40 p-4 shadow-sm">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-2xl">{icono}</span>
|
|
<div>
|
|
<p className="text-base font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
|
{saludo}, {nombre}
|
|
</p>
|
|
<p className="mt-0.5 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
Tienes {leadsNuevos} lead{leadsNuevos !== 1 ? 's' : ''} nuevos hoy,{' '}
|
|
{oportunidadesActivas} oportunidade{oportunidadesActivas !== 1 ? 's' : ''} activa{oportunidadesActivas !== 1 ? 's' : ''},{' '}
|
|
{apartadosPorVencer} apartado{apartadosPorVencer !== 1 ? 's' : ''} por vencer
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EstadoVacio({ mensaje }: { mensaje: string }) {
|
|
return (
|
|
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white dark:border-[#334155] dark:bg-[#1E293B]">
|
|
<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 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 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 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 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] dark:text-[#CBD5E1]'
|
|
|
|
/** Motivos de no compra: valores EXACTOS que valida el API (z.enum MOTIVOS_NO_COMPRA) */
|
|
const MOTIVOS_NO_COMPRA = ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'] as const
|
|
|
|
/** Teléfono clicable (tel:) + botón de WhatsApp con saludo FRASA prellenado */
|
|
function EnlacesContacto({ nombre, telefono }: { nombre: string; telefono: string }) {
|
|
const vendedor = getSesion()?.usuario.nombre ?? ''
|
|
if (telefono.trim() === '') return <span>Sin teléfono</span>
|
|
return (
|
|
<>
|
|
<a
|
|
href={`tel:${telefono.replace(/\s+/g, '')}`}
|
|
className="text-accent hover:underline"
|
|
>
|
|
{telefono}
|
|
</a>
|
|
<a
|
|
href={urlWhatsApp(nombre, telefono, vendedor)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title={`Enviar WhatsApp a ${nombre}`}
|
|
aria-label={`Enviar WhatsApp a ${nombre}`}
|
|
className="ml-1.5 hover:underline"
|
|
>
|
|
💬
|
|
</a>
|
|
</>
|
|
)
|
|
}
|
|
|
|
|
|
/** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */
|
|
function FilaOportunidad({
|
|
op,
|
|
onEtapa,
|
|
onCotizar,
|
|
onAfluencia,
|
|
onDescartar,
|
|
onVerDetalle,
|
|
enviando,
|
|
acciones,
|
|
}: {
|
|
op: OportunidadMiDia
|
|
onEtapa: (etapa: string, fecha?: string) => void
|
|
onCotizar: () => void
|
|
onAfluencia: () => void
|
|
onDescartar: () => void
|
|
onVerDetalle?: () => void
|
|
enviando: boolean
|
|
acciones?: string[]
|
|
}) {
|
|
const [conCita, setConCita] = useState(!!op.fechaCita)
|
|
const [fechaCita, setFechaCita] = useState(op.fechaCita ?? '')
|
|
const fases = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto'] as const
|
|
return (
|
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] 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">
|
|
<button
|
|
type="button"
|
|
onClick={onVerDetalle}
|
|
className="text-left text-accent hover:underline cursor-pointer"
|
|
>
|
|
{op.nombre}
|
|
</button>
|
|
|
|
</p>
|
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
<EnlacesContacto nombre={op.nombre} telefono={op.telefono} />
|
|
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
|
|
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
|
|
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
|
|
</p>
|
|
{op.fechaCita && (
|
|
<p className="mt-0.5 text-[11px] text-accent font-medium">
|
|
📅 Cita: {op.fechaCita.replace('T', ' ')}
|
|
</p>
|
|
)}
|
|
{op.ultimoCambio && (
|
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
|
|
Ú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-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
|
{/* 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 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
|
|
>
|
|
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="datetime-local"
|
|
aria-label="Fecha y hora de la cita"
|
|
value={fechaCita}
|
|
onChange={(e) => setFechaCita(e.target.value)}
|
|
className="rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 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 || (acciones !== undefined && !acciones.includes('descartar_lead'))}
|
|
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 dark:bg-[#0F172A] dark:border-red-800 dark:text-red-400"
|
|
>
|
|
Descartar
|
|
</button>
|
|
</div>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
/** Fila de apartado con botones de fase del pipeline Enkontrol */
|
|
function ModalDescarteGenerico({
|
|
item,
|
|
titulo,
|
|
onCancelar,
|
|
onConfirmar,
|
|
enviando,
|
|
}: {
|
|
item: { id: string; nombre: string }
|
|
titulo: string
|
|
onCancelar: () => void
|
|
onConfirmar: (motivo: string, subcategoria: string | undefined, comentario: string) => void
|
|
enviando: boolean
|
|
}) {
|
|
const [motivo, setMotivo] = useState<string>('Timing')
|
|
const [subcategoria] = useState('')
|
|
const [comentario, setComentario] = useState('')
|
|
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 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
|
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p>
|
|
<form
|
|
className="mt-6 space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
onConfirmar(motivo, subcategoria === '' ? undefined : subcategoria, comentario.trim())
|
|
}}
|
|
>
|
|
<div>
|
|
<label htmlFor="motivo-cancel" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Motivo</label>
|
|
<select
|
|
id="motivo-cancel"
|
|
value={motivo}
|
|
onChange={(e) => setMotivo(e.target.value)}
|
|
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
|
|
>
|
|
{MOTIVOS_NO_COMPRA.map((m) => (
|
|
<option key={m} value={m}>{m === 'Ubicacion' ? 'Ubicación' : m}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="comentario-cancel" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Comentario (opcional)</label>
|
|
<textarea
|
|
id="comentario-cancel"
|
|
value={comentario}
|
|
onChange={(e) => setComentario(e.target.value)}
|
|
rows={3}
|
|
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
|
|
/>
|
|
</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] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 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 hover:bg-red-700 disabled:opacity-50">
|
|
{enviando ? 'Procesando…' : 'Confirmar'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FilaApartado({
|
|
apartado,
|
|
onFase,
|
|
onVerDetalle,
|
|
enviando,
|
|
}: {
|
|
apartado: ApartadoMiDia
|
|
onFase: (fase: FaseApartado) => void
|
|
onVerDetalle?: () => void
|
|
enviando: boolean
|
|
}) {
|
|
const [docsAbiertos, setDocsAbiertos] = useState(false)
|
|
return (
|
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] 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">
|
|
<button
|
|
type="button"
|
|
onClick={onVerDetalle}
|
|
className="text-left text-accent hover:underline cursor-pointer"
|
|
>
|
|
{apartado.nombre}
|
|
</button>
|
|
|
|
{apartado.unidadCodigo ? (
|
|
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
|
) : null}
|
|
</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>
|
|
{apartado.ultimoCambio && (
|
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
|
|
Ú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-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
|
{FASES_APARTADO.map((fase) => (
|
|
<button
|
|
key={fase}
|
|
type="button"
|
|
disabled={enviando}
|
|
onClick={() => {
|
|
// Alta Enkontrol: abre el alta de cliente prellenada con
|
|
// todo lo recolectado del apartado (ligada, sin duplicar).
|
|
// En pestaña nueva para no perder el estado de Mi día.
|
|
if (fase === 'Alta Enkontrol') {
|
|
window.open(`/client/?apartado=${apartado.id}`, '_blank')
|
|
return
|
|
}
|
|
if (fase === 'Cancelacion Apartado') {
|
|
onFase(fase)
|
|
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 dark:bg-[#0F172A] dark:border-red-800 dark:text-red-400'
|
|
: fase === 'Firma Cliente'
|
|
? 'rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-green-700 disabled:opacity-50'
|
|
: fase === 'Alta Enkontrol'
|
|
? '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 === 'Alta Enkontrol' ? 'Alta Enkontrol →' : 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 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] 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] dark:border-[#334155] dark:bg-[#0F172A] p-4">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
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] dark:text-[#CBD5E1]">
|
|
{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 dark:bg-[#0F172A] dark:border-accent dark:text-accent ${
|
|
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] dark:text-[#94A3B8]">
|
|
{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 / Mi tablero / Calendario */
|
|
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero' | 'calendario'
|
|
|
|
function TabsCrm({
|
|
activa,
|
|
conteos,
|
|
onCambiar,
|
|
vistaKanban,
|
|
onToggleKanban,
|
|
}: {
|
|
activa: TabCrm
|
|
conteos: Record<TabCrm, number>
|
|
onCambiar: (tab: TabCrm) => void
|
|
vistaKanban: boolean
|
|
onToggleKanban: () => void
|
|
}) {
|
|
const tabs: { id: TabCrm; titulo: string }[] = [
|
|
{ id: 'leads', titulo: 'Leads' },
|
|
{ id: 'oportunidades', titulo: 'Oportunidades' },
|
|
{ id: 'apartados', titulo: 'Apartados' },
|
|
{ id: 'escrituracion', titulo: 'Escrituración' },
|
|
{ id: 'tablero', titulo: 'Tablero' },
|
|
{ id: 'calendario', titulo: 'Calendario' },
|
|
]
|
|
return (
|
|
<div className="mb-4 flex items-center gap-1 overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white p-1 dark:border-[#1E293B] dark:bg-[#1E293B] 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-3 py-1.5 text-xs font-semibold uppercase tracking-wider transition-colors duration-100 md:px-4 md:py-2 ${
|
|
activo ? 'bg-accent text-white' : 'text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] hover:text-accent'
|
|
}`}
|
|
>
|
|
{tab.titulo}
|
|
{conteos[tab.id] != null && (
|
|
<span className={`ml-1.5 font-normal ${activo ? 'text-blue-100' : 'text-[#94A3B8]'}`}>
|
|
{conteos[tab.id]}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
{activa !== 'tablero' && (
|
|
<div className="ml-auto flex gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={onToggleKanban}
|
|
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-[11px] font-medium text-[#374151] shadow-sm transition-all hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#1E293B]"
|
|
>
|
|
{vistaKanban ? 'Lista' : 'Kanban'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</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,
|
|
onVerDetalle,
|
|
enviando,
|
|
acciones,
|
|
}: {
|
|
lead: LeadMiDia
|
|
onAccion: (estado: string, fecha?: string) => void
|
|
onConvertir: () => void
|
|
onDescartar: () => void
|
|
onVerDetalle?: () => void
|
|
enviando: boolean
|
|
acciones?: string[]
|
|
}) {
|
|
return (
|
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] 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">
|
|
<button
|
|
type="button"
|
|
onClick={onVerDetalle}
|
|
className="text-left text-accent hover:underline cursor-pointer"
|
|
>
|
|
{lead.nombre}
|
|
</button>
|
|
|
|
{/* 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] dark:text-[#94A3B8]">
|
|
<EnlacesContacto nombre={lead.nombre} telefono={lead.telefono} />
|
|
{lead.desarrollo ? ` · ${lead.desarrollo}` : ''}
|
|
{lead.origen ? ` · ${lead.origen}` : ''}
|
|
</p>
|
|
{lead.ultimoCambio && (
|
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
|
|
Ú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-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
|
<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 bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-green-700 disabled:opacity-50"
|
|
>
|
|
Agendo →
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={enviando || (acciones !== undefined && !acciones.includes("descartar_lead"))}
|
|
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 dark:bg-[#0F172A] dark:border-red-800 dark:text-red-400"
|
|
>
|
|
Descartar
|
|
</button>
|
|
</div>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
export default function MiDiaPage({ accionesSesion }: { accionesSesion: AccionesSesion }) {
|
|
const nombreVendedor = getSesion()?.usuario.nombre ?? ''
|
|
const rol = getSesion()?.usuario.rol ?? 'vendedor'
|
|
const acciones = getSesion()?.usuario.acciones ?? []
|
|
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 [leadSeleccionado, setLeadSeleccionado] = useState<LeadMiDia | OportunidadMiDia | ApartadoMiDia | null>(null)
|
|
const [apartadoCancelando, setApartadoCancelando] = useState<{ id: string; nombre: string } | null>(null)
|
|
// Filtros independientes por tab: cada pestaña conserva su propia búsqueda
|
|
const [filtrosPorTab, setFiltrosPorTab] = useState<Record<TabCrm, FiltrosCRM>>({
|
|
leads: FILTROS_VACIOS,
|
|
oportunidades: FILTROS_VACIOS,
|
|
apartados: FILTROS_VACIOS,
|
|
escrituracion: FILTROS_VACIOS,
|
|
tablero: FILTROS_VACIOS,
|
|
calendario: FILTROS_VACIOS,
|
|
})
|
|
const filtros = filtrosPorTab[tabCrm]
|
|
const actualizarFiltros = (f: FiltrosCRM) => setFiltrosPorTab((prev) => ({ ...prev, [tabCrm]: f }))
|
|
// Estados posibles por tab: alimenta el select "Estado" de FiltrosAvanzados
|
|
const estadosPorTab: Partial<Record<TabCrm, readonly string[]>> = {
|
|
leads: ESTADOS_LEAD,
|
|
oportunidades: FASES_OPORTUNIDAD,
|
|
apartados: FASES_APARTADO,
|
|
escrituracion: FASES_ESCRITURACION,
|
|
}
|
|
const [offline, setOffline] = useState(!navigator.onLine)
|
|
const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
|
|
|
|
// Kanban toggle
|
|
const [vistaKanban, setVistaKanban] = useState(false)
|
|
// Configuración de columnas por pestaña. Los IDs son los valores EXACTOS que
|
|
// valida el API (z.enum en los controllers). NUEVO/DESCARTADO/PERDIDO son
|
|
// columnas de solo-display: su drop abre el modal de descarte o no hace nada.
|
|
const columnasLeads = [
|
|
{ id: 'NUEVO', titulo: 'Nuevo', color: 'border-t-gray-400' },
|
|
{ id: 'CONTACTADO', titulo: 'Contactado', color: 'border-t-blue-500' },
|
|
{ id: 'Intento de contacto', titulo: 'Intento de contacto', color: 'border-t-sky-500' },
|
|
{ id: 'INTERESADO', titulo: 'Interesado', color: 'border-t-yellow-500' },
|
|
{ id: 'AGENDA CITA', titulo: 'Agenda Cita', color: 'border-t-green-500' },
|
|
{ id: 'CALIFICADO', titulo: 'Calificado', color: 'border-t-teal-500' },
|
|
{ id: 'DESCARTADO', titulo: 'Descartado', color: 'border-t-red-500' },
|
|
]
|
|
const columnasOportunidades = [
|
|
{ id: 'Afluencia', titulo: 'Afluencia', color: 'border-t-purple-500' },
|
|
{ id: 'Cotizador', titulo: 'Cotizador', color: 'border-t-blue-500' },
|
|
{ id: 'Agendo Cita', titulo: 'Agendó Cita', color: 'border-t-green-500' },
|
|
{ id: 'Aparto', titulo: 'Apartó', color: 'border-t-emerald-600' },
|
|
{ id: 'No Asistio', titulo: 'No Asistió', color: 'border-t-orange-500' },
|
|
{ id: 'Abandono', titulo: 'Abandono', color: 'border-t-yellow-600' },
|
|
{ id: 'PERDIDO', titulo: 'Perdido', color: 'border-t-red-500' },
|
|
]
|
|
const columnasApartados = [
|
|
{ id: 'Pago Apartado', titulo: 'Pago Apartado', color: 'border-t-blue-500' },
|
|
{ id: 'Alta Enkontrol', titulo: 'Alta Enkontrol', color: 'border-t-yellow-500' },
|
|
{ id: 'Entrego Documentacion', titulo: 'Entregó Docs', color: 'border-t-orange-500' },
|
|
{ id: 'Cita Clientes', titulo: 'Cita Clientes', color: 'border-t-green-500' },
|
|
{ id: 'Contrato Enkontrol', titulo: 'Contrato Enkontrol', color: 'border-t-emerald-600' },
|
|
{ id: 'Firma Cliente', titulo: 'Firma Cliente', color: 'border-t-accent' },
|
|
{ id: 'Cancelacion Apartado', titulo: 'Cancelación', color: 'border-t-red-500' },
|
|
]
|
|
const columnasEscrituracion = [
|
|
{ id: 'Venta', titulo: 'Venta', color: 'border-t-gray-400' },
|
|
{ id: 'Seguimiento', titulo: 'Seguimiento', color: 'border-t-blue-500' },
|
|
{ id: 'Integracion', titulo: 'Integración', color: 'border-t-yellow-500' },
|
|
{ id: 'X Autorizar', titulo: 'X Autorizar', color: 'border-t-orange-500' },
|
|
{ id: 'Autorizado', titulo: 'Autorizado', color: 'border-t-green-500' },
|
|
{ id: 'X Escriturar', titulo: 'X Escriturar', color: 'border-t-emerald-500' },
|
|
{ id: 'Escriturado', titulo: 'Escriturado', color: 'border-t-emerald-600' },
|
|
{ id: 'Titulado', titulo: 'Titulado', color: 'border-t-purple-600' },
|
|
{ id: 'Cancelo', titulo: 'Canceló', color: 'border-t-red-500' },
|
|
]
|
|
const obtenerColumnaLead = (lead: LeadMiDia) => {
|
|
const e = (lead.estado ?? '').toUpperCase()
|
|
if (e.includes('DESCART')) return 'DESCARTADO'
|
|
if (e.includes('AGENDO') || e.includes('CITA')) return 'AGENDA CITA'
|
|
if (e.includes('CALIFIC')) return 'CALIFICADO'
|
|
if (e.includes('INTENTO')) return 'Intento de contacto'
|
|
if (e.includes('INTERES')) return 'INTERESADO'
|
|
if (e.includes('CONTACT')) return 'CONTACTADO'
|
|
return 'NUEVO'
|
|
}
|
|
const obtenerColumnaOp = (op: OportunidadMiDia) => {
|
|
const e = (op.etapa ?? '').toUpperCase()
|
|
if (e.includes('PERDIDO') || e.includes('PERDI')) return 'PERDIDO'
|
|
if (e.includes('ASISTIO')) return 'No Asistio'
|
|
if (e.includes('ABANDONO')) return 'Abandono'
|
|
if (e.includes('APARTO')) return 'Aparto'
|
|
if (e.includes('COTIZ')) return 'Cotizador'
|
|
if (e.includes('AGENDO') || e.includes('CITA')) return 'Agendo Cita'
|
|
return 'Afluencia'
|
|
}
|
|
const obtenerColumnaApartado = (a: ApartadoMiDia) => {
|
|
const e = (a.estado ?? '').toUpperCase()
|
|
if (e.includes('PAGO')) return 'Pago Apartado'
|
|
if (e.includes('ALTA') && e.includes('ENKONTROL')) return 'Alta Enkontrol'
|
|
if (e.includes('ENTREG') || e.includes('DOC')) return 'Entrego Documentacion'
|
|
if (e.includes('CITA')) return 'Cita Clientes'
|
|
if (e.includes('CONTRATO')) return 'Contrato Enkontrol'
|
|
if (e.includes('FIRMA')) return 'Firma Cliente'
|
|
if (e.includes('CANCEL')) return 'Cancelacion Apartado'
|
|
return 'Pago Apartado'
|
|
}
|
|
const obtenerColumnaEscrituracion = (a: ApartadoMiDia) => {
|
|
const e = (a.estado ?? '').toUpperCase()
|
|
if (e.includes('VENTA')) return 'Venta'
|
|
if (e.includes('SEGUIMIENTO')) return 'Seguimiento'
|
|
if (e.includes('INTEGRACION')) return 'Integracion'
|
|
if (e.includes('X AUTORIZAR')) return 'X Autorizar'
|
|
if (e.includes('AUTORIZADO')) return 'Autorizado'
|
|
if (e.includes('X ESCRITURAR')) return 'X Escriturar'
|
|
if (e.includes('ESCRITURADO')) return 'Escriturado'
|
|
if (e.includes('TITULADO')) return 'Titulado'
|
|
if (e.includes('CANCEL')) return 'Cancelo'
|
|
return 'Venta'
|
|
}
|
|
|
|
|
|
|
|
// 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 {
|
|
const datos = await fetchResumenMiDia()
|
|
setResumen(datos)
|
|
setError(null)
|
|
setOfflineFecha(null)
|
|
// Fire-and-forget: guardar en IDB para uso offline
|
|
void guardarResumen(datos)
|
|
} catch (err) {
|
|
// Si falla el fetch, intentar datos cacheados
|
|
const cached = await cargarResumenLocal()
|
|
if (cached) {
|
|
setResumen(cached)
|
|
setOfflineFecha(new Date().toLocaleDateString('es-MX'))
|
|
setError(null)
|
|
} else {
|
|
setError(err instanceof Error ? err.message : 'No se pudo cargar tu día')
|
|
}
|
|
} finally {
|
|
setCargando(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
void cargar()
|
|
}, [cargar])
|
|
|
|
// Detectar online/offline
|
|
useEffect(() => {
|
|
const onOnline = () => { setOffline(false); void cargar() }
|
|
const onOffline = () => setOffline(true)
|
|
window.addEventListener('online', onOnline)
|
|
window.addEventListener('offline', onOffline)
|
|
return () => {
|
|
window.removeEventListener('online', onOnline)
|
|
window.removeEventListener('offline', onOffline)
|
|
}
|
|
}, [cargar])
|
|
|
|
// Suscribir a push notifications (fire-and-forget)
|
|
useEffect(() => {
|
|
if ('Notification' in window && Notification.permission === 'default') {
|
|
Notification.requestPermission().then((perm) => {
|
|
if (perm === 'granted') void suscribirPush()
|
|
})
|
|
} else if ('Notification' in window && Notification.permission === 'granted') {
|
|
void suscribirPush()
|
|
}
|
|
}, [])
|
|
|
|
/** 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) {
|
|
toast.error(mensajeError); 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)
|
|
try {
|
|
await convertirLead(lead.id)
|
|
await cargar()
|
|
} catch (err) {
|
|
toast.error(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 queda ligada a ese cliente). 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 {
|
|
toast.error('No se pudo actualizar la etapa; el cotizador se abrirá de todos modos.')
|
|
}
|
|
window.open(urlCotizadorParaOportunidad(op), '_blank')
|
|
}
|
|
|
|
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',
|
|
})
|
|
toast.success('Lead registrado correctamente')
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Listas filtradas por los filtros avanzados; las usan el render por pestaña
|
|
// Y el botón de exportar (para que la exportación respete los filtros activos)
|
|
const leadsFiltrados = resumen
|
|
? aplicarFiltros(resumen.misLeads.filter((l) => l.fuente === 'lead'), filtros, {
|
|
texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`,
|
|
origen: (l) => l.origen,
|
|
estado: (l) => l.estado,
|
|
fecha: (l) => l.updatedAt.slice(0, 10),
|
|
})
|
|
: []
|
|
const opsFiltradas = resumen
|
|
? aplicarFiltros(resumen.misOportunidades.items, filtros, {
|
|
texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`,
|
|
desarrollo: (o) => o.desarrollo,
|
|
estado: (o) => o.etapa,
|
|
fecha: (o) => o.updatedAt.slice(0, 10),
|
|
})
|
|
: []
|
|
const apartadosFiltrados = resumen
|
|
? aplicarFiltros(resumen.misApartados, filtros, {
|
|
texto: (a) => a.nombre,
|
|
estado: (a) => a.estado,
|
|
fecha: (a) => a.fechaLimite,
|
|
})
|
|
: []
|
|
|
|
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 dark:border-[#1E293B] dark:bg-[#1E293B]/95">
|
|
<div className="flex h-14 items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
|
<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] dark:text-[#CBD5E1] transition-colors hover:bg-[#F9FAFB] hover:text-accent"
|
|
>
|
|
← Menú
|
|
</a>
|
|
<p className="shrink-0 text-xs font-semibold uppercase tracking-wider text-accent">
|
|
CRM360 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] dark:text-[#E2E8F0] sm:block">{nombreVendedor}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
|
<DarkModeToggle />
|
|
<button
|
|
type="button"
|
|
onClick={accionesSesion.onCambiarPassword}
|
|
className="hidden rounded-md px-2 py-1.5 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0] md:inline-block"
|
|
>
|
|
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-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 md:px-4 md:py-2 md:text-sm"
|
|
>
|
|
{cargando ? 'Actualizando…' : 'Actualizar'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
|
|
|
|
{offline && (
|
|
<div className="px-4 pt-4 sm:px-6 lg:px-8">
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
|
|
Modo offline{offlineFecha ? ` — datos de ${offlineFecha}` : ''}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<main className={`px-4 py-6 sm:px-6 lg:px-8 ${vistaKanban ? 'w-full' : 'mx-auto max-w-[1280px]'}`}>
|
|
<div className="mb-6">
|
|
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{nombreVendedor}</h1>
|
|
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">{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>
|
|
)}
|
|
{!resumen && cargando && <SkeletonList count={4} />}
|
|
|
|
{resumen && (
|
|
<>
|
|
<ResumenMatutino
|
|
nombre={nombreVendedor}
|
|
leadsNuevos={resumen.misLeads.filter((l) => l.fuente === 'lead').length}
|
|
oportunidadesActivas={resumen.misOportunidades.items.length}
|
|
apartadosPorVencer={resumen.apartadosPorVencer.length}
|
|
/>
|
|
|
|
<section aria-label="Indicadores del día" className="grid gap-4 md: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 ${vistaKanban ? '' : 'md:grid md:grid-cols-2 md:gap-6 lg:grid-cols-3'}`}>
|
|
<div className={`order-2 flex flex-col gap-6 ${vistaKanban ? '' : 'md:order-1 md:col-span-2 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, escrituracion: resumen.misApartados.length, tablero: 0, calendario: 0,
|
|
}}
|
|
onCambiar={setTabCrm}
|
|
vistaKanban={vistaKanban}
|
|
onToggleKanban={() => setVistaKanban(!vistaKanban)}
|
|
|
|
/>
|
|
|
|
{tabCrm !== 'tablero' && (
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1">
|
|
<FiltrosAvanzados valor={filtros} onChange={actualizarFiltros} estados={estadosPorTab[tabCrm]} />
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (tabCrm === 'leads') exportarLeads(leadsFiltrados)
|
|
else if (tabCrm === 'oportunidades') exportarOportunidades(opsFiltradas)
|
|
else if (tabCrm === 'apartados') exportarApartados(apartadosFiltrados)
|
|
}}
|
|
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-xs font-medium text-[#374151] shadow-sm transition-all hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#1E293B]"
|
|
>
|
|
📥 Exportar
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{tabCrm === 'leads' && (
|
|
<>
|
|
{leadsFiltrados.length === 0 ? (
|
|
<EstadoVacio mensaje="Sin leads asignados" />
|
|
) : vistaKanban ? (
|
|
<KanbanView
|
|
items={leadsFiltrados}
|
|
columnas={columnasLeads}
|
|
obtenerColumna={obtenerColumnaLead}
|
|
onCambiarEstado={(id, estado) => {
|
|
if (estado === 'DESCARTADO') {
|
|
const lead = resumen.misLeads.find((l) => l.id === id)
|
|
if (lead) setDescartando(lead)
|
|
return
|
|
}
|
|
if (estado === 'NUEVO') return
|
|
void ejecutar(() => cambiarEstadoLead(id, estado, undefined, 'lead'), 'No se pudo actualizar')
|
|
}}
|
|
/>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{leadsFiltrados.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)}
|
|
onVerDetalle={() => setLeadSeleccionado(lead)}
|
|
acciones={acciones}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{tabCrm === 'oportunidades' && (
|
|
<>
|
|
{opsFiltradas.length === 0 ? (
|
|
<EstadoVacio mensaje="Sin oportunidades asignadas" />
|
|
) : vistaKanban ? (
|
|
<KanbanView
|
|
items={opsFiltradas}
|
|
columnas={columnasOportunidades}
|
|
obtenerColumna={obtenerColumnaOp}
|
|
onCambiarEstado={(id, estado) => {
|
|
if (estado === 'PERDIDO') {
|
|
const op = resumen.misOportunidades.items.find((o) => o.id === id)
|
|
if (op) setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })
|
|
return
|
|
}
|
|
void ejecutar(() => cambiarEtapaOportunidad(id, estado), 'No se pudo actualizar')
|
|
}}
|
|
/>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{opsFiltradas.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.open(urlAfluenciaParaOportunidad(op), '_blank')
|
|
}}
|
|
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
|
|
onVerDetalle={() => setLeadSeleccionado(op)}
|
|
acciones={acciones}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{tabCrm === 'apartados' && (
|
|
<>
|
|
{apartadosFiltrados.length === 0 ? (
|
|
<EstadoVacio mensaje="Sin apartados asignados" />
|
|
) : vistaKanban ? (
|
|
<KanbanView
|
|
items={apartadosFiltrados}
|
|
columnas={columnasApartados}
|
|
obtenerColumna={obtenerColumnaApartado}
|
|
onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')}
|
|
/>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{apartadosFiltrados.map((a) => (
|
|
<li
|
|
key={a.id}
|
|
>
|
|
<FilaApartado
|
|
apartado={a}
|
|
enviando={enviando}
|
|
onVerDetalle={() => setLeadSeleccionado(a)}
|
|
onFase={(fase) => {
|
|
if (fase === 'Firma Cliente') {
|
|
// Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración
|
|
void ejecutar(() => cambiarEstadoApartado(a.id, fase).then(() => setTabCrm('escrituracion')), 'No se pudo actualizar el apartado')
|
|
return
|
|
}
|
|
void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')
|
|
}}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{tabCrm === 'escrituracion' && (
|
|
<section aria-label="Escrituración">
|
|
<TituloSeccion>Escrituración</TituloSeccion>
|
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8] mb-4">
|
|
Seguimiento de escrituración de apartados.
|
|
</p>
|
|
{(() => {
|
|
// Filtrar: solo apartados en fases de escrituración o Firma Cliente
|
|
// Solo fases activas (Cancelo y Escriturado son terminales)
|
|
// Fases activas de escrituración (excluye Cancelo y Escriturado que son terminales)
|
|
const enEscrituracion = (resumen.todosApartados || resumen.misApartados || []).filter((a) => {
|
|
const e = (a.estado ?? '').toUpperCase()
|
|
return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('AUTORIZADO') || e.includes('X ESCRITURAR')
|
|
})
|
|
const apartadosEscrituracion = aplicarFiltros(
|
|
enEscrituracion,
|
|
filtros,
|
|
{
|
|
texto: (a) => a.nombre,
|
|
estado: (a) => a.estado,
|
|
fecha: (a) => a.fechaLimite,
|
|
},
|
|
)
|
|
return apartadosEscrituracion.length === 0 ? (
|
|
<EstadoVacio mensaje="No hay apartados en escrituración" />
|
|
) : vistaKanban ? (
|
|
<KanbanView
|
|
items={apartadosEscrituracion}
|
|
columnas={columnasEscrituracion}
|
|
obtenerColumna={obtenerColumnaEscrituracion}
|
|
onCambiarEstado={(id, estado) => {
|
|
if (estado === 'Cancelo') {
|
|
const a = apartadosEscrituracion.find((x) => x.id === id)
|
|
if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre })
|
|
return
|
|
}
|
|
void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')
|
|
}}
|
|
/>
|
|
) : (
|
|
<ul className="space-y-3">
|
|
{apartadosEscrituracion.map((apartado) => (
|
|
<li key={apartado.id} className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="font-medium text-[#111827] dark:text-[#E2E8F0]">{apartado.nombre}</p>
|
|
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">{apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
|
{(['Venta', 'Seguimiento', 'Integracion', 'X Autorizar', 'Autorizado', 'X Escriturar', 'Escriturado', 'Titulado', 'Cancelo'] as const).map((fase) => (
|
|
<button
|
|
key={fase}
|
|
type="button"
|
|
disabled={enviando}
|
|
onClick={() => {
|
|
if (fase === 'Cancelo') {
|
|
setApartadoCancelando({ id: apartado.id, nombre: apartado.nombre })
|
|
return
|
|
}
|
|
void ejecutar(() => cambiarEstadoApartado(apartado.id, fase), 'No se pudo actualizar')
|
|
}}
|
|
className={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'
|
|
: fase === 'Cancelo'
|
|
? '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 dark:bg-[#0F172A] dark:border-red-800 dark:text-red-400'
|
|
: CLASE_BOTON_ACCION}
|
|
>
|
|
{ETIQUETA_FASE_ESCRITURACION[fase]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)})()}
|
|
</section>
|
|
)}
|
|
|
|
{tabCrm === 'tablero' && (
|
|
<DashboardVendedor />
|
|
)}
|
|
|
|
{tabCrm === 'calendario' && (
|
|
<section aria-label="Calendario">
|
|
<CalendarioCitas
|
|
actividades={resumen.misActividadesHoy}
|
|
oportunidades={resumen.misOportunidades.items}
|
|
onVerDetalle={(op) => setLeadSeleccionado(op)}
|
|
/>
|
|
</section>
|
|
)}
|
|
</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 dark:bg-[#1E293B] dark:border-[#1E293B]"
|
|
>
|
|
<div>
|
|
<p className="flex items-center gap-2 text-sm font-medium">
|
|
{c.nombre}
|
|
|
|
</p>
|
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
{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] dark:text-[#94A3B8]">
|
|
{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 dark:bg-[#1E293B] dark:border-[#1E293B]"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-medium">{c.nombre}</p>
|
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
{[c.proyecto, c.unidadCodigo].filter((p) => p !== '').join(' · ') || 'Alta sin proyecto'}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{/* Estado del apartado; 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] dark:text-[#94A3B8]">
|
|
{formatoFechaCorta(c.capturadoEn.slice(0, 10))}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
<aside className={`order-1 flex flex-col gap-6 ${vistaKanban && tabCrm === 'leads' ? 'hidden' : 'md: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 dark:bg-[#1E293B] dark:border-[#1E293B]"
|
|
>
|
|
<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] dark:text-[#94A3B8]'
|
|
}`}
|
|
>
|
|
{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 dark:bg-[#1E293B] dark:border-[#1E293B]"
|
|
>
|
|
<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 dark:border-[#1E293B] dark:bg-[#1E293B] 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 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 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] dark:text-[#94A3B8]">
|
|
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.usuarioId}
|
|
className="flex items-center justify-between rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:bg-[#1E293B] dark:border-[#1E293B]"
|
|
>
|
|
<p className="text-sm">{v.nombre}</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 && (
|
|
<ModalDescarteGenerico
|
|
item={descartando}
|
|
titulo={descartando.fuente === 'oportunidad' ? 'Descartar oportunidad' : 'Descartar lead'}
|
|
enviando={enviando}
|
|
onCancelar={() => setDescartando(null)}
|
|
onConfirmar={(motivo, subcategoria, comentario) => {
|
|
const item = descartando
|
|
setDescartando(null)
|
|
void ejecutar(
|
|
() => descartarLead(item.id, motivo, comentario, item.fuente, subcategoria),
|
|
'No se pudo descartar el lead',
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{apartadoCancelando && (
|
|
<ModalDescarteGenerico
|
|
item={apartadoCancelando}
|
|
titulo="Cancelar apartado"
|
|
enviando={enviando}
|
|
onCancelar={() => setApartadoCancelando(null)}
|
|
onConfirmar={(motivo, subcategoria, comentario) => {
|
|
const item = apartadoCancelando
|
|
setApartadoCancelando(null)
|
|
void ejecutar(
|
|
() => cambiarEstadoApartado(item.id, 'Cancelacion Apartado', motivo, subcategoria, comentario),
|
|
'No se pudo cancelar el apartado',
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{leadSeleccionado && (
|
|
<DetalleLeadModal
|
|
item={leadSeleccionado}
|
|
onCerrar={() => setLeadSeleccionado(null)}
|
|
onGuardarCita={
|
|
'modeloInteres' in leadSeleccionado
|
|
? (fechaHora) => {
|
|
const op = leadSeleccionado
|
|
return ejecutar(
|
|
() => cambiarEtapaOportunidad(op.id, 'Agendo Cita', fechaHora),
|
|
'No se pudo guardar la cita',
|
|
)
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|