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:
2026-09-08 07:42:54 +00:00
parent 5063ffe281
commit db18685872
34 changed files with 2950 additions and 33 deletions

View 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>
)
}

View 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>
)
}