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:
40
apps/agente-ia/package-lock.json
generated
40
apps/agente-ia/package-lock.json
generated
@@ -9,8 +9,10 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -1811,7 +1813,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/debug": {
|
||||
@@ -1995,6 +1996,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -2335,6 +2345,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/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2468,6 +2487,23 @@
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"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-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
|
||||
@@ -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-6xl items-center justify-between px-4 py-2">
|
||||
<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-md px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:text-[#111827]"
|
||||
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
Contraseña
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,7 @@ function Burbuja({ mensaje }: { mensaje: Mensaje }) {
|
||||
className={`inline-block whitespace-pre-wrap rounded-2xl px-4 py-3 text-sm leading-relaxed ${
|
||||
esUsuario
|
||||
? 'rounded-br-md bg-accent text-white shadow-sm'
|
||||
: 'rounded-bl-md border border-[#E5E7EB] bg-white text-[#111827] shadow-sm'
|
||||
: 'rounded-bl-md border border-[#E5E7EB] bg-white text-[#111827] dark:text-[#E2E8F0] shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{mensaje.contenido}
|
||||
|
||||
@@ -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: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-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 CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
/**
|
||||
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
|
||||
@@ -46,7 +46,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
aria-label="Cambiar contraseña"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Cambiar contraseña
|
||||
</h2>
|
||||
{exito ? (
|
||||
@@ -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] 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] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
32
apps/agente-ia/src/components/DarkModeToggle.tsx
Normal file
32
apps/agente-ia/src/components/DarkModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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-10 text-center">
|
||||
<p className="text-lg font-bold tracking-wide">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider 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: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"
|
||||
/>
|
||||
</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: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"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
21
apps/agente-ia/src/components/Skeleton.tsx
Normal file
21
apps/agente-ia/src/components/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import '@fontsource/inter/300.css'
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
@@ -13,5 +14,6 @@ createRoot(document.getElementById('root')!).render(
|
||||
<AuthGate>
|
||||
<ChatPage />
|
||||
</AuthGate>
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Burbuja from '../components/Burbuja'
|
||||
import DarkModeToggle from '../components/DarkModeToggle'
|
||||
import toast from "react-hot-toast"
|
||||
import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api'
|
||||
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
|
||||
import type { Mensaje } from '../types'
|
||||
@@ -59,7 +61,7 @@ export default function ChatPage() {
|
||||
if (esErrorDeRed(err)) {
|
||||
setSinConexion(true)
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Error al enviar el mensaje')
|
||||
toast.error('Error al enviar el mensaje'); setError(err instanceof Error ? err.message : 'Error al enviar el mensaje')
|
||||
}
|
||||
// el mensaje del usuario se queda visible; puede reintentar
|
||||
} finally {
|
||||
@@ -76,29 +78,32 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
|
||||
<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 h-14 w-full max-w-3xl items-center justify-between 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]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<span className="text-sm tracking-wide">
|
||||
<span className="font-bold">FRASA</span>
|
||||
<span className="text-[#F97316]"> · </span>
|
||||
<span className="font-light text-[#64748B]">ASISTENTE IA</span>
|
||||
<span className="font-light text-[#64748B] dark:text-[#94A3B8]">ASISTENTE IA</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nuevaConversacion}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Nueva conversación
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<DarkModeToggle />
|
||||
<button
|
||||
type="button"
|
||||
onClick={nuevaConversacion}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Nueva conversación
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -117,11 +122,11 @@ export default function ChatPage() {
|
||||
|
||||
{mensajes.length === 0 && !escribiendo ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<div className="w-full max-w-lg rounded-xl border border-[#E5E7EB] bg-white p-6 text-center shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<div className="w-full max-w-lg rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] text-center shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Asistente interno
|
||||
</p>
|
||||
<p className="mt-3 text-xl font-light leading-relaxed text-[#111827]">
|
||||
<p className="mt-3 text-xl font-light leading-relaxed text-[#111827] dark:text-[#E2E8F0]">
|
||||
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de
|
||||
venta o las políticas de Frasa.
|
||||
</p>
|
||||
@@ -131,7 +136,7 @@ export default function ChatPage() {
|
||||
key={pregunta}
|
||||
type="button"
|
||||
onClick={() => setEntrada(pregunta)}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-left text-xs font-medium text-[#374151] transition-colors hover:border-accent hover:text-accent"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-left text-xs font-medium text-[#374151] dark:text-[#CBD5E1] transition-colors hover:border-accent hover:text-accent"
|
||||
>
|
||||
{pregunta}
|
||||
</button>
|
||||
@@ -149,11 +154,11 @@ export default function ChatPage() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="sticky bottom-0 border-t border-[#E5E7EB] bg-white">
|
||||
<footer className="sticky bottom-0 border-t border-[#E5E7EB] dark:border-[#1E293B] bg-white">
|
||||
<div className="relative mx-auto w-full max-w-3xl px-4 sm:px-6">
|
||||
{escribiendo && (
|
||||
<div className="pointer-events-none absolute -top-11 left-4 sm:left-6">
|
||||
<p className="rounded-full border border-[#E5E7EB] bg-white px-4 py-2 text-xs font-medium text-[#64748B] shadow-md">
|
||||
<p className="rounded-full border border-[#E5E7EB] bg-white px-4 py-2 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] shadow-md">
|
||||
Escribiendo…
|
||||
</p>
|
||||
</div>
|
||||
@@ -175,7 +180,7 @@ export default function ChatPage() {
|
||||
onChange={(e) => setEntrada(e.target.value)}
|
||||
placeholder="Escribe tu pregunta…"
|
||||
autoComplete="off"
|
||||
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 py-3 text-sm placeholder:text-[#94A3B8] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm placeholder:text-[#94A3B8] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
38
apps/alta-clientes/package-lock.json
generated
38
apps/alta-clientes/package-lock.json
generated
@@ -12,10 +12,12 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||
"tesseract.js": "^7.0.0",
|
||||
@@ -2155,7 +2157,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
@@ -2452,6 +2453,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -2982,6 +2992,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",
|
||||
@@ -3276,6 +3295,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": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -15,10 +15,12 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||
"tesseract.js": "^7.0.0",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
>
|
||||
← 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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
32
apps/alta-clientes/src/components/DarkModeToggle.tsx
Normal file
32
apps/alta-clientes/src/components/DarkModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
21
apps/alta-clientes/src/components/Skeleton.tsx
Normal file
21
apps/alta-clientes/src/components/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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]'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ interface Props {
|
||||
/** Botones de navegación del wizard */
|
||||
export default function Navegacion({ onAnterior, onSiguiente, textoSiguiente = 'Siguiente' }: Props) {
|
||||
return (
|
||||
<div className="sticky bottom-0 flex items-center justify-between gap-4 rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
|
||||
<div className="sticky bottom-0 flex flex-col-reverse items-center justify-between gap-3 rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4 sm:flex-row">
|
||||
{onAnterior ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -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'
|
||||
@@ -26,5 +27,6 @@ createRoot(document.getElementById('root')!).render(
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import AppHeader from '../components/AppHeader'
|
||||
import { SkeletonCard } from "../components/Skeleton"
|
||||
import toast from "react-hot-toast"
|
||||
import BannerModoLocal from '../components/BannerModoLocal'
|
||||
import Stepper from '../components/Stepper'
|
||||
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
|
||||
@@ -67,7 +69,7 @@ const formatoFechaHora = (iso: string): string =>
|
||||
|
||||
/** Tarjeta del paso actual del wizard (el stepper queda fuera, a la izquierda en lg) */
|
||||
function TarjetaPaso({ children }: { children: ReactNode }) {
|
||||
return <div className="rounded-xl border border-[#E5E7EB] bg-white shadow-sm">{children}</div>
|
||||
return <div className="rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">{children}</div>
|
||||
}
|
||||
|
||||
/** Cuadro con las iniciales de la opción (icono de la tarjeta de entrada) */
|
||||
@@ -226,6 +228,7 @@ export default function AltaClientePage() {
|
||||
const guardar = async () => {
|
||||
try {
|
||||
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos })
|
||||
toast.success("Borrador guardado")
|
||||
setBorradorId(b.id)
|
||||
setAvisoBorrador(
|
||||
`Borrador guardado ${new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`,
|
||||
@@ -283,6 +286,7 @@ export default function AltaClientePage() {
|
||||
const res = datos.progresoId
|
||||
? await completarProgreso(datos.progresoId, alta)
|
||||
: await crearAltaCliente(alta)
|
||||
toast.success("Alta de cliente completada")
|
||||
if (res.ok) {
|
||||
await subirDocumentos(res.id, alta)
|
||||
setRegistro(res.id)
|
||||
@@ -322,9 +326,9 @@ export default function AltaClientePage() {
|
||||
|
||||
if (pantalla === 'cargando') {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-16 sm:px-6">
|
||||
<p className="text-sm text-[#64748B]">Cargando…</p>
|
||||
<SkeletonCard />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
@@ -332,23 +336,23 @@ export default function AltaClientePage() {
|
||||
|
||||
if (pantalla === 'entrada') {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<AppHeader titulo="Alta de Clientes" />
|
||||
<BannerModoLocal />
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<p className="text-sm text-[#64748B]">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Elige cómo registrar al interesado: captura ligera de afluencia o formato completo de alta.
|
||||
</p>
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPantalla('afluencia')}
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
||||
>
|
||||
<IconoIniciales iniciales="AA" />
|
||||
<span className="mt-4 block text-lg font-semibold text-[#111827]">Alta Afluencia</span>
|
||||
<span className="mt-2 block text-sm text-[#64748B]">
|
||||
<span className="mt-4 block text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Alta Afluencia</span>
|
||||
<span className="mt-2 block text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Captura rápida del interesado; entra al embudo de ventas.
|
||||
</span>
|
||||
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
|
||||
@@ -359,11 +363,11 @@ export default function AltaClientePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPantalla('picker')}
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
||||
>
|
||||
<IconoIniciales iniciales="AC" />
|
||||
<span className="mt-4 block text-lg font-semibold text-[#111827]">Alta de Cliente</span>
|
||||
<span className="mt-2 block text-sm text-[#64748B]">
|
||||
<span className="mt-4 block text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Alta de Cliente</span>
|
||||
<span className="mt-2 block text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Formato completo para quien aparta.
|
||||
</span>
|
||||
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
|
||||
@@ -380,14 +384,14 @@ export default function AltaClientePage() {
|
||||
|
||||
if (pantalla === 'afluencia') {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<AppHeader titulo="Alta Afluencia" />
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<p className="text-sm text-[#64748B]">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Captura rápida del interesado; entra al embudo de ventas.
|
||||
</p>
|
||||
<div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<AltaAfluenciaForm
|
||||
valoresIniciales={afluenciaPrevia?.valores}
|
||||
oportunidadId={afluenciaPrevia?.oportunidadId}
|
||||
@@ -406,22 +410,22 @@ export default function AltaClientePage() {
|
||||
|
||||
if (pantalla === 'picker') {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<AppHeader titulo="Alta de Clientes" />
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
|
||||
<div className="max-w-3xl">
|
||||
{borradores.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
|
||||
{borradores.length === 1 ? '' : 's'}
|
||||
</h2>
|
||||
<ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
{borradores.map((b) => (
|
||||
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-[#111827]">{b.nombre}</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">
|
||||
<p className="truncate text-sm text-[#111827] dark:text-[#E2E8F0]">{b.nombre}</p>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -455,11 +459,11 @@ export default function AltaClientePage() {
|
||||
Empezar nuevo
|
||||
</button>
|
||||
|
||||
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] pt-8">
|
||||
<h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] dark:border-[#1E293B] pt-8">
|
||||
<h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Desde un lead de Monday
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Empieza un alta nueva con el nombre, email y celular del lead ya capturados.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
@@ -473,13 +477,13 @@ export default function AltaClientePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<AppHeader
|
||||
titulo="Alta de Clientes"
|
||||
acciones={
|
||||
<>
|
||||
{avisoBorrador && (
|
||||
<span role="status" className="text-xs text-[#64748B]">
|
||||
<span role="status" className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{avisoBorrador}
|
||||
</span>
|
||||
)}
|
||||
@@ -491,7 +495,7 @@ export default function AltaClientePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void guardar()}
|
||||
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]"
|
||||
>
|
||||
Guardar borrador
|
||||
</button>
|
||||
@@ -500,8 +504,8 @@ export default function AltaClientePage() {
|
||||
/>
|
||||
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="lg:grid lg:grid-cols-[260px_minmax(0,1fr)] lg:gap-8">
|
||||
<div className="mb-6 lg:sticky lg:top-20 lg:mb-0 lg:self-start">
|
||||
<div className="md:grid md:grid-cols-[260px_minmax(0,1fr)] md:gap-8 lg:gap-8">
|
||||
<div className="mb-6 md:sticky md:top-20 md:mb-0 md:self-start">
|
||||
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
|
||||
</div>
|
||||
|
||||
@@ -648,23 +652,23 @@ export default function AltaClientePage() {
|
||||
</p>
|
||||
)}
|
||||
{registro && (
|
||||
<div className="mb-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<div className="mb-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Firma en remoto
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus
|
||||
documentos desde su celular, sin usuario ni contraseña.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generarLiga()}
|
||||
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]"
|
||||
>
|
||||
Generar liga para firmar en remoto
|
||||
</button>
|
||||
{ligaUrl && (
|
||||
<p className="mt-4 break-all text-xs text-[#111827]">
|
||||
<p className="mt-4 break-all text-xs text-[#111827] dark:text-[#E2E8F0]">
|
||||
{ligaUrl}
|
||||
{ligaCopiada && (
|
||||
<span role="status" className="ml-2 font-medium text-accent">
|
||||
|
||||
@@ -82,9 +82,9 @@ export default function FirmaRemotaPage() {
|
||||
|
||||
if (errorCarga) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]">
|
||||
<main className="mx-auto max-w-xl px-4 py-24 text-center">
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">FRASA<span className="text-[#F97316]">.</span></span>
|
||||
<p role="alert" className="mt-8 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{errorCarga}
|
||||
</p>
|
||||
@@ -95,29 +95,29 @@ export default function FirmaRemotaPage() {
|
||||
|
||||
if (!cliente) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<main className="mx-auto max-w-xl px-4 py-24">
|
||||
<p className="text-sm text-[#64748B]">Cargando…</p>
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]">
|
||||
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
|
||||
<div className="mx-auto flex max-w-xl items-baseline gap-3">
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
|
||||
<h1 className="text-2xl font-semibold text-[#111827]">Expediente digital</h1>
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">FRASA<span className="text-[#F97316]">.</span></span>
|
||||
<h1 className="text-2xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Expediente digital</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-xl space-y-12 px-4 py-12">
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-[#111827]">
|
||||
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||
Hola, <span className="font-semibold">{cliente.nombre}</span>
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Proyecto {cliente.proyecto}
|
||||
{cliente.unidad ? ` · Unidad ${cliente.unidad}` : ''}. Firma y sube los documentos de tu
|
||||
expediente desde aquí.
|
||||
@@ -125,7 +125,7 @@ export default function FirmaRemotaPage() {
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="firma-titulo">
|
||||
<h3 id="firma-titulo" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h3 id="firma-titulo" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Tu firma
|
||||
</h3>
|
||||
{firmaOk ? (
|
||||
@@ -149,7 +149,7 @@ export default function FirmaRemotaPage() {
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="docs-titulo">
|
||||
<h3 id="docs-titulo" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h3 id="docs-titulo" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Tus documentos
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-4">
|
||||
@@ -157,14 +157,14 @@ export default function FirmaRemotaPage() {
|
||||
const subido = cliente.documentos.find((d) => d.tipo === slot)
|
||||
return (
|
||||
<li key={slot} className="rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{etiqueta}</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
|
||||
{subido ? (
|
||||
<p role="status" className="mt-2 text-sm text-[#111827]">
|
||||
<p role="status" className="mt-2 text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
✓ {subido.nombreArchivo}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2">
|
||||
<label className="inline-block cursor-pointer 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]">
|
||||
<label className="inline-block cursor-pointer 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]">
|
||||
{subiendoSlot === slot ? 'Subiendo…' : 'Seleccionar archivo'}
|
||||
<input
|
||||
type="file"
|
||||
@@ -178,7 +178,7 @@ export default function FirmaRemotaPage() {
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-2 text-xs text-[#64748B]">PDF/JPG/PNG · máx 10 MB</p>
|
||||
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">PDF/JPG/PNG · máx 10 MB</p>
|
||||
</div>
|
||||
)}
|
||||
{erroresDoc[slot] && (
|
||||
|
||||
37
apps/cobranza/package-lock.json
generated
37
apps/cobranza/package-lock.json
generated
@@ -12,9 +12,11 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"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",
|
||||
"recharts": "^2.15.0",
|
||||
"zod": "^3.24.1"
|
||||
@@ -2301,6 +2303,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -2696,6 +2707,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/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2888,6 +2908,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": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"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",
|
||||
"recharts": "^2.15.0",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -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-6xl items-center justify-between px-4 py-2">
|
||||
<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="text-xs font-medium text-[#64748B] transition-colors duration-100 ease-out hover:text-[#111827]"
|
||||
className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-colors duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
Contraseña
|
||||
</button>
|
||||
|
||||
@@ -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: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-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 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 ? (
|
||||
@@ -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] 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] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
32
apps/cobranza/src/components/DarkModeToggle.tsx
Normal file
32
apps/cobranza/src/components/DarkModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -14,13 +14,13 @@ export default function KpiCard({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-[#E5E7EB] bg-white p-6 ${
|
||||
className={`rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] ${
|
||||
plano ? '' : 'shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-[#111827]">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-[#64748B]">{detalle}</p>}
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">{detalle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import DarkModeToggle from './DarkModeToggle'
|
||||
|
||||
export default function Layout({ 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]">
|
||||
<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 h-16 max-w-[1280px] items-center justify-between 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]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<Link to="/" className="text-sm tracking-widest">
|
||||
<span className="font-bold">FRASA</span>
|
||||
<span className="text-[#F97316]">.</span>{' '}
|
||||
<span className="font-light text-[#64748B]">CLIENTES</span>
|
||||
<span className="font-light text-[#64748B] dark:text-[#94A3B8]">CLIENTES</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex items-center gap-3 text-sm sm:gap-6">
|
||||
<DarkModeToggle />
|
||||
<Link
|
||||
to="/"
|
||||
className="text-[#64748B] transition-colors duration-150 hover:text-[#111827]"
|
||||
className="text-[#64748B] dark:text-[#94A3B8] transition-colors duration-150 hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
Cobranza
|
||||
</Link>
|
||||
|
||||
@@ -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-10 text-center">
|
||||
<p className="text-lg font-bold tracking-widest">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider 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: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"
|
||||
/>
|
||||
</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: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"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
@@ -25,7 +25,7 @@ interface Props {
|
||||
onGuardar: (datos: AbonoForm) => void
|
||||
}
|
||||
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563]'
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
/** Modal para registrar un abono (total o parcial) a un pago del plan */
|
||||
export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
@@ -62,13 +62,13 @@ export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="titulo-abono"
|
||||
className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-8 shadow-xl"
|
||||
className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl"
|
||||
>
|
||||
<h2 id="titulo-abono" className="text-xl font-light text-[#111827]">
|
||||
<h2 id="titulo-abono" className="text-xl font-light text-[#111827] dark:text-[#E2E8F0]">
|
||||
Registrar abono — Pago #{pago.numPago}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
Restante: <span className="font-bold text-[#111827]">{formatoMoneda(restante)}</span>
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Restante: <span className="font-bold text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(restante)}</span>
|
||||
</p>
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
@@ -123,7 +123,7 @@ export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
type="button"
|
||||
onClick={onCancelar}
|
||||
aria-label="Cancelar y cerrar el registro de abono"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] 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] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
21
apps/cobranza/src/components/Skeleton.tsx
Normal file
21
apps/cobranza/src/components/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -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'
|
||||
@@ -28,5 +29,6 @@ createRoot(document.getElementById('root')!).render(
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SkeletonList } from "../components/Skeleton"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -162,7 +163,7 @@ export default function DashboardPage() {
|
||||
return errorCarga ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
) : (
|
||||
<p className="text-sm text-[#64748B]">Cargando...</p>
|
||||
<SkeletonList count={5} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,7 +206,7 @@ export default function DashboardPage() {
|
||||
type="button"
|
||||
onClick={() => alternarOrden(columna)}
|
||||
aria-label={`Ordenar por ${titulo}`}
|
||||
className="inline-flex items-center gap-1 font-semibold uppercase tracking-wider transition-colors duration-100 ease-out hover:text-[#111827]"
|
||||
className="inline-flex items-center gap-1 font-semibold uppercase tracking-wider transition-colors duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
{titulo}
|
||||
{orden === columna && <span aria-hidden="true">{dir === 'asc' ? '↑' : '↓'}</span>}
|
||||
@@ -216,7 +217,7 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-xl font-semibold text-[#111827]">Módulo Cobranza — Planes de enganche</h1>
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Módulo Cobranza — Planes de enganche</h1>
|
||||
<Link
|
||||
to="/nuevo"
|
||||
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
||||
@@ -235,7 +236,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-4 md:gap-6 lg:grid-cols-4">
|
||||
<KpiCard titulo="Planes activos" valor={activos.length} />
|
||||
<KpiCard
|
||||
titulo="Al corriente / con atraso"
|
||||
@@ -247,9 +248,9 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Gráficas */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Al corriente vs vencidos
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
@@ -272,8 +273,8 @@ export default function DashboardPage() {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Cobrado por mes (últimos 6 meses)
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
@@ -293,9 +294,9 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */}
|
||||
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
{/* Filtros */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 sm:gap-4 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 md:gap-4 md:px-5">
|
||||
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
|
||||
<div className="flex rounded-lg border border-[#D1D5DB] bg-white" role="group" aria-label="Alcance de planes">
|
||||
<button
|
||||
@@ -303,7 +304,7 @@ export default function DashboardPage() {
|
||||
onClick={() => actualizarParams({ vista: mios ? null : 'mios' })}
|
||||
aria-pressed={mios}
|
||||
className={`rounded-l-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
|
||||
mios ? 'bg-accent text-white shadow-sm' : 'bg-white text-[#64748B] hover:text-[#111827]'
|
||||
mios ? 'bg-accent text-white shadow-sm' : 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:text-[#E2E8F0]'
|
||||
}`}
|
||||
>
|
||||
Mis planes
|
||||
@@ -313,7 +314,7 @@ export default function DashboardPage() {
|
||||
onClick={() => actualizarParams({ vista: mios ? 'todos' : null })}
|
||||
aria-pressed={!mios}
|
||||
className={`rounded-r-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
|
||||
mios ? 'bg-white text-[#64748B] hover:text-[#111827]' : 'bg-accent text-white shadow-sm'
|
||||
mios ? 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:text-[#E2E8F0]' : 'bg-accent text-white shadow-sm'
|
||||
}`}
|
||||
>
|
||||
Todos
|
||||
@@ -328,7 +329,7 @@ export default function DashboardPage() {
|
||||
value={busqueda}
|
||||
onChange={(e) => actualizarParams({ q: e.target.value })}
|
||||
placeholder="Buscar por cliente o unidad…"
|
||||
className="w-64 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 sm:w-64"
|
||||
/>
|
||||
<label htmlFor="filtro-status" className="sr-only">
|
||||
Filtrar por status
|
||||
@@ -337,7 +338,7 @@ export default function DashboardPage() {
|
||||
id="filtro-status"
|
||||
value={filtroStatus}
|
||||
onChange={(e) => actualizarParams({ status: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
className="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="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"
|
||||
>
|
||||
<option value="TODOS">Todos los status</option>
|
||||
<option value="ACTIVO">ACTIVO</option>
|
||||
@@ -351,7 +352,7 @@ export default function DashboardPage() {
|
||||
id="filtro-proyecto"
|
||||
value={filtroProyecto}
|
||||
onChange={(e) => actualizarParams({ proyecto: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
className="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="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"
|
||||
>
|
||||
<option value="TODOS">Todos los proyectos</option>
|
||||
{proyectos.map((p) => (
|
||||
@@ -366,7 +367,7 @@ export default function DashboardPage() {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<th scope="col" className="px-4 py-3">Cliente</th>
|
||||
<th scope="col" className="px-4 py-3">Unidad</th>
|
||||
<th scope="col" className="px-4 py-3">Proyecto</th>
|
||||
@@ -379,7 +380,7 @@ export default function DashboardPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#E5E7EB]">
|
||||
{ordenados.map((d) => (
|
||||
<tr key={d.plan.id} className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]">
|
||||
<tr key={d.plan.id} className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/planes/${d.plan.id}`} className="font-medium text-accent hover:underline">
|
||||
{d.plan.clienteNombre}
|
||||
@@ -395,7 +396,7 @@ export default function DashboardPage() {
|
||||
style={{ width: `${Math.min(100, d.avance * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-[#64748B]">{formatoPorcentaje(d.avance)}</span>
|
||||
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoPorcentaje(d.avance)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SkeletonList } from "../components/Skeleton"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
|
||||
@@ -38,15 +39,15 @@ export default function HoyPage() {
|
||||
return errorCarga ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
) : (
|
||||
<p className="text-sm text-[#64748B]">Cargando...</p>
|
||||
<SkeletonList count={3} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[#111827]">Cobranza del día</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Cobranza del día</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
|
||||
</p>
|
||||
{resultado.origen === 'local' && (
|
||||
@@ -62,10 +63,10 @@ export default function HoyPage() {
|
||||
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<th scope="col" className="px-4 py-3">Cliente</th>
|
||||
<th scope="col" className="px-4 py-3">Teléfono</th>
|
||||
<th scope="col" className="px-4 py-3">Unidad</th>
|
||||
@@ -81,7 +82,7 @@ export default function HoyPage() {
|
||||
{items.map(({ plan, pago, atraso }) => (
|
||||
<tr
|
||||
key={`${plan.id}-${pago.id}`}
|
||||
className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]"
|
||||
className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">{plan.clienteNombre}</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from "react-hot-toast"
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -37,9 +38,9 @@ const esquema = z.object({
|
||||
type NuevoPlanForm = z.infer<typeof esquema>
|
||||
|
||||
const claseInput =
|
||||
'mt-1 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'
|
||||
'mt-1 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 claseError = 'mt-1 text-xs text-red-700'
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563]'
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
export default function NuevoPlanPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -80,6 +81,7 @@ export default function NuevoPlanPage() {
|
||||
try {
|
||||
const { plan } = await crearPlanAsync(datos)
|
||||
void navigate(`/planes/${plan.id}`)
|
||||
toast.success("Plan creado correctamente")
|
||||
} catch (e) {
|
||||
setErrorGuardado(e instanceof Error ? e.message : 'Error al guardar el plan')
|
||||
} finally {
|
||||
@@ -88,16 +90,16 @@ export default function NuevoPlanPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<h1 className="text-xl font-semibold text-[#111827]">Nuevo plan de enganche</h1>
|
||||
<div className="mx-auto max-w-3xl space-y-6 px-4 sm:px-6">
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Nuevo plan de enganche</h1>
|
||||
|
||||
<form
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:p-8"
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(guardar)(e)
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="clienteNombre" className={claseLabel}>Cliente *</label>
|
||||
<input id="clienteNombre" type="text" className={claseInput} {...register('clienteNombre')} />
|
||||
@@ -177,12 +179,12 @@ export default function NuevoPlanPage() {
|
||||
{/* Vista previa del calendario */}
|
||||
{vistaPrevia && (
|
||||
<div className="mt-8 rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] p-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Vista previa — {vistaPrevia.pagos.length} pagos de {formatoMoneda(vistaPrevia.mensualidad)}
|
||||
</h2>
|
||||
<div className="mt-4 max-h-48 overflow-y-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-[#64748B]">
|
||||
<thead className="text-[#64748B] dark:text-[#94A3B8]">
|
||||
<tr className="border-b border-[#D1D5DB]">
|
||||
<th scope="col" className="py-1">#</th>
|
||||
<th scope="col" className="py-1">Vencimiento</th>
|
||||
@@ -203,12 +205,12 @@ export default function NuevoPlanPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex items-center justify-end gap-3">
|
||||
<div className="mt-8 flex flex-col-reverse items-center justify-end gap-3 sm:flex-row">
|
||||
{errorGuardado && <p className="text-xs text-red-700">{errorGuardado}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate('/')}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] 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] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function PlanDetallePage() {
|
||||
}, [id])
|
||||
|
||||
if (cargando) {
|
||||
return <p className="text-sm text-[#64748B]">Cargando...</p>
|
||||
return <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando...</p>
|
||||
}
|
||||
|
||||
if (errorCarga) {
|
||||
@@ -72,8 +72,8 @@ export default function PlanDetallePage() {
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-16 text-center shadow-sm">
|
||||
<p className="text-[#64748B]">Plan no encontrado.</p>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p- dark:border-[#1E293B] dark:bg-[#1E293B] 16 text-center shadow-sm">
|
||||
<p className="text-[#64748B] dark:text-[#94A3B8]">Plan no encontrado.</p>
|
||||
<Link to="/" className="mt-4 inline-block text-accent hover:underline">
|
||||
Volver al dashboard
|
||||
</Link>
|
||||
@@ -141,13 +141,13 @@ export default function PlanDetallePage() {
|
||||
<Link to="/" className="text-sm text-accent hover:underline">
|
||||
← Volver al dashboard
|
||||
</Link>
|
||||
<h1 className="mt-2 text-xl font-semibold text-[#111827]">{plan.clienteNombre}</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
||||
<span className="font-semibold text-[#111827]">{status}</span>
|
||||
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span>
|
||||
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Tel. {plan.clienteTelefono || '—'}
|
||||
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
||||
</p>
|
||||
@@ -169,10 +169,10 @@ export default function PlanDetallePage() {
|
||||
<div className="min-w-0 flex-1 space-y-6">
|
||||
{/* Resumen del plan */}
|
||||
<section
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm sm:p-6"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:p-6"
|
||||
aria-labelledby="titulo-resumen"
|
||||
>
|
||||
<h2 id="titulo-resumen" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 id="titulo-resumen" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Resumen del plan
|
||||
</h2>
|
||||
<div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
@@ -198,8 +198,8 @@ export default function PlanDetallePage() {
|
||||
{/* Barra de avance */}
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-[#64748B]">Avance del plan</span>
|
||||
<span className="font-semibold text-[#111827]">{formatoPorcentaje(avance)}</span>
|
||||
<span className="font-medium text-[#64748B] dark:text-[#94A3B8]">Avance del plan</span>
|
||||
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{formatoPorcentaje(avance)}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-[#E5E7EB]">
|
||||
<div
|
||||
@@ -211,10 +211,10 @@ export default function PlanDetallePage() {
|
||||
</section>
|
||||
|
||||
{/* Tabla de pagos */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<th scope="col" className="px-4 py-3">#</th>
|
||||
<th scope="col" className="px-4 py-3">Vencimiento</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Monto</th>
|
||||
@@ -234,7 +234,7 @@ export default function PlanDetallePage() {
|
||||
const expandido = expandidos.has(pago.id)
|
||||
return (
|
||||
<Fragment key={pago.id}>
|
||||
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]">
|
||||
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<td className="px-4 py-3 font-medium">{pago.numPago}</td>
|
||||
<td className="px-4 py-3">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="px-4 py-3 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
@@ -276,11 +276,11 @@ export default function PlanDetallePage() {
|
||||
</td>
|
||||
</tr>
|
||||
{expandido && (
|
||||
<tr className="bg-[#F8FAFC]">
|
||||
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="text-left font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<th scope="col" className="py-1">Fecha</th>
|
||||
<th scope="col" className="py-1 text-right">Monto</th>
|
||||
<th scope="col" className="py-1">Referencia</th>
|
||||
@@ -289,7 +289,7 @@ export default function PlanDetallePage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{pago.aplicaciones.map((a) => (
|
||||
<tr key={a.id} className="border-t border-[#E5E7EB]">
|
||||
<tr key={a.id} className="border-t border-[#E5E7EB] dark:border-[#1E293B]">
|
||||
<td className="py-1">{formatoFechaCorta(a.fechaPago)}</td>
|
||||
<td className="py-1 text-right">{formatoMoneda(a.monto)}</td>
|
||||
<td className="py-1">{a.referencia}</td>
|
||||
@@ -320,16 +320,16 @@ export default function PlanDetallePage() {
|
||||
{/* Rail derecho: acciones y bitácora */}
|
||||
<aside className="space-y-6 lg:sticky lg:top-20 lg:w-[340px] lg:shrink-0">
|
||||
<section
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
|
||||
aria-labelledby="titulo-acciones"
|
||||
>
|
||||
<h2 id="titulo-acciones" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 id="titulo-acciones" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acciones
|
||||
</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
<button
|
||||
onClick={() => descargar(plan)}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] 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] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Estado de cuenta PDF
|
||||
</button>
|
||||
@@ -345,19 +345,19 @@ export default function PlanDetallePage() {
|
||||
</section>
|
||||
|
||||
{/* Bitácora del plan */}
|
||||
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm" aria-labelledby="titulo-bitacora">
|
||||
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-bitacora">
|
||||
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Bitácora
|
||||
</h2>
|
||||
{(plan.notas ?? []).length === 0 ? (
|
||||
<p className="mt-6 text-sm text-[#64748B]">Sin notas registradas.</p>
|
||||
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">Sin notas registradas.</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-4">
|
||||
{[...(plan.notas ?? [])]
|
||||
.sort((a, b) => b.fecha.localeCompare(a.fecha))
|
||||
.map((nota) => (
|
||||
<li key={nota.id} className="border-l-2 border-[#E5E7EB] pl-4">
|
||||
<p className="text-xs text-[#64748B]">
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{new Date(nota.fecha).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
@@ -366,7 +366,7 @@ export default function PlanDetallePage() {
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#111827]">{nota.texto}</p>
|
||||
<p className="mt-1 text-sm text-[#111827] dark:text-[#E2E8F0]">{nota.texto}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -380,7 +380,7 @@ export default function PlanDetallePage() {
|
||||
>
|
||||
<label
|
||||
htmlFor="nota-texto"
|
||||
className="block text-xs font-medium text-[#4B5563]"
|
||||
className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Nueva nota
|
||||
</label>
|
||||
@@ -390,7 +390,7 @@ export default function PlanDetallePage() {
|
||||
onChange={(e) => setNotaTexto(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Gestión realizada, acuerdos con el cliente…"
|
||||
className="mt-1 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="mt-1 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"
|
||||
/>
|
||||
{errorNota && <p className="mt-1 text-xs text-red-700">{errorNota}</p>}
|
||||
<div className="mt-4 flex justify-end">
|
||||
@@ -410,7 +410,7 @@ export default function PlanDetallePage() {
|
||||
{status === 'CANCELADO' && (
|
||||
<button
|
||||
onClick={() => void navigate('/')}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] 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] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
apps/cotizador/package-lock.json
generated
45
apps/cotizador/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
12
apps/cotizador/public/manifest.json
Normal file
12
apps/cotizador/public/manifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
21
apps/cotizador/public/sw.js
Normal file
21
apps/cotizador/public/sw.js
Normal 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))
|
||||
})
|
||||
@@ -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]"
|
||||
>
|
||||
← 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
32
apps/cotizador/src/components/DarkModeToggle.tsx
Normal file
32
apps/cotizador/src/components/DarkModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]">
|
||||
|
||||
21
apps/cotizador/src/components/Skeleton.tsx
Normal file
21
apps/cotizador/src/components/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'])} />}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
33
apps/cotizador/src/services/offline-db.ts
Normal file
33
apps/cotizador/src/services/offline-db.ts
Normal 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')
|
||||
}
|
||||
37
apps/dashboards/package-lock.json
generated
37
apps/dashboards/package-lock.json
generated
@@ -9,8 +9,10 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2530,6 +2532,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -2997,6 +3008,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",
|
||||
@@ -3218,6 +3238,23 @@
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"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": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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-[#4B5563]">
|
||||
<span className="text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
|
||||
{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.5 text-xs font-medium text-[#4B5563] 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-[#4B5563] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
Contraseña
|
||||
</button>
|
||||
|
||||
@@ -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: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-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 CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
/**
|
||||
* Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente
|
||||
@@ -46,7 +46,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
aria-label="Cambiar contraseña"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<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] 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] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
32
apps/dashboards/src/components/DarkModeToggle.tsx
Normal file
32
apps/dashboards/src/components/DarkModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import type { ReactNode } from 'react'
|
||||
|
||||
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-shadow duration-200 hover:shadow-md">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-[#111827]">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-[#4B5563]">{detalle}</p>}
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-[#4B5563] dark:text-[#CBD5E1]">{detalle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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-10 text-center">
|
||||
<p className="text-lg font-bold tracking-widest">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider 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: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"
|
||||
/>
|
||||
</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: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"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
@@ -44,9 +44,9 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
|
||||
return (
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-xl font-semibold text-[#111827]">Mi tablero</h1>
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Mi tablero</h1>
|
||||
{resumen && (
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
|
||||
</p>
|
||||
)}
|
||||
@@ -69,7 +69,7 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
|
||||
|
||||
{resumen && (
|
||||
<>
|
||||
<section aria-label="Mis indicadores" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<section aria-label="Mis indicadores" className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
titulo="Mis leads activos"
|
||||
valor={resumen.misLeads.length}
|
||||
|
||||
@@ -20,8 +20,8 @@ const DIMENSIONES: { titulo: string; registro: (perfil: ResumenReportes['leads']
|
||||
function TarjetaDimension({ titulo, datos }: { titulo: string; datos: Record<string, number> }) {
|
||||
const rebanadas = rebanadasDe(datos)
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm transition-shadow duration-200 hover:shadow-md">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
||||
{rebanadas.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin datos" />
|
||||
) : (
|
||||
|
||||
@@ -3,8 +3,8 @@ import { EstadoVacio, TituloSeccion } from './comunes'
|
||||
import type { ResumenMiDia } from '../lib/mi-tablero'
|
||||
import { fetchResumenMiDia } from '../services/mi-dia'
|
||||
|
||||
const CLASE_CELDA = 'px-4 py-3 text-sm text-[#111827]'
|
||||
const CLASE_ENCABEZADO = 'bg-[#F8FAFC] px-4 py-2 text-left text-xs font-semibold uppercase text-[#64748B]'
|
||||
const CLASE_CELDA = 'px-4 py-3 text-sm text-[#111827] dark:text-[#E2E8F0]'
|
||||
const CLASE_ENCABEZADO = 'bg-[#F8FAFC] px-4 py-2 text-left text-xs font-semibold uppercase text-[#64748B] dark:text-[#94A3B8]'
|
||||
|
||||
/**
|
||||
* Sección "Equipo" (solo coordinador/corporativo/admin): tabla vendedor →
|
||||
@@ -40,14 +40,14 @@ export default function SeccionEquipo() {
|
||||
{!error && !equipo && <EstadoVacio mensaje="Sin datos de equipo" />}
|
||||
{!error && equipo && (
|
||||
<>
|
||||
<p className="mb-4 text-xs text-[#4B5563]">
|
||||
<p className="mb-4 text-xs text-[#4B5563] dark:text-[#CBD5E1]">
|
||||
Leads nuevos sin asignar:{' '}
|
||||
<span className="font-semibold text-[#111827]">{equipo.leadsNuevosSinAsignar}</span>
|
||||
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{equipo.leadsNuevosSinAsignar}</span>
|
||||
</p>
|
||||
{equipo.porVendedor.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin leads asignados al equipo" />
|
||||
) : (
|
||||
<table className="w-full overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<table className="w-full overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
<thead className="border-b border-[#E5E7EB]">
|
||||
<tr>
|
||||
<th className={CLASE_ENCABEZADO}>Vendedor</th>
|
||||
@@ -57,7 +57,7 @@ export default function SeccionEquipo() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#E5E7EB]">
|
||||
{equipo.porVendedor.map((v) => (
|
||||
<tr key={v.mondayUserId} className="transition-colors duration-100 hover:bg-[#F8FAFC]">
|
||||
<tr key={v.mondayUserId} className="transition-colors duration-100 hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<td className={CLASE_CELDA}>{v.nombre ?? `Monday #${v.mondayUserId}`}</td>
|
||||
<td className={CLASE_CELDA}>{v.leadsAsignados}</td>
|
||||
<td className={CLASE_CELDA}>{v.leadsNuevosSemana}</td>
|
||||
|
||||
21
apps/dashboards/src/components/Skeleton.tsx
Normal file
21
apps/dashboards/src/components/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -89,9 +89,9 @@ export default function TableroGeneral({
|
||||
return (
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-xl font-semibold text-[#111827]">Tablero comercial</h1>
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Tablero comercial</h1>
|
||||
{resumen && (
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
|
||||
</p>
|
||||
)}
|
||||
@@ -108,7 +108,7 @@ export default function TableroGeneral({
|
||||
|
||||
{resumen && (
|
||||
<>
|
||||
<section aria-label="Indicadores clave" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<section aria-label="Indicadores clave" className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
titulo="Leads totales"
|
||||
valor={resumen.leads.total}
|
||||
@@ -151,7 +151,7 @@ export default function TableroGeneral({
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="mt-10 grid gap-6 sm:grid-cols-1 lg:grid-cols-2">
|
||||
<div className="mt-10 grid gap-6 md:grid-cols-2">
|
||||
<section aria-label="Inventario por desarrollo" className="lg:col-span-2">
|
||||
<TarjetaGrafica titulo="Unidades por desarrollo y status">
|
||||
{unidades.length === 0 ? (
|
||||
|
||||
@@ -11,14 +11,14 @@ export type AccionesTablero = { actualizar: () => void; cargando: boolean }
|
||||
|
||||
export function EstadoVacio({ mensaje }: { mensaje: string }) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[#64748B]">{mensaje}</p>
|
||||
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#E5E7EB] bg-white dark:border-[#334155] dark:bg-[#1E293B]">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{mensaje}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TituloSeccion({ children }: { children: string }) {
|
||||
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{children}</h2>
|
||||
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
|
||||
}
|
||||
|
||||
/** Tarjeta contenedora de gráficas: título interno + padding consistente. */
|
||||
@@ -30,8 +30,8 @@ export function TarjetaGrafica({
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</h3>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<h3 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</h3>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import '@fontsource/inter/300.css'
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
@@ -13,5 +14,6 @@ createRoot(document.getElementById('root')!).render(
|
||||
<AuthGate>
|
||||
<DashboardPage />
|
||||
</AuthGate>
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { AccionesTablero } from '../components/comunes'
|
||||
import { esRolConEquipo } from '../lib/mi-tablero'
|
||||
import { getMe, getSesion } from '../services/auth'
|
||||
import BannerModoLocal from '../components/BannerModoLocal'
|
||||
import DarkModeToggle from '../components/DarkModeToggle'
|
||||
|
||||
type Vista = 'mia' | 'general'
|
||||
|
||||
@@ -45,24 +46,25 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div>
|
||||
<BannerModoLocal />
|
||||
<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 justify-between gap-x-4 gap-y-2 px-4 py-2 sm:px-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<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]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-xs font-bold text-white">
|
||||
F
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-[#111827]">FRASA Desarrollos</span>
|
||||
<span className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">FRASA Desarrollos</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<DarkModeToggle />
|
||||
{permitidas.length > 1 && (
|
||||
<div
|
||||
className="inline-flex rounded-lg border border-[#E5E7EB] bg-[#F1F5F9] p-1"
|
||||
className="inline-flex rounded-lg border border-[#E5E7EB] bg-[#F1F5F9] p-0.5 sm:p-1"
|
||||
role="tablist"
|
||||
aria-label="Vista del tablero"
|
||||
>
|
||||
@@ -75,8 +77,8 @@ export default function DashboardPage() {
|
||||
onClick={() => setElegida(v.id)}
|
||||
className={
|
||||
vista === v.id
|
||||
? 'rounded-md bg-white px-3 py-1.5 text-xs font-medium text-[#111827] shadow-sm transition-all duration-100 ease-out'
|
||||
: 'rounded-md px-3 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:text-[#111827]'
|
||||
? 'rounded-md bg-white px-2 py-1.5 text-[10px] font-medium text-[#111827] dark:text-[#E2E8F0] shadow-sm transition-all duration-100 ease-out sm:px-3 sm:text-xs'
|
||||
: 'rounded-md px-2 py-1.5 text-[10px] font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0] sm:px-3 sm:text-xs'
|
||||
}
|
||||
>
|
||||
{v.etiqueta}
|
||||
@@ -89,7 +91,7 @@ export default function DashboardPage() {
|
||||
type="button"
|
||||
onClick={() => acciones?.actualizar()}
|
||||
disabled={!acciones || acciones.cargando}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 disabled:opacity-50"
|
||||
>
|
||||
{acciones?.cargando ? 'Actualizando…' : 'Actualizar'}
|
||||
</button>
|
||||
@@ -99,7 +101,7 @@ export default function DashboardPage() {
|
||||
</header>
|
||||
{vista === null ? (
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-16 sm:px-6">
|
||||
<p className="text-sm text-[#64748B]">Tu rol no tiene tableros asignados por ahora.</p>
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Tu rol no tiene tableros asignados por ahora.</p>
|
||||
</main>
|
||||
) : vista === 'mia' ? (
|
||||
<MiTablero alCambiarAcciones={setAcciones} />
|
||||
|
||||
40
apps/landing/package-lock.json
generated
40
apps/landing/package-lock.json
generated
@@ -9,8 +9,10 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -2173,7 +2175,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": {
|
||||
@@ -2494,6 +2495,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",
|
||||
@@ -3027,6 +3037,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",
|
||||
@@ -3346,6 +3365,23 @@
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
|
||||
@@ -68,22 +68,22 @@ export default function App() {
|
||||
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
|
||||
<BannerModoLocal />
|
||||
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-6">
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 sm:px-6">
|
||||
<section aria-labelledby="titulo-hero" className="py-12 sm:py-16">
|
||||
<h1
|
||||
id="titulo-hero"
|
||||
className="text-3xl font-semibold tracking-tight text-[#111827] sm:text-4xl"
|
||||
className="text-3xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0] sm:text-4xl"
|
||||
>
|
||||
Ecosistema comercial
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] sm:text-base">
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8] sm:text-base">
|
||||
Las herramientas del equipo comercial de Frasa Desarrollos en un solo lugar:
|
||||
cotizaciones, alta de clientes, cobranza, tableros y asistente IA.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<nav aria-label="Módulos del ecosistema">
|
||||
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ul className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{MODULOS.filter((modulo) => esModuloVisible(modulo, permitidos)).map((modulo) => (
|
||||
<TarjetaModulo
|
||||
key={modulo.id}
|
||||
@@ -99,9 +99,9 @@ export default function App() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="mt-16 border-t border-[#E5E7EB]">
|
||||
<footer className="mt-16 border-t border-[#E5E7EB] dark:border-[#1E293B] dark:border-[#1E293B]">
|
||||
<div className="mx-auto max-w-[1280px] px-6 py-8">
|
||||
<p className="text-xs text-[#64748B]">Consult (AS) Technology</p>
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Consult (AS) Technology</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
208
apps/landing/src/components/CommandPalette.tsx
Normal file
208
apps/landing/src/components/CommandPalette.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { API_URL, conAuth, manejar401 } from '../services/auth'
|
||||
|
||||
/** Elemento de resultado de búsqueda global */
|
||||
interface BuscadoItem {
|
||||
tipo: 'lead' | 'oportunidad' | 'contacto' | 'unidad'
|
||||
id: string
|
||||
nombre: string
|
||||
detalle: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const ETIQUETA_TIPO: Record<BuscadoItem['tipo'], string> = {
|
||||
lead: 'Lead',
|
||||
oportunidad: 'Oportunidad',
|
||||
contacto: 'Contacto',
|
||||
unidad: 'Unidad',
|
||||
}
|
||||
|
||||
const COLOR_TIPO: Record<BuscadoItem['tipo'], string> = {
|
||||
lead: 'bg-gray-100 text-gray-700',
|
||||
oportunidad: 'bg-blue-100 text-blue-700',
|
||||
contacto: 'bg-green-100 text-green-700',
|
||||
unidad: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const [abierto, setAbierto] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [resultados, setResultados] = useState<BuscadoItem[]>([])
|
||||
const [cargando, setCargando] = useState(false)
|
||||
const [indiceSeleccionado, setIndiceSeleccionado] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Atajo de teclado Ctrl+K / Cmd+K
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setAbierto((v) => !v)
|
||||
}
|
||||
if (e.key === 'Escape' && abierto) {
|
||||
setAbierto(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [abierto])
|
||||
|
||||
// Focus en input al abrir
|
||||
useEffect(() => {
|
||||
if (abierto) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
} else {
|
||||
setQuery('')
|
||||
setResultados([])
|
||||
setIndiceSeleccionado(0)
|
||||
}
|
||||
}, [abierto])
|
||||
|
||||
// Búsqueda con debounce
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
if (query.trim().length < 2) {
|
||||
setResultados([])
|
||||
return
|
||||
}
|
||||
setCargando(true)
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const resp = await fetch(`${API_URL}/buscar?q=${encodeURIComponent(query.trim())}&limite=20`, conAuth())
|
||||
manejar401(resp)
|
||||
if (resp.ok) {
|
||||
setResultados(await resp.json())
|
||||
}
|
||||
} catch {
|
||||
setResultados([])
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}, 300)
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
// Navegación con teclado
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setIndiceSeleccionado((i) => Math.min(i + 1, resultados.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setIndiceSeleccionado((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter' && resultados[indiceSeleccionado]) {
|
||||
window.location.href = resultados[indiceSeleccionado].url
|
||||
}
|
||||
}
|
||||
|
||||
// Agrupar por tipo
|
||||
const agrupados = resultados.reduce<Record<string, BuscadoItem[]>>((acc, item) => {
|
||||
const key = item.tipo
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(item)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Botón trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAbierto(true)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F1F5F9] hover:text-accent"
|
||||
aria-label="Buscar (Ctrl+K)"
|
||||
title="Buscar (Ctrl+K)"
|
||||
>
|
||||
<Search size={16} />
|
||||
</button>
|
||||
|
||||
{/* Modal */}
|
||||
{abierto && (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[15vh]" role="dialog" aria-modal="true">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black/40" onClick={() => setAbierto(false)} />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative w-full max-w-lg rounded-2xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B] shadow-2xl overflow-hidden">
|
||||
{/* Input */}
|
||||
<div className="flex items-center gap-3 border-b border-[#E5E7EB] dark:border-[#334155] px-4 py-3">
|
||||
<Search size={18} className="shrink-0 text-[#94A3B8]" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Buscar leads, contactos, cotizaciones, unidades…"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setIndiceSeleccionado(0) }}
|
||||
onKeyDown={onKeyDown}
|
||||
className="flex-1 bg-transparent text-sm text-[#111827] dark:text-[#E2E8F0] placeholder-[#94A3B8] focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAbierto(false)}
|
||||
className="rounded-md p-1 text-[#94A3B8] transition-colors hover:bg-[#F1F5F9] hover:text-[#64748B]"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resultados */}
|
||||
<div className="max-h-80 overflow-y-auto p-2">
|
||||
{query.trim().length < 2 ? (
|
||||
<p className="px-4 py-6 text-center text-xs text-[#94A3B8]">
|
||||
Escribe al menos 2 caracteres para buscar
|
||||
</p>
|
||||
) : cargando ? (
|
||||
<p className="px-4 py-6 text-center text-xs text-[#94A3B8]">Buscando…</p>
|
||||
) : resultados.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-xs text-[#94A3B8]">Sin resultados para "{query}"</p>
|
||||
) : (
|
||||
Object.entries(agrupados).map(([tipo, items]) => (
|
||||
<div key={tipo} className="mb-2">
|
||||
<p className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-[#94A3B8]">
|
||||
{ETIQUETA_TIPO[tipo as BuscadoItem['tipo']]}
|
||||
</p>
|
||||
{items.map((item) => {
|
||||
const idxGlobal = resultados.indexOf(item)
|
||||
return (
|
||||
<a
|
||||
key={`${item.tipo}-${item.id}`}
|
||||
href={item.url}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-colors ${
|
||||
idxGlobal === indiceSeleccionado
|
||||
? 'bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${COLOR_TIPO[item.tipo]}`}>
|
||||
{ETIQUETA_TIPO[item.tipo]}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</span>
|
||||
{item.detalle && (
|
||||
<span className="block truncate text-[11px] text-[#94A3B8]">{item.detalle}</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-4 border-t border-[#E5E7EB] dark:border-[#334155] px-4 py-2 text-[10px] text-[#94A3B8]">
|
||||
<span>↑↓ navegar</span>
|
||||
<span>↵ abrir</span>
|
||||
<span>esc cerrar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { ErrorLogin, getSesion, login, logout, type UsuarioSesion } from '../services/auth'
|
||||
import { getMe, getUsuariosMonday, guardarVinculoMonday, type UsuarioMonday } from '../services/api'
|
||||
|
||||
const labelEstilo = 'block text-xs font-medium text-[#4B5563]'
|
||||
const labelEstilo = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
const inputEstilo =
|
||||
'mt-2 w-full 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'
|
||||
'mt-2 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 text-[#111827] dark:text-[#E2E8F0] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
|
||||
|
||||
/** 'director_ventas' → 'Director Ventas' (texto discreto del perfil) */
|
||||
function formatearRol(rol: string): string {
|
||||
@@ -106,9 +106,9 @@ export function ConfiguracionCuenta() {
|
||||
<section
|
||||
id="configuracion"
|
||||
aria-labelledby="titulo-cuenta"
|
||||
className="scroll-mt-20 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm sm:p-8"
|
||||
className="scroll-mt-20 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:p-8"
|
||||
>
|
||||
<h2 id="titulo-cuenta" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 id="titulo-cuenta" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Configuración de cuenta
|
||||
</h2>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ConfiguracionCuenta() {
|
||||
onSubmit={(e) => void entrar(e)}
|
||||
className="mt-6 max-w-sm"
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-[#64748B]">
|
||||
<p className="text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
|
||||
Inicia sesión para vincular tu usuario de Monday.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
@@ -163,24 +163,24 @@ export function ConfiguracionCuenta() {
|
||||
</form>
|
||||
) : (
|
||||
<div className="mt-6 max-w-md">
|
||||
{cargandoPerfil && <p className="text-sm text-[#64748B]">Cargando tu perfil…</p>}
|
||||
{cargandoPerfil && <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando tu perfil…</p>}
|
||||
|
||||
{perfil && (
|
||||
<>
|
||||
<p className="text-sm text-[#111827]">
|
||||
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
Sesión: <span className="font-medium">{perfil.nombre}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">Rol: {formatearRol(perfil.rol)}</p>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">Rol: {formatearRol(perfil.rol)}</p>
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Vínculo Monday actual:{' '}
|
||||
{perfil.mondayUserId === null ? (
|
||||
<span className="text-[#94A3B8]">sin vincular</span>
|
||||
) : vinculado ? (
|
||||
<span className="text-[#111827]">
|
||||
<span className="text-[#111827] dark:text-[#E2E8F0]">
|
||||
{vinculado.nombre} ({vinculado.email})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[#111827]">ID {perfil.mondayUserId}</span>
|
||||
<span className="text-[#111827] dark:text-[#E2E8F0]">ID {perfil.mondayUserId}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -192,13 +192,13 @@ export function ConfiguracionCuenta() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={cargarUsuarios}
|
||||
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 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]"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : usuarios === null ? (
|
||||
<p className="mt-6 text-sm text-[#64748B]">Cargando usuarios de Monday…</p>
|
||||
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando usuarios de Monday…</p>
|
||||
) : (
|
||||
<div className="mt-6">
|
||||
<label htmlFor="cuenta-monday" className={labelEstilo}>
|
||||
@@ -256,7 +256,7 @@ export function ConfiguracionCuenta() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={cerrarSesion}
|
||||
className="mt-8 rounded-lg border border-[#D1D5DB] bg-white px-4 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="mt-8 rounded-lg border border-[#D1D5DB] bg-white px-4 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]"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
|
||||
32
apps/landing/src/components/DarkModeToggle.tsx
Normal file
32
apps/landing/src/components/DarkModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -19,8 +19,8 @@ function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) {
|
||||
if (valor === '') return null
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#64748B]">{etiqueta}</p>
|
||||
<p className="mt-0.5 text-sm text-[#111827]">{valor}</p>
|
||||
<p className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
|
||||
<p className="mt-0.5 text-sm text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,15 +29,15 @@ function ListaRelaciones({ titulo, items }: { titulo: string; items: RelacionCrm
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{items.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[#E5E7EB] px-3 py-2">
|
||||
<span className="text-sm text-[#111827]">
|
||||
<span className="text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
{r.estado || 'Sin estado'}
|
||||
{r.detalle ? <span className="ml-2 text-xs text-[#64748B]">{r.detalle}</span> : null}
|
||||
{r.detalle ? <span className="ml-2 text-xs text-[#64748B] dark:text-[#94A3B8]">{r.detalle}</span> : null}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-[#64748B]">
|
||||
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
|
||||
{r.atendidoPor ?? 'Sin asignar'}
|
||||
</span>
|
||||
</li>
|
||||
@@ -91,10 +91,10 @@ export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
|
||||
if (e.target === e.currentTarget) onCerrar()
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl rounded-2xl border border-[#E5E7EB] bg-white shadow-xl">
|
||||
<div className="w-full max-w-2xl rounded-2xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -xl">
|
||||
<div className="flex items-center justify-between border-b border-[#E5E7EB] px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-[#111827]">{c['nombre'] ?? nombre}</h2>
|
||||
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c['nombre'] ?? nombre}</h2>
|
||||
{c['Estado'] && (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">{c['Estado']}</span>
|
||||
)}
|
||||
@@ -103,7 +103,7 @@ export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
aria-label="Cerrar"
|
||||
className="rounded-lg px-2 py-1.5 text-sm text-[#64748B] transition-colors hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
className="rounded-lg px-2 py-1.5 text-sm text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F9FAFB] hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -115,12 +115,12 @@ export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!detalle && !error && <p className="text-sm text-[#64748B]">Cargando detalle…</p>}
|
||||
{!detalle && !error && <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando detalle…</p>}
|
||||
|
||||
{detalle && (
|
||||
<>
|
||||
<section aria-label="Datos del contacto">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B]">Datos del contacto</p>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Datos del contacto</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
|
||||
<Dato etiqueta="Teléfono" valor={c['Teléfono'] ?? ''} />
|
||||
<Dato etiqueta="Email" valor={c['Email'] ?? ''} />
|
||||
@@ -138,7 +138,7 @@ export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
|
||||
|
||||
{detalle.propiedad && (
|
||||
<section aria-label="Propiedad">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B]">Propiedad</p>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Propiedad</p>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] p-4">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
|
||||
<Dato etiqueta="Desarrollo" valor={detalle.propiedad.desarrollo} />
|
||||
@@ -159,23 +159,23 @@ export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
|
||||
</section>
|
||||
|
||||
<section aria-label="Historial de atención">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Historial de atención
|
||||
</p>
|
||||
{detalle.historial.length === 0 ? (
|
||||
<p className="text-sm text-[#64748B]">Sin movimientos registrados.</p>
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin movimientos registrados.</p>
|
||||
) : (
|
||||
<ul className="max-h-64 space-y-2 overflow-y-auto">
|
||||
{detalle.historial.map((h, i) => (
|
||||
<li key={i} className="flex items-baseline justify-between gap-3 rounded-lg border border-[#E5E7EB] px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-[#111827]">
|
||||
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
{h.detalle}
|
||||
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600">
|
||||
{h.fuente}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-[#64748B]">{h.usuario ?? 'Automatización'}</p>
|
||||
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">{h.usuario ?? 'Automatización'}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-[#94A3B8]">{formatoFecha(h.fechaISO)}</span>
|
||||
</li>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getSesion, type UsuarioSesion } from '../services/auth'
|
||||
import DarkModeToggle from './DarkModeToggle'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
|
||||
interface Props {
|
||||
usuario?: UsuarioSesion | null
|
||||
@@ -9,6 +11,7 @@ interface Props {
|
||||
/** Barra superior: marca a la izquierda, estado de sesión y acceso a configuración a la derecha */
|
||||
export function Navbar({ usuario, onConfiguraciones }: Props) {
|
||||
const [conSesion, setConSesion] = useState(() => getSesion() !== null)
|
||||
const [menuAbierto, setMenuAbierto] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const actualizar = () => setConSesion(getSesion() !== null)
|
||||
@@ -23,15 +26,19 @@ export function Navbar({ usuario, onConfiguraciones }: Props) {
|
||||
const esAdmin = usuario?.rol === 'admin'
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 max-w-[1280px] items-center justify-between px-6">
|
||||
<span className="text-base font-semibold tracking-tight text-[#111827]">
|
||||
<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 h-14 max-w-[1280px] items-center justify-between px-4 sm:px-6">
|
||||
<span className="text-base font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0]">
|
||||
FRASA<span className="text-accent">.</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
{/* Desktop nav */}
|
||||
<div className="hidden items-center gap-4 md:flex">
|
||||
<CommandPalette />
|
||||
<DarkModeToggle />
|
||||
<span
|
||||
className={`hidden items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium sm:inline-flex ${
|
||||
conSesion ? 'bg-green-50 text-green-700' : 'bg-[#F1F5F9] text-[#64748B]'
|
||||
conSesion ? 'bg-green-50 text-green-700' : 'bg-[#F1F5F9] text-[#64748B] dark:text-[#94A3B8]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -45,7 +52,7 @@ export function Navbar({ usuario, onConfiguraciones }: Props) {
|
||||
type="button"
|
||||
onClick={onConfiguraciones}
|
||||
title="Configuraciones"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#64748B] transition-colors hover:bg-[#F1F5F9] hover:text-accent"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F1F5F9] hover:text-accent"
|
||||
aria-label="Abrir configuraciones"
|
||||
>
|
||||
⚙
|
||||
@@ -53,12 +60,70 @@ export function Navbar({ usuario, onConfiguraciones }: Props) {
|
||||
)}
|
||||
<a
|
||||
href="#configuracion"
|
||||
className="text-sm font-medium text-[#64748B] transition-colors hover:text-accent"
|
||||
className="text-sm font-medium text-[#64748B] dark:text-[#94A3B8] transition-colors hover:text-accent"
|
||||
>
|
||||
Configuración
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuAbierto((v) => !v)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#4B5563] dark:text-[#CBD5E1] transition-colors hover:bg-[#F1F5F9] md:hidden"
|
||||
aria-label={menuAbierto ? 'Cerrar menú' : 'Abrir menú'}
|
||||
aria-expanded={menuAbierto}
|
||||
>
|
||||
{menuAbierto ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-5 w-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu panel */}
|
||||
{menuAbierto && (
|
||||
<div className="border-t border-[#E5E7EB] bg-white px-4 py-4 dark:border-[#1E293B] dark:bg-[#1E293B] md:hidden">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CommandPalette />
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium w-fit ${
|
||||
conSesion ? 'bg-green-50 text-green-700' : 'bg-[#F1F5F9] text-[#64748B] dark:text-[#94A3B8]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-1.5 w-1.5 rounded-full ${conSesion ? 'bg-green-500' : 'bg-[#94A3B8]'}`}
|
||||
/>
|
||||
{conSesion ? 'Sesión activa' : 'Sin sesión'}
|
||||
</span>
|
||||
{esAdmin && onConfiguraciones && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onConfiguraciones(); setMenuAbierto(false) }}
|
||||
className="flex items-center gap-2 text-sm font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
⚙ Configuraciones
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="#configuracion"
|
||||
className="text-sm font-medium text-[#64748B] dark:text-[#94A3B8] transition-colors hover:text-accent"
|
||||
onClick={() => setMenuAbierto(false)}
|
||||
>
|
||||
Configuración
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
21
apps/landing/src/components/Skeleton.tsx
Normal file
21
apps/landing/src/components/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -24,19 +24,19 @@ export function TarjetaModulo({ modulo, onAbrir }: Props) {
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-sm font-semibold text-accent"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-sm font-semibold text-accent dark:bg-accent/20"
|
||||
>
|
||||
{iniciales}
|
||||
</span>
|
||||
<h2 className="mt-4 text-base font-semibold text-[#111827]">{modulo.nombre}</h2>
|
||||
<p className="mt-1 text-sm leading-relaxed text-[#64748B]">{modulo.descripcion}</p>
|
||||
<h2 className="mt-4 text-base font-semibold text-[#111827] dark:text-[#E2E8F0]">{modulo.nombre}</h2>
|
||||
<p className="mt-1 text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">{modulo.descripcion}</p>
|
||||
<span className="mt-auto pt-6 text-sm font-medium text-accent transition-colors duration-100 ease-out group-hover:text-accent-hover">
|
||||
Entrar <span aria-hidden="true">→</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const estilo =
|
||||
'group flex h-full w-full flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:shadow-md'
|
||||
'group flex h-full w-full flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:shadow-md dark:border-[#1E293B] dark:bg-[#1E293B]'
|
||||
|
||||
return (
|
||||
<li>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { getContactos, type Contacto } from '../services/api'
|
||||
import { ModalContacto } from './ModalContacto'
|
||||
import { SkeletonList } from "./Skeleton"
|
||||
import toast from "react-hot-toast"
|
||||
|
||||
interface Props {
|
||||
onVolver: () => void
|
||||
@@ -17,34 +19,48 @@ export function VistaContactos({ onVolver }: Props) {
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [seleccionado, setSeleccionado] = useState<Contacto | null>(null)
|
||||
const [busqueda, setBusqueda] = useState('')
|
||||
|
||||
/** Contactos filtrados por texto de búsqueda */
|
||||
const contactosFiltrados = useMemo(() => {
|
||||
if (!contactos) return null
|
||||
if (busqueda.trim() === '') return contactos
|
||||
const q = busqueda.toLowerCase()
|
||||
return contactos.filter(
|
||||
(c) =>
|
||||
c.nombre.toLowerCase().includes(q) ||
|
||||
(c.email && c.email.toLowerCase().includes(q)) ||
|
||||
(c.telefono && c.telefono.includes(q)),
|
||||
)
|
||||
}, [contactos, busqueda])
|
||||
|
||||
const cargar = useCallback(() => {
|
||||
setCargando(true)
|
||||
setError('')
|
||||
getContactos()
|
||||
.then(setContactos)
|
||||
.catch(() => setError('No se pudieron cargar los contactos.'))
|
||||
.catch(() => { toast.error("No se pudieron cargar los contactos."); setError("No se pudieron cargar los contactos."); })
|
||||
.finally(() => setCargando(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => cargar(), [cargar])
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-6 py-12 sm:py-16">
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 py-12 sm:px-6 sm:py-16">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVolver}
|
||||
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]"
|
||||
>
|
||||
← Volver
|
||||
</button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl">Contactos</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0] sm:text-3xl">Contactos</h1>
|
||||
</div>
|
||||
|
||||
<section aria-label="Lista de contactos" className="mt-8">
|
||||
{cargando ? (
|
||||
<p className="text-sm text-[#64748B]">Cargando contactos…</p>
|
||||
<SkeletonList count={5} />
|
||||
) : error ? (
|
||||
<div>
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
@@ -53,35 +69,76 @@ export function VistaContactos({ onVolver }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={cargar}
|
||||
className="mt-3 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="mt-3 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]"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : !contactos || contactos.length === 0 ? (
|
||||
<p className="text-sm text-[#64748B]">No hay contactos para mostrar.</p>
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay contactos para mostrar.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
{contactos.map((contacto) => (
|
||||
<li key={contacto.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSeleccionado(contacto)}
|
||||
className="flex w-full flex-wrap items-center gap-x-4 gap-y-1 px-6 py-4 text-left transition-colors duration-100 hover:bg-[#F8FAFC]"
|
||||
>
|
||||
<span className="text-sm font-semibold text-[#111827]">{contacto.nombre}</span>
|
||||
{contacto.estado && (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
|
||||
{contacto.estado}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-[#64748B]">{contacto.telefono || 'Sin teléfono'}</span>
|
||||
<span className="text-sm text-[#64748B]">{contacto.email || 'Sin email'}</span>
|
||||
<span className="ml-auto text-xs font-medium text-accent">Ver detalle →</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre, email o teléfono…"
|
||||
value={busqueda}
|
||||
onChange={(e) => setBusqueda(e.target.value)}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-4 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"
|
||||
/>
|
||||
</div>
|
||||
{contactosFiltrados && contactosFiltrados.length === 0 ? (
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin resultados para “{busqueda}”</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: cards */}
|
||||
<div className="grid gap-3 md:hidden">
|
||||
{contactosFiltrados?.map((contacto) => (
|
||||
<button
|
||||
key={contacto.id}
|
||||
type="button"
|
||||
onClick={() => setSeleccionado(contacto)}
|
||||
className="flex flex-col gap-2 rounded-xl border border-[#E5E7EB] bg-white p-4 text-left shadow-sm transition-colors duration-100 hover:border-accent dark:border-[#1E293B] dark:bg-[#1E293B]"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">{contacto.nombre}</span>
|
||||
{contacto.estado && (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
|
||||
{contacto.estado}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">{contacto.telefono || 'Sin teléfono'}</span>
|
||||
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">{contacto.email || 'Sin email'}</span>
|
||||
<span className="text-xs font-medium text-accent">Ver detalle →</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Desktop: table-style list */}
|
||||
<ul className="hidden divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] md:block -sm">
|
||||
{contactosFiltrados?.map((contacto) => (
|
||||
<li key={contacto.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSeleccionado(contacto)}
|
||||
className="flex w-full flex-wrap items-center gap-x-4 gap-y-1 px-6 py-4 text-left transition-colors duration-100 hover:bg-[#F8FAFC] dark:bg-[#0F172A]"
|
||||
>
|
||||
<span className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">{contacto.nombre}</span>
|
||||
{contacto.estado && (
|
||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
|
||||
{contacto.estado}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">{contacto.telefono || 'Sin teléfono'}</span>
|
||||
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">{contacto.email || 'Sin email'}</span>
|
||||
<span className="ml-auto text-xs font-medium text-accent">Ver detalle →</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
77
apps/landing/src/components/config/AuditoriaLog.test.tsx
Normal file
77
apps/landing/src/components/config/AuditoriaLog.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { AuditoriaLog } from './AuditoriaLog'
|
||||
|
||||
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
function crearStorageStub(): Storage {
|
||||
const mapa = new Map<string, string>()
|
||||
return {
|
||||
getItem: (k: string) => mapa.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void mapa.set(k, v),
|
||||
removeItem: (k: string) => void mapa.delete(k),
|
||||
clear: () => mapa.clear(),
|
||||
key: () => null,
|
||||
get length() { return mapa.size },
|
||||
} as Storage
|
||||
}
|
||||
|
||||
const sesionFake = { token: 'jwt-prueba', usuario: { nombre: 'Admin', rol: 'admin', asignacion: null } }
|
||||
|
||||
const respuestaOk = (data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
const auditoriaFake = [
|
||||
{ id: 'a1', usuarioId: 'admin', accion: 'LEAD_CREADO', entidad: 'lead', entidadId: 'l1', detalle: 'Lead nuevo', createdAt: '2026-09-01T10:00:00Z' },
|
||||
{ id: 'a2', usuarioId: 'vendedor1', accion: 'ETAPA_CAMBIADA', entidad: 'oportunidad', entidadId: 'o1', detalle: 'Cambiado a Cotizador', createdAt: '2026-09-02T14:00:00Z' },
|
||||
]
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn<FetchFn>()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
vi.stubGlobal('localStorage', crearStorageStub())
|
||||
localStorage.setItem('frasa_auth', JSON.stringify(sesionFake))
|
||||
fetchMock.mockResolvedValue(respuestaOk(auditoriaFake))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('AuditoriaLog', () => {
|
||||
it('renderiza la tabla con registros de auditoría', async () => {
|
||||
render(<AuditoriaLog />)
|
||||
expect(screen.getByText('Registro de auditoría')).toBeTruthy()
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
|
||||
expect(screen.getByText('LEAD_CREADO')).toBeTruthy()
|
||||
expect(screen.getByText('ETAPA_CAMBIADA')).toBeTruthy()
|
||||
// User IDs may appear in multiple cells; verify they exist
|
||||
expect(screen.getAllByText('admin').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('vendedor1').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('muestra "Sin registros" cuando no hay datos', async () => {
|
||||
fetchMock.mockResolvedValue(respuestaOk([]))
|
||||
render(<AuditoriaLog />)
|
||||
expect(await screen.findByText('Sin registros')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('muestra error cuando falla la carga', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
render(<AuditoriaLog />)
|
||||
expect(await screen.findByText(/No se pudieron cargar/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('permite filtrar por entidad', async () => {
|
||||
render(<AuditoriaLog />)
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Todas'), { target: { value: 'lead' } })
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
|
||||
expect(String(fetchMock.mock.calls[1]?.[0])).toContain('entidad=lead')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user