Modulo nuevo (solo admin) para dar de alta desarrollos sin tocar codigo: - CRUD de la ficha, modelos e inventario (alta manual y generador por rango) - Reglas del cotizador por desarrollo, editables y persistidas en la BD - El cotizador consume las reglas desde /desarrollos/reglas-cotizador y cae a las constantes con aviso cuando un desarrollo aun no tiene reglas guardadas - Micrositio publico en /d/:clave (sin sesion) con ficha, modelos y disponibilidad; jamas expone datos de clientes ni cotizaciones Integracion: - La landing monta el modulo en el grid (clave 'desarrollos', solo admin) y el micrositio fuera del login; el rol manager NO accede (matriz y API con 403) - La tabla de roles de Configuracion ahora lista todos los modulos - Se alinea la FK de ReglaCotizadorDesarrollo con la BD viva (Desarrollo.id) Verificado: 708 pruebas del API, 115 del cotizador y 89 de la landing en verde; 12/12 cotizaciones identicas antes y despues del cambio de reglas; prueba en vivo del API con alta, modelos, inventario, reglas, micrositio y baja (30/30).
204 lines
8.3 KiB
TypeScript
204 lines
8.3 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import type { Unidad, TipoPropuesta } from '../types'
|
||
import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades'
|
||
import { formatoMoneda } from '../lib/formatters'
|
||
import { SIN_REGLAS, desarrolloEfectivo, idsCotizables, type ReglasDesarrollo } from '../lib/reglas'
|
||
|
||
interface Props {
|
||
unidades: Unidad[]
|
||
/** Tipo de propuesta activa en el cotizador (se aplica a ambas unidades) */
|
||
tipo: TipoPropuesta
|
||
apartado: number
|
||
acabados: boolean
|
||
/** Reglas comerciales por desarrollo (vacío => constantes) */
|
||
reglas?: ReglasDesarrollo
|
||
}
|
||
|
||
const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`)
|
||
|
||
interface Fila {
|
||
etiqueta: string
|
||
obtener: (c: ColumnaComparador) => string
|
||
destacada?: boolean
|
||
}
|
||
|
||
/**
|
||
* Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado,
|
||
* con la propuesta activa calculada por el motor con los parámetros de cada
|
||
* desarrollo (las reglas del admin con respaldo en las constantes). Solo ofrece
|
||
* unidades DISPONIBLES de desarrollos activos en el cotizador: no se compara
|
||
* (ni se cotiza) inventario apartado, vendido, bloqueado o desactivado.
|
||
*/
|
||
export function ComparadorUnidades({ unidades, tipo, apartado, acabados, reglas = SIN_REGLAS }: Props) {
|
||
const [abierto, setAbierto] = useState(false)
|
||
const [codigoA, setCodigoA] = useState('')
|
||
const [codigoB, setCodigoB] = useState('')
|
||
|
||
const desarrollos = useMemo(() => idsCotizables(reglas), [reglas])
|
||
|
||
const disponibles = useMemo(
|
||
() =>
|
||
unidades.filter(
|
||
(u) =>
|
||
(u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' &&
|
||
desarrollos.includes(u.desarrollo ?? 'DIAGONAL'),
|
||
),
|
||
[unidades, desarrollos],
|
||
)
|
||
|
||
const porCodigo = useMemo(() => {
|
||
const m = new Map<string, Unidad>()
|
||
for (const u of disponibles) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
|
||
return m
|
||
}, [disponibles])
|
||
|
||
const unidadA = porCodigo.get(codigoA) ?? null
|
||
const unidadB = porCodigo.get(codigoB) ?? null
|
||
|
||
const comparacion = useMemo(
|
||
() =>
|
||
unidadA && unidadB
|
||
? compararUnidades(unidadA, unidadB, tipo, apartado, acabados, reglas)
|
||
: null,
|
||
[unidadA, unidadB, tipo, apartado, acabados, reglas],
|
||
)
|
||
|
||
const filas: Fila[] = [
|
||
{ etiqueta: 'Desarrollo', obtener: (c) => c.desarrolloNombre },
|
||
{ etiqueta: 'Modelo', obtener: (c) => c.unidad.modelo },
|
||
{ etiqueta: 'Estatus', obtener: (c) => c.unidad.status },
|
||
{ etiqueta: 'Sup. construcción', obtener: (c) => formatoM2(c.supConstruccion) },
|
||
{ etiqueta: 'Precio de lista', obtener: (c) => formatoMoneda(c.precioLista) },
|
||
{ etiqueta: 'Precio final c/desc.', obtener: (c) => formatoMoneda(c.propuesta.precioFinal) },
|
||
{ etiqueta: 'Enganche', obtener: (c) => formatoMoneda(c.propuesta.enganche) },
|
||
{
|
||
etiqueta: 'Mensualidad',
|
||
obtener: (c) =>
|
||
c.propuesta.notaMensualidades
|
||
? `— (${c.propuesta.notaMensualidades})`
|
||
: c.propuesta.mensualidad === null
|
||
? 'N/A'
|
||
: `${c.propuesta.numMensualidades} × ${formatoMoneda(c.propuesta.mensualidad)}`,
|
||
},
|
||
{ etiqueta: 'Pago final', obtener: (c) => formatoMoneda(c.propuesta.pagoFinal) },
|
||
{
|
||
etiqueta: 'Total pagado',
|
||
obtener: (c) => (c.total === null ? '—' : formatoMoneda(c.total)),
|
||
destacada: true,
|
||
},
|
||
]
|
||
|
||
const selector = (id: string, valor: string, onChange: (v: string) => void, etiqueta: string) => (
|
||
<div>
|
||
<label htmlFor={id} className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
|
||
{etiqueta}
|
||
</label>
|
||
<select
|
||
id={id}
|
||
value={valor}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
|
||
>
|
||
<option value="">Selecciona una unidad…</option>
|
||
{desarrollos.map((dev) => {
|
||
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
|
||
if (delDesarrollo.length === 0) return null
|
||
return (
|
||
<optgroup key={dev} label={desarrolloEfectivo(reglas, dev).nombre}>
|
||
{delDesarrollo.map((u) => (
|
||
<option key={`${dev}|${u.codigo}`} value={`${dev}|${u.codigo}`}>
|
||
{u.codigo} — {u.modelo}
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
)
|
||
})}
|
||
</select>
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<section aria-labelledby="comparador-unidades-titulo">
|
||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||
<h2
|
||
id="comparador-unidades-titulo"
|
||
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
||
>
|
||
Comparar unidades
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
aria-expanded={abierto}
|
||
aria-controls="comparador-unidades-contenido"
|
||
onClick={() => setAbierto((v) => !v)}
|
||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||
>
|
||
{abierto ? 'Ocultar comparación' : 'Comparar 2 unidades'}
|
||
</button>
|
||
</div>
|
||
|
||
{abierto && (
|
||
<div id="comparador-unidades-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||
<div className="grid gap-6 md:grid-cols-2">
|
||
{selector('comparador-a', codigoA, setCodigoA, 'Unidad A')}
|
||
{selector('comparador-b', codigoB, setCodigoB, 'Unidad B')}
|
||
</div>
|
||
|
||
{!comparacion ? (
|
||
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||
Selecciona dos unidades para ver la comparación lado a lado.
|
||
</p>
|
||
) : (
|
||
<div className="mt-6 overflow-x-auto">
|
||
<table className="w-full min-w-[480px] border-collapse text-sm">
|
||
<thead>
|
||
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||
<th scope="col" className="w-48 p-3 text-left">
|
||
<span className="sr-only">Concepto</span>
|
||
</th>
|
||
{[comparacion.a, comparacion.b].map((c) => (
|
||
<th
|
||
key={`${c.desarrolloId}|${c.unidad.codigo}`}
|
||
scope="col"
|
||
className="p-3 text-right text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]"
|
||
>
|
||
{c.unidad.codigo}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filas.map((fila) => (
|
||
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||
<th
|
||
scope="row"
|
||
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
|
||
>
|
||
{fila.etiqueta}
|
||
</th>
|
||
{[comparacion.a, comparacion.b].map((c) => (
|
||
<td
|
||
key={`${c.desarrolloId}|${c.unidad.codigo}`}
|
||
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
|
||
fila.destacada ? 'font-semibold text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'
|
||
}`}
|
||
>
|
||
{fila.obtener(c)}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||
Solo se ofrecen unidades DISPONIBLES. Cada unidad se calcula con los parámetros de
|
||
su desarrollo y el mismo apartado. El enganche ya incluye la deducción del apartado.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|