feat(desarrollos): modulo de alta de desarrollos con inventario, reglas del cotizador por desarrollo y micrositio publico
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).
This commit is contained in:
189
apps/landing/src/components/desarrollos/comun.tsx
Normal file
189
apps/landing/src/components/desarrollos/comun.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { EstadoDesarrollo, TipoDesarrollo } from '../../services/desarrollos'
|
||||
|
||||
// ─── Clases compartidas (mismo lenguaje visual que el resto de la landing) ──
|
||||
|
||||
export const TARJETA =
|
||||
'rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]'
|
||||
|
||||
export const INPUT =
|
||||
'mt-1.5 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]'
|
||||
|
||||
export const INPUT_ERROR = 'border-red-500 dark:border-red-500'
|
||||
|
||||
export const LABEL = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
export const AYUDA = 'mt-1 text-[11px] text-[#64748B] dark:text-[#94A3B8]'
|
||||
|
||||
export const TEXTO_ERROR = 'mt-1 text-xs text-red-600 dark:text-red-400'
|
||||
|
||||
export const BOTON_PRIMARIO =
|
||||
'rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50'
|
||||
|
||||
export const BOTON_SECUNDARIO =
|
||||
'rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]'
|
||||
|
||||
export const BOTON_PELIGRO =
|
||||
'rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50 dark:border-red-500/40 dark:bg-[#0F172A] dark:text-red-400 dark:hover:bg-red-500/10'
|
||||
|
||||
export const BOTON_MINIMO =
|
||||
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]'
|
||||
|
||||
// ─── Etiquetas de dominio ───────────────────────────────────────────
|
||||
|
||||
export const ETIQUETA_ESTADO: Record<EstadoDesarrollo, string> = {
|
||||
ACTIVO: 'Activo',
|
||||
PREVENTA: 'Preventa',
|
||||
CERRADO: 'Cerrado',
|
||||
}
|
||||
|
||||
const CLASE_ESTADO: Record<EstadoDesarrollo, string> = {
|
||||
ACTIVO: 'bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400',
|
||||
PREVENTA: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
|
||||
CERRADO: 'bg-[#F1F5F9] text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]',
|
||||
}
|
||||
|
||||
export const ETIQUETA_TIPO: Record<TipoDesarrollo, string> = {
|
||||
VERTICAL: 'Vertical',
|
||||
HORIZONTAL: 'Horizontal',
|
||||
}
|
||||
|
||||
/** Base sobre la que se calcula un componente del precio ('lista' | 'final') */
|
||||
export const ETIQUETA_BASE = { lista: 'precio de lista', final: 'precio final' } as const
|
||||
|
||||
export const ETIQUETA_MODO_MENSUALIDADES = {
|
||||
remanente: 'sobre el remanente',
|
||||
remanenteLista: 'sobre el remanente del precio de lista',
|
||||
porcentaje: 'sobre el remanente en porcentaje',
|
||||
ninguna: 'sin mensualidades',
|
||||
} as const
|
||||
|
||||
export const ETIQUETA_FUENTE_N = {
|
||||
fechaEntrega: 'Fecha de entrega de la unidad',
|
||||
fijo: 'N fijo (entrega inmediata / terminación de obra)',
|
||||
porUnidad: 'Capturado por unidad',
|
||||
} as const
|
||||
|
||||
/** Estatus posibles del inventario (los mismos que maneja el cotizador) */
|
||||
export const ESTATUS_UNIDAD = [
|
||||
'DISPONIBLE',
|
||||
'APARTADO',
|
||||
'APARTADA',
|
||||
'VENDIDO',
|
||||
'VENDIDA',
|
||||
'ESCRITURADO',
|
||||
'BLOQUEADO',
|
||||
] as const
|
||||
|
||||
const CLASE_ESTATUS: Record<string, string> = {
|
||||
DISPONIBLE: 'bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400',
|
||||
APARTADO: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
|
||||
APARTADA: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
|
||||
VENDIDO: 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400',
|
||||
VENDIDA: 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400',
|
||||
ESCRITURADO: 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400',
|
||||
BLOQUEADO: 'bg-[#F1F5F9] text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]',
|
||||
}
|
||||
|
||||
export function BadgeEstado({ estado }: { estado: EstadoDesarrollo }) {
|
||||
return (
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[estado] ?? CLASE_ESTATUS.BLOQUEADO}`}
|
||||
>
|
||||
{ETIQUETA_ESTADO[estado] ?? estado}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function BadgeEstatus({ estatus }: { estatus: string | null | undefined }) {
|
||||
if (!estatus) return <span className="text-xs text-[#94A3B8]">—</span>
|
||||
return (
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTATUS[estatus] ?? 'bg-[#F1F5F9] text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]'}`}
|
||||
>
|
||||
{estatus}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Chip({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="rounded-full bg-[#F1F5F9] px-2.5 py-0.5 text-[11px] font-medium text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Piezas de formulario ───────────────────────────────────────────
|
||||
|
||||
export function Campo({
|
||||
id,
|
||||
etiqueta,
|
||||
requerido = false,
|
||||
ayuda,
|
||||
error,
|
||||
className = '',
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
etiqueta: string
|
||||
requerido?: boolean
|
||||
ayuda?: string
|
||||
error?: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<label htmlFor={id} className={LABEL}>
|
||||
{etiqueta}
|
||||
{requerido && <span aria-hidden="true"> *</span>}
|
||||
</label>
|
||||
{children}
|
||||
{ayuda && <p className={AYUDA}>{ayuda}</p>}
|
||||
{error && (
|
||||
<p role="alert" className={TEXTO_ERROR}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MensajeError({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-400">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function MensajeExito({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p role="status" className="rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-500/40 dark:bg-green-500/10 dark:text-green-400">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/** Formato de moneda para precios de inventario */
|
||||
export function moneda(valor: number | null | undefined): string {
|
||||
if (valor == null) return '—'
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(valor)
|
||||
}
|
||||
|
||||
/** Convierte '' | null | undefined a null y el resto a número (NaN → null) */
|
||||
export function aNumero(valor: string): number | null {
|
||||
const limpio = valor.trim()
|
||||
if (limpio === '') return null
|
||||
const n = Number(limpio)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function aTexto(valor: number | null | undefined): string {
|
||||
return valor == null ? '' : String(valor)
|
||||
}
|
||||
Reference in New Issue
Block a user