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 && (
|
||||
|
||||
Reference in New Issue
Block a user