feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing
- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador, combobox de unidades, vigencia, PDF ANEXO A - Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE, checklist de expediente, firma digital, 5 PDFs legales - Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF, bitacora, dashboard con filtros - Landing (/): entrada al ecosistema - Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps - 123 tests vitest
This commit is contained in:
260
apps/cotizador/src/pages/CotizadorPage.tsx
Normal file
260
apps/cotizador/src/pages/CotizadorPage.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useUnidades } from '../hooks/useUnidades'
|
||||
import { useCotizacion } from '../hooks/useCotizacion'
|
||||
import { UnitSelector } from '../components/UnitSelector'
|
||||
import { UnitInfoCard } from '../components/UnitInfoCard'
|
||||
import { ProposalCard } from '../components/ProposalCard'
|
||||
import { ComparadorPropuestas } from '../components/ComparadorPropuestas'
|
||||
import { generarPdf } from '../lib/pdf'
|
||||
import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
|
||||
import { calcularVigencia } from '../lib/vigencia'
|
||||
import type { Cliente } from '../types'
|
||||
|
||||
const esquemaCliente = z.object({
|
||||
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
|
||||
email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(),
|
||||
telefono: z
|
||||
.union([z.literal(''), z.string().regex(/^[0-9+\-\s()]{7,15}$/, 'Teléfono inválido')])
|
||||
.optional(),
|
||||
})
|
||||
|
||||
type FormularioCliente = z.infer<typeof esquemaCliente>
|
||||
|
||||
const TITULOS_PROPUESTAS: [string, keyof import('../types').DescuentosPropuestas][] = [
|
||||
['Crédito Bancario', 'creditoBancario'],
|
||||
['Crédito Infonavit-Cofinavit', 'creditoInfonavit'],
|
||||
['Contado', 'contado'],
|
||||
['Semi Contado', 'semiContado'],
|
||||
]
|
||||
|
||||
const campoInputBase =
|
||||
'w-full border bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
|
||||
const campoInput = `${campoInputBase} border-neutral-300`
|
||||
const campoInputError = `${campoInputBase} border-red-400`
|
||||
|
||||
const etiquetaCampo = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500'
|
||||
|
||||
export default function CotizadorPage() {
|
||||
const { unidades, cargando, error } = useUnidades()
|
||||
const cotizacion = useCotizacion()
|
||||
const {
|
||||
unidad,
|
||||
setUnidad,
|
||||
acabados,
|
||||
setAcabados,
|
||||
apartado,
|
||||
setApartado,
|
||||
setDescuento,
|
||||
precioLista,
|
||||
numMeses,
|
||||
propuestas,
|
||||
} = cotizacion
|
||||
|
||||
const {
|
||||
register,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormularioCliente>({
|
||||
resolver: zodResolver(esquemaCliente),
|
||||
mode: 'onChange',
|
||||
defaultValues: { nombre: '', email: '', telefono: '' },
|
||||
})
|
||||
|
||||
const nombreCliente = watch('nombre') ?? ''
|
||||
const puedeGenerarPdf = Boolean(unidad) && nombreCliente.trim().length >= 3
|
||||
|
||||
const handleGenerarPdf = () => {
|
||||
if (!unidad) return
|
||||
const cliente: Cliente = {
|
||||
nombre: nombreCliente.trim(),
|
||||
email: watch('email') || undefined,
|
||||
telefono: watch('telefono') || undefined,
|
||||
}
|
||||
generarPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<header className="border-b border-neutral-200 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 tracking-widest text-neutral-900">
|
||||
<span className="font-bold">FRASA DESARROLLOS</span>{' '}
|
||||
<span className="font-light text-neutral-500">— Cotizador</span>
|
||||
</h1>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-bold tracking-widest text-neutral-500 uppercase capitalize">
|
||||
{formatoFechaLarga(new Date())}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
|
||||
</p>
|
||||
</div>
|
||||
</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 border border-neutral-200 bg-white p-6">
|
||||
{error && <p className="text-xs text-red-700">{error}</p>}
|
||||
{cargando ? (
|
||||
<p className="text-sm text-neutral-500">Cargando unidades…</p>
|
||||
) : (
|
||||
<UnitSelector
|
||||
unidades={unidades}
|
||||
valor={unidad?.codigo ?? ''}
|
||||
onChange={(codigo) =>
|
||||
setUnidad(unidades.find((u) => u.codigo === codigo) ?? null)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className={etiquetaCampo}>Cocina y closet</span>
|
||||
<div className="inline-flex border border-neutral-300" 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-bold tracking-widest uppercase transition-colors duration-150 ${
|
||||
activo
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-white text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{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={campoInput}
|
||||
/>
|
||||
</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-neutral-200 pt-8">
|
||||
<p className="text-xs font-bold tracking-widest text-neutral-500 uppercase">
|
||||
Precio de lista
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-light text-neutral-900">
|
||||
{formatoMoneda(precioLista)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
|
||||
</aside>
|
||||
|
||||
{/* Panel de propuestas */}
|
||||
<section>
|
||||
{!unidad ? (
|
||||
<div className="flex h-64 items-center justify-center border border-dashed border-neutral-300 bg-white">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Selecciona una unidad para ver las propuestas comerciales.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{propuestas.map((p, i) => (
|
||||
<ProposalCard
|
||||
key={p.tipo}
|
||||
numero={i + 1}
|
||||
titulo={TITULOS_PROPUESTAS[i][0]}
|
||||
propuesta={p}
|
||||
onDescuentoChange={(v) => setDescuento(TITULOS_PROPUESTAS[i][1], v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ComparadorPropuestas
|
||||
precioLista={precioLista}
|
||||
apartado={apartado}
|
||||
propuestas={propuestas}
|
||||
titulos={TITULOS_PROPUESTAS.map(([titulo]) => titulo)}
|
||||
/>
|
||||
<div className="mt-16 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerarPdf}
|
||||
disabled={!puedeGenerarPdf}
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold tracking-widest text-white uppercase transition-colors duration-150 hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Generar PDF
|
||||
</button>
|
||||
</div>
|
||||
{!puedeGenerarPdf && (
|
||||
<p className="mt-2 text-right text-xs text-neutral-400">
|
||||
Selecciona una unidad y captura el nombre del cliente para generar el PDF.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user