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,9 @@
node_modules
dist
.git
.gitignore
*.log
coverage
.vscode
Dockerfile
.dockerignore

26
apps/cotizador/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
coverage

14
apps/cotizador/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# Etapa 1: build
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Etapa 2: servidor estático
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

32
apps/cotizador/README.md Normal file
View File

@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

12
apps/cotizador/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FRASA Desarrollos — Cotizador</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

18
apps/cotizador/nginx.conf Normal file
View File

@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback: cualquier ruta no existente sirve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Assets con hash: cache agresivo
location /quote/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

4909
apps/cotizador/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "cotizador",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "^5.3.0",
"@hookform/resolvers": "^3.9.1",
"jspdf": "^2.5.2",
"jspdf-autotable": "^3.8.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-router-dom": "^7.1.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.8",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.3",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}

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

View File

@@ -0,0 +1,43 @@
import type { Unidad } from '../types'
// Inventario real — 38 unidades, todas DISPONIBLE
export const UNIDADES_SEED: Unidad[] = [
{ codigo: 'U-101', modelo: '2RA', fechaEntrega: '2027-10-08', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 21.336, precioSinAcabados: 4415000, precioConAcabados: 4579000, status: 'DISPONIBLE' },
{ codigo: 'U-102', modelo: '1R', fechaEntrega: '2027-10-08', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 9.132, precioSinAcabados: 3107108.39, precioConAcabados: 3269000, status: 'DISPONIBLE' },
{ codigo: 'U-103', modelo: '1R', fechaEntrega: '2027-10-08', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 9.132, precioSinAcabados: 3107108.39, precioConAcabados: 3269000, status: 'DISPONIBLE' },
{ codigo: 'U-104', modelo: '1R', fechaEntrega: '2027-10-08', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 9.132, precioSinAcabados: 3107108.39, precioConAcabados: 3269000, status: 'DISPONIBLE' },
{ codigo: 'U-105', modelo: '1R', fechaEntrega: '2027-10-08', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 9.132, precioSinAcabados: 3107108.39, precioConAcabados: 3269000, status: 'DISPONIBLE' },
{ codigo: 'U-106', modelo: '1R', fechaEntrega: '2027-10-08', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 9.132, precioSinAcabados: 3107108.39, precioConAcabados: 3269000, status: 'DISPONIBLE' },
{ codigo: 'U-107', modelo: '2RA', fechaEntrega: '2027-10-08', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 20.842, precioSinAcabados: 4415000, precioConAcabados: 4579000, status: 'DISPONIBLE' },
{ codigo: 'U-108', modelo: '2RA', fechaEntrega: '2027-10-08', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 18.97, precioSinAcabados: 4415000, precioConAcabados: 4579000, status: 'DISPONIBLE' },
{ codigo: 'U-109', modelo: '1R', fechaEntrega: '2027-10-08', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 9.13, precioSinAcabados: 3107108.39, precioConAcabados: 3269000, status: 'DISPONIBLE' },
{ codigo: 'U-110', modelo: '1R', fechaEntrega: '2027-10-08', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 9.13, precioSinAcabados: 3107108.39, precioConAcabados: 3269000, status: 'DISPONIBLE' },
{ codigo: 'U-111', modelo: '1R', fechaEntrega: '2027-10-08', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 9.13, precioSinAcabados: 3107108.39, precioConAcabados: 3269000, status: 'DISPONIBLE' },
{ codigo: 'U-112', modelo: '1R', fechaEntrega: '2027-10-08', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 9.13, precioSinAcabados: 3107108.39, precioConAcabados: 3269000, status: 'DISPONIBLE' },
{ codigo: 'U-113', modelo: '1R', fechaEntrega: '2027-10-08', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 9.13, precioSinAcabados: 3107108.39, precioConAcabados: 3269000, status: 'DISPONIBLE' },
{ codigo: 'U-114', modelo: '2RA', fechaEntrega: '2027-10-08', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 18.476, precioSinAcabados: 4415000, precioConAcabados: 4579000, status: 'DISPONIBLE' },
{ codigo: 'U-201', modelo: '2RA', fechaEntrega: '2027-10-15', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 0, precioSinAcabados: 3960000, precioConAcabados: 4127000, status: 'DISPONIBLE' },
{ codigo: 'U-202', modelo: '1R', fechaEntrega: '2027-10-15', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 0, precioSinAcabados: 2850000, precioConAcabados: 2950000, status: 'DISPONIBLE' },
{ codigo: 'U-203', modelo: '1R', fechaEntrega: '2027-10-22', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 0, precioSinAcabados: 2850000, precioConAcabados: 2950000, status: 'DISPONIBLE' },
{ codigo: 'U-204', modelo: '1R', fechaEntrega: '2027-10-22', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 0, precioSinAcabados: 2850000, precioConAcabados: 2950000, status: 'DISPONIBLE' },
{ codigo: 'U-205', modelo: '1R', fechaEntrega: '2027-10-22', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 0, precioSinAcabados: 2850000, precioConAcabados: 2950000, status: 'DISPONIBLE' },
{ codigo: 'U-206', modelo: '1R', fechaEntrega: '2027-10-29', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 0, precioSinAcabados: 2850000, precioConAcabados: 2950000, status: 'DISPONIBLE' },
{ codigo: 'U-207', modelo: '2RA', fechaEntrega: '2027-10-29', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 0, precioSinAcabados: 3960000, precioConAcabados: 4127000, status: 'DISPONIBLE' },
{ codigo: 'U-208', modelo: '2RA', fechaEntrega: '2027-10-29', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 0, precioSinAcabados: 3960000, precioConAcabados: 4127000, status: 'DISPONIBLE' },
{ codigo: 'U-209', modelo: '1R', fechaEntrega: '2027-10-29', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 0, precioSinAcabados: 2850000, precioConAcabados: 2950000, status: 'DISPONIBLE' },
{ codigo: 'U-210', modelo: '1R', fechaEntrega: '2027-10-29', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 0, precioSinAcabados: 2850000, precioConAcabados: 2950000, status: 'DISPONIBLE' },
{ codigo: 'U-211', modelo: '1R', fechaEntrega: '2027-11-05', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 0, precioSinAcabados: 2850000, precioConAcabados: 2950000, status: 'DISPONIBLE' },
{ codigo: 'U-212', modelo: '1R', fechaEntrega: '2027-11-05', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 0, precioSinAcabados: 2850000, precioConAcabados: 2950000, status: 'DISPONIBLE' },
{ codigo: 'U-213', modelo: '1R', fechaEntrega: '2027-11-12', supEstacionamiento: 14.31, supConstruccion: 47.87, supExcedente: 0, precioSinAcabados: 2850000, precioConAcabados: 2950000, status: 'DISPONIBLE' },
{ codigo: 'U-214', modelo: '2RA', fechaEntrega: '2027-11-12', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 0, precioSinAcabados: 3960000, precioConAcabados: 4127000, status: 'DISPONIBLE' },
{ codigo: 'U-301', modelo: '2RA', fechaEntrega: '2027-11-12', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 0, precioSinAcabados: 4150000, precioConAcabados: 4315000, status: 'DISPONIBLE' },
{ codigo: 'U-302', modelo: '2RB', fechaEntrega: '2027-11-12', supEstacionamiento: 28.62, supConstruccion: 68.757, supExcedente: 0, precioSinAcabados: 4120000, precioConAcabados: 4280000, status: 'DISPONIBLE' },
{ codigo: 'U-303', modelo: '2RC', fechaEntrega: '2027-11-19', supEstacionamiento: 28.62, supConstruccion: 72.328, supExcedente: 0, precioSinAcabados: 4300000, precioConAcabados: 4450000, status: 'DISPONIBLE' },
{ codigo: 'U-304', modelo: '2RC', fechaEntrega: '2027-11-19', supEstacionamiento: 28.62, supConstruccion: 72.328, supExcedente: 0, precioSinAcabados: 4300000, precioConAcabados: 4450000, status: 'DISPONIBLE' },
{ codigo: 'U-305', modelo: '2RA', fechaEntrega: '2027-11-19', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 0, precioSinAcabados: 4150000, precioConAcabados: 4315000, status: 'DISPONIBLE' },
{ codigo: 'U-306', modelo: '2RA', fechaEntrega: '2027-11-19', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 0, precioSinAcabados: 4150000, precioConAcabados: 4315000, status: 'DISPONIBLE' },
{ codigo: 'U-307', modelo: '2RC', fechaEntrega: '2027-11-26', supEstacionamiento: 28.62, supConstruccion: 72.328, supExcedente: 0, precioSinAcabados: 4300000, precioConAcabados: 4450000, status: 'DISPONIBLE' },
{ codigo: 'U-308', modelo: '2RC', fechaEntrega: '2027-11-26', supEstacionamiento: 28.62, supConstruccion: 72.328, supExcedente: 0, precioSinAcabados: 4300000, precioConAcabados: 4450000, status: 'DISPONIBLE' },
{ codigo: 'U-309', modelo: '2RB', fechaEntrega: '2027-11-26', supEstacionamiento: 28.62, supConstruccion: 68.757, supExcedente: 0, precioSinAcabados: 4120000, precioConAcabados: 4280000, status: 'DISPONIBLE' },
{ codigo: 'U-310', modelo: '2RA', fechaEntrega: '2027-11-26', supEstacionamiento: 28.62, supConstruccion: 69.485, supExcedente: 0, precioSinAcabados: 4150000, precioConAcabados: 4315000, status: 'DISPONIBLE' },
]

