feat(ux): 17 mejoras de experiencia de usuario
Backend (10 endpoints nuevos): - Recordatorios personales (CRUD + tabla RecordatorioVendedor) - Lead scoring 0-100 (etapa+recencia+datos) - Dashboard mora (pagos vencidos, monto, días atraso) - Timeline de pagos (pagado/vence/futuro) - Perfil completo contacto (pagos+cotizaciones+docs+score) - Exportar contactos a CSV Frontend Mi día: - Resumen matutino (saludo+KPIs) - Historial visual lead (timeline) - Score badge 0-100 (verde/amarillo/rojo) - Vista kanban (drag & drop) Frontend Cobranza: - Dashboard mora (tabla+WhatsApp) - Timeline pagos visual (nodos coloreados) Frontend Alta Clientes: - Checklist documentos visual - Foto cliente (cámara) Frontend Landing: - Perfil contacto completo - Exportar contactos CSV Frontend Mi día (extras): - Comparativa mensual - KPIs dashboard (leads hoy, ventas, conversión) 806 tests, 5 apps construidas.
This commit is contained in:
106
apps/alta-clientes/src/components/ChecklistDocumentos.tsx
Normal file
106
apps/alta-clientes/src/components/ChecklistDocumentos.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/** Documento requerido en el checklist */
|
||||
export interface DocumentoChecklist {
|
||||
tipo: string
|
||||
nombre: string
|
||||
obligatorio: boolean
|
||||
}
|
||||
|
||||
const DOCUMENTOS_DEFAULT: DocumentoChecklist[] = [
|
||||
{ tipo: 'INE', nombre: 'INE / Identificación oficial', obligatorio: true },
|
||||
{ tipo: 'RFC', nombre: 'RFC', obligatorio: true },
|
||||
{ tipo: 'CURP', nombre: 'CURP', obligatorio: true },
|
||||
{ tipo: 'COMPROBANTE_DOMICILIO', nombre: 'Comprobante de domicilio', obligatorio: true },
|
||||
{ tipo: 'ACTA_NACIMIENTO', nombre: 'Acta de nacimiento', obligatorio: true },
|
||||
]
|
||||
|
||||
function iconoDoc(recibido: boolean, obligatorio: boolean): string {
|
||||
if (recibido) return '✅'
|
||||
return obligatorio ? '⏳' : '📄'
|
||||
}
|
||||
|
||||
interface ChecklistDocumentosProps {
|
||||
documentos?: DocumentoChecklist[]
|
||||
recibidos?: string[] // tipos ya marcados como recibidos
|
||||
onToggle?: (tipo: string, recibido: boolean) => void
|
||||
soloLectura?: boolean
|
||||
}
|
||||
|
||||
export default function ChecklistDocumentos({
|
||||
documentos = DOCUMENTOS_DEFAULT,
|
||||
recibidos: recibidosProp,
|
||||
onToggle,
|
||||
soloLectura = false,
|
||||
}: ChecklistDocumentosProps) {
|
||||
const [recibidosInterno, setRecibidosInterno] = useState<Set<string>>(() => new Set(recibidosProp ?? []))
|
||||
const recibidos = recibidosProp ? new Set(recibidosProp) : recibidosInterno
|
||||
|
||||
const toggle = (tipo: string) => {
|
||||
if (soloLectura) return
|
||||
const nuevo = new Set(recibidosInterno)
|
||||
if (nuevo.has(tipo)) nuevo.delete(tipo)
|
||||
else nuevo.add(tipo)
|
||||
setRecibidosInterno(nuevo)
|
||||
onToggle?.(tipo, nuevo.has(tipo))
|
||||
}
|
||||
|
||||
const total = documentos.length
|
||||
const completados = documentos.filter((d) => recibidos.has(d.tipo)).length
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Documentos requeridos
|
||||
</h3>
|
||||
<span className="rounded-full bg-blue-50 dark:bg-blue-900/30 px-2.5 py-0.5 text-xs font-semibold text-blue-700 dark:text-blue-400">
|
||||
{completados} de {total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Barra de progreso */}
|
||||
<div className="mt-3 h-2 rounded-full bg-gray-100 dark:bg-[#334155]">
|
||||
<div
|
||||
className="h-full rounded-full bg-green-500 transition-all duration-300"
|
||||
style={{ width: `${total > 0 ? (completados / total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{documentos.map((doc) => {
|
||||
const recibido = recibidos.has(doc.tipo)
|
||||
return (
|
||||
<li
|
||||
key={doc.tipo}
|
||||
onClick={() => toggle(doc.tipo)}
|
||||
className={`flex items-center gap-3 rounded-lg border px-4 py-3 transition-colors ${
|
||||
recibido
|
||||
? 'border-green-200 bg-green-50 dark:border-green-900/40 dark:bg-green-950/20'
|
||||
: 'border-[#E5E7EB] bg-white dark:border-[#334155] dark:bg-[#0F172A]'
|
||||
} ${soloLectura ? '' : 'cursor-pointer hover:border-accent'}`}
|
||||
>
|
||||
<span className="text-lg">{iconoDoc(recibido, doc.obligatorio)}</span>
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${recibido ? 'text-green-700 dark:text-green-400 line-through' : 'text-[#111827] dark:text-[#E2E8F0]'}`}>
|
||||
{doc.nombre}
|
||||
</p>
|
||||
{doc.obligatorio && !recibido && (
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400">Requerido</p>
|
||||
)}
|
||||
</div>
|
||||
{!soloLectura && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={recibido}
|
||||
onChange={() => toggle(doc.tipo)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
162
apps/alta-clientes/src/components/FotoCliente.tsx
Normal file
162
apps/alta-clientes/src/components/FotoCliente.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
interface FotoClienteProps {
|
||||
foto?: string | null // base64 data URL
|
||||
onFoto?: (base64: string) => void
|
||||
soloLectura?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Componente para capturar la foto del cliente (selfie frontal).
|
||||
* Usa cámara del dispositivo; fallback a input file si no hay cámara.
|
||||
*/
|
||||
export default function FotoCliente({ foto, onFoto, soloLectura = false }: FotoClienteProps) {
|
||||
const [preview, setPreview] = useState<string | null>(foto ?? null)
|
||||
const [mostrandoCamara, setMostrandoCamara] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const capturar = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!video || !canvas) return
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.drawImage(video, 0, 0)
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.8)
|
||||
setPreview(dataUrl)
|
||||
onFoto?.(dataUrl)
|
||||
// Detener cámara
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop())
|
||||
streamRef.current = null
|
||||
setMostrandoCamara(false)
|
||||
}, [onFoto])
|
||||
|
||||
const iniciarCamara = useCallback(async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } },
|
||||
})
|
||||
streamRef.current = stream
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream
|
||||
await videoRef.current.play()
|
||||
}
|
||||
setMostrandoCamara(true)
|
||||
} catch {
|
||||
setError('No se pudo acceder a la cámara. Use "Subir foto" como alternativa.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const detenerCamara = useCallback(() => {
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop())
|
||||
streamRef.current = null
|
||||
setMostrandoCamara(false)
|
||||
}, [])
|
||||
|
||||
const manejarArchivo = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const archivo = e.target.files?.[0]
|
||||
if (!archivo) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
setPreview(result)
|
||||
onFoto?.(result)
|
||||
}
|
||||
reader.readAsDataURL(archivo)
|
||||
e.target.value = ''
|
||||
},
|
||||
[onFoto],
|
||||
)
|
||||
|
||||
if (soloLectura) {
|
||||
return preview ? (
|
||||
<img src={preview} alt="Foto del cliente" className="h-24 w-24 rounded-lg object-cover" />
|
||||
) : (
|
||||
<p className="text-xs text-[#94A3B8]">Sin foto</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Preview */}
|
||||
{preview && !mostrandoCamara && (
|
||||
<div className="relative inline-block">
|
||||
<img src={preview} alt="Foto del cliente" className="h-32 w-32 rounded-lg object-cover border border-[#E5E7EB]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setPreview(null); onFoto?.('') }}
|
||||
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white hover:bg-red-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cámara */}
|
||||
{mostrandoCamara && (
|
||||
<div className="space-y-2">
|
||||
<div className="relative overflow-hidden rounded-lg border border-[#E5E7EB] dark:border-[#334155]">
|
||||
<video ref={videoRef} className="w-full max-w-xs rounded-lg" muted playsInline />
|
||||
</div>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={capturar}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white shadow-sm hover:bg-accent-hover"
|
||||
>
|
||||
Capturar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={detenerCamara}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-xs font-medium text-[#374151] hover:bg-[#F9FAFB] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botones de acción */}
|
||||
{!mostrandoCamara && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={iniciarCamara}
|
||||
className="rounded-lg border border-accent bg-white px-4 py-2 text-xs font-medium text-accent shadow-sm transition-all hover:bg-accent hover:text-white dark:bg-[#0F172A]"
|
||||
>
|
||||
📷 Tomar foto
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-xs font-medium text-[#374151] shadow-sm hover:bg-[#F9FAFB] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
|
||||
>
|
||||
📁 Subir foto
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="user"
|
||||
className="hidden"
|
||||
onChange={manejarArchivo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
apps/alta-clientes/src/data/cps.ts
Normal file
89
apps/alta-clientes/src/data/cps.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Base de datos local de códigos postales de la zona Frasa (Guanajuato / Querétaro).
|
||||
* CPs más comunes de las ciudades donde opera Frasa Desarrollos.
|
||||
* Para producción, reemplazar con una API de CPs de México (p. ej. sepomex).
|
||||
*/
|
||||
|
||||
export interface DireccionPorCP {
|
||||
cp: string
|
||||
colonia: string
|
||||
municipio: string
|
||||
estado: string
|
||||
}
|
||||
|
||||
const CP_DB: DireccionPorCP[] = [
|
||||
// León, Gto
|
||||
{ cp: '37000', colonia: 'Centro', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37150', colonia: 'Los Santos', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37200', colonia: 'La Aurora', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37250', colonia: 'El Potrero', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37270', colonia: 'Vista Hermosa', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37300', colonia: 'San José de los Duranes', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37350', colonia: 'San Francisco', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37400', colonia: 'El Coecillo', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37450', colonia: 'Revolución', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37500', colonia: 'La Tierrita', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37550', colonia: 'Los Freseros', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37600', colonia: 'San Juan de Abajo', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37650', colonia: 'San Felipe', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37670', colonia: 'El Palote', municipio: 'León', estado: 'Guanajuato' },
|
||||
{ cp: '37700', colonia: 'La Hacienda', municipio: 'León', estado: 'Guanajuato' },
|
||||
// Silao, Gto
|
||||
{ cp: '36100', colonia: 'Centro', municipio: 'Silao', estado: 'Guanajuato' },
|
||||
{ cp: '36110', colonia: 'San Francisco', municipio: 'Silao', estado: 'Guanajuato' },
|
||||
{ cp: '36120', colonia: 'El Tajo', municipio: 'Silao', estado: 'Guanajuato' },
|
||||
// Celaya, Gto
|
||||
{ cp: '38000', colonia: 'Centro', municipio: 'Celaya', estado: 'Guanajuato' },
|
||||
{ cp: '38010', colonia: 'San Antonio', municipio: 'Celaya', estado: 'Guanajuato' },
|
||||
{ cp: '38020', colonia: 'El Pueblito', municipio: 'Celaya', estado: 'Guanajuato' },
|
||||
{ cp: '38060', colonia: 'Las Américas', municipio: 'Celaya', estado: 'Guanajuato' },
|
||||
{ cp: '38070', colonia: 'Los Virreyes', municipio: 'Celaya', estado: 'Guanajuato' },
|
||||
// Querétaro, Qro
|
||||
{ cp: '76000', colonia: 'Centro', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76010', colonia: 'San Sebastián Buerenas', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76020', colonia: 'LaVertexAttribArray', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76030', colonia: 'San Francisco Galindo', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76040', colonia: 'San Pablo Xochimecas', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76060', colonia: 'Villas del Sol', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76070', colonia: 'Vista Alegre', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76080', colonia: 'El Paraíso', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76090', colonia: 'El Jacal', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76100', colonia: 'Los Aztecas', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76110', colonia: 'El Mirador', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76120', colonia: 'La Esperanza', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76125', colonia: 'Santa María de Aguayo', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76126', colonia: 'Villa los Álamos', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76127', colonia: 'Puerta del Sol', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76128', colonia: 'Vista Real', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76130', colonia: 'Los Robles', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76135', colonia: 'Arboledas del Cimatario', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76136', colonia: 'Condado de Sayavedra', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76137', colonia: 'Prados del Quetzal', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
{ cp: '76138', colonia: 'Lomas del Cimatario', municipio: 'Querétaro', estado: 'Querétaro' },
|
||||
// San Miguel de Allende, Gto
|
||||
{ cp: '37700', colonia: 'Centro', municipio: 'San Miguel de Allende', estado: 'Guanajuato' },
|
||||
// Irapuato, Gto
|
||||
{ cp: '36500', colonia: 'Centro', municipio: 'Irapuato', estado: 'Guanajuato' },
|
||||
{ cp: '36510', colonia: 'San José', municipio: 'Irapuato', estado: 'Guanajuato' },
|
||||
// Guanajuato Capital
|
||||
{ cp: '36000', colonia: 'Centro', municipio: 'Guanajuato', estado: 'Guanajuato' },
|
||||
// SLP
|
||||
{ cp: '78000', colonia: 'Centro', municipio: 'San Luis Potosí', estado: 'San Luis Potosí' },
|
||||
]
|
||||
|
||||
/** Busca dirección por CP (devuelve todas las colonias que coinciden) */
|
||||
export function buscarPorCP(cp: string): DireccionPorCP[] {
|
||||
return CP_DB.filter((e) => e.cp === cp)
|
||||
}
|
||||
|
||||
/** Autocompleta colonia/municipio/estado a partir de un CP de 5 dígitos */
|
||||
export function autocompletarPorCP(cp: string): { colonia: string; municipio: string; estado: string } | null {
|
||||
const resultados = buscarPorCP(cp)
|
||||
if (resultados.length === 0) return null
|
||||
// Si hay varias colonias para el mismo CP, usar la primera y concatenar
|
||||
return {
|
||||
colonia: resultados.map((r) => r.colonia).join(', '),
|
||||
municipio: resultados[0].municipio,
|
||||
estado: resultados[0].estado,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
import { CampoTexto, CampoSelect, CampoTelefono } from '../components/Campos'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import { compararConDatos } from '../lib/curp'
|
||||
import { ESTADOS_CIVILES, REGIMENES_BIENES, TIPOS_VIVIENDA, TIPOS_COMPRA } from '../data/constantes'
|
||||
import { autocompletarPorCP } from '../data/cps'
|
||||
|
||||
interface Props {
|
||||
prefix: string
|
||||
@@ -11,7 +13,7 @@ interface Props {
|
||||
|
||||
/** Campos de datos generales; reutilizable con prefijo (cliente y co-acreditado) */
|
||||
export default function DatosGeneralesCampos({ prefix, compacto = false }: Props) {
|
||||
const { watch } = useFormContext()
|
||||
const { watch, setValue } = useFormContext()
|
||||
const p = (campo: string) => `${prefix}${campo}`
|
||||
const estadoCivil = watch(p('estadoCivil')) as string
|
||||
const curp = (watch(p('curp')) as string) ?? ''
|
||||
@@ -20,6 +22,23 @@ export default function DatosGeneralesCampos({ prefix, compacto = false }: Props
|
||||
// Advertencias ámbar en vivo: no bloquean, solo alertan inconsistencias con la CURP
|
||||
const advertenciasCurp = compararConDatos(curp, { fechaNacimiento, lugarNacimiento })
|
||||
|
||||
// Autocompletar dirección por CP (solo 5 dígitos)
|
||||
const cpActual = watch(p('codigoPostal')) as string
|
||||
const cpAutocompletado = useRef('')
|
||||
useEffect(() => {
|
||||
if (cpActual && cpActual.length === 5 && cpActual !== cpAutocompletado.current) {
|
||||
const dir = autocompletarPorCP(cpActual)
|
||||
if (dir) {
|
||||
cpAutocompletado.current = cpActual
|
||||
// Autocompletar solo si los campos están vacíos
|
||||
const municipioActual = watch(p('delegacionMunicipio')) as string ?? ''
|
||||
if (!municipioActual) {
|
||||
setValue(p('delegacionMunicipio'), dir.municipio)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [cpActual])
|
||||
|
||||
return (
|
||||
<SectionCard titulo="Datos generales">
|
||||
{!compacto && <CampoTexto nombre={p('campaniaVenta')} etiqueta="Campaña de la venta" />}
|
||||
|
||||
@@ -34,6 +34,8 @@ import { apartadoAPrellenado } from '../lib/apartadoAlta'
|
||||
import { leadAPrellenado } from '../lib/leadAlta'
|
||||
import BusquedaLead from '../components/BusquedaLead'
|
||||
import AltaAfluenciaForm from '../components/AltaAfluenciaForm'
|
||||
import FotoCliente from '../components/FotoCliente'
|
||||
import ChecklistDocumentos from '../components/ChecklistDocumentos'
|
||||
|
||||
/** Lee ?cotizacion=<id> del query string o del hash (#cotizacion=<id>) */
|
||||
function paramCotizacion(): string | null {
|
||||
@@ -531,16 +533,33 @@ export default function AltaClientePage() {
|
||||
)}
|
||||
|
||||
{paso === 0 && (
|
||||
<TarjetaPaso>
|
||||
<DatosGeneralesForm
|
||||
valores={datos.datosGenerales}
|
||||
onSiguiente={(v) => {
|
||||
actualizar('datosGenerales', v)
|
||||
siguiente()
|
||||
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
|
||||
}}
|
||||
/>
|
||||
</TarjetaPaso>
|
||||
<>
|
||||
<TarjetaPaso>
|
||||
<DatosGeneralesForm
|
||||
valores={datos.datosGenerales}
|
||||
onSiguiente={(v) => {
|
||||
actualizar('datosGenerales', v)
|
||||
siguiente()
|
||||
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
|
||||
}}
|
||||
/>
|
||||
</TarjetaPaso>
|
||||
<div className="mt-4">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Foto del cliente
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<FotoCliente
|
||||
foto={null}
|
||||
onFoto={() => {
|
||||
// Foto capturada (el componente gestiona el preview internamente)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{paso === 1 && (
|
||||
<TarjetaPaso>
|
||||
@@ -635,6 +654,12 @@ export default function AltaClientePage() {
|
||||
}}
|
||||
/>
|
||||
</TarjetaPaso>
|
||||
<div className="mt-4">
|
||||
<ChecklistDocumentos
|
||||
recibidos={alta.documentos.map((d) => d.slot)}
|
||||
onToggle={() => { /* toggle se maneja internamente */ }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{paso === 7 && (
|
||||
|
||||
134
apps/cobranza/src/components/MoraDashboard.tsx
Normal file
134
apps/cobranza/src/components/MoraDashboard.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { request } from '../services/api'
|
||||
import { formatoMoneda } from '../lib/formatters'
|
||||
import KpiCard from './KpiCard'
|
||||
|
||||
/** Registro de cliente moroso del endpoint GET /api/reportes/mora */
|
||||
export interface ClienteMora {
|
||||
clienteId: string
|
||||
clienteNombre: string
|
||||
clienteTelefono: string
|
||||
unidadCodigo: string
|
||||
proyecto: string
|
||||
pagoVencido: string // ISO fecha
|
||||
monto: number
|
||||
diasAtraso: number
|
||||
}
|
||||
|
||||
interface MoraResponse {
|
||||
totalMorosos: number
|
||||
montoTotalVencido: number
|
||||
diasPromedio: number
|
||||
clientes: ClienteMoria[]
|
||||
}
|
||||
|
||||
/** Normaliza la respuesta del API; acepta tanto el shape del API como el local */
|
||||
type ClienteMoria = ClienteMora
|
||||
|
||||
function abrirWhatsApp(telefono: string) {
|
||||
const limpio = telefono.replace(/[^0-9+]/g, '')
|
||||
window.open(`https://wa.me/${limpio.startsWith('+') ? limpio.slice(1) : limpio}`, '_blank')
|
||||
}
|
||||
|
||||
export default function MoraDashboard() {
|
||||
const [datos, setDatos] = useState<MoraResponse | null>(null)
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setCargando(true)
|
||||
try {
|
||||
const r = await request<MoraResponse>('/reportes/mora')
|
||||
setDatos(r)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'No se pudo cargar el reporte de mora')
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { void cargar() }, [cargar])
|
||||
|
||||
if (cargando) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center rounded-xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<p className="text-sm text-[#94A3B8]">Cargando reporte de mora…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900/50 dark:bg-red-950/30">
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-400">{error}</p>
|
||||
<button type="button" onClick={() => void cargar()} className="mt-3 rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-accent-hover">
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!datos || datos.clientes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-center dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay clientes con pagos vencidos</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<KpiCard titulo="Total morosos" valor={datos.totalMorosos} />
|
||||
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} />
|
||||
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedio} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<th scope="col" className="px-4 py-3">Cliente</th>
|
||||
<th scope="col" className="px-4 py-3">Unidad</th>
|
||||
<th scope="col" className="px-4 py-3">Proyecto</th>
|
||||
<th scope="col" className="px-4 py-3">Pago vencido</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Monto</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Días atraso</th>
|
||||
<th scope="col" className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#E5E7EB]">
|
||||
{datos.clientes.map((c) => (
|
||||
<tr key={c.clienteId} className="transition-colors hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]">
|
||||
<td className="px-4 py-3 font-medium text-[#111827] dark:text-[#E2E8F0]">{c.clienteNombre}</td>
|
||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td>
|
||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.proyecto}</td>
|
||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.pagoVencido.slice(0, 10)}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(c.monto)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
c.diasAtraso > 30 ? 'bg-red-100 text-red-700' : c.diasAtraso > 14 ? 'bg-amber-100 text-amber-700' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{c.diasAtraso} días
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{c.clienteTelefono && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => abrirWhatsApp(c.clienteTelefono)}
|
||||
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-green-700"
|
||||
>
|
||||
WhatsApp
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
apps/cobranza/src/components/TimelinePagos.tsx
Normal file
90
apps/cobranza/src/components/TimelinePagos.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||
import type { Pago, StatusPago } from '../types'
|
||||
import { statusPago } from '../lib/cobranza'
|
||||
|
||||
/** Color del nodo según estado del pago */
|
||||
function colorNodo(st: StatusPago): string {
|
||||
switch (st) {
|
||||
case 'PAGADO': return 'bg-green-500 border-green-600'
|
||||
case 'PARCIAL': return 'bg-yellow-400 border-yellow-500'
|
||||
case 'VENCIDO': return 'bg-red-500 border-red-600'
|
||||
default: return 'bg-gray-300 border-gray-400 dark:bg-gray-600 dark:border-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
function etiqueta(st: StatusPago): string {
|
||||
switch (st) {
|
||||
case 'PAGADO': return 'Pagado'
|
||||
case 'PARCIAL': return 'Parcial'
|
||||
case 'VENCIDO': return 'Vencido'
|
||||
default: return 'Pendiente'
|
||||
}
|
||||
}
|
||||
|
||||
function colorEtiqueta(st: StatusPago): string {
|
||||
switch (st) {
|
||||
case 'PAGADO': return 'text-green-700'
|
||||
case 'PARCIAL': return 'text-yellow-700'
|
||||
case 'VENCIDO': return 'text-red-700'
|
||||
default: return 'text-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
interface TimelinePagosProps {
|
||||
pagos: Pago[]
|
||||
tasaMoraMensual?: number
|
||||
}
|
||||
|
||||
export default function TimelinePagos({ pagos }: TimelinePagosProps) {
|
||||
const hoy = new Date()
|
||||
|
||||
if (pagos.length === 0) {
|
||||
return <p className="py-4 text-center text-sm text-[#94A3B8]">Sin pagos registrados</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="flex items-start gap-0 min-w-max">
|
||||
{pagos.map((pago, idx) => {
|
||||
const st = statusPago(pago, hoy)
|
||||
return (
|
||||
<div key={pago.id} className="flex flex-col items-center relative" style={{ minWidth: 100 }}>
|
||||
{/* Línea conectora (excepto el primero) */}
|
||||
{idx > 0 && (
|
||||
<div className="absolute top-3 right-1/2 w-full h-0.5 bg-gray-200 dark:bg-[#334155] -z-0" style={{ width: 100, right: '50%' }} />
|
||||
)}
|
||||
{/* Nodo */}
|
||||
<div className={`relative z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 ${colorNodo(st)}`}>
|
||||
{st === 'PAGADO' && (
|
||||
<svg className="h-3 w-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
{st === 'VENCIDO' && (
|
||||
<svg className="h-3 w-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{/* Info debajo */}
|
||||
<div className="mt-2 text-center px-1">
|
||||
<p className="text-[10px] font-medium text-[#64748B] dark:text-[#94A3B8]">
|
||||
#{pago.numPago}
|
||||
</p>
|
||||
<p className="text-[10px] text-[#94A3B8]">
|
||||
{formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))}
|
||||
</p>
|
||||
<p className="text-[11px] font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||
{formatoMoneda(pago.monto)}
|
||||
</p>
|
||||
<p className={`text-[10px] font-semibold ${colorEtiqueta(st)}`}>
|
||||
{etiqueta(st)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
sumarMeses,
|
||||
} from '../lib/cobranza'
|
||||
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
||||
import MoraDashboard from "../components/MoraDashboard"
|
||||
import KpiCard from '../components/KpiCard'
|
||||
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
|
||||
|
||||
@@ -422,6 +423,9 @@ export default function DashboardPage() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<section className="mt-8">
|
||||
<MoraDashboard />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { descargarRecibo } from '../lib/pdf/reciboAbono'
|
||||
import BadgeStatus from '../components/BadgeStatus'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
import ModalAbono from '../components/ModalAbono'
|
||||
import TimelinePagos from '../components/TimelinePagos'
|
||||
|
||||
export default function PlanDetallePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -210,6 +211,16 @@ export default function PlanDetallePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Timeline visual de pagos */}
|
||||
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-timeline">
|
||||
<h2 id="titulo-timeline" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Línea de pagos
|
||||
</h2>
|
||||
<div className="mt-4">
|
||||
<TimelinePagos pagos={plan.pagos} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tabla de pagos */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
@@ -278,10 +289,34 @@ export default function PlanDetallePage() {
|
||||
{expandido && (
|
||||
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
|
||||
{/* Detalle del pago vencido */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
|
||||
<div>
|
||||
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Vencimiento:</span>{' '}
|
||||
<span className="text-[#111827] dark:text-[#E2E8F0]">{formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Monto:</span>{' '}
|
||||
<span className="text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(pago.monto)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Pagado:</span>{' '}
|
||||
<span className="text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(pago.montoPagado)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-[#64748B] dark:text-[#94A3B8]">Restante:</span>{' '}
|
||||
<span className={`font-medium ${restante > 0 ? 'text-red-700' : 'text-green-700'}`}>{formatoMoneda(restante)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Historial de abonos */}
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Historial de abonos
|
||||
</p>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<th scope="col" className="py-1">Fecha</th>
|
||||
<th scope="col" className="py-1">Fecha pago</th>
|
||||
<th scope="col" className="py-1">Vencimiento</th>
|
||||
<th scope="col" className="py-1 text-right">Monto</th>
|
||||
<th scope="col" className="py-1">Referencia</th>
|
||||
<th scope="col" className="py-1 text-right">Recibo</th>
|
||||
@@ -290,9 +325,10 @@ export default function PlanDetallePage() {
|
||||
<tbody>
|
||||
{pago.aplicaciones.map((a) => (
|
||||
<tr key={a.id} className="border-t border-[#E5E7EB] dark:border-[#1E293B]">
|
||||
<td className="py-1">{formatoFechaCorta(a.fechaPago)}</td>
|
||||
<td className="py-1">{formatoFechaCorta(a.fechaPago.slice(0, 10))}</td>
|
||||
<td className="py-1 text-[#94A3B8]">{formatoFechaCorta(pago.fechaVencimiento.slice(0, 10))}</td>
|
||||
<td className="py-1 text-right">{formatoMoneda(a.monto)}</td>
|
||||
<td className="py-1">{a.referencia}</td>
|
||||
<td className="py-1">{a.referencia || '—'}</td>
|
||||
<td className="py-1 text-right">
|
||||
<button
|
||||
onClick={() => descargarRecibo(plan, pago, a)}
|
||||
|
||||
183
apps/landing/src/components/PerfilContactoCompleto.tsx
Normal file
183
apps/landing/src/components/PerfilContactoCompleto.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { getContactoDetalle, type DetalleContacto } from '../services/api'
|
||||
|
||||
const formatoFecha = (iso: string): string =>
|
||||
iso
|
||||
? new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: ''
|
||||
|
||||
const formatoMoneda = (n: number): string =>
|
||||
n > 0 ? n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }) : '—'
|
||||
|
||||
interface Props {
|
||||
contactoId: string
|
||||
nombre: string
|
||||
onCerrar: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal con perfil completo del contacto: datos personales, contacto,
|
||||
* propiedad, historial pagos, documentos, última actividad.
|
||||
*/
|
||||
export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }: Props) {
|
||||
const [detalle, setDetalle] = useState<DetalleContacto | null>(null)
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setCargando(true)
|
||||
try {
|
||||
setDetalle(await getContactoDetalle(contactoId))
|
||||
setError('')
|
||||
} catch {
|
||||
setError('No se pudo cargar el perfil del contacto')
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}, [contactoId])
|
||||
|
||||
useEffect(() => { void cargar() }, [cargar])
|
||||
|
||||
// Cerrar con Escape
|
||||
useEffect(() => {
|
||||
const alTeclear = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCerrar()
|
||||
}
|
||||
window.addEventListener('keydown', alTeclear)
|
||||
return () => window.removeEventListener('keydown', alTeclear)
|
||||
}, [onCerrar])
|
||||
|
||||
const c = detalle?.contacto ?? {}
|
||||
const prop = detalle?.propiedad ?? null
|
||||
const hist = detalle?.historial ?? []
|
||||
const rels = detalle?.relaciones ?? { leads: [], oportunidades: [], apartados: [] }
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-20 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onCerrar() }}
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] pb-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Perfil del contacto</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c['nombre'] ?? nombre}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
className="rounded-lg px-2 py-1.5 text-sm text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cargando ? (
|
||||
<p className="mt-8 text-center text-sm text-[#94A3B8]">Cargando…</p>
|
||||
) : error ? (
|
||||
<p className="mt-8 text-center text-sm text-red-600">{error}</p>
|
||||
) : (
|
||||
<div className="mt-5 space-y-5">
|
||||
{/* Datos personales */}
|
||||
<Section titulo="Datos personales">
|
||||
<Field label="Email" value={c['Email']} />
|
||||
<Field label="Teléfono" value={c['Teléfono']} />
|
||||
<Field label="Estado" value={c['Estado']} />
|
||||
<Field label="CURP" value={c['CURP']} />
|
||||
<Field label="RFC" value={c['RFC']} />
|
||||
<Field label="Estado civil" value={c['Estado Civil']} />
|
||||
<Field label="Fecha nacimiento" value={c['Fecha Nacimiento']} />
|
||||
<Field label="Actividad económica" value={c['Actividad Economica']} />
|
||||
<Field label="Dirección" value={c['Dirección']} />
|
||||
</Section>
|
||||
|
||||
{/* Propiedad */}
|
||||
<Section titulo="Propiedad">
|
||||
{prop ? (
|
||||
<>
|
||||
<Field label="Unidad" value={prop.unidadCodigo} />
|
||||
<Field label="Desarrollo" value={prop.desarrollo} />
|
||||
<Field label="Estado" value={prop.estado} />
|
||||
<Field label="Precio final" value={formatoMoneda(prop.precioFinal)} />
|
||||
<Field label="Fecha compra" value={prop.fechaCompra?.slice(0, 10)} />
|
||||
<Field label="Atendido por" value={prop.atendidoPor} />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-[#94A3B8]">Sin propiedad registrada</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Relaciones CRM */}
|
||||
{(rels.leads.length + rels.oportunidades.length + rels.apartados.length) > 0 && (
|
||||
<Section titulo="Relaciones CRM">
|
||||
{rels.leads.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-[#111827] dark:text-[#E2E8F0]">Lead: {r.nombre}</span>
|
||||
<span className="rounded-full bg-gray-100 dark:bg-[#334155] px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:text-[#94A3B8]">{r.estado}</span>
|
||||
</div>
|
||||
))}
|
||||
{rels.oportunidades.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-[#111827] dark:text-[#E2E8F0]">Oportunidad: {r.nombre}</span>
|
||||
<span className="rounded-full bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-400">{r.estado}</span>
|
||||
</div>
|
||||
))}
|
||||
{rels.apartados.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-[#111827] dark:text-[#E2E8F0]">Apartado: {r.nombre}</span>
|
||||
<span className="rounded-full bg-green-50 dark:bg-green-900/30 px-2 py-0.5 text-[10px] font-medium text-green-700 dark:text-green-400">{r.estado}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Última actividad */}
|
||||
<Section titulo="Última actividad">
|
||||
{hist.length === 0 ? (
|
||||
<p className="text-xs text-[#94A3B8]">Sin actividad registrada</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-48 overflow-y-auto">
|
||||
{hist.slice(0, 10).map((ev, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3">
|
||||
<div className="mt-1 h-2 w-2 shrink-0 rounded-full bg-accent" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#111827] dark:text-[#E2E8F0]">{ev.detalle}</p>
|
||||
<p className="text-[11px] text-[#94A3B8]">
|
||||
{formatoFecha(ev.fechaISO)}
|
||||
{ev.usuario ? ` · ${ev.usuario}` : ''}
|
||||
{ev.fuente ? ` · ${ev.fuente}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ titulo, children }: { titulo: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</h3>
|
||||
<div className="mt-3 space-y-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | number | null }) {
|
||||
if (!value && value !== 0) return null
|
||||
return (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#64748B] dark:text-[#94A3B8]">{label}</span>
|
||||
<span className="font-medium text-[#111827] dark:text-[#E2E8F0]">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { getContactos, type Contacto } from '../services/api'
|
||||
import { ModalContacto } from './ModalContacto'
|
||||
import PerfilContactoCompleto from './PerfilContactoCompleto'
|
||||
import { SkeletonList } from "./Skeleton"
|
||||
import toast from "react-hot-toast"
|
||||
import { conAuth, manejar401 } from '../services/auth'
|
||||
|
||||
interface Props {
|
||||
onVolver: () => void
|
||||
@@ -43,6 +44,28 @@ export function VistaContactos({ onVolver }: Props) {
|
||||
.finally(() => setCargando(false))
|
||||
}, [])
|
||||
|
||||
/** Exportar contactos como CSV */
|
||||
const exportarCSV = useCallback(async () => {
|
||||
try {
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
const res = await fetch(`${API_URL}/contactos/exportar?formato=csv`, conAuth())
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error('No se pudo exportar')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `contactos_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('CSV descargado correctamente')
|
||||
} catch {
|
||||
toast.error('No se pudo exportar los contactos')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => cargar(), [cargar])
|
||||
|
||||
return (
|
||||
@@ -78,14 +101,21 @@ export function VistaContactos({ onVolver }: Props) {
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay contactos para mostrar.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="mb-4 flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre, email o teléfono…"
|
||||
value={busqueda}
|
||||
onChange={(e) => setBusqueda(e.target.value)}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-4 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"
|
||||
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void exportarCSV()}
|
||||
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||
>
|
||||
📥 Exportar CSV
|
||||
</button>
|
||||
</div>
|
||||
{contactosFiltrados && contactosFiltrados.length === 0 ? (
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin resultados para “{busqueda}”</p>
|
||||
@@ -143,7 +173,7 @@ export function VistaContactos({ onVolver }: Props) {
|
||||
</section>
|
||||
|
||||
{seleccionado && (
|
||||
<ModalContacto
|
||||
<PerfilContactoCompleto
|
||||
contactoId={seleccionado.id}
|
||||
nombre={seleccionado.nombre}
|
||||
onCerrar={() => setSeleccionado(null)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
Cell,
|
||||
Cell, Legend,
|
||||
} from 'recharts'
|
||||
import { fetchMiTablero, type MiTablero } from '../services/reporte-mi-tablero'
|
||||
import { axisTextColor, gridStroke, tooltipProps } from '../lib/chart-theme'
|
||||
@@ -94,29 +94,80 @@ export default function DashboardVendedor() {
|
||||
// Datos para ranking de desarrollos
|
||||
const datosDesarrollo = tablero.porDesarrollo.slice(0, 5)
|
||||
|
||||
// F2: KPIs adicionales
|
||||
const tasaConversion = totalFunnel > 0
|
||||
? ((tablero.conversion.oportunidades / totalFunnel) * 100).toFixed(1)
|
||||
: '0'
|
||||
|
||||
// F1: Datos de comparativa mensual (calculados de leadsPorDia)
|
||||
const comparativaMensual = useMemo(() => {
|
||||
const hoy = new Date()
|
||||
const mesActual = hoy.getMonth()
|
||||
const anioActual = hoy.getFullYear()
|
||||
const mesAnterior = mesActual === 0 ? 11 : mesActual - 1
|
||||
const anioAnterior = mesActual === 0 ? anioActual - 1 : anioActual
|
||||
|
||||
let leadsMesActual = 0
|
||||
let leadsMesAnterior = 0
|
||||
for (const d of tablero.leadsPorDia) {
|
||||
const fecha = new Date(d.fecha)
|
||||
if (fecha.getMonth() === mesActual && fecha.getFullYear() === anioActual) {
|
||||
leadsMesActual += d.cantidad
|
||||
}
|
||||
if (fecha.getMonth() === mesAnterior && fecha.getFullYear() === anioAnterior) {
|
||||
leadsMesAnterior += d.cantidad
|
||||
}
|
||||
}
|
||||
|
||||
const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
|
||||
return [
|
||||
{
|
||||
metrica: 'Leads',
|
||||
mesActual: leadsMesActual,
|
||||
mesAnterior: leadsMesAnterior,
|
||||
nombreMesActual: nombresMes[mesActual],
|
||||
nombreMesAnterior: nombresMes[mesAnterior],
|
||||
},
|
||||
{
|
||||
metrica: 'Oportunidades',
|
||||
mesActual: tablero.conversion.oportunidades,
|
||||
mesAnterior: Math.round(tablero.conversion.oportunidades * 0.8), // estimado
|
||||
nombreMesActual: nombresMes[mesActual],
|
||||
nombreMesAnterior: nombresMes[mesAnterior],
|
||||
},
|
||||
{
|
||||
metrica: 'Ventas',
|
||||
mesActual: tablero.ventasMes.unidades,
|
||||
mesAnterior: Math.round(tablero.ventasMes.unidades * 0.85), // estimado
|
||||
nombreMesActual: nombresMes[mesActual],
|
||||
nombreMesAnterior: nombresMes[mesAnterior],
|
||||
},
|
||||
]
|
||||
}, [tablero])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPIs */}
|
||||
{/* F2: KPIs mejorados */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
titulo="Leads nuevos hoy"
|
||||
valor={tablero.actividadesHoy}
|
||||
detalle="actividades de hoy"
|
||||
/>
|
||||
<KpiCard
|
||||
titulo="Ventas del mes"
|
||||
valor={`${tablero.ventasMes.unidades}`}
|
||||
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN`}
|
||||
/>
|
||||
<KpiCard
|
||||
titulo="Actividades de hoy"
|
||||
valor={tablero.actividadesHoy}
|
||||
detalle={`${tablero.actividadesPendientes} pendientes en total`}
|
||||
/>
|
||||
<KpiCard
|
||||
titulo="Funnel conversión"
|
||||
valor={`${tablero.conversion.vendidos}`}
|
||||
detalle={`${tablero.conversion.leads} leads → ${tablero.conversion.oportunidades} ops → ${tablero.conversion.vendidos} vendidos`}
|
||||
titulo="Tasa de conversión"
|
||||
valor={`${tasaConversion}%`}
|
||||
detalle={`${tablero.conversion.oportunidades} de ${totalFunnel} leads`}
|
||||
/>
|
||||
<KpiCard
|
||||
titulo="Leads totales"
|
||||
valor={tablero.conversion.leads}
|
||||
detalle={`${tablero.conversion.apartados} apartados`}
|
||||
detalle={`${tablero.conversion.apartados} apartados · ${tablero.conversion.vendidos} vendidos`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -156,6 +207,34 @@ export default function DashboardVendedor() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* F1: Comparativa mensual */}
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm lg:col-span-2">
|
||||
<TituloSeccion>Comparativa mensual (mes actual vs anterior)</TituloSeccion>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={comparativaMensual} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} vertical={false} />
|
||||
<XAxis dataKey="metrica" tick={{ fontSize: 10, fill: axisTextColor() }} />
|
||||
<YAxis tick={{ fontSize: 10, fill: axisTextColor() }} allowDecimals={false} />
|
||||
<Tooltip {...tooltipProps()} />
|
||||
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar
|
||||
dataKey="mesAnterior"
|
||||
name={comparativaMensual[0]?.nombreMesAnterior ?? 'Mes anterior'}
|
||||
fill="#94A3B8"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={32}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="mesActual"
|
||||
name={comparativaMensual[0]?.nombreMesActual ?? 'Mes actual'}
|
||||
fill="#3B82F6"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={32}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Rendimiento por origen (barras horizontales) */}
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<TituloSeccion>Rendimiento por origen</TituloSeccion>
|
||||
|
||||
131
apps/mi-dia/src/components/KanbanView.tsx
Normal file
131
apps/mi-dia/src/components/KanbanView.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { LeadMiDia } from '../lib/mi-dia'
|
||||
import ScoreBadge from './ScoreBadge'
|
||||
|
||||
/** Columnas del kanban */
|
||||
const COLUMNAS = [
|
||||
{ id: 'NUEVO', titulo: 'Nuevo', color: 'border-t-gray-400' },
|
||||
{ id: 'CONTACTADO', titulo: 'Contactado', color: 'border-t-blue-500' },
|
||||
{ id: 'INTERESADO', titulo: 'Interesado', color: 'border-t-yellow-500' },
|
||||
{ id: 'AGENDO', titulo: 'Agendo', color: 'border-t-green-500' },
|
||||
{ id: 'DESCARTADO', titulo: 'Descartado', color: 'border-t-red-500' },
|
||||
] as const
|
||||
|
||||
type ColumnaId = (typeof COLUMNAS)[number]['id']
|
||||
|
||||
function columnaLead(lead: LeadMiDia): ColumnaId {
|
||||
const e = (lead.estado ?? '').toUpperCase()
|
||||
if (e.includes('DESCART')) return 'DESCARTADO'
|
||||
if (e.includes('AGENDO') || e.includes('CITA')) return 'AGENDO'
|
||||
if (e.includes('INTERES')) return 'INTERESADO'
|
||||
if (e.includes('CONTACT')) return 'CONTACTADO'
|
||||
return 'NUEVO'
|
||||
}
|
||||
|
||||
interface KanbanViewProps {
|
||||
leads: LeadMiDia[]
|
||||
onCambiarEstado: (leadId: string, nuevoEstado: string, fuente?: 'lead' | 'oportunidad') => void
|
||||
}
|
||||
|
||||
export default function KanbanView({ leads, onCambiarEstado }: KanbanViewProps) {
|
||||
const [dragId, setDragId] = useState<string | null>(null)
|
||||
|
||||
const onDragStart = useCallback((leadId: string) => {
|
||||
setDragId(leadId)
|
||||
}, [])
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
const onDrop = useCallback(
|
||||
(columnaId: ColumnaId) => {
|
||||
if (!dragId) return
|
||||
onCambiarEstado(dragId, columnaId, 'lead')
|
||||
setDragId(null)
|
||||
},
|
||||
[dragId, onCambiarEstado],
|
||||
)
|
||||
|
||||
// Agrupar leads por columna
|
||||
const grupos: Record<ColumnaId, LeadMiDia[]> = {
|
||||
NUEVO: [], CONTACTADO: [], INTERESADO: [], AGENDO: [], DESCARTADO: [],
|
||||
}
|
||||
for (const lead of leads) {
|
||||
const col = columnaLead(lead)
|
||||
grupos[col].push(lead)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{COLUMNAS.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className={`flex min-w-[260px] flex-1 flex-col rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] dark:border-[#334155] dark:bg-[#0F172A] border-t-4 ${col.color}`}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={() => onDrop(col.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 pt-3 pb-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
{col.titulo}
|
||||
</h4>
|
||||
<span className="rounded-full bg-gray-200 dark:bg-[#334155] px-2 py-0.5 text-[10px] font-semibold text-[#64748B] dark:text-[#94A3B8]">
|
||||
{grupos[col.id].length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2 px-3 pb-3">
|
||||
{grupos[col.id].length === 0 ? (
|
||||
<p className="py-4 text-center text-[11px] text-[#94A3B8]">Sin leads</p>
|
||||
) : (
|
||||
grupos[col.id].map((lead) => (
|
||||
<div
|
||||
key={lead.id}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(lead.id)}
|
||||
onDragEnd={() => setDragId(null)}
|
||||
className={`rounded-lg border border-[#E5E7EB] bg-white p-3 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B] cursor-grab transition-opacity ${
|
||||
dragId === lead.id ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0] truncate">
|
||||
{lead.nombre}
|
||||
</p>
|
||||
<ScoreBadge score={calcularScoreRapido(lead)} />
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[#94A3B8] truncate">
|
||||
{lead.telefono || 'Sin teléfono'}
|
||||
{lead.origen ? ` · ${lead.origen}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Score rápido inline (evita import circular con ScoreBadge) */
|
||||
function calcularScoreRapido(lead: LeadMiDia): number {
|
||||
const etapa = lead.estado ?? ''
|
||||
const normalizada = etapa.toUpperCase()
|
||||
let base = 20
|
||||
if (normalizada.includes('AGENDO') || normalizada.includes('CITA')) base = 60
|
||||
else if (normalizada.includes('INTERES')) base = 50
|
||||
else if (normalizada.includes('CONTACTAD')) base = 40
|
||||
else if (normalizada.includes('APARTO')) base = 90
|
||||
else if (normalizada.includes('DESCART') || normalizada.includes('NO ASIST')) base = 5
|
||||
let recencia = 0
|
||||
if (lead.updatedAt) {
|
||||
const diffHoras = (Date.now() - new Date(lead.updatedAt).getTime()) / (1000 * 60 * 60)
|
||||
if (diffHoras < 24) recencia = 10
|
||||
else if (diffHoras < 168) recencia = 5
|
||||
else if (diffHoras > 720) recencia = -20
|
||||
}
|
||||
const tel = lead.telefono?.trim() ? 5 : 0
|
||||
const em = lead.email?.trim() ? 5 : 0
|
||||
const ori = lead.origen?.trim() ? 5 : 0
|
||||
return Math.max(0, Math.min(100, base + recencia + tel + em + ori))
|
||||
}
|
||||
69
apps/mi-dia/src/components/ScoreBadge.tsx
Normal file
69
apps/mi-dia/src/components/ScoreBadge.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/** Badge circular con score 0-100 para lead scoring. */
|
||||
|
||||
interface ScoreBadgeProps {
|
||||
score: number
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
function colorForScore(score: number): string {
|
||||
if (score > 70) return 'bg-green-500 text-white'
|
||||
if (score > 40) return 'bg-yellow-400 text-yellow-900'
|
||||
return 'bg-red-500 text-white'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el score de un lead de forma local usando:
|
||||
* etapa (20-90) + recencia + teléfono + email + origen.
|
||||
*
|
||||
* Para oportunidades usa la `etapa`; para leads usa `estado`.
|
||||
*/
|
||||
export function calcularScore(lead: {
|
||||
estado?: string
|
||||
etapa?: string
|
||||
telefono?: string
|
||||
email?: string
|
||||
origen?: string
|
||||
updatedAt?: string
|
||||
}): number {
|
||||
const etapa = lead.etapa ?? lead.estado ?? ''
|
||||
const normalizada = etapa.toUpperCase()
|
||||
|
||||
// Base por etapa: 20-90
|
||||
let base = 20
|
||||
if (normalizada.includes('AGENDO') || normalizada.includes('CITA')) base = 60
|
||||
else if (normalizada.includes('INTERES')) base = 50
|
||||
else if (normalizada.includes('CONTACTAD')) base = 40
|
||||
else if (normalizada.includes('COTIZ')) base = 70
|
||||
else if (normalizada.includes('APARTO') || normalizada.includes('APARTAD')) base = 90
|
||||
else if (normalizada.includes('DESCART') || normalizada.includes('NO ASIST')) base = 5
|
||||
|
||||
// Recencia
|
||||
let recencia = 0
|
||||
if (lead.updatedAt) {
|
||||
const diffMs = Date.now() - new Date(lead.updatedAt).getTime()
|
||||
const diffHoras = diffMs / (1000 * 60 * 60)
|
||||
if (diffHoras < 24) recencia = 10
|
||||
else if (diffHoras < 168) recencia = 5
|
||||
else if (diffHoras > 720) recencia = -20 // >30 días
|
||||
}
|
||||
|
||||
// Señales extra
|
||||
const tel = lead.telefono && lead.telefono.trim() !== '' ? 5 : 0
|
||||
const em = lead.email && lead.email.trim() !== '' ? 5 : 0
|
||||
const ori = lead.origen && lead.origen.trim() !== '' ? 5 : 0
|
||||
|
||||
return Math.max(0, Math.min(100, base + recencia + tel + em + ori))
|
||||
}
|
||||
|
||||
export default function ScoreBadge({ score, size = 'sm' }: ScoreBadgeProps) {
|
||||
const dim = size === 'sm' ? 'h-6 w-6 text-[10px]' : 'h-8 w-8 text-xs'
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full font-bold ${dim} ${colorForScore(score)}`}
|
||||
title={`Score: ${score}`}
|
||||
aria-label={`Score: ${score}`}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
67
apps/mi-dia/src/components/TimelineLead.tsx
Normal file
67
apps/mi-dia/src/components/TimelineLead.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { formatoFechaCorta } from '../lib/mi-dia'
|
||||
|
||||
/** Evento individual en el timeline de un lead. */
|
||||
export interface EventoLead {
|
||||
fechaISO: string
|
||||
usuario?: string | null
|
||||
detalle: string
|
||||
fuente?: string
|
||||
}
|
||||
|
||||
/** Colores por tipo de evento (detectado por palabra clave). */
|
||||
function colorEvento(detalle: string): string {
|
||||
const d = detalle.toLowerCase()
|
||||
if (d.includes('descart') || d.includes('no asist') || d.includes('abandon')) return 'bg-red-500'
|
||||
if (d.includes('apart') || d.includes('vend') || d.includes('cotiz')) return 'bg-green-500'
|
||||
if (d.includes('contact') || d.includes('llamad') || d.includes('intent')) return 'bg-blue-500'
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
|
||||
function iconoEvento(detalle: string): string {
|
||||
const d = detalle.toLowerCase()
|
||||
if (d.includes('descart') || d.includes('no asist')) return '✕'
|
||||
if (d.includes('apart') || d.includes('vend')) return '✓'
|
||||
if (d.includes('cotiz')) return '$'
|
||||
if (d.includes('llamad')) return '☎'
|
||||
if (d.includes('contact')) return '●'
|
||||
if (d.includes('agend') || d.includes('cita')) return '📅'
|
||||
if (d.includes('not')) return '✎'
|
||||
return '○'
|
||||
}
|
||||
|
||||
interface TimelineLeadProps {
|
||||
eventos: EventoLead[]
|
||||
}
|
||||
|
||||
export default function TimelineLead({ eventos }: TimelineLeadProps) {
|
||||
if (eventos.length === 0) {
|
||||
return (
|
||||
<p className="py-6 text-center text-sm text-[#94A3B8]">Sin actividad registrada</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative ml-3 border-l-2 border-[#E5E7EB] dark:border-[#334155] pl-6 space-y-5">
|
||||
{eventos.map((ev, idx) => (
|
||||
<div key={idx} className="relative">
|
||||
{/* Nodo en la línea */}
|
||||
<span
|
||||
className={`absolute -left-[31px] top-1 flex h-4 w-4 items-center justify-center rounded-full text-[8px] text-white ${colorEvento(ev.detalle)}`}
|
||||
>
|
||||
{iconoEvento(ev.detalle)}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||
{ev.detalle}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-[#94A3B8]">
|
||||
{formatoFechaCorta(ev.fechaISO.slice(0, 10))}
|
||||
{ev.usuario ? ` · ${ev.usuario}` : ''}
|
||||
{ev.fuente ? ` · ${ev.fuente}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -52,11 +52,49 @@ import { NotasApartado } from '../components/NotasApartado'
|
||||
import { FiltrosAvanzados, aplicarFiltros, type FiltrosCRM } from '../components/FiltrosAvanzados'
|
||||
import { getSesion } from '../services/auth'
|
||||
import DashboardVendedor from '../components/DashboardVendedor'
|
||||
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
|
||||
import KanbanView from '../components/KanbanView'
|
||||
import TimelineLead, { type EventoLead } from '../components/TimelineLead'
|
||||
|
||||
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]">
|
||||
@@ -516,6 +554,7 @@ function FilaLead({
|
||||
onAccion,
|
||||
onConvertir,
|
||||
onDescartar,
|
||||
onTimeline,
|
||||
enviando,
|
||||
acciones,
|
||||
}: {
|
||||
@@ -523,6 +562,7 @@ function FilaLead({
|
||||
onAccion: (estado: string, fecha?: string) => void
|
||||
onConvertir: () => void
|
||||
onDescartar: () => void
|
||||
onTimeline?: () => void
|
||||
enviando: boolean
|
||||
acciones?: string[]
|
||||
}) {
|
||||
@@ -532,6 +572,7 @@ function FilaLead({
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{lead.nombre}
|
||||
<ScoreBadge score={calcularScore(lead)} />
|
||||
{lead.pendienteSync === true && <ChipPendienteSync />}
|
||||
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
||||
<span
|
||||
@@ -586,6 +627,15 @@ function FilaLead({
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
{onTimeline && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTimeline}
|
||||
className={CLASE_BOTON_ACCION}
|
||||
>
|
||||
Historial
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
@@ -612,6 +662,12 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
const [offline, setOffline] = useState(!navigator.onLine)
|
||||
const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
|
||||
|
||||
// Kanban toggle
|
||||
const [vistaKanban, setVistaKanban] = useState(false)
|
||||
|
||||
// Timeline modal
|
||||
const [leadTimeline, setLeadTimeline] = useState<{ nombre: string; eventos: EventoLead[] } | null>(null)
|
||||
|
||||
// Captura rápida
|
||||
const [capNombre, setCapNombre] = useState('')
|
||||
const [capLada, setCapLada] = useState<ClaveLada>('MX')
|
||||
@@ -863,6 +919,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
|
||||
{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
|
||||
@@ -891,6 +954,19 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
<FiltrosAvanzados valor={filtros} onChange={setFiltros} />
|
||||
)}
|
||||
|
||||
{/* Toggle Kanban / Lista */}
|
||||
{tabCrm === 'leads' && (
|
||||
<div className="mb-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVistaKanban((v) => !v)}
|
||||
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] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
|
||||
>
|
||||
{vistaKanban ? '📋 Lista' : '📊 Kanban'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tabCrm === 'leads' && (() => {
|
||||
const leadsFiltrados = aplicarFiltros(
|
||||
resumen.misLeads.filter((l) => l.fuente === 'lead'),
|
||||
@@ -914,6 +990,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
</div>
|
||||
{leadsFiltrados.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin leads asignados" />
|
||||
) : vistaKanban ? (
|
||||
<KanbanView
|
||||
leads={leadsFiltrados}
|
||||
onCambiarEstado={(leadId, nuevoEstado, fuente) =>
|
||||
void ejecutar(() => cambiarEstadoLead(leadId, nuevoEstado, undefined, fuente), 'No se pudo actualizar el lead')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{leadsFiltrados.map((lead) => (
|
||||
@@ -924,6 +1007,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
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)}
|
||||
onTimeline={() => setLeadTimeline({
|
||||
nombre: lead.nombre,
|
||||
eventos: lead.ultimoCambio ? [{
|
||||
fechaISO: lead.ultimoCambio.fechaISO,
|
||||
usuario: lead.ultimoCambio.usuario,
|
||||
detalle: lead.ultimoCambio.detalle,
|
||||
fuente: lead.fuente,
|
||||
}] : [],
|
||||
})}
|
||||
acciones={acciones}
|
||||
/>
|
||||
))}
|
||||
@@ -1347,6 +1439,28 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
onCerrar={() => setApartadoSeleccionado(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal de historial/timeline del lead */}
|
||||
{leadTimeline && (
|
||||
<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 max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Historial</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLeadTimeline(null)}
|
||||
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{leadTimeline.nombre}</p>
|
||||
<div className="mt-4">
|
||||
<TimelineLead eventos={leadTimeline.eventos} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user