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

@@ -5,8 +5,8 @@ import LoginScreen from './LoginScreen'
/**
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
* app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio)
* y botón "Salir".
* app con un header blanco sticky (marca a la izquierda; "nombre · rol",
* "Contraseña" (modal de cambio) y botón "Salir" a la derecha).
*/
export default function AuthGate({ children }: { children: ReactNode }) {
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
@@ -18,16 +18,22 @@ 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">
<span className="text-xs font-medium text-[#64748B]">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</span>
<div className="flex items-center gap-4">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
<p className="text-sm text-[#111827]">
<span className="font-bold">
FRASA DESARROLLOS<span className="text-[#F97316]">.</span>
</span>{' '}
<span className="hidden font-normal text-[#64748B] sm:inline">— Cotizador</span>
</p>
<div className="flex items-center gap-3 sm:gap-4">
<span className="hidden text-xs font-medium text-[#64748B] md:inline">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</span>
<button
type="button"
onClick={() => setModalPassword(true)}
className="text-xs font-medium text-[#64748B] transition-colors duration-100 hover:text-[#111827]"
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]"
>
Contraseña
</button>
@@ -43,7 +49,7 @@ export default function AuthGate({ children }: { children: ReactNode }) {
</button>
</div>
</div>
</div>
</header>
{children}
{modalPassword && <CambiarPasswordModal onCerrar={() => setModalPassword(false)} />}
</div>

View File

@@ -72,7 +72,7 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
}
return (
<section className="mt-16" aria-labelledby="comparador-titulo">
<section aria-labelledby="comparador-titulo">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2
id="comparador-titulo"

View File

@@ -99,7 +99,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
)
return (
<section className="mt-16" aria-labelledby="comparador-unidades-titulo">
<section aria-labelledby="comparador-unidades-titulo">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2
id="comparador-unidades-titulo"

View File

@@ -255,278 +255,276 @@ export default function CotizadorPage() {
return (
<div className="min-h-screen bg-[#F8FAFC]">
<header className="border-b border-[#E5E7EB] bg-white">
<div className="mx-auto flex max-w-7xl flex-wrap items-baseline justify-between gap-2 px-4 py-6">
<h1 className="text-lg text-[#111827]">
<span className="font-bold">FRASA DESARROLLOS<span className="text-[#F97316]">.</span></span>{' '}
<span className="font-normal text-[#64748B]">— Cotizador</span>
</h1>
<div className="text-right">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] capitalize">
{formatoFechaLarga(new Date())}
</p>
<p className="mt-1 text-xs text-[#64748B]">
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
</p>
</div>
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 lg:py-10">
{/* Encabezado de página */}
<div className="mb-8">
<h1 className="text-xl font-semibold text-[#111827]">Cotizador</h1>
<p className="mt-1 text-sm text-[#64748B] capitalize">{formatoFechaLarga(new Date())}</p>
<p className="text-sm text-[#64748B]">
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
</p>
</div>
</header>
<main className="mx-auto grid max-w-7xl gap-x-8 gap-y-16 px-4 py-16 lg:grid-cols-[minmax(320px,380px)_1fr]">
{/* Panel de configuración */}
<aside className="space-y-16">
<section className="space-y-8 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
{error && <p className="text-xs text-red-700">{error}</p>}
{usandoSeed && !cargando && (
<p className="text-xs text-amber-700">Datos de demostración (sin conexión al servidor)</p>
)}
{cargando ? (
<p className="text-sm text-[#64748B]">Cargando unidades…</p>
) : (
<>
<div>
<span className={etiquetaCampo}>Desarrollo</span>
<div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
{LISTA_DESARROLLOS.map((id) => {
const activo = id === desarrollo
const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length
const disponibles = unidades.filter(
(u) => (u.desarrollo ?? 'DIAGONAL') === id && u.status === 'DISPONIBLE',
).length
return (
<button
key={id}
type="button"
aria-pressed={activo}
onClick={() => setDesarrollo(id)}
className={`px-4 py-2.5 text-xs font-medium transition-colors duration-100 ${
activo
? 'bg-accent text-white'
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
}`}
>
{obtenerDesarrollo(id).nombre}
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B]'}`}>
{disponibles}/{total}
</span>
</button>
)
})}
<div className="grid items-start gap-6 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
{/* Panel de configuración */}
<aside className="space-y-6 lg:sticky lg:top-24">
<section className="space-y-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
{error && <p className="text-xs text-red-700">{error}</p>}
{usandoSeed && !cargando && (
<p className="text-xs text-amber-700">Datos de demostración (sin conexión al servidor)</p>
)}
{cargando ? (
<p className="text-sm text-[#64748B]">Cargando unidades…</p>
) : (
<>
<div>
<span className={etiquetaCampo}>Desarrollo</span>
<div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
{LISTA_DESARROLLOS.map((id) => {
const activo = id === desarrollo
const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length
const disponibles = unidades.filter(
(u) => (u.desarrollo ?? 'DIAGONAL') === id && u.status === 'DISPONIBLE',
).length
return (
<button
key={id}
type="button"
aria-pressed={activo}
onClick={() => setDesarrollo(id)}
className={`px-4 py-2.5 text-xs font-medium transition-colors duration-100 ${
activo
? 'bg-accent text-white'
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
}`}
>
{obtenerDesarrollo(id).nombre}
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B]'}`}>
{disponibles}/{total}
</span>
</button>
)
})}
</div>
<p className="mt-2 text-xs text-[#64748B]">
{unidadesDesarrollo.length} unidades · {disponiblesDesarrollo} disponibles
</p>
</div>
<p className="mt-2 text-xs text-[#64748B]">
{unidadesDesarrollo.length} unidades · {disponiblesDesarrollo} disponibles
<UnitSelector
unidades={unidadesDesarrollo}
valor={unidad?.codigo ?? ''}
onChange={(codigo) =>
setUnidad(unidadesDesarrollo.find((u) => u.codigo === codigo) ?? null)
}
/>
</>
)}
{!params.precioUnico && (
<div>
<span className={etiquetaCampo}>Cocina y closet</span>
<div className="inline-flex overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
{(['SI', 'NO'] as const).map((opcion) => {
const activo = (opcion === 'SI') === acabados
return (
<button
key={opcion}
type="button"
aria-pressed={activo}
onClick={() => setAcabados(opcion === 'SI')}
className={`px-5 py-2.5 text-xs font-medium transition-colors duration-100 ${
activo
? 'bg-accent text-white'
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
}`}
>
{opcion}
</button>
)
})}
</div>
</div>
)}
<div>
<label htmlFor="apartado" className={etiquetaCampo}>
Monto de apartado (MXN)
</label>
<input
id="apartado"
type="number"
min={0}
step={1000}
value={apartado}
onChange={(e) => {
const v = Number(e.target.value)
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
}}
className={errorApartado ? campoInputError : campoInput}
/>
{errorApartado ? (
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
) : (
<p className="mt-1 text-xs text-[#64748B]">{formatoMoneda(apartado)}</p>
)}
</div>
<div>
<label htmlFor="nombre" className={etiquetaCampo}>
Nombre del cliente *
</label>
<input
id="nombre"
type="text"
className={errors.nombre ? campoInputError : campoInput}
{...register('nombre')}
/>
{errors.nombre && <p className="mt-1 text-xs text-red-700">{errors.nombre.message}</p>}
</div>
<div>
<label htmlFor="email" className={etiquetaCampo}>
Correo electrónico (opcional)
</label>
<input
id="email"
type="email"
className={errors.email ? campoInputError : campoInput}
{...register('email')}
/>
{errors.email && <p className="mt-1 text-xs text-red-700">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="telefono" className={etiquetaCampo}>
Teléfono (opcional)
</label>
<input
id="telefono"
type="tel"
className={errors.telefono ? campoInputError : campoInput}
{...register('telefono')}
/>
{errors.telefono && (
<p className="mt-1 text-xs text-red-700">{errors.telefono.message}</p>
)}
</div>
{unidad && (
<div className="border-t border-[#E5E7EB] pt-6">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Precio de lista
</p>
<p className="mt-2 text-3xl font-semibold text-[#111827]">
{formatoMoneda(precioLista)}
</p>
</div>
<UnitSelector
unidades={unidadesDesarrollo}
valor={unidad?.codigo ?? ''}
onChange={(codigo) =>
setUnidad(unidadesDesarrollo.find((u) => u.codigo === codigo) ?? null)
}
)}
{unidad && (
<button
type="button"
onClick={handleNueva}
className="w-full 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]"
>
Nueva cotización
</button>
)}
</section>
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
</aside>
{/* Panel de propuestas */}
<section className="min-w-0">
{!unidad ? (
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white">
<p className="text-sm text-[#64748B]">
Selecciona una unidad para ver las propuestas comerciales.
</p>
</div>
) : (
<div className="space-y-8">
<div className="grid gap-6 md:grid-cols-2">
{propuestas.map((p, i) => (
<ProposalCard
key={p.tipo}
numero={i + 1}
titulo={p.titulo}
propuesta={p}
onDescuentoChange={(v) => setDescuento(params.propuestas[i].claveDescuento, v)}
/>
))}
</div>
<ComparadorPropuestas
precioLista={precioLista}
apartado={apartadoEfectivo}
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/>
</>
)}
{!params.precioUnico && (
<div>
<span className={etiquetaCampo}>Cocina y closet</span>
<div className="inline-flex overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
{(['SI', 'NO'] as const).map((opcion) => {
const activo = (opcion === 'SI') === acabados
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-center justify-end gap-4">
<div>
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
Propuesta elegida
</label>
<select
id="propuesta-elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</div>
<button
key={opcion}
type="button"
aria-pressed={activo}
onClick={() => setAcabados(opcion === 'SI')}
className={`px-5 py-2.5 text-xs font-medium transition-colors duration-100 ${
activo
? 'bg-accent text-white'
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
}`}
onClick={handleCompartir}
disabled={!puedeGenerarPdf}
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
{opcion}
Compartir
</button>
)
})}
<button
type="button"
onClick={handleGenerarPdf}
disabled={!puedeGenerarPdf}
className="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 disabled:cursor-not-allowed disabled:opacity-40"
>
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
</button>
</div>
{aviso && <p className="mt-2 text-right text-xs text-[#64748B]">{aviso}</p>}
{!puedeGenerarPdf && !guardando && (
<p className="mt-2 text-right text-xs text-[#64748B]">
Selecciona una unidad para guardar y generar el PDF.
</p>
)}
</div>
</div>
</div>
)}
<div>
<label htmlFor="apartado" className={etiquetaCampo}>
Monto de apartado (MXN)
</label>
<input
id="apartado"
type="number"
min={0}
step={1000}
value={apartado}
onChange={(e) => {
const v = Number(e.target.value)
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
}}
className={errorApartado ? campoInputError : campoInput}
/>
{errorApartado ? (
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
) : (
<p className="mt-1 text-xs text-[#64748B]">{formatoMoneda(apartado)}</p>
)}
</div>
<div>
<label htmlFor="nombre" className={etiquetaCampo}>
Nombre del cliente *
</label>
<input
id="nombre"
type="text"
className={errors.nombre ? campoInputError : campoInput}
{...register('nombre')}
/>
{errors.nombre && <p className="mt-1 text-xs text-red-700">{errors.nombre.message}</p>}
</div>
<div>
<label htmlFor="email" className={etiquetaCampo}>
Correo electrónico (opcional)
</label>
<input
id="email"
type="email"
className={errors.email ? campoInputError : campoInput}
{...register('email')}
/>
{errors.email && <p className="mt-1 text-xs text-red-700">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="telefono" className={etiquetaCampo}>
Teléfono (opcional)
</label>
<input
id="telefono"
type="tel"
className={errors.telefono ? campoInputError : campoInput}
{...register('telefono')}
/>
{errors.telefono && (
<p className="mt-1 text-xs text-red-700">{errors.telefono.message}</p>
)}
</div>
{unidad && (
<div className="border-t border-[#E5E7EB] pt-8">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Precio de lista
</p>
<p className="mt-2 text-3xl font-semibold text-[#111827]">
{formatoMoneda(precioLista)}
</p>
</div>
)}
{unidad && (
<button
type="button"
onClick={handleNueva}
className="w-full 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]"
>
Nueva cotización
</button>
)}
</section>
</div>
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
</aside>
{/* Panel de propuestas */}
<section>
{/* Secciones de ancho completo */}
<div className="mt-10 space-y-8">
<MisCotizaciones
cotizaciones={misCotizaciones}
cargando={cargandoMias}
error={errorMias}
onRetomar={handleRetomar}
/>
{!unidad ? (
<div className="mt-16 flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white">
<p className="text-sm text-[#64748B]">
Selecciona una unidad para ver las propuestas comerciales.
</p>
</div>
) : (
<div className="mt-16">
<div className="grid gap-8 md:grid-cols-2">
{propuestas.map((p, i) => (
<ProposalCard
key={p.tipo}
numero={i + 1}
titulo={p.titulo}
propuesta={p}
onDescuentoChange={(v) => setDescuento(params.propuestas[i].claveDescuento, v)}
/>
))}
</div>
<ComparadorPropuestas
precioLista={precioLista}
apartado={apartadoEfectivo}
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/>
<div className="mt-16 flex flex-wrap items-center justify-end gap-4">
<div>
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
Propuesta elegida
</label>
<select
id="propuesta-elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</div>
<button
type="button"
onClick={handleCompartir}
disabled={!puedeGenerarPdf}
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
Compartir
</button>
<button
type="button"
onClick={handleGenerarPdf}
disabled={!puedeGenerarPdf}
className="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 disabled:cursor-not-allowed disabled:opacity-40"
>
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
</button>
</div>
{aviso && <p className="mt-2 text-right text-xs text-[#64748B]">{aviso}</p>}
{!puedeGenerarPdf && !guardando && (
<p className="mt-2 text-right text-xs text-[#64748B]">
Selecciona una unidad para guardar y generar el PDF.
</p>
)}
</div>
)}
<ComparadorUnidades
unidades={unidades}
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
apartado={apartadoEfectivo}
acabados={acabados}
/>
</section>
</div>
</main>
<ModalCliente