View File

@@ -0,0 +1,65 @@
import { useMemo, useState } from 'react'
import type { Unidad, DescuentosPropuestas } from '../types'
import {
APARTADO_DEFAULT,
DESCUENTOS_DEFAULT,
calcularNumMensualidades,
calcularPropuestas,
obtenerPrecioLista,
type PropuestaResultado,
} from '../lib/calculations'
interface UseCotizacionResult {
unidad: Unidad | null
setUnidad: (u: Unidad | null) => void
acabados: boolean
setAcabados: (v: boolean) => void
apartado: number
setApartado: (v: number) => void
descuentos: DescuentosPropuestas
setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void
precioLista: number
numMeses: number
propuestas: PropuestaResultado[]
}
/** Estado central de la cotización: unidad, acabados, apartado y descuentos */
export function useCotizacion(): UseCotizacionResult {
const [unidad, setUnidad] = useState<Unidad | null>(null)
const [acabados, setAcabados] = useState(false)
const [apartado, setApartado] = useState<number>(APARTADO_DEFAULT)
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>({ ...DESCUENTOS_DEFAULT })
const setDescuento = (clave: keyof DescuentosPropuestas, valor: number) => {
setDescuentos((prev) => ({ ...prev, [clave]: valor }))
}
const numMeses = useMemo(
() => (unidad ? calcularNumMensualidades(unidad.fechaEntrega) : 0),
[unidad],
)
const precioLista = useMemo(
() => (unidad ? obtenerPrecioLista(unidad, acabados) : 0),
[unidad, acabados],
)
const propuestas = useMemo(() => {
if (!unidad) return []
return calcularPropuestas({ unidad, acabados, apartado, descuentos })
}, [unidad, acabados, apartado, descuentos])
return {
unidad,
setUnidad,
acabados,
setAcabados,
apartado,
setApartado,
descuentos,
setDescuento,
precioLista,
numMeses,
propuestas,
}
}

