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

@@ -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>
))
)}