feat(mejoras): 14 mejoras al CRM Frasa

Fase 1 - UI: dark mode, skeletons, toasts
Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K
Fase 3 - Datos: export Excel, notas apartados, auditoría
Fase 4 - Dashboard: gráficas vendedor + métricas uso
Fase 5 - Responsive: tablets optimizadas
Fase 6 - PWA: IndexedDB offline, push notifications

Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos
721 API tests + fronts verdes.
This commit is contained in:
2026-09-07 06:04:49 +00:00
parent 388e1da338
commit c77b4c0628
170 changed files with 5802 additions and 637 deletions

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563EB" />
<link rel="manifest" href="/quote/manifest.webmanifest" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/quote/icons/icon-192.png" />
<title>FRASA Desarrollos — Cotizador</title>
</head>

View File

@@ -10,12 +10,15 @@
"dependencies": {
"@fontsource/inter": "^5.3.0",
"@hookform/resolvers": "^3.9.1",
"idb": "^8.0.3",
"jspdf": "^2.5.2",
"jspdf-autotable": "^3.8.4",
"lucide-react": "^1.41.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.1.1",
"zod": "^3.24.1"
},
@@ -2396,7 +2399,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -2767,6 +2769,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/goober": {
"version": "2.1.19",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz",
"integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2859,6 +2870,12 @@
"node": ">=0.10.0"
}
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -3352,6 +3369,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.41.0.tgz",
"integrity": "sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -3775,6 +3801,23 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",

View File

@@ -13,12 +13,15 @@
"dependencies": {
"@fontsource/inter": "^5.3.0",
"@hookform/resolvers": "^3.9.1",
"idb": "^8.0.3",
"jspdf": "^2.5.2",
"jspdf-autotable": "^3.8.4",
"lucide-react": "^1.41.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.1.1",
"zod": "^3.24.1"
},

View File

