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,146 @@
import { useState } from 'react'
import type { PropuestaResultado } from '../lib/calculations'
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
interface Props {
precioLista: number
apartado: number
propuestas: PropuestaResultado[]
titulos: string[]
}
interface Fila {
etiqueta: string
obtener: (p: PropuestaResultado, i: number) => string
destacada?: boolean
}
/** Sección colapsable con la tabla comparativa de las 4 propuestas */
export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulos }: Props) {
const [abierto, setAbierto] = useState(false)
const totales = propuestas.map((p) => totalPagado(p, apartado))
const ganador = indiceMenorTotal(totales)
const filas: Fila[] = [
{ etiqueta: 'Precio de lista', obtener: () => formatoMoneda(precioLista) },
{
etiqueta: 'Descuento',
obtener: (p) => `${formatoMoneda(p.montoDescuento)} (${formatoPorcentaje(p.descuento)})`,
},
{ etiqueta: 'Precio final c/desc.', obtener: (p) => formatoMoneda(p.precioFinal) },
{ etiqueta: 'Enganche', obtener: (p) => formatoMoneda(p.enganche) },
{
etiqueta: 'Mensualidad',
obtener: (p) =>
p.mensualidad === null
? 'N/A'
: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
},
{
etiqueta: 'Total mensualidades',
obtener: (p) => (p.mensualidadesTotal === null ? 'N/A' : formatoMoneda(p.mensualidadesTotal)),
},
{ etiqueta: 'Pago final', obtener: (p) => formatoMoneda(p.pagoFinal) },
{ etiqueta: 'Total pagado', obtener: (_p, i) => formatoMoneda(totales[i]), destacada: true },
{
etiqueta: 'Diferencia vs precio de lista',
obtener: (_p, i) => formatoMoneda(totales[i] - precioLista),
},
]
/** Borde acento que enmarca la columna con el menor total pagado */
const bordeColumna = (i: number, posicion: 'inicio' | 'media' | 'fin'): string => {
if (i !== ganador) return ''
const laterales = 'border-x-2 border-x-accent'
if (posicion === 'inicio') return `${laterales} border-t-2 border-t-accent`
if (posicion === 'fin') return `${laterales} border-b-2 border-b-accent`
return laterales
}
return (
<section className="mt-16" aria-labelledby="comparador-titulo">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2
id="comparador-titulo"
className="text-xs font-bold tracking-widest text-neutral-500 uppercase"
>
Comparar propuestas
</h2>
<button
type="button"
aria-expanded={abierto}
aria-controls="comparador-contenido"
onClick={() => setAbierto((v) => !v)}
className="border border-neutral-900 bg-white px-5 py-2.5 text-xs font-bold tracking-widest text-neutral-900 uppercase transition-colors duration-150 hover:bg-neutral-100"
>
{abierto ? 'Ocultar comparación' : 'Comparar propuestas'}
</button>
</div>
{abierto && (
<div id="comparador-contenido" className="mt-6 border border-neutral-200 bg-white p-6">
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] border-collapse text-sm">
<thead>
<tr>
<th scope="col" className="w-48 p-3 text-left align-bottom">
<span className="sr-only">Concepto</span>
</th>
{propuestas.map((p, i) => (
<th
key={p.tipo}
scope="col"
className={`p-3 text-right align-bottom ${bordeColumna(i, 'inicio')}`}
>
<span className="block text-xs font-bold tracking-widest text-neutral-900 uppercase">
{titulos[i] ?? p.tipo}
</span>
{i === ganador && (
<span className="mt-2 inline-block border border-accent px-2 py-0.5 text-xs font-bold tracking-widest text-accent uppercase">
Menor total
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{filas.map((fila, indiceFila) => (
<tr key={fila.etiqueta}>
<th
scope="row"
className="border-t border-neutral-200 p-3 text-left text-xs font-bold tracking-widest text-neutral-500 uppercase"
>
{fila.etiqueta}
</th>
{propuestas.map((p, i) => (
<td
key={p.tipo}
className={`border-t border-neutral-200 p-3 text-right whitespace-nowrap ${
fila.destacada
? `font-bold ${i === ganador ? 'text-accent' : 'text-neutral-900'}`
: 'font-light text-neutral-900'
} ${bordeColumna(
i,
indiceFila === filas.length - 1 ? 'fin' : 'media',
)}`}
>
{fila.obtener(p, i)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-6 text-xs text-neutral-400">
El enganche ya incluye la deducción del apartado. El total pagado suma apartado +
enganche + mensualidades + pago final.
</p>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,83 @@
import type { PropuestaResultado } from '../lib/calculations'
import { formatoMoneda } from '../lib/formatters'
interface Props {
numero: number
titulo: string
propuesta: PropuestaResultado
onDescuentoChange: (fraccion: number) => void
}
function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; nota?: string }) {
return (
<div className="flex items-baseline justify-between gap-2 py-2">
<span className="text-xs font-bold tracking-widest text-neutral-500 uppercase">
{etiqueta}
{nota && (
<span className="mt-0.5 block text-xs font-normal tracking-normal text-neutral-400 normal-case">
{nota}
</span>
)}
</span>
<span className="text-lg font-light text-neutral-900">{valor}</span>
</div>
)
}
/** Tarjeta de una propuesta comercial con descuento editable */
export function ProposalCard({ numero, titulo, propuesta, onDescuentoChange }: Props) {
const porcentaje = propuesta.descuento * 100
return (
<article className="flex flex-col border border-neutral-200 bg-white p-6">
<header className="mb-6 border-b border-neutral-200 pb-4">
<p className="text-xs font-bold tracking-widest text-accent uppercase">
Propuesta comercial #{numero}
</p>
<h2 className="mt-1 text-xl font-light text-neutral-900">{titulo}</h2>
</header>
<div className="mb-6">
<label
htmlFor={`descuento-${propuesta.tipo}`}
className="mb-2 block text-xs font-bold tracking-widest text-neutral-500 uppercase"
>
Descuento (%)
</label>
<input
id={`descuento-${propuesta.tipo}`}
type="number"
min={0}
max={100}
step={0.5}
value={porcentaje}
onChange={(e) => {
const v = Number(e.target.value)
onDescuentoChange(Number.isFinite(v) ? Math.min(100, Math.max(0, v)) / 100 : 0)
}}
className="w-28 border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
/>
</div>
<div className="divide-y divide-neutral-200">
<Renglon etiqueta="Monto de descuento" valor={formatoMoneda(propuesta.montoDescuento)} />
<Renglon etiqueta="Precio final c/desc." valor={formatoMoneda(propuesta.precioFinal)} />
<Renglon
etiqueta="Enganche"
nota="incluye deducción del apartado"
valor={formatoMoneda(propuesta.enganche)}
/>
{propuesta.mensualidad !== null && propuesta.mensualidadesTotal !== null ? (
<Renglon
etiqueta="Mensualidades"
nota={`${propuesta.numMensualidades} × ${formatoMoneda(propuesta.mensualidad)}`}
valor={formatoMoneda(propuesta.mensualidadesTotal)}
/>
) : (
<Renglon etiqueta="Mensualidades" valor="N/A" />
)}
<Renglon etiqueta="Pago final" valor={formatoMoneda(propuesta.pagoFinal)} />
</div>
</article>
)
}

View File

@@ -0,0 +1,35 @@
import type { Unidad } from '../types'
import { formatoFechaCorta } from '../lib/formatters'
interface Props {
unidad: Unidad
numMeses: number
}
function Fila({ etiqueta, valor }: { etiqueta: string; valor: string }) {
return (
<div className="flex items-center justify-between py-2">
<dt className="text-sm text-neutral-500">{etiqueta}</dt>
<dd className="text-sm font-medium text-neutral-900">{valor}</dd>
</div>
)
}
/** Tarjeta con la ficha técnica de la unidad seleccionada */
export function UnitInfoCard({ unidad, numMeses }: Props) {
return (
<section className="border border-neutral-200 bg-white p-6">
<h2 className="mb-4 text-xs font-bold tracking-widest text-neutral-900 uppercase">
Unidad {unidad.codigo}
</h2>
<dl className="divide-y divide-neutral-200">
<Fila etiqueta="Modelo" valor={unidad.modelo} />
<Fila etiqueta="Sup. construcción" valor={`${unidad.supConstruccion.toLocaleString('es-MX')} m²`} />
<Fila etiqueta="Sup. excedente" valor={`${unidad.supExcedente.toLocaleString('es-MX')} m²`} />
<Fila etiqueta="Sup. estacionamiento" valor={`${unidad.supEstacionamiento.toLocaleString('es-MX')} m²`} />
<Fila etiqueta="Fecha de entrega (obra)" valor={formatoFechaCorta(unidad.fechaEntrega)} />
<Fila etiqueta="Meses restantes" valor={String(numMeses)} />
</dl>
</section>
)
}

View File

@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react'
import type { Unidad } from '../types'
import { filtrarUnidades } from '../lib/filtros'
import { formatoMoneda } from '../lib/formatters'
interface Props {
unidades: Unidad[]
valor: string
onChange: (codigo: string) => void
}
function etiquetaUnidad(u: Unidad): string {
return `${u.codigo} — Modelo ${u.modelo} — ${formatoMoneda(u.precioSinAcabados)}`
}
/** Combobox con búsqueda de unidades disponibles: filtra por código, modelo o precio */
export function UnitSelector({ unidades, valor, onChange }: Props) {
const seleccionada = unidades.find((u) => u.codigo === valor) ?? null
const [texto, setTexto] = useState(() => (seleccionada ? etiquetaUnidad(seleccionada) : ''))
const [abierto, setAbierto] = useState(false)
const [indiceActivo, setIndiceActivo] = useState(-1)
const contenedorRef = useRef<HTMLDivElement>(null)
// Si el texto es la etiqueta de la unidad ya elegida, se muestran todas las opciones
const filtro = seleccionada && texto === etiquetaUnidad(seleccionada) ? '' : texto
const opciones = filtrarUnidades(unidades, filtro)
const cerrar = () => {
setAbierto(false)
setIndiceActivo(-1)
}
const elegir = (u: Unidad) => {
setTexto(etiquetaUnidad(u))
onChange(u.codigo)
cerrar()
}
// Click fuera: cierra y restaura la etiqueta de la unidad seleccionada
useEffect(() => {
if (!abierto) return
const onPointerDown = (e: MouseEvent) => {
if (contenedorRef.current && !contenedorRef.current.contains(e.target as Node)) {
cerrar()
setTexto(seleccionada ? etiquetaUnidad(seleccionada) : '')
}
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [abierto, seleccionada])
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!abierto) {
setAbierto(true)
setIndiceActivo(opciones.length > 0 ? 0 : -1)
} else {
setIndiceActivo((i) => Math.min(i + 1, opciones.length - 1))
}
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setIndiceActivo((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
if (abierto && indiceActivo >= 0 && opciones[indiceActivo]) {
e.preventDefault()
elegir(opciones[indiceActivo])
}
} else if (e.key === 'Escape') {
cerrar()
setTexto(seleccionada ? etiquetaUnidad(seleccionada) : '')
}
}
const opcionActiva = indiceActivo >= 0 ? opciones[indiceActivo] : undefined
return (
<div ref={contenedorRef} className="relative">
<label
htmlFor="unidad"
className="mb-2 block text-xs font-bold tracking-widest text-neutral-500 uppercase"
>
Unidad
</label>
<input
id="unidad"
type="text"
role="combobox"
aria-expanded={abierto}
aria-controls="unidad-listbox"
aria-autocomplete="list"
aria-activedescendant={opcionActiva ? `unidad-opcion-${opcionActiva.codigo}` : undefined}
autoComplete="off"
placeholder="Busca por código, modelo o precio…"
value={texto}
onChange={(e) => {
setTexto(e.target.value)
setAbierto(true)
setIndiceActivo(-1)
}}
onFocus={() => setAbierto(true)}
onKeyDown={onKeyDown}
className="w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
/>
{abierto && (
<ul
id="unidad-listbox"
role="listbox"
aria-label="Unidades disponibles"
className="absolute z-10 mt-1 max-h-64 w-full overflow-auto border border-neutral-300 bg-white"
>
{opciones.length === 0 ? (
<li className="px-3 py-2.5 text-sm text-neutral-500">Sin unidades que coincidan</li>
) : (
opciones.map((u, i) => (
<li
key={u.codigo}
id={`unidad-opcion-${u.codigo}`}
role="option"
aria-selected={u.codigo === valor}
ref={(el) => {
if (i === indiceActivo) el?.scrollIntoView({ block: 'nearest' })
}}
onMouseDown={(e) => {
e.preventDefault()
elegir(u)
}}
onMouseEnter={() => setIndiceActivo(i)}
className={`cursor-pointer px-3 py-2.5 text-sm ${
i === indiceActivo ? 'bg-neutral-100' : 'bg-white'
}`}
>
<span className="font-medium text-neutral-900">{u.codigo}</span>
<span className="text-neutral-500"> — Modelo {u.modelo} — </span>
<span className="text-neutral-900">{formatoMoneda(u.precioSinAcabados)}</span>
</li>
))
)}
</ul>
)}
</div>
)
}