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

@@ -27,9 +27,9 @@ interface Props {
const emailSchema = z.string().trim().email()
const inputClase =
'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'
'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'
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563]'
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
interface Errores {
nombre?: string
@@ -222,7 +222,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
<button
type="button"
onClick={onVolver}
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]"
>
Volver
</button>
@@ -244,7 +244,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
) : (
<div className="mb-6 border-b border-[#E5E7EB] pb-6">
<BusquedaLead onSeleccion={prellenarDesdeLead} />
<p className="mt-2 text-xs text-[#64748B]">
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Si el interesado ya está en Monday (lead u oportunidad), sus datos se prellenan solos.
</p>
</div>
@@ -273,7 +273,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
aria-label="Lada del teléfono"
value={lada}
onChange={(e) => setLada(e.target.value as 'MX' | 'US')}
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 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"
>
<option value="MX">🇲🇽 +52</option>
<option value="US">🇺🇸 +1</option>
@@ -527,7 +527,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
<button
type="button"
onClick={onVolver}
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]"
>
Volver
</button>

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import DarkModeToggle from './DarkModeToggle'
interface Props {
titulo: string
@@ -8,19 +9,22 @@ interface Props {
/** Barra superior de la app: marca a la izquierda, acciones a la derecha */
export default function AppHeader({ titulo, acciones }: Props) {
return (
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
<div className="mx-auto flex min-h-14 max-w-[1280px] flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2 sm:px-6">
<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>
<span className="text-sm font-bold tracking-widest text-[#111827]">
<span className="text-sm font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">
FRASA<span className="text-[#F97316]">.</span>
</span>
<h1 className="text-xl font-semibold text-[#111827]">{titulo}</h1>
{acciones && <div className="ml-auto flex items-center gap-3">{acciones}</div>}
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{titulo}</h1>
<div className="ml-auto flex items-center gap-3">
<DarkModeToggle />
{acciones}
</div>
</div>
</header>
)

View File

@@ -17,17 +17,17 @@ export default function AuthGate({ children }: { children: ReactNode }) {
}
return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
<div className="border-b border-[#E5E7EB] bg-white">
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
<div className="border-b border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="mx-auto flex max-w-[1280px] items-center justify-between px-4 py-2 sm:px-6">
<span className="text-xs font-medium text-[#64748B]">
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</span>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setModalPassword(true)}
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] transition-colors duration-100 hover:text-[#111827]"
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-colors duration-100 hover:text-[#111827] dark:text-[#E2E8F0]"
>
Contraseña
</button>

View File

@@ -86,19 +86,19 @@ export default function BotonOcrIne({ archivo, onAplicar }: Props) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm">
<div className="flex items-center justify-between gap-4">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">Lectura automática (OCR)</p>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Lectura automática (OCR)</p>
<button
type="button"
onClick={() => void leer()}
disabled={estado.fase === 'procesando'}
className="shrink-0 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] disabled:opacity-40"
className="shrink-0 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] disabled:opacity-40"
>
{estado.fase === 'procesando' ? `Leyendo… ${estado.progreso}%` : 'Leer INE con OCR'}
</button>
</div>
{estado.fase === 'procesando' && (
<p className="mt-3 text-xs text-[#64748B]" role="status">
<p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]" role="status">
Reconociendo texto de la credencial… {estado.progreso}%
</p>
)}
@@ -111,14 +111,14 @@ export default function BotonOcrIne({ archivo, onAplicar }: Props) {
{estado.fase === 'listo' && (
<div className="mt-3">
<p className="text-xs text-[#64748B]">
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
Sugerencias detectadas — nada se sobreescribe sin tu confirmación:
</p>
<ul className="mt-2 space-y-2">
{aVisibles(estado.sugerencias).map((s) => (
<li key={s.clave} className="flex items-baseline justify-between gap-4 text-sm">
<span className="text-[#111827]">
<span className="mr-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
<span className="text-[#111827] dark:text-[#E2E8F0]">
<span className="mr-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
{s.etiqueta}
</span>
{s.valor}
@@ -130,7 +130,7 @@ export default function BotonOcrIne({ archivo, onAplicar }: Props) {
<button
type="button"
onClick={() => aplicar(s)}
className="shrink-0 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="shrink-0 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]"
>
Aplicar
</button>

View File

@@ -46,7 +46,7 @@ export default function BusquedaLead({ onSeleccion }: Props) {
<div>
<label
htmlFor="busqueda-lead"
className="mb-1 block text-xs font-medium text-[#4B5563]"
className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
>
Buscar lead (nombre, teléfono o email)
</label>
@@ -56,29 +56,29 @@ export default function BusquedaLead({ onSeleccion }: Props) {
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Escribe al menos 2 caracteres…"
className="w-full max-w-md rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
className="w-full max-w-md rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm text-[#111827] dark:text-[#E2E8F0] 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 leads que coincidan.</p>
<p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]">Sin leads que coincidan.</p>
)}
{resultados !== null && resultados.length > 0 && (
<ul className="mt-3 max-w-md divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
<ul className="mt-3 max-w-md 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
type="button"
onClick={() => onSeleccion(lead)}
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}
{/* Insignia de procedencia: gris = archivo histórico, azul = modelo nuevo */}
<span
@@ -91,7 +91,7 @@ export default function BusquedaLead({ 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-4 py-3 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563]'
'w-full rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
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-3 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-3 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Cancelar
</button>

View File

@@ -8,9 +8,9 @@ export interface CampoBaseProps {
}
const inputClase =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm uppercase transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
'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 uppercase transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563]'
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
function Etiqueta({ etiqueta, requerido }: { etiqueta: string; requerido?: boolean }) {
return (
@@ -136,7 +136,7 @@ export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoB
aria-label="Lada"
value={lada}
onChange={(e) => actualizar(e.target.value, numero)}
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 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"
>
<option value="+52">🇲🇽 +52</option>
<option value="+1">🇺🇸 +1</option>

View File

@@ -12,15 +12,15 @@ export default function ChecklistExpediente({ documentos, firmaDataUrl }: Props)
return (
<section
aria-label="Checklist del expediente"
className="border-t border-[#E5E7EB] px-6 py-6 first:border-t-0"
className="border-t border-[#E5E7EB] dark:border-[#1E293B] px-6 py-6 first:border-t-0"
>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Expediente: {estado.completos}/{estado.total}
</p>
<ul className="mt-3 space-y-2">
{estado.items.map((item) => (
<li key={item.clave} className="flex items-baseline justify-between gap-4 text-sm">
<span className="text-[#111827]">{item.etiqueta}</span>
<span className="text-[#111827] dark:text-[#E2E8F0]">{item.etiqueta}</span>
{item.completo ? (
<span className="rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700">✓</span>
) : (

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

@@ -25,14 +25,14 @@ function Miniatura({ documento, url }: { documento: DocumentoCargado; url: strin
rel="noreferrer"
title={`Abrir ${documento.nombre} en una pestaña nueva`}
aria-label={`Abrir ${documento.nombre} en una pestaña nueva`}
className="block h-20 w-20 shrink-0 overflow-hidden rounded-lg border border-[#E5E7EB] bg-[#F8FAFC]"
className="block h-20 w-20 shrink-0 overflow-hidden rounded-lg border border-[#E5E7EB] bg-[#F8FAFC] dark:bg-[#0F172A]"
>
{esImagen ? (
<img src={url} alt={`Vista previa de ${documento.nombre}`} className="h-full w-full object-cover" />
) : (
<span className="flex h-full w-full flex-col items-center justify-center gap-1">
<span className="text-xs font-semibold text-[#111827]">PDF</span>
<span className="max-w-full truncate px-1 text-[10px] text-[#64748B]">{documento.nombre}</span>
<span className="text-xs font-semibold text-[#111827] dark:text-[#E2E8F0]">PDF</span>
<span className="max-w-full truncate px-1 text-[10px] text-[#64748B] dark:text-[#94A3B8]">{documento.nombre}</span>
</span>
)}
</a>
@@ -80,8 +80,8 @@ export default function FileUploader({ slot, etiqueta, documento, onAgregar, onQ
<div className="flex min-w-0 items-center gap-4">
{urlObjeto && <Miniatura documento={documento} url={urlObjeto} />}
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{etiqueta}</p>
<p className="mt-1 truncate text-sm text-[#111827]">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
<p className="mt-1 truncate text-sm text-[#111827] dark:text-[#E2E8F0]">
{documento.nombre} · {formatoTamano(documento.tamano)}
</p>
</div>
@@ -101,16 +101,16 @@ export default function FileUploader({ slot, etiqueta, documento, onAgregar, onQ
<div>
<div
{...getRootProps()}
className={`cursor-pointer rounded-xl border border-dashed border-[#D1D5DB] bg-white px-4 py-6 text-center transition-colors duration-200 ${
className={`cursor-pointer rounded-xl border border-dashed border-[#D1D5DB] bg-white px- dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 4 py-6 text-center transition-colors duration-200 ${
isDragActive ? 'border-accent bg-accent/5' : 'hover:border-accent'
}`}
>
<input {...getInputProps()} aria-label={`Seleccionar archivo para ${etiqueta}`} />
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{etiqueta}</p>
<p className="mt-2 text-xs text-[#64748B]">Arrastra el archivo aquí · PDF/JPG/PNG · máx 10 MB</p>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">Arrastra el archivo aquí · PDF/JPG/PNG · máx 10 MB</p>
<button
type="button"
className="mt-4 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="mt-4 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]"
>
Seleccionar archivo
</button>

View File

@@ -23,10 +23,10 @@ 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-10 text-center">
<p className="text-lg font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></p>
<p className="mt-2 text-xs font-medium uppercase tracking-wider text-[#64748B]">
<p className="text-lg font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">FRASA<span className="text-[#F97316]">.</span></p>
<p className="mt-2 text-xs font-medium uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Acceso al ecosistema
</p>
</div>
@@ -34,7 +34,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>
@@ -46,13 +46,13 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
autoComplete="username"
autoFocus
required
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-4 py-3 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-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
</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>
@@ -63,7 +63,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-4 py-3 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-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
/>
</div>
{error && (

View File

@@ -9,10 +9,10 @@ interface Props {
/** Sección interna del paso del wizard (separada por divisor dentro de la tarjeta del paso) */
export default function SectionCard({ titulo, descripcion, children }: Props) {
return (
<section className="border-t border-[#E5E7EB] px-6 py-6 first:border-t-0">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</h3>
{descripcion && <p className="mt-2 text-sm text-[#64748B]">{descripcion}</p>}
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">{children}</div>
<section className="border-t border-[#E5E7EB] dark:border-[#1E293B] px-6 py-6 first:border-t-0">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</h3>
{descripcion && <p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">{descripcion}</p>}
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">{children}</div>
</section>
)
}

View File

@@ -39,7 +39,7 @@ export default function SignaturePad({ onFirma }: Props) {
<button
type="button"
onClick={limpiar}
className="mt-4 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="mt-4 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]"
>
Limpiar firma
</button>

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

@@ -27,10 +27,10 @@ export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
return (
<>
{/* Móvil: barra horizontal de progreso */}
<div className="lg:hidden">
<div className="md:hidden">
<div className="flex items-baseline justify-between gap-4">
<p className="text-sm font-medium text-[#111827]">{pasos[pasoActual]}</p>
<p className="text-xs text-[#64748B]">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{pasos[pasoActual]}</p>
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
Paso {pasoActual + 1} de {pasos.length}
</p>
</div>
@@ -43,7 +43,7 @@ export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
</div>
{/* Escritorio: lista vertical con el estado de cada paso */}
<nav aria-label="Progreso" className="hidden lg:block">
<nav aria-label="Progreso" className="hidden md:block">
<ol className="space-y-1">
{pasos.map((nombre, i) => {
const activo = i === pasoActual
@@ -59,7 +59,7 @@ export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
activo
? 'bg-accent/5 font-semibold text-accent'
: completado
? 'text-[#111827] hover:bg-[#F1F5F9]'
? 'text-[#111827] dark:text-[#E2E8F0] hover:bg-[#F1F5F9]'
: 'text-[#94A3B8]'
}`}
>