View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react'
import type { Unidad } from '../types'
import { getUnidades } from '../services/api'
interface UseUnidadesResult {
unidades: Unidad[]
cargando: boolean
error: string | null
}
/** Carga el inventario de unidades (hoy desde seed; mañana desde el API Gateway) */
export function useUnidades(): UseUnidadesResult {
const [unidades, setUnidades] = useState<Unidad[]>([])
const [cargando, setCargando] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let activo = true
getUnidades()
.then((data) => {
if (activo) {
setUnidades(data)
setCargando(false)
}
})
.catch(() => {
if (activo) {
setError('No se pudo cargar el inventario de unidades.')
setCargando(false)
}
})
return () => {
activo = false
}
}, [])
return { unidades, cargando, error }
}

View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-accent: #1B4332;
--color-accent-hover: #132f23;
--radius-xs: 0;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-2xl: 0;
--radius-3xl: 0;
--radius-4xl: 0;
}
@layer base {
body {
@apply bg-white text-neutral-900 antialiased;
}
:focus-visible {
outline: 2px solid #1B4332;
outline-offset: 2px;
}
}

View File

@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest'
import {
calcularPropuestas,
calcularNumMensualidades,
obtenerPrecioLista,
totalPagado,
indiceMenorTotal,
APARTADO_DEFAULT,
} from './calculations'
import { UNIDADES_SEED } from '../data/unidades'
import type { Unidad } from '../types'
// Fixture validado contra el Excel real: U-101, con acabados, apartado 20000, N = 14
const U101: Unidad = UNIDADES_SEED.find((u) => u.codigo === 'U-101')!
const N = 14
function calcular(descuentos = { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 }) {
return calcularPropuestas({
unidad: U101,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos,
numMeses: N,
})
}
describe('fixture U-101 (validado contra Excel de producción)', () => {
it('precio de lista con acabados', () => {
expect(obtenerPrecioLista(U101, true)).toBe(4579000)
expect(obtenerPrecioLista(U101, false)).toBe(4415000)
})
it('Propuesta 1 — CRÉDITO BANCARIO (desc 0)', () => {
const [p] = calcular()
expect(p.enganche).toBeCloseTo(94475, 2)
expect(p.mensualidadesTotal).toBeCloseTo(343425, 2)
expect(p.mensualidad).toBeCloseTo(24530.357142, 4)
expect(p.pagoFinal).toBeCloseTo(4121100, 2)
})
it('Propuesta 2 — CRÉDITO INFONAVIT-COFINAVIT (desc 0)', () => {
const [, p] = calcular()
expect(p.enganche).toBeCloseTo(94475, 2)
expect(p.mensualidadesTotal).toBeCloseTo(114475, 2)
expect(p.mensualidad).toBeCloseTo(8176.785714, 4)
expect(p.pagoFinal).toBeCloseTo(4350050, 2)
})
it('Propuesta 3 — CONTADO (desc 2%)', () => {
const [, , p] = calcular()
expect(p.montoDescuento).toBeCloseTo(91580, 2)
expect(p.precioFinal).toBeCloseTo(4487420, 2)
expect(p.enganche).toBeCloseTo(4018678, 2)
expect(p.pagoFinal).toBeCloseTo(448742, 2)
expect(p.mensualidad).toBeNull()
expect(p.mensualidadesTotal).toBeNull()
})
it('Propuesta 4 — SEMI CONTADO (desc 1%)', () => {
const [, , , p] = calcular()
expect(p.montoDescuento).toBeCloseTo(45790, 2)
expect(p.precioFinal).toBeCloseTo(4533210, 2)
expect(p.enganche).toBeCloseTo(2246605, 2)
expect(p.mensualidadesTotal).toBeCloseTo(1813284, 2)
expect(p.mensualidad).toBeCloseTo(129520.285714, 4)
expect(p.pagoFinal).toBeCloseTo(453321, 2)
})
})
describe('comportamiento documentado del Excel', () => {
it('Bancario con descuento > 0: el enganche sigue calculándose sobre precio LISTA', () => {
const [p] = calcular({ creditoBancario: 0.05, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 })
// Enganche sobre precio lista (4579000), no sobre precio final (4350050)
expect(p.enganche).toBeCloseTo(0.025 * 4579000 - APARTADO_DEFAULT, 2)
expect(p.enganche).toBeCloseTo(94475, 2)
expect(p.montoDescuento).toBeCloseTo(228950, 2)
expect(p.precioFinal).toBeCloseTo(4350050, 2)
expect(p.pagoFinal).toBeCloseTo(0.9 * 4350050, 2)
// Mensualidades sí usan precio final
const total = 4350050 - APARTADO_DEFAULT - 94475 - 0.9 * 4350050
expect(p.mensualidadesTotal).toBeCloseTo(total, 2)
})
it('Infonavit usa precioLista (no precioFinal) en el total de mensualidades', () => {
const [, p] = calcular({ creditoBancario: 0, creditoInfonavit: 0.05, contado: 0.02, semiContado: 0.01 })
// Replicado tal cual del Excel: precioLista - pagoFinal - apartado - enganche
const total = 4579000 - p.pagoFinal - APARTADO_DEFAULT - p.enganche
expect(p.mensualidadesTotal).toBeCloseTo(total, 2)
})
})
describe('calcularNumMensualidades', () => {
it('calcula meses redondeados con 30.4 días por mes', () => {
const hoy = new Date('2026-08-20T00:00:00')
// 2027-10-08 son ~414 días => ~13.6 => 14 meses
expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(14)
})
it('nunca devuelve menos de 1', () => {
const hoy = new Date('2030-01-01T00:00:00')
expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(1)
})
})
describe('totalPagado', () => {
it('suma apartado + enganche + mensualidades + pago final', () => {
const [bancario] = calcular()
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(
APARTADO_DEFAULT + 94475 + 343425 + 4121100,
2,
)
// En el Excel el total pagado del bancario coincide con el precio de lista (desc 0)
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(4579000, 2)
})
it('contado sin mensualidades: apartado + enganche + pago final = precio final c/desc', () => {
const [, , contado] = calcular()
expect(totalPagado(contado, APARTADO_DEFAULT)).toBeCloseTo(contado.precioFinal, 2)
expect(totalPagado(contado, APARTADO_DEFAULT)).toBeCloseTo(4487420, 2)
})
it('semi contado: incluye las mensualidades', () => {
const [, , , semi] = calcular()
expect(totalPagado(semi, APARTADO_DEFAULT)).toBeCloseTo(semi.precioFinal, 2)
expect(totalPagado(semi, APARTADO_DEFAULT)).toBeCloseTo(4533210, 2)
})
})
describe('indiceMenorTotal', () => {
it('devuelve el índice de la propuesta con menor total pagado', () => {
const propuestas = calcular()
const totales = propuestas.map((p) => totalPagado(p, APARTADO_DEFAULT))
// Contado (2% desc) tiene el menor total pagado
expect(indiceMenorTotal(totales)).toBe(2)
})
it('en empate gana el primero', () => {
expect(indiceMenorTotal([100, 100, 200])).toBe(0)
})
it('lista vacía devuelve -1', () => {
expect(indiceMenorTotal([])).toBe(-1)
})
})

