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:
@@ -173,7 +173,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => void enviar(e)} noValidate className="max-w-2xl">
|
||||
<form onSubmit={(e) => void enviar(e)} noValidate>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<label htmlFor="afluencia-nombre" className="block sm:col-span-2">
|
||||
<span className={labelClase}>
|
||||
|
||||
21
apps/alta-clientes/src/components/AppHeader.tsx
Normal file
21
apps/alta-clientes/src/components/AppHeader.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
titulo: string
|
||||
acciones?: ReactNode
|
||||
}
|
||||
|
||||
/** Barra superior de la app: marca a la izquierda, acciones a la derecha */
|
||||
export default function AppHeader({ titulo, acciones }: Props) {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-[1280px] flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2 sm:px-6">
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827]">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold text-[#111827]">{titulo}</h1>
|
||||
{acciones && <div className="ml-auto flex items-center gap-3">{acciones}</div>}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<div className="border-b border-[#E5E7EB] bg-white">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
||||
<div className="mx-auto flex max-w-[1280px] items-center justify-between px-4 py-2 sm:px-6">
|
||||
<span className="text-xs font-medium text-[#64748B]">
|
||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||
</span>
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function ChecklistExpediente({ documentos, firmaDataUrl }: Props)
|
||||
return (
|
||||
<section
|
||||
aria-label="Checklist del expediente"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm"
|
||||
className="border-t border-[#E5E7EB] px-6 py-6 first:border-t-0"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
Expediente: {estado.completos}/{estado.total}
|
||||
|
||||
@@ -6,13 +6,13 @@ interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Tarjeta de sección del wizard */
|
||||
/** Sección interna del paso del wizard (separada por divisor dentro de la tarjeta del paso) */
|
||||
export default function SectionCard({ titulo, descripcion, children }: Props) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<section className="border-t border-[#E5E7EB] px-6 py-6 first:border-t-0">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</h3>
|
||||
{descripcion && <p className="mt-2 text-sm text-[#64748B]">{descripcion}</p>}
|
||||
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">{children}</div>
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,37 +4,84 @@ interface Props {
|
||||
onIrAPaso: (paso: number) => void
|
||||
}
|
||||
|
||||
/** Stepper de progreso del wizard; permite volver a pasos ya visitados */
|
||||
export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
|
||||
/** Palomita de paso completado */
|
||||
function IconoCheck() {
|
||||
return (
|
||||
<nav aria-label="Progreso">
|
||||
<ol className="flex flex-wrap items-baseline gap-x-6 gap-y-3 border-b border-[#E5E7EB] pb-4">
|
||||
{pasos.map((nombre, i) => {
|
||||
const activo = i === pasoActual
|
||||
const completado = i < pasoActual
|
||||
return (
|
||||
<li key={nombre}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => completado && onIrAPaso(i)}
|
||||
disabled={!completado}
|
||||
aria-current={activo ? 'step' : undefined}
|
||||
className={`text-xs font-medium transition-colors duration-150 ${
|
||||
activo
|
||||
? 'font-semibold text-accent'
|
||||
: completado
|
||||
? 'text-[#111827] hover:text-accent'
|
||||
: 'text-[#94A3B8]'
|
||||
}`}
|
||||
title={nombre}
|
||||
>
|
||||
<span className="mr-2">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="hidden md:inline">{nombre}</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.704 5.29a1 1 0 010 1.42l-7.2 7.2a1 1 0 01-1.42 0l-3.2-3.2a1 1 0 111.42-1.42l2.49 2.5 6.49-6.5a1 1 0 011.42 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stepper de progreso del wizard; permite volver a pasos ya visitados.
|
||||
* En lg se muestra vertical (sidebar sticky en la página); en móvil colapsa
|
||||
* a una barra horizontal de progreso.
|
||||
*/
|
||||
export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
|
||||
const avance = Math.round(((pasoActual + 1) / pasos.length) * 100)
|
||||
return (
|
||||
<>
|
||||
{/* Móvil: barra horizontal de progreso */}
|
||||
<div className="lg:hidden">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<p className="text-sm font-medium text-[#111827]">{pasos[pasoActual]}</p>
|
||||
<p className="text-xs text-[#64748B]">
|
||||
Paso {pasoActual + 1} de {pasos.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 rounded-full bg-[#E5E7EB]">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-all duration-200"
|
||||
style={{ width: `${avance}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Escritorio: lista vertical con el estado de cada paso */}
|
||||
<nav aria-label="Progreso" className="hidden lg:block">
|
||||
<ol className="space-y-1">
|
||||
{pasos.map((nombre, i) => {
|
||||
const activo = i === pasoActual
|
||||
const completado = i < pasoActual
|
||||
return (
|
||||
<li key={nombre}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => completado && onIrAPaso(i)}
|
||||
disabled={!completado}
|
||||
aria-current={activo ? 'step' : undefined}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors duration-150 ${
|
||||
activo
|
||||
? 'bg-accent/5 font-semibold text-accent'
|
||||
: completado
|
||||
? 'text-[#111827] hover:bg-[#F1F5F9]'
|
||||
: 'text-[#94A3B8]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium ${
|
||||
activo
|
||||
? 'bg-accent text-white'
|
||||
: completado
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'border border-[#D1D5DB] bg-white text-[#94A3B8]'
|
||||
}`}
|
||||
>
|
||||
{completado ? <IconoCheck /> : i + 1}
|
||||
</span>
|
||||
<span className="truncate">{nombre}</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function CoacreditadoForm({ valores, onAnterior, onSiguiente }: P
|
||||
return (
|
||||
<FormProvider {...metodos}>
|
||||
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
|
||||
<label htmlFor="tieneCoacreditado" className="flex items-center gap-3 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<label htmlFor="tieneCoacreditado" className="flex items-center gap-3 px-6 py-5">
|
||||
<input
|
||||
id="tieneCoacreditado"
|
||||
type="checkbox"
|
||||
@@ -31,7 +31,7 @@ export default function CoacreditadoForm({ valores, onAnterior, onSiguiente }: P
|
||||
</label>
|
||||
|
||||
{tieneCoacreditado && (
|
||||
<div className="mt-16 space-y-16">
|
||||
<div className="border-t border-[#E5E7EB]">
|
||||
<DatosGeneralesCampos prefix="generales." compacto />
|
||||
<EmpleoCampos prefix="empleo." />
|
||||
<EconomicosCampos prefix="economicos." />
|
||||
|
||||
@@ -31,9 +31,7 @@ export default function DocumentosForm({
|
||||
const ine = documentos.find((d) => d.slot === 'INE')
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<ChecklistExpediente documentos={documentos} firmaDataUrl={firmaDataUrl} />
|
||||
</div>
|
||||
<ChecklistExpediente documentos={documentos} firmaDataUrl={firmaDataUrl} />
|
||||
<SectionCard
|
||||
titulo="Documentos del expediente"
|
||||
descripcion="Los archivos se conservan en memoria y se enviarán al backend junto con el alta."
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div>
|
||||
<SectionCard titulo="Resumen del alta">
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 text-sm sm:col-span-2 lg:col-span-3 lg:grid-cols-3">
|
||||
<div>
|
||||
@@ -126,7 +126,7 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
|
||||
</label>
|
||||
</SectionCard>
|
||||
|
||||
{error && <p className="text-xs text-red-700">{error}</p>}
|
||||
{error && <p className="px-6 py-2 text-xs text-red-700">{error}</p>}
|
||||
|
||||
<SectionCard titulo="Generar documentos PDF">
|
||||
{avisoExpediente && (
|
||||
@@ -158,7 +158,7 @@ export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props)
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div>
|
||||
<div className="sticky bottom-0 flex items-center rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAnterior}
|
||||
|
||||
@@ -8,7 +8,7 @@ interface Props {
|
||||
/** Botones de navegación del wizard */
|
||||
export default function Navegacion({ onAnterior, onSiguiente, textoSiguiente = 'Siguiente' }: Props) {
|
||||
return (
|
||||
<div className="mt-16 flex items-center justify-between">
|
||||
<div className="sticky bottom-0 flex items-center justify-between gap-4 rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
|
||||
{onAnterior ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
|
||||
))}
|
||||
</SectionCard>
|
||||
{errorRaiz && typeof errorRaiz.message === 'string' && (
|
||||
<p className="mt-2 text-xs font-semibold text-red-600">{errorRaiz.message}</p>
|
||||
<p className="mt-2 px-6 text-xs font-semibold text-red-600">{errorRaiz.message}</p>
|
||||
)}
|
||||
<Navegacion onAnterior={onAnterior} />
|
||||
</form>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente }
|
||||
return (
|
||||
<FormProvider {...metodos}>
|
||||
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
|
||||
<div className="space-y-16">
|
||||
<div>
|
||||
<SectionCard titulo="Datos vivienda">
|
||||
<CampoSelect
|
||||
nombre="proyecto"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user