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:
2026-08-21 22:27:36 +00:00
commit ecd4edac02
147 changed files with 27345 additions and 0 deletions

View 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>
)
}