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:
9
apps/cotizador/.dockerignore
Normal file
9
apps/cotizador/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
coverage
|
||||
.vscode
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
26
apps/cotizador/.gitignore
vendored
Normal file
26
apps/cotizador/.gitignore
vendored
Normal 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
14
apps/cotizador/Dockerfile
Normal 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
32
apps/cotizador/README.md
Normal 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
12
apps/cotizador/index.html
Normal 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
18
apps/cotizador/nginx.conf
Normal 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
4909
apps/cotizador/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
apps/cotizador/package.json
Normal file
35
apps/cotizador/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
43
apps/cotizador/src/data/unidades.ts
Normal file
43
apps/cotizador/src/data/unidades.ts
Normal 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' },
|
||||
]
|
||||
65
apps/cotizador/src/hooks/useCotizacion.ts
Normal file
65
apps/cotizador/src/hooks/useCotizacion.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
38
apps/cotizador/src/hooks/useUnidades.ts
Normal file
38
apps/cotizador/src/hooks/useUnidades.ts
Normal 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 }
|
||||
}
|
||||
26
apps/cotizador/src/index.css
Normal file
26
apps/cotizador/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
144
apps/cotizador/src/lib/calculations.test.ts
Normal file
144
apps/cotizador/src/lib/calculations.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
176
apps/cotizador/src/lib/calculations.ts
Normal file
176
apps/cotizador/src/lib/calculations.ts
Normal 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),
|
||||
]
|
||||
}
|
||||
44
apps/cotizador/src/lib/filtros.test.ts
Normal file
44
apps/cotizador/src/lib/filtros.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
21
apps/cotizador/src/lib/filtros.ts
Normal file
21
apps/cotizador/src/lib/filtros.ts
Normal 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
|
||||
}
|
||||
36
apps/cotizador/src/lib/formatters.ts
Normal file
36
apps/cotizador/src/lib/formatters.ts
Normal 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}`
|
||||
}
|
||||
30
apps/cotizador/src/lib/pdf.test.ts
Normal file
30
apps/cotizador/src/lib/pdf.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
179
apps/cotizador/src/lib/pdf.ts
Normal file
179
apps/cotizador/src/lib/pdf.ts
Normal 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)
|
||||
}
|
||||
16
apps/cotizador/src/lib/vigencia.test.ts
Normal file
16
apps/cotizador/src/lib/vigencia.test.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
9
apps/cotizador/src/lib/vigencia.ts
Normal file
9
apps/cotizador/src/lib/vigencia.ts
Normal 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
|
||||
}
|
||||
20
apps/cotizador/src/main.tsx
Normal file
20
apps/cotizador/src/main.tsx
Normal 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>,
|
||||
)
|
||||
260
apps/cotizador/src/pages/CotizadorPage.tsx
Normal file
260
apps/cotizador/src/pages/CotizadorPage.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useUnidades } from '../hooks/useUnidades'
|
||||
import { useCotizacion } from '../hooks/useCotizacion'
|
||||
import { UnitSelector } from '../components/UnitSelector'
|
||||
import { UnitInfoCard } from '../components/UnitInfoCard'
|
||||
import { ProposalCard } from '../components/ProposalCard'
|
||||
import { ComparadorPropuestas } from '../components/ComparadorPropuestas'
|
||||
import { generarPdf } from '../lib/pdf'
|
||||
import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
|
||||
import { calcularVigencia } from '../lib/vigencia'
|
||||
import type { Cliente } from '../types'
|
||||
|
||||
const esquemaCliente = z.object({
|
||||
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
|
||||
email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(),
|
||||
telefono: z
|
||||
.union([z.literal(''), z.string().regex(/^[0-9+\-\s()]{7,15}$/, 'Teléfono inválido')])
|
||||
.optional(),
|
||||
})
|
||||
|
||||
type FormularioCliente = z.infer<typeof esquemaCliente>
|
||||
|
||||
const TITULOS_PROPUESTAS: [string, keyof import('../types').DescuentosPropuestas][] = [
|
||||
['Crédito Bancario', 'creditoBancario'],
|
||||
['Crédito Infonavit-Cofinavit', 'creditoInfonavit'],
|
||||
['Contado', 'contado'],
|
||||
['Semi Contado', 'semiContado'],
|
||||
]
|
||||
|
||||
const campoInputBase =
|
||||
'w-full border bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
|
||||
const campoInput = `${campoInputBase} border-neutral-300`
|
||||
const campoInputError = `${campoInputBase} border-red-400`
|
||||
|
||||
const etiquetaCampo = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500'
|
||||
|
||||
export default function CotizadorPage() {
|
||||
const { unidades, cargando, error } = useUnidades()
|
||||
const cotizacion = useCotizacion()
|
||||
const {
|
||||
unidad,
|
||||
setUnidad,
|
||||
acabados,
|
||||
setAcabados,
|
||||
apartado,
|
||||
setApartado,
|
||||
setDescuento,
|
||||
precioLista,
|
||||
numMeses,
|
||||
propuestas,
|
||||
} = cotizacion
|
||||
|
||||
const {
|
||||
register,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormularioCliente>({
|
||||
resolver: zodResolver(esquemaCliente),
|
||||
mode: 'onChange',
|
||||
defaultValues: { nombre: '', email: '', telefono: '' },
|
||||
})
|
||||
|
||||
const nombreCliente = watch('nombre') ?? ''
|
||||
const puedeGenerarPdf = Boolean(unidad) && nombreCliente.trim().length >= 3
|
||||
|
||||
const handleGenerarPdf = () => {
|
||||
if (!unidad) return
|
||||
const cliente: Cliente = {
|
||||
nombre: nombreCliente.trim(),
|
||||
email: watch('email') || undefined,
|
||||
telefono: watch('telefono') || undefined,
|
||||
}
|
||||
generarPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<header className="border-b border-neutral-200 bg-white">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-baseline justify-between gap-2 px-4 py-6">
|
||||
<h1 className="text-lg tracking-widest text-neutral-900">
|
||||
<span className="font-bold">FRASA DESARROLLOS</span>{' '}
|
||||
<span className="font-light text-neutral-500">— Cotizador</span>
|
||||
</h1>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-bold tracking-widest text-neutral-500 uppercase capitalize">
|
||||
{formatoFechaLarga(new Date())}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto grid max-w-7xl gap-x-8 gap-y-16 px-4 py-16 lg:grid-cols-[minmax(320px,380px)_1fr]">
|
||||
{/* Panel de configuración */}
|
||||
<aside className="space-y-16">
|
||||
<section className="space-y-8 border border-neutral-200 bg-white p-6">
|
||||
{error && <p className="text-xs text-red-700">{error}</p>}
|
||||
{cargando ? (
|
||||
<p className="text-sm text-neutral-500">Cargando unidades…</p>
|
||||
) : (
|
||||
<UnitSelector
|
||||
unidades={unidades}
|
||||
valor={unidad?.codigo ?? ''}
|
||||
onChange={(codigo) =>
|
||||
setUnidad(unidades.find((u) => u.codigo === codigo) ?? null)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className={etiquetaCampo}>Cocina y closet</span>
|
||||
<div className="inline-flex border border-neutral-300" role="group">
|
||||
{(['SI', 'NO'] as const).map((opcion) => {
|
||||
const activo = (opcion === 'SI') === acabados
|
||||
return (
|
||||
<button
|
||||
key={opcion}
|
||||
type="button"
|
||||
aria-pressed={activo}
|
||||
onClick={() => setAcabados(opcion === 'SI')}
|
||||
className={`px-5 py-2.5 text-xs font-bold tracking-widest uppercase transition-colors duration-150 ${
|
||||
activo
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-white text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{opcion}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="apartado" className={etiquetaCampo}>
|
||||
Monto de apartado (MXN)
|
||||
</label>
|
||||
<input
|
||||
id="apartado"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
value={apartado}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value)
|
||||
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
|
||||
}}
|
||||
className={campoInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="nombre" className={etiquetaCampo}>
|
||||
Nombre del cliente *
|
||||
</label>
|
||||
<input
|
||||
id="nombre"
|
||||
type="text"
|
||||
className={errors.nombre ? campoInputError : campoInput}
|
||||
{...register('nombre')}
|
||||
/>
|
||||
{errors.nombre && <p className="mt-1 text-xs text-red-700">{errors.nombre.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className={etiquetaCampo}>
|
||||
Correo electrónico (opcional)
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className={errors.email ? campoInputError : campoInput}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && <p className="mt-1 text-xs text-red-700">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="telefono" className={etiquetaCampo}>
|
||||
Teléfono (opcional)
|
||||
</label>
|
||||
<input
|
||||
id="telefono"
|
||||
type="tel"
|
||||
className={errors.telefono ? campoInputError : campoInput}
|
||||
{...register('telefono')}
|
||||
/>
|
||||
{errors.telefono && (
|
||||
<p className="mt-1 text-xs text-red-700">{errors.telefono.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unidad && (
|
||||
<div className="border-t border-neutral-200 pt-8">
|
||||
<p className="text-xs font-bold tracking-widest text-neutral-500 uppercase">
|
||||
Precio de lista
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-light text-neutral-900">
|
||||
{formatoMoneda(precioLista)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
|
||||
</aside>
|
||||
|
||||
{/* Panel de propuestas */}
|
||||
<section>
|
||||
{!unidad ? (
|
||||
<div className="flex h-64 items-center justify-center border border-dashed border-neutral-300 bg-white">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Selecciona una unidad para ver las propuestas comerciales.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{propuestas.map((p, i) => (
|
||||
<ProposalCard
|
||||
key={p.tipo}
|
||||
numero={i + 1}
|
||||
titulo={TITULOS_PROPUESTAS[i][0]}
|
||||
propuesta={p}
|
||||
onDescuentoChange={(v) => setDescuento(TITULOS_PROPUESTAS[i][1], v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ComparadorPropuestas
|
||||
precioLista={precioLista}
|
||||
apartado={apartado}
|
||||
propuestas={propuestas}
|
||||
titulos={TITULOS_PROPUESTAS.map(([titulo]) => titulo)}
|
||||
/>
|
||||
<div className="mt-16 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerarPdf}
|
||||
disabled={!puedeGenerarPdf}
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold tracking-widest text-white uppercase transition-colors duration-150 hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Generar PDF
|
||||
</button>
|
||||
</div>
|
||||
{!puedeGenerarPdf && (
|
||||
<p className="mt-2 text-right text-xs text-neutral-400">
|
||||
Selecciona una unidad y captura el nombre del cliente para generar el PDF.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
apps/cotizador/src/services/api.ts
Normal file
10
apps/cotizador/src/services/api.ts
Normal 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
|
||||
}
|
||||
30
apps/cotizador/src/types/index.ts
Normal file
30
apps/cotizador/src/types/index.ts
Normal 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
|
||||
}
|
||||
22
apps/cotizador/tsconfig.app.json
Normal file
22
apps/cotizador/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
apps/cotizador/tsconfig.json
Normal file
7
apps/cotizador/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
19
apps/cotizador/tsconfig.node.json
Normal file
19
apps/cotizador/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
9
apps/cotizador/vite.config.ts
Normal file
9
apps/cotizador/vite.config.ts
Normal 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()],
|
||||
})
|
||||
11
apps/cotizador/vitest.config.ts
Normal file
11
apps/cotizador/vitest.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user