@@ -0,0 +1,12 @@
{
"name": "FRASA CRM",
"short_name": "FRASA",
"start_url": "/quote/",
"display": "standalone",
"background_color": "#F8FAFC",
"theme_color": "#2563EB",
"icons": [
{ "src": "/quote/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/quote/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -0,0 +1,21 @@
/* eslint-env serviceworker */
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {}
const titulo = data.titulo || 'FRASA'
const cuerpo = data.cuerpo || ''
const url = data.url || '/'
event.waitUntil(
self.registration.showNotification(titulo, {
body: cuerpo,
data: { url },
icon: '/quote/icons/icon-192.png',
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = (event.notification.data && event.notification.data.url) || '/'
event.waitUntil(clients.openWindow(url))
})

View File

@@ -17,31 +17,31 @@ export default function AuthGate({ children }: { children: ReactNode }) {
}
return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]/95 backdrop-blur">
<div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex items-center gap-3">
<a
href="/"
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827] dark:text-[#E2E8F0]"
>
&larr; Menú
</a>
<p className="text-sm text-[#111827]">
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">
<span className="font-bold">
FRASA DESARROLLOS<span className="text-[#F97316]">.</span>
</span>{' '}
<span className="hidden font-normal text-[#64748B] sm:inline">— Cotizador</span>
<span className="hidden font-normal text-[#64748B] dark:text-[#94A3B8] sm:inline">— Cotizador</span>
</p>
</div>
<div className="flex items-center gap-3 sm:gap-4">
<span className="hidden text-xs font-medium text-[#64748B] md:inline">
<span className="hidden text-xs font-medium text-[#64748B] dark:text-[#94A3B8] md:inline">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</span>
<button
type="button"
onClick={() => setModalPassword(true)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Contraseña
</button>

View File

@@ -44,7 +44,7 @@ export function BuscadorCliente({ onSeleccion }: Props) {
return (
<div>
<label htmlFor="busqueda-cliente" className="mb-2 block text-xs font-medium text-[#4B5563]">
<label htmlFor="busqueda-cliente" className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Buscar cliente en Monday (lead u oportunidad)
</label>
<input
@@ -53,21 +53,21 @@ export function BuscadorCliente({ onSeleccion }: Props) {
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Nombre, teléfono o email…"
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
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:outline-none focus:ring-2 focus:ring-accent/20"
/>
{buscando && <p className="mt-3 text-xs text-[#64748B]">Buscando…</p>}
{buscando && <p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]">Buscando…</p>}
{error && (
<p role="alert" className="mt-3 text-xs text-red-700">
{error}
</p>
)}
{!buscando && !error && resultados !== null && resultados.length === 0 && (
<p className="mt-3 text-xs text-[#64748B]">Sin coincidencias.</p>
<p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]">Sin coincidencias.</p>
)}
{resultados !== null && resultados.length > 0 && (
<ul className="mt-3 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
<ul className="mt-3 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
{resultados.map((lead) => (
<li key={`${lead.fuente}-${lead.id}`}>
<button
@@ -77,9 +77,9 @@ export function BuscadorCliente({ onSeleccion }: Props) {
setQ('')
setResultados(null)
}}
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC]"
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC] dark:bg-[#0F172A]"
>
<span className="flex items-baseline gap-2 text-sm text-[#111827]">
<span className="flex items-baseline gap-2 text-sm text-[#111827] dark:text-[#E2E8F0]">
{lead.nombre}
<span
className={
@@ -91,7 +91,7 @@ export function BuscadorCliente({ onSeleccion }: Props) {
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
</span>
</span>
<span className="text-xs text-[#64748B]">
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">
{lead.telefono || 'sin teléfono'}
{lead.estado ? ` · ${lead.estado}` : ''}
</span>

View File

@@ -3,8 +3,8 @@ import { validarConfirmacion, validarPasswordNueva } from '../lib/password'
import { cambiarPassword } from '../services/auth'
const CLASE_INPUT =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563]'
'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'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
/**
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
@@ -45,8 +45,8 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
aria-modal="true"
aria-label="Cambiar contraseña"
>
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl">
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Cambiar contraseña
</h2>
{exito ? (
@@ -92,7 +92,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
required
className={CLASE_INPUT}
/>
<p className="mt-2 text-xs text-[#64748B]">Mínimo 8 caracteres y un número</p>
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">Mínimo 8 caracteres y un número</p>
</div>
<div>
<label htmlFor="password-confirmar" className={CLASE_LABEL}>
@@ -127,7 +127,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
<button
type="button"
onClick={onCerrar}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Cancelar
</button>

View File

@@ -76,7 +76,7 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
<div className="flex flex-wrap items-center justify-between gap-4">
<h2
id="comparador-titulo"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Comparar propuestas
</h2>
@@ -85,18 +85,18 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
aria-expanded={abierto}
aria-controls="comparador-contenido"
onClick={() => setAbierto((v) => !v)}
className="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]"
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 propuestas'}
</button>
</div>
{abierto && (
<div id="comparador-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<div id="comparador-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] border-collapse text-sm">
<thead>
<tr className="bg-[#F8FAFC]">
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
<th scope="col" className="w-48 p-3 text-left align-bottom">
<span className="sr-only">Concepto</span>
</th>
@@ -106,7 +106,7 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
scope="col"
className={`p-3 text-right align-bottom ${bordeColumna(i, 'inicio')}`}
>
<span className="block text-xs font-semibold uppercase tracking-wider text-[#111827]">
<span className="block text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
{titulos[i] ?? p.tipo}
</span>
{i === ganador && (
@@ -120,20 +120,20 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
</thead>
<tbody>
{filas.map((fila, indiceFila) => (
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC]">
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<th
scope="row"
className="border-t border-[#E5E7EB] p-3 text-left text-xs font-medium text-[#64748B]"
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
>
{fila.etiqueta}
</th>
{propuestas.map((p, i) => (
<td
key={p.tipo}
className={`border-t border-[#E5E7EB] p-3 text-right whitespace-nowrap ${
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
fila.destacada
? `font-semibold ${i === ganador ? 'text-accent' : 'text-[#111827]'}`
: 'text-[#111827]'
? `font-semibold ${i === ganador ? 'text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'}`
: 'text-[#111827] dark:text-[#E2E8F0]'
} ${bordeColumna(
i,
indiceFila === filas.length - 1 ? 'fin' : 'media',
@@ -147,7 +147,7 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
</tbody>
</table>
</div>
<p className="mt-6 text-xs text-[#64748B]">
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
El enganche ya incluye la deducción del apartado. El total pagado suma apartado +
enganche + mensualidades + pago final.
</p>

View File

@@ -71,14 +71,14 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
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]">
<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 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
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>
{LISTA_DESARROLLOS.map((dev) => {
@@ -103,7 +103,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
<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]"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Comparar unidades
</h2>
@@ -112,28 +112,28 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
aria-expanded={abierto}
aria-controls="comparador-unidades-contenido"
onClick={() => setAbierto((v) => !v)}
className="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]"
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 shadow-sm">
<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]">
<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]">
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
<th scope="col" className="w-48 p-3 text-left">
<span className="sr-only">Concepto</span>
</th>
@@ -141,7 +141,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
<th
key={`${c.desarrolloId}|${c.unidad.codigo}`}
scope="col"
className="p-3 text-right text-xs font-semibold uppercase tracking-wider text-[#111827]"
className="p-3 text-right text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]"
>
{c.unidad.codigo}
</th>
@@ -150,18 +150,18 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
</thead>
<tbody>
{filas.map((fila) => (
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC]">
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<th
scope="row"
className="border-t border-[#E5E7EB] p-3 text-left text-xs font-medium text-[#64748B]"
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] p-3 text-right whitespace-nowrap ${
fila.destacada ? 'font-semibold text-accent' : 'text-[#111827]'
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)}
@@ -171,7 +171,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
))}
</tbody>
</table>
<p className="mt-6 text-xs text-[#64748B]">
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
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>

View File

@@ -0,0 +1,32 @@
import { Sun, Moon } from "lucide-react"
import { useEffect, useState } from "react"
function getStoredTheme(): boolean {
if (typeof window === "undefined") return false
try {
const stored = localStorage.getItem("frasa_theme")
if (stored) return stored === "dark"
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false
} catch {
return false
}
}
export default function DarkModeToggle() {
const [dark, setDark] = useState(getStoredTheme)
useEffect(() => {
document.documentElement.classList.toggle("dark", dark)
try {
localStorage.setItem("frasa_theme", dark ? "dark" : "light")
} catch {
// localStorage not available (test env)
}
}, [dark])
return (
<button onClick={() => setDark(!dark)} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]" aria-label="Cambiar tema">
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
)
}

View File

@@ -23,12 +23,12 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
return (
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4">
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm">
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="mb-8 text-center">
<p className="text-lg font-bold tracking-wide">
FRASA<span className="text-[#F97316]">.</span>
</p>
<p className="mt-2 text-xs font-medium text-[#64748B]">
<p className="mt-2 text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
Acceso al ecosistema
</p>
</div>
@@ -36,7 +36,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
<div>
<label
htmlFor="login-usuario"
className="mb-2 block text-xs font-medium text-[#4B5563]"
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
>
Usuario
</label>
@@ -48,13 +48,13 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
autoComplete="username"
autoFocus
required
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
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"
/>
</div>
<div>
<label
htmlFor="login-password"
className="mb-2 block text-xs font-medium text-[#4B5563]"
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
>
Contraseña
</label>
@@ -65,7 +65,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
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"
/>
</div>
{error && (

View File

@@ -36,7 +36,7 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
<div className="flex flex-wrap items-center justify-between gap-4">
<h2
id="mis-cotizaciones-titulo"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Mis cotizaciones
</h2>
@@ -45,18 +45,18 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
aria-expanded={abierto}
aria-controls="mis-cotizaciones-contenido"
onClick={() => setAbierto((v) => !v)}
className="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]"
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 historial' : `Ver historial (${cotizaciones.length})`}
</button>
</div>
{abierto && (
<div id="mis-cotizaciones-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
{cargando && <p className="text-sm text-[#64748B]">Cargando historial…</p>}
<div id="mis-cotizaciones-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
{cargando && <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando historial…</p>}
{error && <p className="text-xs text-red-700">{error}</p>}
{!cargando && !error && cotizaciones.length === 0 && (
<p className="text-sm text-[#64748B]">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Aún no tienes cotizaciones guardadas. Se guardan al generar el PDF.
</p>
)}
@@ -64,13 +64,13 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] border-collapse text-sm">
<thead>
<tr className="bg-[#F8FAFC]">
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
{['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Precio final', ''].map(
(h) => (
<th
key={h}
scope="col"
className="p-3 text-left text-xs font-semibold uppercase tracking-wider text-[#64748B]"
className="p-3 text-left text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
{h}
</th>
@@ -80,25 +80,25 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
</thead>
<tbody>
{cotizaciones.map((c) => (
<tr key={c.id} className="hover:bg-[#F8FAFC]">
<td className="border-t border-[#E5E7EB] p-3 whitespace-nowrap">
<tr key={c.id} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{formatoFechaHora(c.createdAt)}
</td>
<td className="border-t border-[#E5E7EB] p-3 whitespace-nowrap">
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{c.folio ?? '—'}
</td>
<td className="border-t border-[#E5E7EB] p-3">{c.clienteNombre}</td>
<td className="border-t border-[#E5E7EB] p-3 whitespace-nowrap">
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3">{c.clienteNombre}</td>
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{c.unidadCodigo}
<span className="ml-1 text-xs text-[#64748B]">{c.desarrollo}</span>
<span className="ml-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{c.desarrollo}</span>
</td>
<td className="border-t border-[#E5E7EB] p-3 whitespace-nowrap">
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{TITULO_PROPUESTA[c.tipoPropuesta] ?? c.tipoPropuesta}
</td>
<td className="border-t border-[#E5E7EB] p-3 text-right whitespace-nowrap">
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap">
{formatoMoneda(c.precioFinal)}
</td>
<td className="border-t border-[#E5E7EB] p-3 text-right">
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right">
<div className="flex justify-end gap-2">
<button
type="button"
@@ -109,7 +109,7 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
</button>
<a
href={`/client/?cotizacion=${c.id}`}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Iniciar alta
</a>

View File

@@ -12,8 +12,8 @@ interface Props {
}
const campoInput =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none'
const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563]'
'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'
const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
/**
* Modal sencillo para pedir los datos del cliente cuando se va a
@@ -55,14 +55,14 @@ export function ModalCliente({ abierto, nombreInicial = '', telefonoInicial = ''
aria-modal="true"
aria-labelledby="modal-cliente-titulo"
>
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl">
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<h2
id="modal-cliente-titulo"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Datos del cliente
</h2>
<p className="mt-2 text-sm text-[#4B5563]">
<p className="mt-2 text-sm text-[#4B5563] dark:text-[#CBD5E1]">
Se necesitan para guardar la cotización y generar el PDF.
</p>
@@ -113,7 +113,7 @@ export function ModalCliente({ abierto, nombreInicial = '', telefonoInicial = ''
<button
type="button"
onClick={onCancelar}
className="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]"
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]"
>
Cancelar
</button>

View File

@@ -10,15 +10,15 @@ interface Props {
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-medium text-[#64748B]">
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{etiqueta}
{nota && (
<span className="mt-0.5 block text-xs font-normal text-[#64748B]">
<span className="mt-0.5 block text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
{nota}
</span>
)}
</span>
<span className="text-lg font-medium text-[#111827]">{valor}</span>
<span className="text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{valor}</span>
</div>
)
}
@@ -26,12 +26,12 @@ function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; n
/** Tarjeta de una propuesta comercial (el descuento ya no es editable: usa el del Excel) */
export function ProposalCard({ numero, titulo, propuesta }: Props) {
return (
<article className="flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-all duration-200 hover:shadow-md">
<article className="flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-all duration-200 hover:shadow-md">
<header className="mb-6 border-b border-[#E5E7EB] pb-4">
<p className="text-xs font-semibold uppercase tracking-wider text-accent">
Propuesta comercial #{numero}
</p>
<h2 className="mt-1 text-xl font-semibold text-[#111827]">{titulo}</h2>
<h2 className="mt-1 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{titulo}</h2>
</header>
<div className="divide-y divide-[#E5E7EB]">

View File

@@ -0,0 +1,21 @@
export default function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded-lg bg-[#E2E8F0] dark:bg-[#334155] ${className}`} />
}
export function SkeletonCard() {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<Skeleton className="h-4 w-1/3 mb-3" />
<Skeleton className="h-3 w-2/3 mb-2" />
<Skeleton className="h-3 w-1/2" />
</div>
)
}
export function SkeletonList({ count = 5 }: { count?: number }) {
return (
<div className="space-y-3">
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} />)}
</div>
)
}

View File

@@ -8,9 +8,9 @@ interface Props {
function Fila({ etiqueta, valor }: { etiqueta: string; valor: string }) {
return (
<div className="flex items-center justify-between py-2">
<dt className="text-sm text-[#64748B]">{etiqueta}</dt>
<dd className="text-sm font-medium text-[#111827]">{valor}</dd>
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{valor}</dd>
</div>
)
}
@@ -33,8 +33,8 @@ export function UnitInfoCard({ unidad, numMeses }: Props) {
const extras = unidad.extras ?? {}
const etiquetaStatus = ETIQUETA_STATUS[unidad.status]
return (
<section className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#111827]">
<section className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
Unidad {unidad.codigo}
</h2>
{etiquetaStatus !== undefined && unidad.etapa != null && unidad.etapa !== '' && (
@@ -44,7 +44,7 @@ export function UnitInfoCard({ unidad, numMeses }: Props) {
</span>
</p>
)}
<dl className="divide-y divide-[#E5E7EB]">
<dl className="divide-y divide-[#E5E7EB] md:grid md:grid-cols-2 md:divide-x md:divide-y-0">
<Fila etiqueta="Modelo" valor={unidad.modelo} />
{extras['prototipo'] != null && <Fila etiqueta="Prototipo" valor={String(extras['prototipo'])} />}
{extras['etapa'] != null && <Fila etiqueta="Etapa" valor={String(extras['etapa'])} />}

View File

@@ -78,7 +78,7 @@ export function UnitSelector({ unidades, valor, onChange }: Props) {
<div ref={contenedorRef} className="relative">
<label
htmlFor="unidad"
className="mb-2 block text-xs font-medium text-[#4B5563]"
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
>
Unidad
</label>
@@ -100,17 +100,17 @@ export function UnitSelector({ unidades, valor, onChange }: Props) {
}}
onFocus={() => setAbierto(true)}
onKeyDown={onKeyDown}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
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"
/>
{abierto && (
<ul
id="unidad-listbox"
role="listbox"
aria-label="Unidades disponibles"
className="absolute z-10 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-[#E5E7EB] bg-white shadow-xl"
className="absolute z-10 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -xl"
>
{opciones.length === 0 ? (
<li className="px-3 py-2.5 text-sm text-[#64748B]">Sin unidades que coincidan</li>
<li className="px-3 py-2.5 text-sm text-[#64748B] dark:text-[#94A3B8]">Sin unidades que coincidan</li>
) : (
opciones.map((u, i) => (
<li
@@ -130,9 +130,9 @@ export function UnitSelector({ unidades, valor, onChange }: Props) {
i === indiceActivo ? 'bg-[#F8FAFC]' : 'bg-white'
}`}
>
<span className="font-medium text-[#111827]">{u.codigo}</span>
<span className="text-[#64748B]"> — Modelo {u.modelo} — </span>
<span className="text-[#111827]">{formatoMoneda(u.precioSinAcabados)}</span>
<span className="font-medium text-[#111827] dark:text-[#E2E8F0]">{u.codigo}</span>
<span className="text-[#64748B] dark:text-[#94A3B8]"> — Modelo {u.modelo} — </span>
<span className="text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(u.precioSinAcabados)}</span>
</li>
))
)}

View File

@@ -1,3 +1,5 @@
@custom-variant dark (&:where(.dark, .dark *));
@import "tailwindcss";
@theme {

View File

@@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
@@ -19,5 +20,6 @@ createRoot(document.getElementById('root')!).render(
</Routes>
</AuthGate>
</BrowserRouter>
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
</StrictMode>,
)

View File

@@ -6,6 +6,9 @@ import QRCode from 'qrcode'
import { useUnidades } from '../hooks/useUnidades'
import { useCotizacion } from '../hooks/useCotizacion'
import BannerModoLocal from '../components/BannerModoLocal'
import DarkModeToggle from '../components/DarkModeToggle'
import { SkeletonList } from "../components/Skeleton"
import toast from "react-hot-toast"
import { UnitSelector } from '../components/UnitSelector'
import { UnitInfoCard } from '../components/UnitInfoCard'
import { ProposalCard } from '../components/ProposalCard'
@@ -45,7 +48,7 @@ const campoInputBase =
const campoInput = `${campoInputBase} border-[#D1D5DB]`
const campoInputError = `${campoInputBase} border-red-400`
const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563]'
const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
export default function CotizadorPage() {
const { unidades, cargando, error, usandoSeed } = useUnidades()
@@ -178,10 +181,11 @@ export default function CotizadorPage() {
})
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
setUltimaGuardada(conFolio)
toast.success('Cotización guardada correctamente')
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
return conFolio
} catch (err) {
console.warn('[api] No se pudo guardar la cotización; el PDF sale sin folio/QR.', err)
toast.error('No se pudo guardar la cotización'); console.warn('[api] No se pudo guardar la cotización; el PDF sale sin folio/QR.', err)
setAviso('Sin conexión al servidor: la cotización no se guardó (PDF sin folio/QR).')
return null
}
@@ -300,16 +304,19 @@ export default function CotizadorPage() {
}
return (
<div className="min-h-screen bg-[#F8FAFC]">
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
<BannerModoLocal />
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 lg:py-10">
{/* Encabezado de página */}
<div className="mb-8">
<h1 className="text-xl font-semibold text-[#111827]">Cotizador</h1>
<p className="mt-1 text-sm text-[#64748B] capitalize">{formatoFechaLarga(new Date())}</p>
<p className="text-sm text-[#64748B]">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Cotizador</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8] capitalize">{formatoFechaLarga(new Date())}</p>
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
</p>
</div>
<DarkModeToggle />
</div>
{oportunidadId !== null && nombreCliente.trim() !== '' && (
@@ -321,16 +328,16 @@ export default function CotizadorPage() {
</p>
)}
<div className="grid items-start gap-6 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
<div className="grid items-start gap-6 md:grid-cols-1 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
{/* Panel de configuración */}
<aside className="space-y-6 lg:sticky lg:top-24">
<section className="space-y-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
<section className="space-y-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
{error && <p className="text-xs text-red-700">{error}</p>}
{usandoSeed && !cargando && (
<p className="text-xs text-amber-700">Datos de demostración (sin conexión al servidor)</p>
)}
{cargando ? (
<p className="text-sm text-[#64748B]">Cargando unidades…</p>
<SkeletonList count={3} />
) : (
<>
<div>
@@ -351,18 +358,18 @@ export default function CotizadorPage() {
className={`px-4 py-2.5 text-xs font-medium transition-colors duration-100 ${
activo
? 'bg-accent text-white'
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
: 'bg-white text-[#4B5563] dark:text-[#CBD5E1] hover:bg-[#F9FAFB]'
}`}
>
{obtenerDesarrollo(id).nombre}
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B]'}`}>
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B] dark:text-[#94A3B8]'}`}>
{disponibles}/{total}
</span>
</button>
)
})}
</div>
<p className="mt-2 text-xs text-[#64748B]">
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
{unidadesDesarrollo.length} unidades · {disponiblesDesarrollo} disponibles
</p>
</div>
@@ -391,7 +398,7 @@ export default function CotizadorPage() {
className={`px-5 py-2.5 text-xs font-medium transition-colors duration-100 ${
activo
? 'bg-accent text-white'
: 'bg-white text-[#4B5563] hover:bg-[#F9FAFB]'
: 'bg-white text-[#4B5563] dark:text-[#CBD5E1] hover:bg-[#F9FAFB]'
}`}
>
{opcion}
@@ -421,7 +428,7 @@ export default function CotizadorPage() {
{errorApartado ? (
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
) : (
<p className="mt-1 text-xs text-[#64748B]">{formatoMoneda(apartado)}</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p>
)}
</div>
@@ -451,7 +458,7 @@ export default function CotizadorPage() {
{errorEnganche ? (
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
) : (
<p className="mt-1 text-xs text-[#64748B]">
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Aplica a crédito bancario e Infonavit/Cofinavit; mínimo 2.5%
{unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''}
</p>
@@ -515,11 +522,11 @@ export default function CotizadorPage() {
</div>
{unidad && (
<div className="border-t border-[#E5E7EB] pt-6">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
<div className="border-t border-[#E5E7EB] dark:border-[#1E293B] pt-6">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Precio de lista
</p>
<p className="mt-2 text-3xl font-semibold text-[#111827]">
<p className="mt-2 text-3xl font-semibold text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(precioLista)}
</p>
</div>
@@ -529,7 +536,7 @@ export default function CotizadorPage() {
<button
type="button"
onClick={handleNueva}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Nueva cotización
</button>
@@ -543,13 +550,13 @@ export default function CotizadorPage() {
<section className="min-w-0">
{!unidad ? (
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white">
<p className="text-sm text-[#64748B]">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para ver las propuestas comerciales.
</p>
</div>
) : (
<div className="space-y-8">
<div className="grid gap-6 md:grid-cols-2">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{propuestas.map((p, i) => (
<ProposalCard
key={p.tipo}
@@ -565,8 +572,8 @@ export default function CotizadorPage() {
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/>
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-center justify-end gap-4">
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end sm:justify-end">
<div>
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
Propuesta elegida
@@ -575,7 +582,7 @@ export default function CotizadorPage() {
id="propuesta-elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
className="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"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
@@ -601,9 +608,9 @@ export default function CotizadorPage() {
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
</button>
</div>
{aviso && <p className="mt-2 text-right text-xs text-[#64748B]">{aviso}</p>}
{aviso && <p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">{aviso}</p>}
{!puedeGenerarPdf && !guardando && (
<p className="mt-2 text-right text-xs text-[#64748B]">
<p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para guardar y generar el PDF.
</p>
)}

View File

@@ -1,6 +1,7 @@
import type { Unidad, TipoPropuesta } from '../types'
import { INVENTARIO_SEED } from '../data/unidades'
import { conAuth, manejar401 } from './auth'
import { guardarUnidades, cargarUnidadesLocal } from './offline-db'
// TODO produccion: '/api' same-origin detras de NGINX
const API_URL = import.meta.env.VITE_API_URL ?? '/api'
@@ -24,9 +25,15 @@ export async function getUnidades(): Promise<GetUnidadesResult> {
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const unidades = (await resp.json()) as Unidad[]
// Cache en IDB para uso offline
void guardarUnidades(unidades)
return { unidades, usandoSeed: false }
} catch (err) {
console.warn('[api] No se pudo obtener el inventario del API; usando seed local.', err)
console.warn('[api] No se pudo obtener el inventario del API; intentando cache offline.', err)
const cached = await cargarUnidadesLocal()
if (cached && Array.isArray(cached) && cached.length > 0) {
return { unidades: cached as Unidad[], usandoSeed: true }
}
return { unidades: INVENTARIO_SEED, usandoSeed: true }
}
}

View File

@@ -0,0 +1,33 @@
import { openDB, type IDBPDatabase } from 'idb'
const DB_NAME = 'frasa-cotizador'
const DB_VERSION = 1
async function getDB(): Promise<IDBPDatabase> {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains('unidades')) db.createObjectStore('unidades')
if (!db.objectStoreNames.contains('cotizaciones')) db.createObjectStore('cotizaciones')
},
})
}
export async function guardarUnidades(unidades: unknown[]) {
const db = await getDB()
await db.put('unidades', unidades, 'actual')
}
export async function cargarUnidadesLocal() {
const db = await getDB()
return db.get('unidades', 'actual')
}
export async function guardarCotizaciones(cotizaciones: unknown[]) {
const db = await getDB()
await db.put('cotizaciones', cotizaciones, 'historial')
}
export async function cargarCotizacionesLocal() {
const db = await getDB()
return db.get('cotizaciones', 'historial')
}