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:
146
apps/cotizador/src/components/ComparadorPropuestas.tsx
Normal file
146
apps/cotizador/src/components/ComparadorPropuestas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
apps/cotizador/src/components/ProposalCard.tsx
Normal file
83
apps/cotizador/src/components/ProposalCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
apps/cotizador/src/components/UnitInfoCard.tsx
Normal file
35
apps/cotizador/src/components/UnitInfoCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
143
apps/cotizador/src/components/UnitSelector.tsx
Normal file
143
apps/cotizador/src/components/UnitSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user