style: rediseno de layout profesional en las 7 apps

Headers sticky con marca y acciones, contenedores max-w-[1280px],
grids de contenido con rails laterales sticky (cotizador, mi-dia,
detalle de cobranza), stepper vertical en el wizard de alta, toolbar+
tabla unificadas en cobranza, tabs como segmented control en
dashboards, chat con barra de entrada fija y preguntas sugeridas,
landing con navbar + hero + grid de modulos + footer.
This commit is contained in:
2026-09-03 04:28:56 +00:00
parent 599dce2629
commit d59c8c0d7e
39 changed files with 1622 additions and 1235 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import AppHeader from '../components/AppHeader'
import Stepper from '../components/Stepper'
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
import EmpleoForm from '../forms/EmpleoForm'
@@ -40,6 +41,36 @@ function paramCotizacion(): string | null {
const formatoFechaHora = (iso: string): string =>
new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
/** Tarjeta del paso actual del wizard (el stepper queda fuera, a la izquierda en lg) */
function TarjetaPaso({ children }: { children: ReactNode }) {
return <div className="rounded-xl border border-[#E5E7EB] bg-white shadow-sm">{children}</div>
}
/** Cuadro con las iniciales de la opción (icono de la tarjeta de entrada) */
function IconoIniciales({ iniciales }: { iniciales: string }) {
return (
<span
aria-hidden="true"
className="flex h-11 w-11 items-center justify-center rounded-lg bg-accent/10 text-sm font-semibold text-accent"
>
{iniciales}
</span>
)
}
/** Flecha del CTA de las tarjetas de entrada */
function IconoFlecha() {
return (
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-4 w-4">
<path
fillRule="evenodd"
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
)
}
export default function AltaClientePage() {
const {
paso,
@@ -239,7 +270,7 @@ export default function AltaClientePage() {
if (pantalla === 'cargando') {
return (
<div className="min-h-screen bg-[#F8FAFC]">
<main className="mx-auto max-w-6xl px-4 py-16">
<main className="mx-auto max-w-[1280px] px-4 py-16 sm:px-6">
<p className="text-sm text-[#64748B]">Cargando…</p>
</main>
</div>
@@ -249,34 +280,44 @@ export default function AltaClientePage() {
if (pantalla === 'entrada') {
return (
<div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
<div className="grid gap-6 sm:grid-cols-2">
<button
type="button"
onClick={() => setPantalla('afluencia')}
className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
>
<h2 className="text-lg font-semibold text-[#111827]">Alta Afluencia</h2>
<p className="mt-2 text-sm text-[#64748B]">
Captura rápida del interesado; entra al embudo de ventas.
</p>
</button>
<button
type="button"
onClick={() => setPantalla('picker')}
className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
>
<h2 className="text-lg font-semibold text-[#111827]">Alta de Cliente</h2>
<p className="mt-2 text-sm text-[#64748B]">
Formato completo para quien aparta.
</p>
</button>
<AppHeader titulo="Alta de Clientes" />
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-12">
<div className="mx-auto max-w-4xl">
<p className="text-sm text-[#64748B]">
Elige cómo registrar al interesado: captura ligera de afluencia o formato completo de alta.
</p>
<div className="mt-6 grid gap-6 sm:grid-cols-2">
<button
type="button"
onClick={() => setPantalla('afluencia')}
className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
>
<IconoIniciales iniciales="AA" />
<span className="mt-4 block text-lg font-semibold text-[#111827]">Alta Afluencia</span>
<span className="mt-2 block text-sm text-[#64748B]">
Captura rápida del interesado; entra al embudo de ventas.
</span>
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
Comenzar
<IconoFlecha />
</span>
</button>
<button
type="button"
onClick={() => setPantalla('picker')}
className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
>
<IconoIniciales iniciales="AC" />
<span className="mt-4 block text-lg font-semibold text-[#111827]">Alta de Cliente</span>
<span className="mt-2 block text-sm text-[#64748B]">
Formato completo para quien aparta.
</span>
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
Comenzar
<IconoFlecha />
</span>
</button>
</div>
</div>
</main>
</div>
@@ -286,17 +327,19 @@ export default function AltaClientePage() {
if (pantalla === 'afluencia') {
return (
<div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
<h1 className="text-2xl font-semibold text-[#111827]">Alta Afluencia</h1>
<AppHeader titulo="Alta Afluencia" />
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
<div className="mx-auto max-w-3xl">
<p className="text-sm text-[#64748B]">
Captura rápida del interesado; entra al embudo de ventas.
</p>
<div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<AltaAfluenciaForm
onVolver={() => setPantalla('entrada')}
onPasarAAltaCliente={empezarDesdeLead}
/>
</div>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
<AltaAfluenciaForm
onVolver={() => setPantalla('entrada')}
onPasarAAltaCliente={empezarDesdeLead}
/>
</main>
</div>
)
@@ -305,69 +348,66 @@ export default function AltaClientePage() {
if (pantalla === 'picker') {
return (
<div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
{borradores.length > 0 && (
<>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
{borradores.length === 1 ? '' : 's'}
</h2>
<ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
{borradores.map((b) => (
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm text-[#111827]">{b.nombre}</p>
<p className="mt-1 text-xs text-[#64748B]">
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => void continuarBorrador(b.id)}
className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white"
>
Continuar
</button>
<button
type="button"
onClick={() => void borrarBorrador(b.id)}
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-50"
>
Eliminar
</button>
</div>
</li>
))}
</ul>
</>
)}
{errorCarga && <p className="mt-4 text-xs text-red-700">{errorCarga}</p>}
<button
type="button"
onClick={empezarNuevo}
className="mt-8 rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
>
Empezar nuevo
</button>
<AppHeader titulo="Alta de Clientes" />
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
<div className="max-w-3xl">
{borradores.length > 0 && (
<>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
{borradores.length === 1 ? '' : 's'}
</h2>
<ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
{borradores.map((b) => (
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm text-[#111827]">{b.nombre}</p>
<p className="mt-1 text-xs text-[#64748B]">
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => void continuarBorrador(b.id)}
className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white"
>
Continuar
</button>
<button
type="button"
onClick={() => void borrarBorrador(b.id)}
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-50"
>
Eliminar
</button>
</div>
</li>
))}
</ul>
</>
)}
{errorCarga && <p className="mt-4 text-xs text-red-700">{errorCarga}</p>}
<button
type="button"
onClick={empezarNuevo}
className="mt-8 rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
>
Empezar nuevo
</button>
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] pt-8">
<h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Desde un lead de Monday
</h3>
<p className="mt-2 text-sm text-[#64748B]">
Empieza un alta nueva con el nombre, email y celular del lead ya capturados.
</p>
<div className="mt-4">
<BusquedaLead onSeleccion={empezarDesdeLead} />
</div>
</section>
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] pt-8">
<h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Desde un lead de Monday
</h3>
<p className="mt-2 text-sm text-[#64748B]">
Empieza un alta nueva con el nombre, email y celular del lead ya capturados.
</p>
<div className="mt-4">
<BusquedaLead onSeleccion={empezarDesdeLead} />
</div>
</section>
</div>
</main>
</div>
)
@@ -375,11 +415,10 @@ export default function AltaClientePage() {
return (
<div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline gap-3">
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
<h1 className="text-2xl font-semibold text-[#111827]">Alta de Clientes</h1>
<div className="ml-auto flex items-center gap-3">
<AppHeader
titulo="Alta de Clientes"
acciones={
<>
{avisoBorrador && (
<span role="status" className="text-xs text-[#64748B]">
{avisoBorrador}
@@ -397,172 +436,192 @@ export default function AltaClientePage() {
>
Guardar borrador
</button>
</>
}
/>
<main className="mx-auto max-w-[1280px] px-4 py-6 sm:px-6 sm:py-8">
<div className="lg:grid lg:grid-cols-[260px_minmax(0,1fr)] lg:gap-8">
<div className="mb-6 lg:sticky lg:top-20 lg:mb-0 lg:self-start">
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
</div>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
<div className="w-full max-w-3xl">
{errorCarga && (
<p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
{errorCarga}
</p>
)}
{errorCarga && (
<p role="alert" className="mt-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
{errorCarga}
</p>
)}
<div className="mt-16">
{paso === 0 && (
<DatosGeneralesForm
valores={datos.datosGenerales}
onSiguiente={(v) => {
actualizar('datosGenerales', v)
siguiente()
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
}}
/>
)}
{paso === 1 && (
<EmpleoForm
valores={datos.empleo}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('empleo', v)
siguiente()
guardarAvance({ ...datos, empleo: v }, paso + 1)
}}
/>
)}
{paso === 2 && (
<EconomicosForm
valores={datos.economicos}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('economicos', v)
siguiente()
guardarAvance({ ...datos, economicos: v }, paso + 1)
}}
/>
)}
{paso === 3 && (
<CoacreditadoForm
valores={datos.coacreditado}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('coacreditado', v)
siguiente()
guardarAvance({ ...datos, coacreditado: v }, paso + 1)
}}
/>
)}
{paso === 4 && (
<ReferenciasForm
valores={datos.referencias}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('referencias', v)
siguiente()
guardarAvance({ ...datos, referencias: v }, paso + 1)
}}
/>
)}
{paso === 5 && (
<ViviendaCreditoForm
valores={datos.viviendaCredito}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('viviendaCredito', v)
siguiente()
guardarAvance({ ...datos, viviendaCredito: v }, paso + 1)
}}
/>
)}
{paso === 6 && (
<>
{cargoDeBorrador && (
<p
role="note"
className="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
>
Retomaste un borrador: los archivos de los documentos no se guardan en el borrador,
vuelve a adjuntarlos.
</p>
)}
<DocumentosForm
documentos={alta.documentos}
firmaDataUrl={alta.firma.firmaDataUrl}
onAgregar={agregarDocumento}
onQuitar={quitarDocumento}
onAplicarDatosGenerales={(campos) =>
actualizar('datosGenerales', { ...datos.datosGenerales, ...campos })
}
onAnterior={anterior}
onSiguiente={() => {
void registrarAlta()
siguiente()
guardarAvance(datos, paso + 1)
}}
/>
</>
)}
{paso === 7 && (
<>
{registro && (
<p className="mb-8 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
Alta registrada en el servidor: {registro}
</p>
)}
{sinConexion && (
<p
role="alert"
className="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
>
Sin conexión al servidor — el expediente se generó solo localmente
</p>
)}
{errorValidacion && (
<p
role="alert"
className="mb-8 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700"
>
El servidor rechazó el alta: {errorValidacion}
</p>
)}
{registro && (
<div className="mb-8 rounded-xl border border-[#E5E7EB] bg-white px-4 py-4 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Firma en remoto
</p>
<p className="mt-2 text-sm text-[#64748B]">
Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus
documentos desde su celular, sin usuario ni contraseña.
</p>
<button
type="button"
onClick={() => void generarLiga()}
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
{paso === 0 && (
<TarjetaPaso>
<DatosGeneralesForm
valores={datos.datosGenerales}
onSiguiente={(v) => {
actualizar('datosGenerales', v)
siguiente()
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 1 && (
<TarjetaPaso>
<EmpleoForm
valores={datos.empleo}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('empleo', v)
siguiente()
guardarAvance({ ...datos, empleo: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 2 && (
<TarjetaPaso>
<EconomicosForm
valores={datos.economicos}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('economicos', v)
siguiente()
guardarAvance({ ...datos, economicos: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 3 && (
<TarjetaPaso>
<CoacreditadoForm
valores={datos.coacreditado}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('coacreditado', v)
siguiente()
guardarAvance({ ...datos, coacreditado: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 4 && (
<TarjetaPaso>
<ReferenciasForm
valores={datos.referencias}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('referencias', v)
siguiente()
guardarAvance({ ...datos, referencias: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 5 && (
<TarjetaPaso>
<ViviendaCreditoForm
valores={datos.viviendaCredito}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('viviendaCredito', v)
siguiente()
guardarAvance({ ...datos, viviendaCredito: v }, paso + 1)
}}
/>
</TarjetaPaso>
)}
{paso === 6 && (
<>
{cargoDeBorrador && (
<p
role="note"
className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
>
Generar liga para firmar en remoto
</button>
{ligaUrl && (
<p className="mt-4 break-all text-xs text-[#111827]">
{ligaUrl}
{ligaCopiada && (
<span role="status" className="ml-2 font-medium text-accent">
Liga y mensaje de WhatsApp copiados
</span>
)}
Retomaste un borrador: los archivos de los documentos no se guardan en el borrador,
vuelve a adjuntarlos.
</p>
)}
<TarjetaPaso>
<DocumentosForm
documentos={alta.documentos}
firmaDataUrl={alta.firma.firmaDataUrl}
onAgregar={agregarDocumento}
onQuitar={quitarDocumento}
onAplicarDatosGenerales={(campos) =>
actualizar('datosGenerales', { ...datos.datosGenerales, ...campos })
}
onAnterior={anterior}
onSiguiente={() => {
void registrarAlta()
siguiente()
guardarAvance(datos, paso + 1)
}}
/>
</TarjetaPaso>
</>
)}
{paso === 7 && (
<>
{registro && (
<p className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
Alta registrada en el servidor: {registro}
</p>
)}
{sinConexion && (
<p
role="alert"
className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
>
Sin conexión al servidor — el expediente se generó solo localmente
</p>
)}
{errorValidacion && (
<p
role="alert"
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700"
>
El servidor rechazó el alta: {errorValidacion}
</p>
)}
{registro && (
<div className="mb-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Firma en remoto
</p>
)}
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
</div>
)}
<FirmasForm
alta={alta}
onCambiarFirma={(f) => actualizar('firma', f)}
onAnterior={anterior}
/>
</>
)}
<p className="mt-2 text-sm text-[#64748B]">
Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus
documentos desde su celular, sin usuario ni contraseña.
</p>
<button
type="button"
onClick={() => void generarLiga()}
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Generar liga para firmar en remoto
</button>
{ligaUrl && (
<p className="mt-4 break-all text-xs text-[#111827]">
{ligaUrl}
{ligaCopiada && (
<span role="status" className="ml-2 font-medium text-accent">
Liga y mensaje de WhatsApp copiados
</span>
)}
</p>
)}
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
</div>
)}
<TarjetaPaso>
<FirmasForm
alta={alta}
onCambiarFirma={(f) => actualizar('firma', f)}
onAnterior={anterior}
/>
</TarjetaPaso>
</>
)}
</div>
</div>
</main>
</div>