View File

@@ -0,0 +1,176 @@
import type { Unidad, TipoPropuesta, DescuentosPropuestas } from '../types'
/**
* Cálculos replicados fielmente del Excel de producción "COTIZADOR" de
* Frasa Desarrollos (ya validado). NO "corregir" las fórmulas: algunas
* usan precio de lista donde intuitivamente iría precio final; así está
* en el Excel y así debe quedar.
*/
export const APARTADO_DEFAULT = 20000
// Descuentos por defecto de cada propuesta (fracción, no porcentaje)
export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
creditoBancario: 0,
creditoInfonavit: 0,
contado: 0.02,
semiContado: 0.01,
}
export interface PropuestaResultado {
tipo: TipoPropuesta
descuento: number // fracción (0.02 = 2%)
montoDescuento: number
precioFinal: number // precio lista - descuento
enganche: number // NETO del apartado (el apartado ya se pagó)
numMensualidades: number
mensualidad: number | null // null => N/A (Contado)
mensualidadesTotal: number | null // null => N/A (Contado)
pagoFinal: number
}
export interface CotizacionInput {
unidad: Unidad
acabados: boolean // true => incluye cocina y closet
apartado: number
descuentos: DescuentosPropuestas
/** Permite forzar N en tests; por defecto se calcula desde fechaEntrega */
numMeses?: number
}
const MS_POR_MES = 30.4 * 86400000
/** Meses (redondeados, mínimo 1) desde hoy hasta la terminación de obra */
export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new Date()): number {
const entrega = new Date(`${fechaEntrega}T00:00:00`)
return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES))
}
function baseComun(precioLista: number, descuento: number) {
// Excel COTIZADOR: MONTO DE DESCUENTO y PRECIO FINAL C/DESC
const montoDescuento = precioLista * descuento
const precioFinal = precioLista - montoDescuento
return { montoDescuento, precioFinal }
}
/** PROPUESTA 1 — CRÉDITO BANCARIO */
function calcularBancario(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M17 — enganche 2.5% sobre precio LISTA, neto del apartado
const enganche = 0.025 * precioLista - apartado
// Excel COTIZADOR!M19 — pago final 90% del precio final c/desc
const pagoFinal = 0.9 * precioFinal
// Excel COTIZADOR!M18 — remanente a mensualidades
const mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
return {
tipo: 'CREDITO_BANCARIO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** PROPUESTA 2 — CRÉDITO INFONAVIT-COFINAVIT */
function calcularInfonavit(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M26 — enganche 2.5% sobre precio LISTA, neto del apartado
const enganche = 0.025 * precioLista - apartado
// Excel COTIZADOR!M28 — pago final 95% del precio final c/desc
const pagoFinal = 0.95 * precioFinal
// Excel COTIZADOR!M27 — OJO: el Excel usa precioLista aquí, NO precioFinal. Replicar tal cual.
const mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
return {
tipo: 'CREDITO_INFONAVIT_COFINAVIT',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** PROPUESTA 3 — CONTADO (sin mensualidades) */
function calcularContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M36 — enganche 90% del precio final c/desc, neto del apartado
const enganche = 0.9 * precioFinal - apartado
// Excel COTIZADOR!M37 — pago final 10% del precio final c/desc
const pagoFinal = 0.1 * precioFinal
return {
tipo: 'CONTADO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: null, // N/A
mensualidadesTotal: null, // N/A
pagoFinal,
}
}
/** PROPUESTA 4 — SEMI CONTADO */
function calcularSemiContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M45 — enganche 50% del precio final c/desc, neto del apartado
const enganche = 0.5 * precioFinal - apartado
// Excel COTIZADOR!M46 — 40% del precio final a mensualidades (= 1 - 0.5 - 0.1)
const mensualidadesTotal = precioFinal * 0.4
// Excel COTIZADOR!M47 — pago final 10% del precio final c/desc
const pagoFinal = 0.1 * precioFinal
return {
tipo: 'SEMI_CONTADO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** Precio de lista según si incluye cocina y closet (acabados) */
export function obtenerPrecioLista(unidad: Unidad, acabados: boolean): number {
return acabados ? unidad.precioConAcabados : unidad.precioSinAcabados
}
/**
* Total pagado por el cliente en una propuesta:
* apartado + enganche (neto del apartado) + mensualidades + pago final.
*/
export function totalPagado(p: PropuestaResultado, apartado: number): number {
return apartado + p.enganche + (p.mensualidadesTotal ?? 0) + p.pagoFinal
}
/** Índice del menor total pagado; en empate gana el primero. -1 si la lista está vacía */
export function indiceMenorTotal(totales: number[]): number {
if (totales.length === 0) return -1
let indice = 0
for (let i = 1; i < totales.length; i++) {
if (totales[i] < totales[indice]) indice = i
}
return indice
}
/** Calcula las 4 propuestas comerciales */
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
const { unidad, acabados, apartado, descuentos } = input
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = input.numMeses ?? calcularNumMensualidades(unidad.fechaEntrega)
return [
calcularBancario(precioLista, apartado, descuentos.creditoBancario, n),
calcularInfonavit(precioLista, apartado, descuentos.creditoInfonavit, n),
calcularContado(precioLista, apartado, descuentos.contado, n),
calcularSemiContado(precioLista, apartado, descuentos.semiContado, n),
]
}

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { filtrarUnidades } from './filtros'
import { UNIDADES_SEED } from '../data/unidades'
import type { Unidad } from '../types'
describe('filtrarUnidades', () => {
it('sin texto devuelve todas las unidades disponibles', () => {
expect(filtrarUnidades(UNIDADES_SEED, '')).toHaveLength(UNIDADES_SEED.length)
expect(filtrarUnidades(UNIDADES_SEED, ' ')).toHaveLength(UNIDADES_SEED.length)
})
it('excluye unidades no disponibles', () => {
const conApartada: Unidad[] = [
...UNIDADES_SEED,
{ ...UNIDADES_SEED[0], codigo: 'U-999', status: 'APARTADA' },
]
const resultado = filtrarUnidades(conApartada, '')
expect(resultado).toHaveLength(UNIDADES_SEED.length)
expect(resultado.every((u) => u.status === 'DISPONIBLE')).toBe(true)
})
it('filtra por código', () => {
const resultado = filtrarUnidades(UNIDADES_SEED, '101')
expect(resultado.map((u) => u.codigo)).toEqual(['U-101'])
})
it('filtra por modelo sin importar mayúsculas', () => {
const resultado = filtrarUnidades(UNIDADES_SEED, '2ra')
expect(resultado.length).toBeGreaterThan(0)
expect(resultado.every((u) => u.modelo === '2RA')).toBe(true)
})
it('filtra por precio', () => {
// '45' aparece en 4,579,000 (U-101, U-107, U-108, U-114) y 4,450,000 (U-303, U-304, U-307, U-308)
const resultado = filtrarUnidades(UNIDADES_SEED, '45')
expect(resultado.map((u) => u.codigo).sort()).toEqual(
['U-101', 'U-107', 'U-108', 'U-114', 'U-303', 'U-304', 'U-307', 'U-308'].sort(),
)
})
it('sin coincidencias devuelve lista vacía', () => {
expect(filtrarUnidades(UNIDADES_SEED, 'xyz-000')).toEqual([])
})
})

View File

@@ -0,0 +1,21 @@
import type { Unidad } from '../types'
/**
* Filtra las unidades DISPONIBLE por código, modelo o precio.
* El texto se compara en minúsculas contra código y modelo, y como
* subcadena contra los precios (sin y con acabados).
*/
export function filtrarUnidades(unidades: Unidad[], texto: string): Unidad[] {
const disponibles = unidades.filter((u) => u.status === 'DISPONIBLE')
const q = texto.trim().toLowerCase()
if (!q) return disponibles
return disponibles.filter((u) => coincideUnidad(u, q))
}
function coincideUnidad(u: Unidad, q: string): boolean {
if (u.codigo.toLowerCase().includes(q)) return true
if (u.modelo.toLowerCase().includes(q)) return true
if (String(u.precioSinAcabados).includes(q)) return true
if (String(u.precioConAcabados).includes(q)) return true
return false
}

View File

@@ -0,0 +1,36 @@
const formatoMXN = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
})
/** Formato monetario MXN, p. ej. $4,579,000.00 */
export function formatoMoneda(valor: number): string {
return formatoMXN.format(valor)
}
/** Fracción (0.02) a texto porcentaje (2%) */
export function formatoPorcentaje(fraccion: number): string {
return `${(fraccion * 100).toLocaleString('es-MX', { maximumFractionDigits: 2 })}%`
}
/** Fecha en formato largo es-MX: "20 de agosto de 2026" */
export function formatoFechaLarga(fecha: Date): string {
return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })
}
/** ISO 'YYYY-MM-DD' a fecha legible corta: "08/10/2027" */
export function formatoFechaCorta(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
/** Fecha ISO del día (para nombre de archivo): 'YYYY-MM-DD' */
export function fechaISOHoy(fecha: Date = new Date()): string {
const y = fecha.getFullYear()
const m = String(fecha.getMonth() + 1).padStart(2, '0')
const d = String(fecha.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { crearDocumentoPdf } from './pdf'
import { calcularPropuestas, APARTADO_DEFAULT, DESCUENTOS_DEFAULT } from './calculations'
import { UNIDADES_SEED } from '../data/unidades'
describe('crearDocumentoPdf', () => {
it('construye el documento ANEXO A sin errores y con el nombre correcto', () => {
const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
})
const { doc, nombreArchivo } = crearDocumentoPdf({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
precioLista: unidad.precioConAcabados,
propuestas,
cliente: { nombre: 'Juan Pérez' },
})
expect(nombreArchivo).toMatch(/^cotizacion_U-101_\d{4}-\d{2}-\d{2}\.pdf$/)
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
// El contenido incluye los textos clave del formato
const texto = doc.output('datauristring')
expect(texto.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,179 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { Unidad, Cliente } from '../types'
import type { PropuestaResultado } from './calculations'
import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters'
export interface PdfInput {
unidad: Unidad
acabados: boolean
apartado: number
precioLista: number
propuestas: PropuestaResultado[]
cliente: Cliente
}
const TITULOS: Record<PropuestaResultado['tipo'], string> = {
CREDITO_BANCARIO: 'CREDITO BANCARIO',
CREDITO_INFONAVIT_COFINAVIT: 'CREDITO INFONAVIT-COFINAVIT',
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI CONTADO',
}
const DISCLAIMER =
'ESTA COTIZACION NO INCLUYE GASTOS DE ESCRITURACION. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL MONTO TOTAL DE ENGANCHE INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.'
const CUENTA_DEPOSITO =
'PARA CUENTAS EN MEXICO:\nTITULAR: FRASA DESARROLLOS, S.A.P.I. DE C.V.\n\nBBVA BANCOMER PAGO SOLO EN PESOS\nCUENTA: 0111525921 CLABE: 012028001115259210\n\nBBVA BANCOMER PAGO SOLO EN DOLARES\nCUENTA: 0111571737 CLABE: 012028001115717370\n\nPARA RECIBIR TRANSFERENCIAS DESDE EL EXTRANJERO:\nBANCO DEL BENEFICIARIO: BBVA Bancomer.\nSWIFT/BIC CODE: BCMRMXMMXXX\nBENEFICIARIO: FRASA DESARROLLOS, S.A.P.I. DE C.V.\nCUENTA: 01111571737\n(CLABE 18 DÍGITOS): 012 028 001 115 717 370\nSOLO PAGO EN DOLARES'
const DOCUMENTOS =
'1. Identificación oficial (INE o Pasaporte)\n2. Constancia de situación fiscal (RFC/CIF)\n3. CURP, Acta de Nacimiento / Matrimonio\n4. Comprobante de domicilio (no mayor a 3 meses)\n5. Estado de cuenta de donde saldrán los depositos'
const MARGEN = 14
// jspdf-autotable expone finalY en doc.lastAutoTable en runtime; sus tipos no lo declaran
type DocConTabla = jsPDF & { lastAutoTable: { finalY: number } }
const finalY = (doc: jsPDF): number => (doc as DocConTabla).lastAutoTable.finalY
export interface DocumentoPdf {
doc: jsPDF
nombreArchivo: string
}
/** Construye el documento PDF "ANEXO A" (sin descargarlo) */
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente }: PdfInput): DocumentoPdf {
const doc = new jsPDF()
const hoy = new Date()
const limiteDocumentos = new Date(hoy.getTime() + 7 * 86400000)
let y = MARGEN
const parrafo = (texto: string, opciones?: { negritas?: boolean; tamano?: number }) => {
const tamano = opciones?.tamano ?? 8
doc.setFontSize(tamano)
doc.setFont('helvetica', opciones?.negritas ? 'bold' : 'normal')
const lineas = doc.splitTextToSize(texto, 210 - MARGEN * 2) as string[]
// Salto de página si el bloque no cabe
if (y + lineas.length * tamano * 0.4 > 285) {
doc.addPage()
y = MARGEN
}
doc.text(lineas, MARGEN, y)
y += lineas.length * tamano * 0.4 + 3
}
// 1. Encabezado
doc.setFontSize(16)
doc.setFont('helvetica', 'bold')
doc.text('ANEXO A', 105, y, { align: 'center' })
y += 7
doc.setFontSize(10)
doc.setFont('helvetica', 'normal')
doc.text(formatoFechaLarga(hoy), 105, y, { align: 'center' })
y += 8
// 2. Tabla de unidad
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7 },
head: [[
'UNIDAD', 'MODELO', 'COCINA Y CLOSET', 'PRECIO DE LISTA $ MN', 'ESTATUS',
'SUP. CONSTRUCCION', 'SUP. EXCEDENTE', 'SUP. ESTACIONAMIENTO',
'MONTO APARTADO $ MN', 'CUOTA MANTENIMIENTO',
]],
body: [[
`CASA ${unidad.codigo}`,
unidad.modelo,
acabados ? 'SI' : 'NO',
formatoMoneda(precioLista),
unidad.status,
`${unidad.supConstruccion} m2`,
`${unidad.supExcedente} m2`,
`${unidad.supEstacionamiento} m2`,
formatoMoneda(apartado),
'1,500 Aprox.',
]],
})
y = finalY(doc) + 8
// 3. Propuesta general — una tabla por propuesta
doc.setFontSize(12)
doc.setFont('helvetica', 'bold')
doc.text('PROPUESTA GENERAL', MARGEN, y)
y += 4
propuestas.forEach((p, i) => {
const mensualidades =
p.mensualidad !== null && p.mensualidadesTotal !== null
? {
pagos: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
total: formatoMoneda(p.mensualidadesTotal),
}
: { pagos: 'N/A', total: 'N/A' }
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 8 },
headStyles: { fillColor: [4, 120, 87] },
head: [[`PROPUESTA COMERCIAL #${i + 1} — ${TITULOS[p.tipo]}`, '%', 'PAGOS MENSUALES', 'TOTAL']],
body: [
['DESCUENTO', formatoPorcentaje(p.descuento), '', ''],
['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)],
['PRECIO FINAL C/DESC', '', '', formatoMoneda(p.precioFinal)],
['ENGANCHE', '', '', formatoMoneda(p.enganche)],
['MENSUALIDADES', '', mensualidades.pagos, mensualidades.total],
['PAGO FINAL', '', '', formatoMoneda(p.pagoFinal)],
],
})
y = finalY(doc) + 6
// Salto de página antes de la siguiente tabla si no cabe
if (y > 240 && i < propuestas.length - 1) {
doc.addPage()
y = MARGEN
}
})
y += 2
// 4. ¿Qué sigue?
parrafo('¿QUÉ SIGUE?', { negritas: true, tamano: 11 })
parrafo(`ENTREGAR LOS SIGUIENTES DOCUMENTOS MÁXIMO EL DÍA: ${formatoFechaLarga(limiteDocumentos)}`, {
negritas: true,
})
parrafo(DOCUMENTOS)
y += 2
// 5. Disclaimer
parrafo(DISCLAIMER)
y += 2
// 6. Cuenta para depósito
parrafo('CUENTA PARA DEPOSITO:', { negritas: true, tamano: 10 })
parrafo(CUENTA_DEPOSITO)
y += 4
// 7. Aceptación y firmas
parrafo('PROPUESTA COMERCIAL ACEPTADA # ____', { negritas: true, tamano: 10 })
y += 12
doc.setFontSize(9)
doc.line(MARGEN, y, MARGEN + 80, y)
doc.line(115, y, 195, y)
y += 5
doc.setFont('helvetica', 'bold')
doc.text(`CLIENTE: ${cliente.nombre}`, MARGEN, y)
doc.text('LIC. DEYSI ALEMAN', 115, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.text('ADMVO. DE VENTAS', 115, y)
y += 5
doc.text('Cel. 664-399-8451', 115, y)
return { doc, nombreArchivo: `cotizacion_${unidad.codigo}_${fechaISOHoy(hoy)}.pdf` }
}
/** Genera y descarga el PDF "ANEXO A" de la cotización */
export function generarPdf(input: PdfInput): void {
const { doc, nombreArchivo } = crearDocumentoPdf(input)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { calcularVigencia, VIGENCIA_DIAS } from './vigencia'
describe('calcularVigencia', () => {
it('la vigencia es de 3 días naturales', () => {
expect(VIGENCIA_DIAS).toBe(3)
})
it('suma 3 días naturales a la fecha de emisión', () => {
expect(calcularVigencia(new Date('2026-08-20T12:00:00'))).toEqual(new Date('2026-08-23T12:00:00'))
})
it('cruza el fin de mes correctamente', () => {
expect(calcularVigencia(new Date('2026-01-30T12:00:00'))).toEqual(new Date('2026-02-02T12:00:00'))
})
})

View File

@@ -0,0 +1,9 @@
/** Días naturales de vigencia de la cotización (igual que en el PDF) */
export const VIGENCIA_DIAS = 3
/** Fecha límite de vigencia: fecha de emisión + 3 días naturales */
export function calcularVigencia(hoy: Date = new Date()): Date {
const limite = new Date(hoy)
limite.setDate(limite.getDate() + VIGENCIA_DIAS)
return limite
}

View File

@@ -0,0 +1,20 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/700.css'
import './index.css'
import CotizadorPage from './pages/CotizadorPage'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename="/quote">
<Routes>
<Route path="/" element={<CotizadorPage />} />
<Route path="*" element={<CotizadorPage />} />
</Routes>
</BrowserRouter>
</StrictMode>,
)

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

