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:
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] && (
|
||||
|
||||
Reference in New Issue
Block a user