fix(6-mejoras): subcategoría+apartado libre+PDF+coacreditado+docs+dedup
1. Subcategoría en modal descarte (catálogo completo + select) 2. Apartado sin monto mínimo (eliminada validación de 0,000) 3. PDF solo con propuesta elegida (no las 4) 4. Co-acreditado: checkbox 'no tiene' valida sin campos obligatorios 5. Documentos apartado: previsualizar + descargar (endpoint + frontend) 6. Oportunidades: sin duplicados, orden Confirmar→Afluencia→Cotizar→Aparto(verde)→Descartar
This commit is contained in:
@@ -2,11 +2,9 @@ import { useMemo, useState } from 'react'
|
||||
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types'
|
||||
import {
|
||||
APARTADO_DEFAULT,
|
||||
APARTADO_MINIMO,
|
||||
calcularPropuestas,
|
||||
obtenerPrecioLista,
|
||||
resolverNumMensualidades,
|
||||
validarApartado,
|
||||
validarEnganchePct,
|
||||
type PropuestaResultado,
|
||||
} from '../lib/calculations'
|
||||
@@ -23,10 +21,8 @@ interface UseCotizacionResult {
|
||||
setAcabados: (v: boolean) => void
|
||||
apartado: number
|
||||
setApartado: (v: number) => void
|
||||
/** Apartado aplicado a los cálculos (acotado al mínimo cuando la captura es inválida) */
|
||||
/** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */
|
||||
apartadoEfectivo: number
|
||||
/** Mensaje de error de la captura del apartado; null si es válida */
|
||||
errorApartado: string | null
|
||||
/** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */
|
||||
enganchePct: number | null
|
||||
setEnganchePct: (v: number | null) => void
|
||||
@@ -125,9 +121,9 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
[unidad, acabados],
|
||||
)
|
||||
|
||||
// Capturas por debajo del mínimo no se usan: los cálculos se acotan
|
||||
const errorApartado = validarApartado(apartado)
|
||||
const apartadoEfectivo = Math.max(apartado, APARTADO_MINIMO)
|
||||
// Sin mínimo de apartado: el vendedor puede apartar con cualquier monto,
|
||||
// incluso $0; solo se acota a >= 0 como guarda contra capturas raras
|
||||
const apartadoEfectivo = Math.max(0, apartado)
|
||||
|
||||
// Enganche personalizado: null = default del Excel; inválido no entra al cálculo
|
||||
const errorEnganche = validarEnganchePct(enganchePct)
|
||||
@@ -149,7 +145,6 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
apartado,
|
||||
setApartado,
|
||||
apartadoEfectivo,
|
||||
errorApartado,
|
||||
enganchePct,
|
||||
setEnganchePct,
|
||||
errorEnganche,
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
obtenerPrecioLista,
|
||||
totalPagado,
|
||||
indiceMenorTotal,
|
||||
validarApartado,
|
||||
validarEnganchePct,
|
||||
APARTADO_DEFAULT,
|
||||
APARTADO_MINIMO,
|
||||
} from './calculations'
|
||||
import { UNIDADES_SEED } from '../data/unidades'
|
||||
import type { Unidad } from '../types'
|
||||
@@ -124,19 +122,12 @@ describe('apartado editable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('validarApartado', () => {
|
||||
it('acepta el mínimo y montos superiores', () => {
|
||||
expect(validarApartado(APARTADO_MINIMO)).toBeNull()
|
||||
expect(validarApartado(50000)).toBeNull()
|
||||
})
|
||||
|
||||
it('rechaza montos por debajo del mínimo', () => {
|
||||
expect(validarApartado(19999)).toBe('El apartado mínimo es $20,000')
|
||||
expect(validarApartado(0)).toBe('El apartado mínimo es $20,000')
|
||||
})
|
||||
|
||||
it('rechaza valores no finitos', () => {
|
||||
expect(validarApartado(NaN)).toBe('El apartado mínimo es $20,000')
|
||||
describe('apartado sin mínimo', () => {
|
||||
it('acepta cualquier monto, incluso $0', () => {
|
||||
const conCero = calcular({ creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 }, 0)
|
||||
expect(conCero.length).toBeGreaterThan(0)
|
||||
// El enganche ya no se descuenta del apartado (no hay apartado que descontar)
|
||||
expect(conCero[0].enganche).toBeCloseTo(0.025 * 4579000, 2)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -20,9 +20,6 @@ import { DESARROLLO_DEFAULT, obtenerDesarrollo } from '../data/desarrollos'
|
||||
|
||||
export const APARTADO_DEFAULT = 20000
|
||||
|
||||
/** Monto mínimo de apartado aceptado por el cotizador */
|
||||
export const APARTADO_MINIMO = 20000
|
||||
|
||||
/** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */
|
||||
export const ENGANCHE_MINIMO_PCT = 0.025
|
||||
|
||||
@@ -48,17 +45,6 @@ export function aplicaEnganchePersonalizado(cfg: PropuestaConfig): boolean {
|
||||
return cfg.modoMensualidades !== 'ninguna' && cfg.pctEnganche > 0 && cfg.pctEnganche <= 0.05
|
||||
}
|
||||
|
||||
/**
|
||||
* Mensaje de error si el apartado capturado es inválido; null si es válido.
|
||||
* La UI acota el valor al mínimo para los cálculos y muestra este mensaje.
|
||||
*/
|
||||
export function validarApartado(apartado: number): string | null {
|
||||
if (!Number.isFinite(apartado) || apartado < APARTADO_MINIMO) {
|
||||
return 'El apartado mínimo es $20,000'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Descuentos por defecto de DIAGONAL (fracción, no porcentaje).
|
||||
// Para otros desarrollos usar descuentosDefault(desarrollo) de data/desarrollos.
|
||||
export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface PdfInput {
|
||||
acabados: boolean
|
||||
apartado: number
|
||||
precioLista: number
|
||||
/** Propuestas a imprimir; desde la app llega SOLO la propuesta elegida */
|
||||
propuestas: PropuestaResultado[]
|
||||
cliente: Cliente
|
||||
/** Parámetros del desarrollo; por defecto DIAGONAL */
|
||||
@@ -143,12 +144,18 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro
|
||||
}
|
||||
: { pagos: 'N/A', total: 'N/A' }
|
||||
|
||||
// Con una sola propuesta (la elegida por el vendedor) no se numera
|
||||
const encabezado =
|
||||
propuestas.length === 1
|
||||
? `PROPUESTA COMERCIAL — ${p.titulo.toUpperCase()}`
|
||||
: `PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}`
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 8 },
|
||||
headStyles: { fillColor: [37, 99, 235] },
|
||||
head: [[`PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}`, '%', 'PAGOS MENSUALES', 'TOTAL']],
|
||||
head: [[encabezado, '%', 'PAGOS MENSUALES', 'TOTAL']],
|
||||
body: [
|
||||
['DESCUENTO', formatoPorcentaje(p.descuento), '', ''],
|
||||
['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)],
|
||||
|
||||
@@ -65,7 +65,6 @@ export default function CotizadorPage() {
|
||||
apartado,
|
||||
setApartado,
|
||||
apartadoEfectivo,
|
||||
errorApartado,
|
||||
enganchePct,
|
||||
setEnganchePct,
|
||||
errorEnganche,
|
||||
@@ -181,7 +180,7 @@ export default function CotizadorPage() {
|
||||
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
||||
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
|
||||
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
|
||||
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando
|
||||
const puedeGenerarPdf = Boolean(unidad) && !errorEnganche && !guardando
|
||||
|
||||
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
|
||||
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
|
||||
@@ -290,7 +289,8 @@ export default function CotizadorPage() {
|
||||
acabados,
|
||||
apartado: apartadoEfectivo,
|
||||
precioLista,
|
||||
propuestas,
|
||||
// El PDF imprime SOLO la propuesta elegida por el vendedor
|
||||
propuestas: propuestaActiva ? [propuestaActiva] : [],
|
||||
cliente,
|
||||
desarrollo: params,
|
||||
...(folio ? { folio } : {}),
|
||||
@@ -530,20 +530,15 @@ export default function CotizadorPage() {
|
||||
<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={errorApartado ? campoInputError : campoInput}
|
||||
className={campoInput}
|
||||
/>
|
||||
{errorApartado ? (
|
||||
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user