View File

@@ -0,0 +1,10 @@
import type { Unidad } from '../types'
import { UNIDADES_SEED } from '../data/unidades'
/**
* Devuelve el inventario de unidades.
* TODO: reemplazar por fetch('/api/unidades') cuando el API Gateway esté disponible
*/
export async function getUnidades(): Promise<Unidad[]> {
return UNIDADES_SEED
}

View File

@@ -0,0 +1,30 @@
export interface Unidad {
codigo: string // 'U-101'
modelo: string // '1R' | '2RA' | '2RB' | '2RC'
fechaEntrega: string // ISO 'YYYY-MM-DD' (terminación de obra)
supEstacionamiento: number // m2
supConstruccion: number // m2
supExcedente: number // m2
precioSinAcabados: number // MXN
precioConAcabados: number // MXN
status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'
}
export interface Cliente {
nombre: string
email?: string
telefono?: string
}
export type TipoPropuesta =
| 'CREDITO_BANCARIO'
| 'CREDITO_INFONAVIT_COFINAVIT'
| 'CONTADO'
| 'SEMI_CONTADO'
export interface DescuentosPropuestas {
creditoBancario: number
creditoInfonavit: number
contado: number
semiContado: number
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// La app se sirve detrás de NGINX bajo el prefijo /quote
export default defineConfig({
base: '/quote/',
plugins: [react(), tailwindcss()],
})

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
// Config de tests separada para no mezclar los tipos de Vite y Vitest
export default defineConfig({
test: {
environment: 'node',
coverage: {
provider: 'v8',
},
},
})