Update project including dist

This commit is contained in:
Isaac Aracena
2026-08-20 14:19:35 -04:00
commit 7262b8de30
48 changed files with 9759 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
VITE_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
VITE_REPORT_WEBHOOK_URL="https://TU-N8N/webhook/cruces-glm-reportar"
VITE_DEMO_AUTH="false"
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Local environment variables
.env
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
pnpm-lock.yaml
package-lock.json
bun.lock
routeTree.gen.ts
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
File diff suppressed because one or more lines are too long
+184
View File
@@ -0,0 +1,184 @@
# Cruces GLM
Aplicación interna de GomezLee Marketing para centralizar los cruces administrativos.
## Arquitectura
- React 19 + Vite + TanStack Router.
- Supabase Auth con Google.
- Supabase como **fuente de verdad** de usuarios, permisos, formularios y métricas.
- RPCs de Supabase para todas las lecturas/mutaciones sensibles.
- n8n para los formularios de cruces y el botón **Reportar**.
## Desarrollo local
Requisitos: Node.js 22+ y npm.
```bash
npm install
npm run dev
```
La app usa el subpath `/cruces-glm/`.
## Build
```bash
npm run build
```
La salida se genera en `dist/`.
## Variables de entorno
```env
VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
VITE_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
VITE_REPORT_WEBHOOK_URL="https://TU-N8N/webhook/cruces-glm-reportar"
VITE_DEMO_AUTH="false"
```
### Google + Supabase real
La versión final viene con `VITE_DEMO_AUTH="false"`, por lo que **Continuar con Google** usa directamente Supabase Auth y Google OAuth. El modo demostrativo queda únicamente como opción de emergencia si se cambia explícitamente la variable a `true`.
No existe una allowlist de correos dentro del frontend. Después de autenticarse, Cruces GLM consulta la RPC `cruces_glm_get_my_access()` y Supabase decide si el usuario puede entrar y si puede administrar formularios.
Para el despliegue bajo `/cruces-glm/`, la URL de retorno de la app es:
```text
https://digitalcompass.agency/cruces-glm/
```
En desarrollo local se usa:
```text
http://localhost:5173/cruces-glm/
```
Ambas deben estar autorizadas en la configuración de redirects de Supabase Auth cuando quieras usar ambos entornos. El callback del proveedor Google hacia Supabase continúa siendo:
```text
https://dbit.digitalcompass.agency/auth/v1/callback
```
## Supabase
Ejecuta una vez en SQL Editor:
```text
supabase/Cruces_GLM_Supabase_Completo.sql
```
El script deja creados:
- `cruces_glm_permissions`: acceso a la app y permiso de administración.
- `cruces_glm_forms`: todos los cruces/formularios del Hub.
- `cruces_glm_metrics_summary`: totales globales.
- `cruces_glm_form_metrics`: métricas por cruce.
- `cruces_glm_user_metrics`: métricas por usuario.
- `cruces_glm_metric_events`: historial de login/creación/apertura.
- RPCs para login, formularios, permisos y consultas de métricas.
Las tablas no necesitan acceso directo desde el navegador; el frontend trabaja mediante RPCs protegidas.
## Administración del login desde Supabase
Los correos autorizados están en `public.cruces_glm_permissions`.
Campos principales:
- `is_allowed = true`: puede entrar a Cruces GLM.
- `is_allowed = false`: no puede entrar.
- `can_manage_forms = true`: ve **Agregar formulario**, Editar y Eliminar.
- `can_manage_forms = false`: usa los cruces, pero no administra el catálogo.
### Quitar acceso a una persona
```sql
update public.cruces_glm_permissions
set is_allowed = false
where email = 'correo@gomezleemarketing.com';
```
### Volver a darle acceso
```sql
update public.cruces_glm_permissions
set is_allowed = true
where email = 'correo@gomezleemarketing.com';
```
### Dar permiso para administrar formularios
```sql
update public.cruces_glm_permissions
set can_manage_forms = true
where email = 'correo@gomezleemarketing.com';
```
## Formularios en Supabase
En login real, `public.cruces_glm_forms` es la única fuente de verdad de los cruces. Crear, editar, activar/desactivar y eliminar se ejecuta mediante RPCs:
- `cruces_glm_get_forms()`
- `cruces_glm_get_form(p_form_id)`
- `cruces_glm_save_form(...)`
- `cruces_glm_delete_form(p_form_id)`
- `cruces_glm_record_form_open(p_form_id)`
### Migración automática del localStorage anterior
La aplicación conserva una migración de una sola ejecución para evitar perder los formularios que ya hayas creado durante las pruebas.
En el **primer inicio de sesión real de un usuario con `can_manage_forms = true`**:
1. La app consulta Supabase.
2. Si `cruces_glm_forms` todavía está vacío, revisa el antiguo `localStorage` (`glm-hub-formularios-v1`).
3. Si encuentra formularios, los guarda en Supabase mediante `cruces_glm_save_form()`.
4. Marca esa migración como completada para no volver a importar elementos eliminados posteriormente.
En modo real, después de esa migración, crear/editar/eliminar formularios **ya no escribe la configuración de los cruces en localStorage**. Solo favoritos y usados recientemente permanecen como preferencias locales del navegador.
> Si los formularios actuales existen únicamente en un navegador/origen específico (por ejemplo `localhost:5173`), haz el primer login real desde ese mismo navegador/origen antes de limpiar sus datos para que la migración pueda leerlos.
## RPCs de métricas
Para un futuro dashboard administrativo quedan listas:
- `cruces_glm_get_metrics_summary()`
- `cruces_glm_get_form_metrics()`
- `cruces_glm_get_user_metrics()`
- `cruces_glm_get_recent_events(p_limit)`
Solo usuarios con `can_manage_forms = true` pueden ejecutar esas consultas.
También quedan preparadas para una futura pantalla de accesos:
- `cruces_glm_admin_get_access_list()`
- `cruces_glm_admin_save_access(...)`
## Métricas guardadas
Supabase registra:
- Total histórico de formularios colocados.
- Total de formularios abiertos.
- Total de logueos.
- Aperturas por formulario.
- Logueos y aperturas por usuario.
- Eventos detallados de login, creación y apertura.
Una edición no vuelve a incrementar `total_formularios_colocados`; solamente una creación nueva.
## Reportes de usuario
Importa `Cruces_GLM_Reporte_Usuario_n8n.json`, selecciona la credencial Gmail correspondiente y coloca la Production URL del webhook en `VITE_REPORT_WEBHOOK_URL` antes del build de producción.
## Sesión y navegación en producción
- La sesión de Supabase se renueva automáticamente antes de que expire.
- Cada RPC comprueba que el access token siga vigente; si Supabase responde `JWT expired`, la app renueva el token y repite esa RPC una sola vez.
- Al volver a una pestaña que estuvo en segundo plano, la sesión se valida de inmediato.
- Un clic normal en **Abrir** mantiene la vista integrada dentro de Cruces GLM.
- `Ctrl+click` / `Cmd+click` (y abrir el enlace en otra pestaña desde el navegador) usa directamente la URL real del formulario n8n, evitando rutas SPA que un servidor estático podría responder con 404.
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"css": "src/styles.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+49
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="author" content="GomezLee Marketing" />
<meta
name="description"
content="Centraliza los cruces y automatizaciones internas de GomezLee Marketing."
/>
<meta property="og:title" content="Cruces GLM | GomezLee Marketing" />
<meta
property="og:description"
content="Centraliza los cruces y automatizaciones internas de GomezLee Marketing."
/>
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="/cruces-glm/favicon.png" type="image/png" />
<link rel="apple-touch-icon" href="/cruces-glm/favicon.png" />
<title>Cruces GLM | GomezLee Marketing</title>
<script type="module" crossorigin src="/cruces-glm/assets/index-CZJnQ1j6.js"></script>
<link rel="stylesheet" crossorigin href="/cruces-glm/assets/index-DCvYaajV.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
import js from "@eslint/js";
import eslintPluginPrettier from "eslint-plugin-prettier/recommended";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
eslintPluginPrettier,
);
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="author" content="GomezLee Marketing" />
<meta
name="description"
content="Centraliza los cruces y automatizaciones internas de GomezLee Marketing."
/>
<meta property="og:title" content="Cruces GLM | GomezLee Marketing" />
<meta
property="og:description"
content="Centraliza los cruces y automatizaciones internas de GomezLee Marketing."
/>
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="/cruces-glm/favicon.png" type="image/png" />
<link rel="apple-touch-icon" href="/cruces-glm/favicon.png" />
<title>Cruces GLM | GomezLee Marketing</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4742
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "cruces-glm",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "vite preview --host 0.0.0.0 --port 3000",
"build:dev": "vite build --mode development",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-router": "1.170.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.3.4",
"vite-tsconfig-paths": "^6.0.2"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"prettier": "^3.7.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+213
View File
@@ -0,0 +1,213 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { LoaderCircle } from "lucide-react";
import {
DEMO_AUTH_ENABLED,
DEMO_USER,
GLM_LOGO_URL,
initializeAuth,
keepSessionFresh,
signInWithGoogle,
signOut,
type AuthUser,
} from "@/lib/auth";
import { AuthContext, type AuthContextValue } from "@/lib/auth-context";
function GoogleIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-5 shrink-0">
<path
fill="#4285F4"
d="M21.6 12.23c0-.71-.06-1.4-.18-2.07H12v3.92h5.38a4.6 4.6 0 0 1-2 3.02v2.54h3.24c1.9-1.75 2.98-4.33 2.98-7.41Z"
/>
<path
fill="#34A853"
d="M12 22c2.7 0 4.98-.9 6.64-2.36l-3.24-2.54c-.9.6-2.05.96-3.4.96-2.61 0-4.82-1.76-5.61-4.13H3.04v2.62A10 10 0 0 0 12 22Z"
/>
<path
fill="#FBBC05"
d="M6.39 13.93A6.02 6.02 0 0 1 6.08 12c0-.67.12-1.32.31-1.93V7.45H3.04A10 10 0 0 0 2 12c0 1.61.39 3.13 1.04 4.55l3.35-2.62Z"
/>
<path
fill="#EA4335"
d="M12 5.94c1.47 0 2.79.5 3.83 1.5l2.88-2.88A9.64 9.64 0 0 0 12 2a10 10 0 0 0-8.96 5.45l3.35 2.62C7.18 7.7 9.39 5.94 12 5.94Z"
/>
</svg>
);
}
function LoginScreen({
message,
onDemoLogin,
}: {
message?: string | undefined;
onDemoLogin: () => void;
}) {
const [redirecting, setRedirecting] = useState(false);
const [localError, setLocalError] = useState<string | undefined>();
const login = () => {
if (DEMO_AUTH_ENABLED) {
onDemoLogin();
return;
}
try {
setLocalError(undefined);
setRedirecting(true);
signInWithGoogle();
} catch (error) {
setRedirecting(false);
setLocalError(error instanceof Error ? error.message : "No se pudo iniciar sesión.");
}
};
const error = localError ?? message;
return (
<main className="glm-login-background min-h-screen px-5 py-10">
<section className="glm-login-card" aria-label="Inicio de sesión de Cruces GLM">
<div className="glm-login-topline" />
<div className="px-10 pt-11 pb-12 sm:px-12">
<div className="mx-auto flex size-[92px] items-center justify-center rounded-[24px] border border-white/80 bg-white shadow-[0_14px_34px_rgba(79,117,139,0.12)]">
<img
src={GLM_LOGO_URL}
alt="GomezLee Marketing"
className="w-[66px] max-w-full object-contain"
/>
</div>
<div className="mt-8 text-center">
<h1 className="text-[34px] leading-none font-semibold tracking-[-0.045em] text-[#45697D]">
Cruces GLM
</h1>
<p className="mt-3 text-[11px] font-bold tracking-[0.27em] text-[#7F95AE] uppercase">
GomezLee Marketing
</p>
</div>
<button
type="button"
onClick={login}
disabled={redirecting}
className="mt-11 flex h-[52px] w-full items-center justify-center gap-3 rounded-[14px] bg-gradient-to-r from-[#466F87] to-[#628CA4] px-5 text-[14px] font-bold text-white shadow-[0_12px_24px_rgba(69,105,125,0.18)] transition hover:-translate-y-0.5 hover:shadow-[0_15px_30px_rgba(69,105,125,0.24)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-glm-green focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-75 disabled:hover:translate-y-0"
>
{redirecting ? (
<LoaderCircle className="size-5 animate-spin" aria-hidden="true" />
) : (
<GoogleIcon />
)}
{redirecting ? "Abriendo Google…" : "Continuar con Google"}
</button>
{error && (
<p
role="alert"
className="mt-5 rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-center text-xs leading-relaxed text-red-700"
>
{error}
</p>
)}
</div>
</section>
</main>
);
}
function LoadingScreen() {
return (
<main className="glm-login-background flex min-h-screen items-center justify-center px-5">
<div className="flex items-center gap-3 rounded-2xl border border-white/80 bg-white/85 px-5 py-4 text-sm text-glm-blue shadow-lg backdrop-blur">
<LoaderCircle className="size-5 animate-spin" />
Validando sesión
</div>
</main>
);
}
type GateState =
| { status: "loading" }
| { status: "unauthenticated"; message?: string }
| { status: "authenticated"; user: AuthUser; canManageForms: boolean };
export function AuthGate({ children }: { children: ReactNode }) {
const [state, setState] = useState<GateState>(
DEMO_AUTH_ENABLED ? { status: "unauthenticated" } : { status: "loading" },
);
useEffect(() => {
if (DEMO_AUTH_ENABLED) return;
let alive = true;
initializeAuth().then((result) => {
if (!alive) return;
setState(result);
});
return () => {
alive = false;
};
}, []);
useEffect(() => {
if (DEMO_AUTH_ENABLED || state.status !== "authenticated") return;
let alive = true;
const refresh = async () => {
const valid = await keepSessionFresh();
if (!alive || valid) return;
setState({
status: "unauthenticated",
message: "Tu sesión terminó. Inicia sesión nuevamente.",
});
};
const refreshWhenVisible = () => {
if (document.visibilityState === "visible") void refresh();
};
// Los access tokens suelen durar alrededor de una hora. Revisar cada minuto
// permite renovarlos con margen; al volver a una pestaña suspendida también
// hacemos una comprobación inmediata.
const intervalId = window.setInterval(() => void refresh(), 60_000);
window.addEventListener("focus", refreshWhenVisible);
document.addEventListener("visibilitychange", refreshWhenVisible);
return () => {
alive = false;
window.clearInterval(intervalId);
window.removeEventListener("focus", refreshWhenVisible);
document.removeEventListener("visibilitychange", refreshWhenVisible);
};
}, [state.status]);
const value = useMemo<AuthContextValue | null>(() => {
if (state.status !== "authenticated") return null;
return {
user: state.user,
canManageForms: state.canManageForms,
logout: async () => {
if (!DEMO_AUTH_ENABLED) {
await signOut();
}
setState({ status: "unauthenticated" });
},
};
}, [state]);
if (state.status === "loading") return <LoadingScreen />;
if (state.status === "unauthenticated") {
return (
<LoginScreen
message={state.message}
onDemoLogin={() =>
setState({ status: "authenticated", user: DEMO_USER, canManageForms: true })
}
/>
);
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
+169
View File
@@ -0,0 +1,169 @@
import { useRouter } from "@tanstack/react-router";
import type { MouseEvent } from "react";
import { ArrowUpRight, ExternalLink, Pencil, Star, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { GlmForm } from "@/lib/glm-forms";
import { recordFormOpened } from "@/lib/metrics";
type Props = {
form: GlmForm;
favorito: boolean;
canManageForms: boolean;
onFavorito: (id: string) => void;
onEditar: (form: GlmForm) => void;
onEliminar: (form: GlmForm) => void;
onAbrir: (form: GlmForm) => void;
};
export function FormCard({
form,
favorito,
canManageForms,
onFavorito,
onEditar,
onEliminar,
onAbrir,
}: Props) {
const disponible = form.activo;
const router = useRouter();
const abrir = (event: MouseEvent<HTMLAnchorElement>) => {
// El href real del <a> es el formulario n8n. Así Ctrl/Cmd+click, click con
// rueda o "Abrir enlace en una pestaña nueva" van directo al formulario y
// nunca intentan abrir una ruta SPA que el servidor estático no conoce.
const modifiedClick = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
onAbrir(form);
if (modifiedClick) {
void recordFormOpened(form);
return;
}
event.preventDefault();
void router.navigate({
to: "/formularios/$formId",
params: { formId: form.id },
});
};
return (
<article
className={`glm-card group relative flex flex-col overflow-hidden ${
disponible ? "" : "glm-card-disabled"
}`}
aria-disabled={!disponible}
>
<div className={disponible ? "glm-strip" : "h-1 w-full bg-muted-foreground/45"} />
<div className="flex flex-1 flex-col gap-3 p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-sm bg-glm-leaf-bg px-2 py-0.5 text-[10px] font-bold tracking-[0.12em] text-glm-blue uppercase">
{form.categoria}
</span>
{!disponible && (
<span className="rounded-sm bg-muted px-2 py-0.5 text-[10px] font-bold tracking-[0.12em] text-muted-foreground uppercase">
No disponible
</span>
)}
</div>
<button
type="button"
aria-label={favorito ? "Quitar de favoritos" : "Marcar como favorito"}
onClick={() => onFavorito(form.id)}
className="text-muted-foreground transition-colors hover:text-glm-green"
>
<Star
className="size-4"
strokeWidth={1.8}
fill={favorito ? "currentColor" : "none"}
color={favorito ? "var(--glm-green)" : undefined}
/>
</button>
</div>
{disponible ? (
<a
href={form.url}
onClick={abrir}
className="text-base leading-snug font-bold text-glm-blue transition-colors hover:text-glm-teal"
>
{form.nombre}
</a>
) : (
<h3 className="cursor-not-allowed text-base leading-snug font-bold text-muted-foreground">
{form.nombre}
</h3>
)}
<p className="line-clamp-3 text-sm leading-relaxed text-glm-body">{form.descripcion}</p>
<div className="mt-auto pt-3">
<div className="glm-rule" />
<div className="mt-3 flex items-center justify-between">
{disponible ? (
<a
href={form.url}
onClick={abrir}
className="inline-flex items-center gap-1.5 text-xs font-bold tracking-[0.08em] text-glm-blue uppercase transition-colors hover:text-glm-green"
>
Abrir <ArrowUpRight className="size-3.5" />
</a>
) : (
<span className="inline-flex cursor-not-allowed items-center gap-1.5 text-xs font-bold tracking-[0.08em] text-muted-foreground uppercase">
No disponible
</span>
)}
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{disponible && (
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label="Abrir en n8n"
asChild
>
<a
href={form.url}
target="_blank"
rel="noreferrer"
onClick={() => {
onAbrir(form);
void recordFormOpened(form);
}}
>
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{canManageForms && (
<>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label="Editar"
onClick={() => onEditar(form)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label="Eliminar"
onClick={() => onEliminar(form)}
>
<Trash2 className="size-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
</div>
</article>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { useEffect, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { CATEGORIAS, slugify, type GlmForm } from "@/lib/glm-forms";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
inicial?: GlmForm | null;
onGuardar: (form: GlmForm) => void | Promise<void>;
};
const vacio: GlmForm = {
id: "",
nombre: "",
descripcion: "",
categoria: "Seguridad Social",
url: "",
activo: true,
};
function RequiredMark() {
return (
<>
<span className="ml-0.5 text-glm-orange" aria-hidden="true">
*
</span>
<span className="sr-only"> obligatorio</span>
</>
);
}
export function FormDialog({ open, onOpenChange, inicial, onGuardar }: Props) {
const [form, setForm] = useState<GlmForm>(inicial ?? vacio);
const [error, setError] = useState<string | null>(null);
const [guardando, setGuardando] = useState(false);
useEffect(() => {
if (open) {
setForm(inicial ?? vacio);
setError(null);
setGuardando(false);
}
}, [open, inicial]);
const set = <K extends keyof GlmForm>(key: K, value: GlmForm[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const todosCompletos = useMemo(
() =>
Boolean(
form.nombre.trim() &&
form.url.trim() &&
form.descripcion.trim() &&
form.categoria.trim(),
),
[form],
);
const submit = async () => {
if (!todosCompletos) {
setError("Completa todos los campos marcados con * antes de guardar.");
return;
}
if (!/^https?:\/\//i.test(form.url.trim())) {
setError("La URL del formulario n8n debe empezar con http:// o https://");
return;
}
setGuardando(true);
setError(null);
try {
await onGuardar({
...form,
nombre: form.nombre.trim(),
descripcion: form.descripcion.trim(),
categoria: form.categoria.trim(),
url: form.url.trim(),
id: form.id || slugify(form.nombre),
});
onOpenChange(false);
} catch (saveError) {
setError(
saveError instanceof Error
? saveError.message
: "No se pudo guardar el formulario. Intenta nuevamente.",
);
} finally {
setGuardando(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-glm-blue">
{inicial ? "Editar formulario" : "Nuevo formulario"}
</DialogTitle>
<DialogDescription>
Registra el enlace del formulario n8n para que el equipo lo encuentre en el hub.
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="space-y-2">
<Label htmlFor="nombre">
Nombre
<RequiredMark />
</Label>
<Input
id="nombre"
value={form.nombre}
onChange={(e) => {
set("nombre", e.target.value);
if (error) setError(null);
}}
placeholder="Cruce de Seguridad Social - Honduras"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="url">
URL del formulario n8n
<RequiredMark />
</Label>
<Input
id="url"
value={form.url}
onChange={(e) => {
set("url", e.target.value);
if (error) setError(null);
}}
placeholder="https://n8n.tuempresa.com/form/..."
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="descripcion">
Descripción
<RequiredMark />
</Label>
<Textarea
id="descripcion"
rows={4}
value={form.descripcion}
onChange={(e) => {
set("descripcion", e.target.value);
if (error) setError(null);
}}
placeholder="Describe brevemente qué hace este cruce."
required
/>
</div>
<div className="grid gap-5 sm:grid-cols-2 sm:items-end">
<div className="space-y-2">
<Label>
Categoría
<RequiredMark />
</Label>
<Select
value={form.categoria}
onValueChange={(value) => {
set("categoria", value);
if (error) setError(null);
}}
>
<SelectTrigger aria-required="true">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORIAS.map((categoria) => (
<SelectItem key={categoria} value={categoria}>
{categoria}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex h-10 items-center gap-3 sm:justify-start">
<Switch
id="activo"
checked={form.activo}
onCheckedChange={(value) => set("activo", value)}
/>
<Label htmlFor="activo" className="cursor-pointer">
Disponible
<RequiredMark />
</Label>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
Los campos marcados con <span className="font-bold text-glm-orange">*</span> son
obligatorios.
</p>
{error ? (
<p
role="alert"
className="rounded-md border border-border bg-glm-section-bg px-3 py-2 text-xs font-bold text-glm-blue"
>
{error}
</p>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={guardando}>
Cancelar
</Button>
<Button onClick={() => void submit()} disabled={!todosCompletos || guardando}>
{guardando ? "Guardando…" : "Guardar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { useState } from "react";
import { CheckCircle2, LoaderCircle, MessageSquareWarning, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth-context";
const REPORT_WEBHOOK_URL = (import.meta.env["VITE_REPORT_WEBHOOK_URL"] ?? "").trim();
function metadataString(metadata: Record<string, unknown> | undefined, key: string) {
const value = metadata?.[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getUserName(email: string, metadata?: Record<string, unknown>) {
const directName =
metadataString(metadata, "full_name") ?? metadataString(metadata, "name");
if (directName) return directName;
const composedName = [
metadataString(metadata, "given_name"),
metadataString(metadata, "family_name"),
]
.filter(Boolean)
.join(" ");
return composedName || email;
}
export function ReportIssue() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
const reset = () => {
setMessage("");
setError(null);
setSent(false);
setSending(false);
};
const changeOpen = (next: boolean) => {
setOpen(next);
if (!next) reset();
};
const submit = async () => {
const cleanMessage = message.trim();
if (!cleanMessage) {
setError("Debes escribir una descripción antes de enviar el reporte.");
return;
}
if (!REPORT_WEBHOOK_URL) {
setError(
"El webhook de reportes todavía no está configurado. Agrega VITE_REPORT_WEBHOOK_URL y vuelve a compilar la aplicación.",
);
return;
}
setSending(true);
setError(null);
try {
const response = await fetch(REPORT_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
app: "Cruces GLM",
userEmail: user.email,
userName: getUserName(user.email, user.user_metadata),
message: cleanMessage,
submittedAt: new Date().toISOString(),
}),
});
if (!response.ok) {
throw new Error(`El servidor respondió con estado ${response.status}.`);
}
setSent(true);
setMessage("");
} catch (submitError) {
setError(
submitError instanceof Error
? `No se pudo enviar el reporte. ${submitError.message}`
: "No se pudo enviar el reporte. Intenta nuevamente.",
);
} finally {
setSending(false);
}
};
return (
<section className="border-t border-border py-8 text-center">
<p className="mb-3 text-xs text-muted-foreground">
¿Encontraste un problema o necesitas reportar algo?
</p>
<Button variant="outline" onClick={() => setOpen(true)} className="gap-2">
<MessageSquareWarning className="size-4" />
Reportar
</Button>
<Dialog open={open} onOpenChange={changeOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-glm-blue">Reportar</DialogTitle>
<DialogDescription className="sr-only">
Formulario para enviar un reporte de Cruces GLM.
</DialogDescription>
</DialogHeader>
{sent ? (
<div className="flex items-start gap-3 rounded-md border border-glm-green/40 bg-glm-leaf-bg p-4 text-left">
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-glm-green" />
<div>
<p className="text-sm font-bold text-glm-blue">Reporte enviado</p>
<p className="mt-1 text-sm text-glm-body">
Tu mensaje fue enviado correctamente.
</p>
</div>
</div>
) : (
<div className="space-y-2 py-1 text-left">
<Label htmlFor="reporte-mensaje">Descripción del reporte</Label>
<Textarea
id="reporte-mensaje"
value={message}
onChange={(event) => {
setMessage(event.target.value);
if (error) setError(null);
}}
rows={6}
required
placeholder="Explica qué ocurrió, qué esperabas que pasara y cualquier detalle que pueda ayudar a revisarlo."
className="resize-y"
/>
<p className="text-[11px] text-muted-foreground">Se enviará como: {user.email}</p>
</div>
)}
{error ? (
<p role="alert" className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-left text-xs text-red-700 dark:border-red-900/60 dark:bg-red-950/35 dark:text-red-300">
{error}
</p>
) : null}
<DialogFooter>
{sent ? (
<Button onClick={() => changeOpen(false)}>Cerrar</Button>
) : (
<>
<Button variant="outline" onClick={() => changeOpen(false)} disabled={sending}>
Cancelar
</Button>
<Button onClick={() => void submit()} disabled={!message.trim() || sending}>
{sending ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
{sending ? "Enviando…" : "Enviar reporte"}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { Link } from "@tanstack/react-router";
import { LogOut, Moon, Sun } from "lucide-react";
import { useState } from "react";
import { getStoredTheme, saveTheme, type AppTheme } from "@/lib/theme";
import { useAuth } from "@/lib/auth-context";
const HEADER_LOGO_URL = `${import.meta.env.BASE_URL}glm-logo-header.png`;
function metadataString(metadata: Record<string, unknown> | undefined, key: string) {
const value = metadata?.[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getProfilePhoto(metadata?: Record<string, unknown>) {
return (
metadataString(metadata, "avatar_url") ??
metadataString(metadata, "picture") ??
metadataString(metadata, "photo_url")
);
}
function getInitials(email: string, metadata?: Record<string, unknown>) {
const givenName = metadataString(metadata, "given_name");
const familyName = metadataString(metadata, "family_name");
if (givenName && familyName) {
return `${givenName.charAt(0)}${familyName.charAt(0)}`.toUpperCase();
}
const fullName = metadataString(metadata, "full_name") ?? metadataString(metadata, "name");
if (fullName) {
const parts = fullName.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]?.charAt(0) ?? ""}${parts.at(-1)?.charAt(0) ?? ""}`.toUpperCase();
}
if (parts[0]) return parts[0].slice(0, 2).toUpperCase();
}
const localPart = email.split("@")[0] ?? "";
const emailParts = localPart.split(/[._-]+/).filter(Boolean);
if (emailParts.length >= 2) {
return `${emailParts[0]?.charAt(0) ?? ""}${emailParts.at(-1)?.charAt(0) ?? ""}`.toUpperCase();
}
return localPart.slice(0, 2).toUpperCase() || "GL";
}
function UserAvatar({
email,
metadata,
}: {
email: string;
metadata?: Record<string, unknown>;
}) {
const photoUrl = getProfilePhoto(metadata);
const [photoFailed, setPhotoFailed] = useState(false);
const initials = getInitials(email, metadata);
return (
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-glm-leaf-bg text-sm font-bold text-glm-blue shadow-sm">
{photoUrl && !photoFailed ? (
<img
src={photoUrl}
alt="Foto de perfil"
className="size-full object-cover"
referrerPolicy="no-referrer"
onError={() => setPhotoFailed(true)}
/>
) : (
<span aria-label={`Iniciales ${initials}`}>{initials}</span>
)}
</div>
);
}
export function SiteHeader() {
const { user, logout } = useAuth();
const [theme, setTheme] = useState<AppTheme>(() => getStoredTheme());
const toggleTheme = () => {
const nextTheme: AppTheme = theme === "dark" ? "light" : "dark";
setTheme(nextTheme);
saveTheme(nextTheme);
};
return (
<header className="sticky top-0 z-30 border-b border-border/80 bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 py-4">
<Link to="/" className="flex shrink-0 items-center" aria-label="Ir a Cruces GLM">
<img src={HEADER_LOGO_URL} alt="GomezLee Marketing" className="h-11 w-auto sm:h-12" />
</Link>
<div className="flex min-w-0 items-center gap-3 sm:gap-4">
<div className="flex min-w-0 items-center gap-2.5">
<UserAvatar email={user.email} metadata={user.user_metadata} />
<span className="hidden max-w-64 truncate text-sm text-muted-foreground sm:block">
{user.email}
</span>
</div>
<button
type="button"
onClick={toggleTheme}
className="inline-flex size-10 shrink-0 items-center justify-center rounded-md border border-glm-blue/25 bg-card text-glm-blue shadow-sm transition-all hover:border-glm-green hover:bg-glm-leaf-bg hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-glm-green focus-visible:ring-offset-2"
title={theme === "dark" ? "Cambiar a modo claro" : "Cambiar a modo oscuro"}
aria-label={theme === "dark" ? "Cambiar a modo claro" : "Cambiar a modo oscuro"}
>
{theme === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
<button
type="button"
onClick={() => void logout()}
className="inline-flex h-10 shrink-0 items-center gap-2 rounded-md border border-glm-blue/25 bg-card px-4 text-sm font-semibold text-glm-blue shadow-sm transition-all hover:border-glm-green hover:bg-glm-leaf-bg hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-glm-green focus-visible:ring-offset-2"
title="Cerrar sesión"
>
<LogOut className="size-4" />
<span className="hidden sm:inline">Cerrar sesión</span>
</button>
</div>
</div>
</header>
);
}
+115
View File
@@ -0,0 +1,115 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+104
View File
@@ -0,0 +1,104 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background cursor-pointer transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+21
View File
@@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+152
View File
@@ -0,0 +1,152 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background cursor-pointer data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-select-content-transform-origin)",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
+16
View File
@@ -0,0 +1,16 @@
import { createContext, useContext } from "react";
import type { AuthUser } from "@/lib/auth";
export type AuthContextValue = {
user: AuthUser;
canManageForms: boolean;
logout: () => Promise<void>;
};
export const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth() {
const value = useContext(AuthContext);
if (!value) throw new Error("useAuth debe usarse dentro de AuthGate.");
return value;
}
+409
View File
@@ -0,0 +1,409 @@
export const GLM_LOGO_URL =
"https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png";
// El login real Google + Supabase es el modo de producción.
// El modo demo solo se activa de forma explícita con VITE_DEMO_AUTH="true".
export const DEMO_AUTH_ENABLED =
(import.meta.env["VITE_DEMO_AUTH"] ?? "false").trim().toLowerCase() === "true";
const SESSION_STORAGE_KEY = "cruces-glm-supabase-session-v1";
const SUPABASE_URL = (import.meta.env["VITE_SUPABASE_URL"] ?? "").replace(/\/+$/, "");
const SUPABASE_ANON_KEY = import.meta.env["VITE_SUPABASE_ANON_KEY"] ?? "";
export type AuthUser = {
id: string;
email: string;
user_metadata?: Record<string, unknown> | undefined;
};
export type AuthAccess = {
allowed: boolean;
canManageForms: boolean;
displayName?: string | undefined;
};
export const DEMO_USER: AuthUser = {
id: "demo-iaracena",
email: "iaracena@gomezleemarketing.com",
user_metadata: {
full_name: "Isaac Aracena",
given_name: "Isaac",
family_name: "Aracena",
},
};
type StoredSession = {
accessToken: string;
refreshToken: string;
expiresAt: number;
};
// Renovamos el access token antes de que caduque. Los navegadores pueden
// suspender timers en pestañas en segundo plano, por eso cada RPC también
// valida/refresca la sesión justo antes de llamar a Supabase.
const SESSION_REFRESH_BUFFER_SECONDS = 5 * 60;
let refreshInFlight: Promise<StoredSession | null> | null = null;
export type AuthInitResult =
| { status: "authenticated"; user: AuthUser; canManageForms: boolean }
| { status: "unauthenticated"; message?: string };
function assertConfig() {
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
throw new Error(
"Faltan VITE_SUPABASE_URL o VITE_SUPABASE_ANON_KEY en las variables de entorno.",
);
}
}
function normalizeEmail(email?: string | null) {
return (email ?? "").trim().toLowerCase();
}
function getRedirectUrl() {
if (typeof window === "undefined") return "";
return new URL(import.meta.env.BASE_URL, window.location.origin).toString();
}
function saveSession(session: StoredSession) {
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
}
function loadSession(): StoredSession | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredSession>;
if (
typeof parsed.accessToken !== "string" ||
typeof parsed.refreshToken !== "string" ||
typeof parsed.expiresAt !== "number"
) {
return null;
}
return parsed as StoredSession;
} catch {
return null;
}
}
export function getStoredAccessToken() {
return loadSession()?.accessToken ?? null;
}
function clearSession() {
if (typeof window !== "undefined") {
window.localStorage.removeItem(SESSION_STORAGE_KEY);
}
}
function consumeOAuthFragment(): { message?: string; freshLogin: boolean } {
if (typeof window === "undefined" || !window.location.hash) return { freshLogin: false };
const params = new URLSearchParams(window.location.hash.slice(1));
const accessToken = params.get("access_token");
const refreshToken = params.get("refresh_token");
const errorDescription = params.get("error_description") ?? params.get("error");
if (accessToken && refreshToken) {
const expiresAtFromHash = Number(params.get("expires_at"));
const expiresIn = Number(params.get("expires_in"));
const expiresAt =
Number.isFinite(expiresAtFromHash) && expiresAtFromHash > 0
? expiresAtFromHash
: Math.floor(Date.now() / 1000) +
(Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600);
saveSession({ accessToken, refreshToken, expiresAt });
}
if (accessToken || errorDescription) {
window.history.replaceState(
{},
document.title,
`${window.location.pathname}${window.location.search}`,
);
}
return errorDescription
? { message: decodeURIComponent(errorDescription), freshLogin: false }
: { freshLogin: Boolean(accessToken && refreshToken) };
}
async function refreshSessionOnce(session: StoredSession): Promise<StoredSession | null> {
assertConfig();
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
"content-type": "application/json",
},
body: JSON.stringify({ refresh_token: session.refreshToken }),
});
if (!response.ok) {
// Un refresh token inválido/expirado sí termina la sesión. Un error temporal
// del servidor no debe borrar una sesión que puede recuperarse al volver.
if ([400, 401, 403].includes(response.status)) {
clearSession();
return null;
}
throw new Error(`No se pudo renovar la sesión (${response.status}).`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
expires_at?: number;
};
if (!data.access_token || !data.refresh_token) {
clearSession();
return null;
}
const next: StoredSession = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt:
typeof data.expires_at === "number"
? data.expires_at
: Math.floor(Date.now() / 1000) + (data.expires_in ?? 3600),
};
saveSession(next);
return next;
}
async function refreshSessionLocked(session: StoredSession): Promise<StoredSession | null> {
if (!refreshInFlight) {
refreshInFlight = refreshSessionOnce(session).finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
/**
* Devuelve un access token vigente. Si está próximo a expirar (o se fuerza la
* renovación después de un 401/JWT expired), usa el refresh token y guarda la
* nueva sesión antes de continuar.
*/
export async function getValidAccessToken(forceRefresh = false): Promise<string | null> {
if (DEMO_AUTH_ENABLED) return null;
let session = loadSession();
if (!session) return null;
const now = Math.floor(Date.now() / 1000);
const shouldRefresh =
forceRefresh || session.expiresAt <= now + SESSION_REFRESH_BUFFER_SECONDS;
if (shouldRefresh) {
session = await refreshSessionLocked(session);
if (!session) return null;
}
return session.accessToken;
}
export async function keepSessionFresh(): Promise<boolean> {
if (DEMO_AUTH_ENABLED) return true;
try {
return Boolean(await getValidAccessToken(false));
} catch {
// Si solo hubo un problema temporal de red/servidor, conservamos la sesión
// y volveremos a intentar al recuperar el foco o en la siguiente RPC.
return true;
}
}
async function recordLoginMetric(accessToken: string) {
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) return;
try {
await fetch(`${SUPABASE_URL}/rest/v1/rpc/cruces_glm_record_login`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: "{}",
keepalive: true,
});
} catch {
// Las métricas no deben bloquear el inicio de sesión.
}
}
async function getUser(accessToken: string): Promise<AuthUser | null> {
assertConfig();
const response = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) return null;
const data = (await response.json()) as {
id?: string;
email?: string;
user_metadata?: Record<string, unknown> | undefined;
};
const email = normalizeEmail(data.email);
if (!data.id || !email) return null;
return {
id: data.id,
email,
user_metadata: data.user_metadata,
};
}
async function getMyAccess(accessToken: string): Promise<AuthAccess> {
assertConfig();
const response = await fetch(`${SUPABASE_URL}/rest/v1/rpc/cruces_glm_get_my_access`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: "{}",
});
if (!response.ok) {
return { allowed: false, canManageForms: false };
}
const rows = (await response.json()) as Array<{
allowed?: boolean;
can_manage_forms?: boolean;
display_name?: string | null;
}>;
const row = rows[0];
const displayName =
typeof row?.display_name === "string" && row.display_name.trim()
? row.display_name.trim()
: null;
return {
allowed: row?.allowed === true,
canManageForms: row?.allowed === true && row?.can_manage_forms === true,
...(displayName ? { displayName } : {}),
};
}
export async function initializeAuth(): Promise<AuthInitResult> {
try {
assertConfig();
const oauthResult = consumeOAuthFragment();
if (oauthResult.message) {
return { status: "unauthenticated", message: oauthResult.message };
}
let session = loadSession();
if (!session) return { status: "unauthenticated" };
const now = Math.floor(Date.now() / 1000);
if (session.expiresAt <= now + 30) {
session = await refreshSessionLocked(session);
if (!session) return { status: "unauthenticated" };
}
let user = await getUser(session.accessToken);
if (!user) {
const refreshed = await refreshSessionLocked(session);
if (!refreshed) return { status: "unauthenticated" };
session = refreshed;
user = await getUser(refreshed.accessToken);
}
if (!user) {
clearSession();
return { status: "unauthenticated" };
}
// La fuente de verdad del acceso ya no está en el frontend. Supabase decide
// si el correo puede entrar y si puede administrar formularios.
const access = await getMyAccess(session.accessToken);
if (!access.allowed) {
await signOut();
return {
status: "unauthenticated",
message: `La cuenta ${user.email} no tiene acceso a Cruces GLM.`,
};
}
if (access.displayName && !user.user_metadata?.["full_name"]) {
user = {
...user,
user_metadata: {
...(user.user_metadata ?? {}),
full_name: access.displayName,
},
};
}
if (oauthResult.freshLogin) {
await recordLoginMetric(session.accessToken);
}
return {
status: "authenticated",
user,
canManageForms: access.canManageForms,
};
} catch (error) {
clearSession();
return {
status: "unauthenticated",
message: error instanceof Error ? error.message : "No se pudo validar la sesión.",
};
}
}
export function signInWithGoogle() {
assertConfig();
if (typeof window === "undefined") return;
const redirectTo = getRedirectUrl();
const params = new URLSearchParams({
provider: "google",
redirect_to: redirectTo,
});
window.location.assign(`${SUPABASE_URL}/auth/v1/authorize?${params.toString()}`);
}
export async function signOut() {
const session = loadSession();
clearSession();
if (!session || !SUPABASE_URL || !SUPABASE_ANON_KEY) return;
try {
await fetch(`${SUPABASE_URL}/auth/v1/logout`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${session.accessToken}`,
},
});
} catch {
// La sesión local ya quedó eliminada; el cierre remoto es best-effort.
}
}
+399
View File
@@ -0,0 +1,399 @@
import { useCallback, useEffect, useState } from "react";
import { DEMO_AUTH_ENABLED } from "@/lib/auth";
import { callAuthenticatedRpc } from "@/lib/supabase-api";
export type GlmForm = {
id: string;
nombre: string;
descripcion: string;
categoria: string;
url: string;
activo: boolean;
};
export const CATEGORIAS = ["Seguridad Social", "IR", "Reportes"] as const;
// Estos datos solo son el fallback del modo demostrativo. En modo real la fuente
// de verdad es public.cruces_glm_forms en Supabase.
export const FORMULARIOS_SEED: GlmForm[] = [
{
id: "cruce-facturacion-clientes",
nombre: "Cruce de facturación vs. órdenes de cliente",
descripcion:
"Compara las facturas emitidas contra las órdenes de compra registradas y devuelve las diferencias por cuenta.",
categoria: "Reportes",
url: "https://n8n.example.com/form/cruce-facturacion-clientes",
activo: true,
},
{
id: "cruce-proveedores-pagos",
nombre: "Cruce de proveedores vs. pagos ejecutados",
descripcion:
"Valida que cada factura de proveedor cargada tenga su pago conciliado en el período seleccionado.",
categoria: "Reportes",
url: "https://n8n.example.com/form/cruce-proveedores-pagos",
activo: true,
},
{
id: "conciliacion-bancaria",
nombre: "Conciliación bancaria automática",
descripcion:
"Sube el extracto bancario y el libro mayor para obtener partidas conciliadas y pendientes.",
categoria: "Reportes",
url: "https://n8n.example.com/form/conciliacion-bancaria",
activo: true,
},
{
id: "solicitud-orden-compra",
nombre: "Solicitud de orden de compra",
descripcion:
"Registra una nueva solicitud de compra y dispara el flujo de aprobación con el área responsable.",
categoria: "Reportes",
url: "https://n8n.example.com/form/solicitud-orden-compra",
activo: true,
},
{
id: "carga-facturas-proveedor",
nombre: "Carga de facturas de proveedor",
descripcion:
"Envía la factura en PDF y sus datos fiscales para que el flujo la extraiga y la registre.",
categoria: "Reportes",
url: "https://n8n.example.com/form/carga-facturas-proveedor",
activo: true,
},
{
id: "reporte-cuentas-por-cobrar",
nombre: "Reporte de cuentas por cobrar",
descripcion:
"Genera el aging de cartera por cliente y lo envía por correo al cierre del período elegido.",
categoria: "Reportes",
url: "https://n8n.example.com/form/reporte-cuentas-por-cobrar",
activo: true,
},
];
const LEGACY_FORMS_KEY = "glm-hub-formularios-v1";
const MIGRATION_KEY = "cruces-glm-supabase-forms-migrated-v1";
const FAVS_KEY = "glm-hub-favoritos-v1";
const RECENT_KEY = "glm-hub-recientes-v1";
const CATEGORIA_SET = new Set<string>(CATEGORIAS);
type RpcFormRow = {
id?: string;
nombre?: string;
descripcion?: string;
categoria?: string;
url?: string;
activo?: boolean;
};
function normalizarCategoria(categoria: string) {
return CATEGORIA_SET.has(categoria) ? categoria : "Reportes";
}
function normalizarFormulario(form: GlmForm): GlmForm {
return {
...form,
categoria: normalizarCategoria(form.categoria),
};
}
function normalizarFormularios(formularios: GlmForm[]) {
return formularios.map(normalizarFormulario);
}
function fromRpcRow(row: RpcFormRow): GlmForm | null {
if (
typeof row.id !== "string" ||
typeof row.nombre !== "string" ||
typeof row.descripcion !== "string" ||
typeof row.categoria !== "string" ||
typeof row.url !== "string" ||
typeof row.activo !== "boolean"
) {
return null;
}
return normalizarFormulario({
id: row.id,
nombre: row.nombre,
descripcion: row.descripcion,
categoria: row.categoria,
url: row.url,
activo: row.activo,
});
}
function read<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function write(key: string, value: unknown) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* almacenamiento no disponible */
}
}
function hasMigrationMarker() {
if (typeof window === "undefined") return true;
return window.localStorage.getItem(MIGRATION_KEY) === "1";
}
function markMigrationComplete() {
if (typeof window === "undefined") return;
window.localStorage.setItem(MIGRATION_KEY, "1");
}
function readLegacyFormsIfPresent(): GlmForm[] | null {
if (typeof window === "undefined") return null;
const raw = window.localStorage.getItem(LEGACY_FORMS_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return null;
const forms = parsed
.filter((item): item is GlmForm => {
if (!item || typeof item !== "object") return false;
const row = item as Partial<GlmForm>;
return (
typeof row.id === "string" &&
typeof row.nombre === "string" &&
typeof row.descripcion === "string" &&
typeof row.categoria === "string" &&
typeof row.url === "string" &&
typeof row.activo === "boolean"
);
})
.map(normalizarFormulario);
return forms;
} catch {
return null;
}
}
async function rpcListForms(): Promise<GlmForm[]> {
const rows = await callAuthenticatedRpc<RpcFormRow[]>("cruces_glm_get_forms");
return rows.map(fromRpcRow).filter((form): form is GlmForm => form !== null);
}
async function rpcGetForm(formId: string): Promise<GlmForm | null> {
const rows = await callAuthenticatedRpc<RpcFormRow[]>("cruces_glm_get_form", {
p_form_id: formId,
});
return fromRpcRow(rows[0] ?? {});
}
async function rpcSaveForm(form: GlmForm): Promise<GlmForm> {
const normalizado = normalizarFormulario(form);
const rows = await callAuthenticatedRpc<RpcFormRow[]>("cruces_glm_save_form", {
p_form_id: normalizado.id,
p_nombre: normalizado.nombre,
p_descripcion: normalizado.descripcion,
p_categoria: normalizado.categoria,
p_url: normalizado.url,
p_activo: normalizado.activo,
});
const saved = fromRpcRow(rows[0] ?? {});
if (!saved) throw new Error("Supabase no devolvió el formulario guardado.");
return saved;
}
async function rpcDeleteForm(formId: string): Promise<void> {
await callAuthenticatedRpc<boolean>("cruces_glm_delete_form", {
p_form_id: formId,
});
}
async function migrateLegacyFormsIfNeeded(canManageForms: boolean, remoteForms: GlmForm[]) {
if (DEMO_AUTH_ENABLED || hasMigrationMarker()) return remoteForms;
if (remoteForms.length > 0) {
markMigrationComplete();
return remoteForms;
}
// Solo un administrador puede migrar la información histórica del navegador.
if (!canManageForms) return remoteForms;
const legacyForms = readLegacyFormsIfPresent();
if (!legacyForms || legacyForms.length === 0) {
markMigrationComplete();
return remoteForms;
}
for (const form of legacyForms) {
await rpcSaveForm(form);
}
markMigrationComplete();
return rpcListForms();
}
export function slugify(value: string) {
return (
value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 48) || `formulario-${Date.now()}`
);
}
export function useFormularios(canManageForms = false) {
const [formularios, setFormularios] = useState<GlmForm[]>(
DEMO_AUTH_ENABLED ? FORMULARIOS_SEED : [],
);
const [favoritos, setFavoritos] = useState<string[]>([]);
const [recientes, setRecientes] = useState<string[]>([]);
const [listo, setListo] = useState(false);
const [errorCarga, setErrorCarga] = useState<string | null>(null);
const cargar = useCallback(async () => {
setErrorCarga(null);
try {
if (DEMO_AUTH_ENABLED) {
const cargados = normalizarFormularios(
read<GlmForm[]>(LEGACY_FORMS_KEY, FORMULARIOS_SEED),
);
setFormularios(cargados);
write(LEGACY_FORMS_KEY, cargados);
} else {
let cargados = await rpcListForms();
cargados = await migrateLegacyFormsIfNeeded(canManageForms, cargados);
setFormularios(cargados);
}
} catch (error) {
setFormularios([]);
setErrorCarga(
error instanceof Error
? error.message
: "No se pudieron cargar los formularios desde Supabase.",
);
} finally {
setListo(true);
}
}, [canManageForms]);
useEffect(() => {
setFavoritos(read<string[]>(FAVS_KEY, []));
setRecientes(read<string[]>(RECENT_KEY, []));
void cargar();
}, [cargar]);
useEffect(() => {
if (DEMO_AUTH_ENABLED || typeof window === "undefined") return;
const refreshOnFocus = () => {
if (document.visibilityState === "visible") void cargar();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
return () => {
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
};
}, [cargar]);
const guardar = useCallback(async (form: GlmForm) => {
const normalizado = normalizarFormulario(form);
if (DEMO_AUTH_ENABLED) {
setFormularios((prev) => {
const existe = prev.some((f) => f.id === normalizado.id);
const next = existe
? prev.map((f) => (f.id === normalizado.id ? normalizado : f))
: [normalizado, ...prev];
write(LEGACY_FORMS_KEY, next);
return next;
});
return normalizado;
}
const saved = await rpcSaveForm(normalizado);
setFormularios((prev) => {
const existe = prev.some((f) => f.id === saved.id);
return existe ? prev.map((f) => (f.id === saved.id ? saved : f)) : [saved, ...prev];
});
return saved;
}, []);
const eliminar = useCallback(async (id: string) => {
if (DEMO_AUTH_ENABLED) {
setFormularios((prev) => {
const next = prev.filter((f) => f.id !== id);
write(LEGACY_FORMS_KEY, next);
return next;
});
return;
}
await rpcDeleteForm(id);
setFormularios((prev) => prev.filter((f) => f.id !== id));
setFavoritos((prev) => {
const next = prev.filter((formId) => formId !== id);
write(FAVS_KEY, next);
return next;
});
setRecientes((prev) => {
const next = prev.filter((formId) => formId !== id);
write(RECENT_KEY, next);
return next;
});
}, []);
const alternarFavorito = useCallback((id: string) => {
setFavoritos((prev) => {
const next = prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id];
write(FAVS_KEY, next);
return next;
});
}, []);
const registrarUso = useCallback((id: string) => {
setRecientes((prev) => {
const next = [id, ...prev.filter((r) => r !== id)].slice(0, 6);
write(RECENT_KEY, next);
return next;
});
}, []);
return {
formularios,
favoritos,
recientes,
listo,
errorCarga,
guardar,
eliminar,
alternarFavorito,
registrarUso,
recargar: cargar,
};
}
export async function obtenerFormularioPorId(formId: string): Promise<GlmForm | null> {
if (DEMO_AUTH_ENABLED) {
const forms = normalizarFormularios(read<GlmForm[]>(LEGACY_FORMS_KEY, FORMULARIOS_SEED));
return forms.find((form) => form.id === formId) ?? null;
}
return rpcGetForm(formId);
}
+19
View File
@@ -0,0 +1,19 @@
import { DEMO_AUTH_ENABLED } from "@/lib/auth";
import type { GlmForm } from "@/lib/glm-forms";
import { callAuthenticatedRpc } from "@/lib/supabase-api";
/**
* Registra una apertura real. El RPC toma nombre/categoría directamente desde
* cruces_glm_forms para no confiar en datos enviados por el navegador.
*/
export async function recordFormOpened(form: GlmForm) {
if (DEMO_AUTH_ENABLED || !form.activo) return;
try {
await callAuthenticatedRpc<void>("cruces_glm_record_form_open", {
p_form_id: form.id,
});
} catch {
// Una falla de telemetría no debe bloquear la navegación del usuario.
}
}
+102
View File
@@ -0,0 +1,102 @@
import { DEMO_AUTH_ENABLED, getValidAccessToken } from "@/lib/auth";
const SUPABASE_URL = (import.meta.env["VITE_SUPABASE_URL"] ?? "").replace(/\/+$/, "");
const SUPABASE_ANON_KEY = import.meta.env["VITE_SUPABASE_ANON_KEY"] ?? "";
function parseErrorMessage(payload: unknown, fallback: string) {
if (!payload || typeof payload !== "object") return fallback;
const record = payload as Record<string, unknown>;
const message = record["message"];
const error = record["error"];
const details = record["details"];
if (typeof message === "string" && message.trim()) return message;
if (typeof error === "string" && error.trim()) return error;
if (typeof details === "string" && details.trim()) return details;
return fallback;
}
function isExpiredJwt(status: number, payload: unknown) {
if (status === 401) return true;
const text =
typeof payload === "string"
? payload
: payload && typeof payload === "object"
? JSON.stringify(payload)
: "";
return /jwt\s+expired|token\s+.*expired|expired\s+jwt/i.test(text);
}
async function rpcRequest(functionName: string, payload: Record<string, unknown>, accessToken: string) {
const response = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${functionName}`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
const text = await response.text();
let parsed: unknown = null;
if (text) {
try {
parsed = JSON.parse(text) as unknown;
} catch {
parsed = text;
}
}
return { response, parsed };
}
export async function hasRealSupabaseSession() {
if (DEMO_AUTH_ENABLED) return false;
return Boolean(await getValidAccessToken());
}
export async function callAuthenticatedRpc<T>(
functionName: string,
payload: Record<string, unknown> = {},
): Promise<T> {
if (DEMO_AUTH_ENABLED) {
throw new Error("Esta operación de Supabase no está disponible en modo demostrativo.");
}
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
throw new Error("Faltan las variables VITE_SUPABASE_URL o VITE_SUPABASE_ANON_KEY.");
}
let accessToken = await getValidAccessToken();
if (!accessToken) {
throw new Error("La sesión terminó. Vuelve a iniciar sesión.");
}
let result = await rpcRequest(functionName, payload, accessToken);
// Cinturón de seguridad: si una pestaña estuvo suspendida y el JWT venció
// entre comprobaciones, renovamos la sesión y repetimos la RPC una sola vez.
if (!result.response.ok && isExpiredJwt(result.response.status, result.parsed)) {
accessToken = await getValidAccessToken(true);
if (!accessToken) {
throw new Error("La sesión terminó. Vuelve a iniciar sesión.");
}
result = await rpcRequest(functionName, payload, accessToken);
}
if (!result.response.ok) {
throw new Error(
parseErrorMessage(
result.parsed,
`Supabase rechazó la operación ${functionName} (${result.response.status}).`,
),
);
}
return result.parsed as T;
}
+25
View File
@@ -0,0 +1,25 @@
export type AppTheme = "light" | "dark";
const THEME_STORAGE_KEY = "cruces-glm-theme";
export function getStoredTheme(): AppTheme {
if (typeof window === "undefined") return "light";
return window.localStorage.getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light";
}
export function applyTheme(theme: AppTheme) {
if (typeof document === "undefined") return;
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
}
export function saveTheme(theme: AppTheme) {
if (typeof window !== "undefined") {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
}
applyTheme(theme);
}
export function initializeTheme() {
applyTheme(getStoredTheme());
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+20
View File
@@ -0,0 +1,20 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./router";
import "./styles.css";
import { initializeTheme } from "./lib/theme";
initializeTheme();
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("No se encontró el elemento raíz de Cruces GLM.");
}
createRoot(rootElement).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
+78
View File
@@ -0,0 +1,78 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as FormulariosFormIdRouteImport } from './routes/formularios.$formId'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const FormulariosFormIdRoute = FormulariosFormIdRouteImport.update({
id: '/formularios/$formId',
path: '/formularios/$formId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/formularios/$formId': typeof FormulariosFormIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/formularios/$formId': typeof FormulariosFormIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/formularios/$formId': typeof FormulariosFormIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/formularios/$formId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/formularios/$formId'
id: '__root__' | '/' | '/formularios/$formId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
FormulariosFormIdRoute: typeof FormulariosFormIdRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/formularios/$formId': {
id: '/formularios/$formId'
path: '/formularios/$formId'
fullPath: '/formularios/$formId'
preLoaderRoute: typeof FormulariosFormIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
FormulariosFormIdRoute: FormulariosFormIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
+20
View File
@@ -0,0 +1,20 @@
import { QueryClient } from "@tanstack/react-query";
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
const queryClient = new QueryClient();
const basepath = import.meta.env.BASE_URL.replace(/\/$/, "") || "/";
export const router = createRouter({
routeTree,
context: { queryClient },
basepath,
scrollRestoration: true,
defaultPreloadStaleTime: 0,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Link, Outlet, createRootRouteWithContext, useRouter } from "@tanstack/react-router";
import { AuthGate } from "@/components/glm/auth-gate";
function NotFoundComponent() {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md text-center">
<h1 className="text-7xl font-bold text-foreground">404</h1>
<h2 className="mt-4 text-xl font-semibold text-foreground">Página no encontrada</h2>
<p className="mt-2 text-sm text-muted-foreground">
La página que buscas no existe o fue movida.
</p>
<div className="mt-6">
<Link
to="/"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Ir al inicio
</Link>
</div>
</div>
</div>
);
}
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
console.error(error);
const router = useRouter();
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md text-center">
<h1 className="text-xl font-semibold tracking-tight text-foreground">
Esta página no pudo cargar
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Ocurrió un error. Puedes intentar nuevamente o volver al inicio.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<button
onClick={() => {
router.invalidate();
reset();
}}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Intentar nuevamente
</button>
<Link
to="/"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
Ir al inicio
</Link>
</div>
</div>
</div>
);
}
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: RootComponent,
notFoundComponent: NotFoundComponent,
errorComponent: ErrorComponent,
});
function RootComponent() {
const { queryClient } = Route.useRouteContext();
return (
<QueryClientProvider client={queryClient}>
<AuthGate>
<Outlet />
</AuthGate>
</QueryClientProvider>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { ArrowLeft, ExternalLink, Link2, LoaderCircle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SiteHeader } from "@/components/glm/site-header";
import { ReportIssue } from "@/components/glm/report-issue";
import { obtenerFormularioPorId, type GlmForm } from "@/lib/glm-forms";
import { recordFormOpened } from "@/lib/metrics";
export const Route = createFileRoute("/formularios/$formId")({
component: FormularioPage,
});
function FormularioPage() {
const { formId } = Route.useParams();
const [form, setForm] = useState<GlmForm | null>(null);
const [listo, setListo] = useState(false);
const [errorCarga, setErrorCarga] = useState<string | null>(null);
const [recarga, setRecarga] = useState(0);
const [copiado, setCopiado] = useState(false);
useEffect(() => {
let alive = true;
setListo(false);
setErrorCarga(null);
setForm(null);
obtenerFormularioPorId(formId)
.then((encontrado) => {
if (!alive) return;
setForm(encontrado);
setListo(true);
if (encontrado?.activo) {
void recordFormOpened(encontrado);
}
})
.catch((error) => {
if (!alive) return;
setErrorCarga(
error instanceof Error
? error.message
: "No se pudo consultar este formulario en Supabase.",
);
setListo(true);
});
return () => {
alive = false;
};
}, [formId]);
const copiar = async () => {
if (!form) return;
try {
await navigator.clipboard.writeText(form.url);
setCopiado(true);
setTimeout(() => setCopiado(false), 1800);
} catch {
/* portapapeles no disponible */
}
};
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="mx-auto w-full max-w-[1680px] flex-1 px-4 py-6 sm:px-6 lg:px-8">
<Link
to="/"
className="inline-flex items-center gap-1.5 text-xs font-bold tracking-[0.08em] text-muted-foreground uppercase transition-colors hover:text-glm-green"
>
<ArrowLeft className="size-3.5" /> Volver al hub
</Link>
{!listo ? (
<div className="mt-10 flex items-center gap-3 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" /> Cargando formulario
</div>
) : errorCarga ? (
<div className="mt-10 rounded-md border border-border bg-glm-section-bg p-8">
<h1 className="text-xl font-bold text-glm-blue">No se pudo cargar el formulario</h1>
<p className="mt-2 text-sm text-glm-body">{errorCarga}</p>
</div>
) : !form ? (
<div className="mt-10 glm-card p-8">
<h1 className="text-xl font-bold text-glm-blue">Formulario no encontrado</h1>
<p className="mt-2 text-sm text-glm-body">
Este formulario ya no existe en el hub o fue eliminado.
</p>
</div>
) : !form.activo ? (
<div className="mt-10 rounded-md border border-border bg-muted/45 p-8">
<span className="text-[11px] font-bold tracking-[0.16em] text-muted-foreground uppercase">
{form.categoria}
</span>
<h1 className="mt-2 text-2xl font-bold text-muted-foreground">{form.nombre}</h1>
<p className="mt-3 max-w-2xl text-sm text-muted-foreground">
Este formulario no está disponible actualmente. Cuando vuelva a habilitarse podrás
abrirlo desde Cruces GLM.
</p>
</div>
) : (
<>
<div className="mt-6 flex flex-wrap items-end justify-between gap-4">
<div>
<span className="text-[11px] font-bold tracking-[0.16em] text-glm-green uppercase">
{form.categoria}
</span>
<h1 className="mt-1 max-w-3xl text-3xl leading-tight font-bold text-glm-blue">
{form.nombre}
</h1>
<p className="mt-2 max-w-2xl text-sm text-glm-body">{form.descripcion}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={copiar}>
<Link2 className="size-4" /> {copiado ? "Copiado" : "Copiar enlace"}
</Button>
<Button variant="outline" size="sm" onClick={() => setRecarga((r) => r + 1)}>
<RotateCw className="size-4" /> Reiniciar
</Button>
<Button size="sm" asChild>
<a href={form.url} target="_blank" rel="noreferrer">
<ExternalLink className="size-4" /> Abrir en pestaña nueva
</a>
</Button>
</div>
</div>
<div className="mt-5 overflow-hidden rounded-md border border-border shadow-sm">
<div className="glm-strip" />
<iframe
key={recarga}
src={form.url}
title={form.nombre}
className="h-[82vh] min-h-[720px] w-full bg-white"
/>
</div>
<p className="mt-3 text-[11px] italic text-muted-foreground">
Si el formulario no carga aquí, presiona el botón Abrir en pestaña nueva.
</p>
</>
)}
<ReportIssue />
</main>
</div>
);
}
+403
View File
@@ -0,0 +1,403 @@
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { useMemo, useState, type MouseEvent } from "react";
import { ChevronLeft, ChevronRight, Plus, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { SiteHeader } from "@/components/glm/site-header";
import { FormCard } from "@/components/glm/form-card";
import { FormDialog } from "@/components/glm/form-dialog";
import { ReportIssue } from "@/components/glm/report-issue";
import { CATEGORIAS, useFormularios, type GlmForm } from "@/lib/glm-forms";
import { useAuth } from "@/lib/auth-context";
import { recordFormOpened } from "@/lib/metrics";
export const Route = createFileRoute("/")({
component: Index,
});
const PAGE_SIZE = 15;
const CATEGORIAS_HUB = ["Todos", ...CATEGORIAS] as const;
type PaginationItem = number | "start-ellipsis" | "end-ellipsis";
function buildPagination(currentPage: number, totalPages: number): PaginationItem[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
if (currentPage <= 4) {
return [1, 2, 3, 4, 5, "end-ellipsis", totalPages];
}
if (currentPage >= totalPages - 3) {
return [
1,
"start-ellipsis",
totalPages - 4,
totalPages - 3,
totalPages - 2,
totalPages - 1,
totalPages,
];
}
return [
1,
"start-ellipsis",
currentPage - 1,
currentPage,
currentPage + 1,
"end-ellipsis",
totalPages,
];
}
function Index() {
const router = useRouter();
const { canManageForms } = useAuth();
const {
formularios,
favoritos,
recientes,
listo,
errorCarga,
guardar,
eliminar,
alternarFavorito,
registrarUso,
} = useFormularios(canManageForms);
const [busqueda, setBusqueda] = useState("");
const [categoria, setCategoria] = useState<(typeof CATEGORIAS_HUB)[number]>("Todos");
const [paginaPorCategoria, setPaginaPorCategoria] = useState<Record<string, number>>({
Todos: 1,
});
const [dialogo, setDialogo] = useState(false);
const [editando, setEditando] = useState<GlmForm | null>(null);
const [porEliminar, setPorEliminar] = useState<GlmForm | null>(null);
const filtrados = useMemo(() => {
const q = busqueda.trim().toLowerCase();
return formularios
.filter((f) => categoria === "Todos" || f.categoria === categoria)
.filter(
(f) =>
!q ||
`${f.nombre} ${f.descripcion} ${f.categoria}`.toLowerCase().includes(q),
)
.sort((a, b) => {
const fa = favoritos.includes(a.id) ? 0 : 1;
const fb = favoritos.includes(b.id) ? 0 : 1;
return fa - fb || a.nombre.localeCompare(b.nombre);
});
}, [formularios, categoria, busqueda, favoritos]);
const totalPaginas = Math.max(1, Math.ceil(filtrados.length / PAGE_SIZE));
const paginaGuardada = paginaPorCategoria[categoria] ?? 1;
const paginaActual = Math.min(paginaGuardada, totalPaginas);
const inicio = (paginaActual - 1) * PAGE_SIZE;
const formulariosPagina = filtrados.slice(inicio, inicio + PAGE_SIZE);
const itemsPaginacion = buildPagination(paginaActual, totalPaginas);
const cambiarPagina = (pagina: number) => {
const siguiente = Math.min(Math.max(pagina, 1), totalPaginas);
setPaginaPorCategoria((prev) => ({ ...prev, [categoria]: siguiente }));
};
const cambiarCategoria = (nuevaCategoria: (typeof CATEGORIAS_HUB)[number]) => {
setCategoria(nuevaCategoria);
setPaginaPorCategoria((prev) => ({
...prev,
[nuevaCategoria]: prev[nuevaCategoria] ?? 1,
}));
};
const recientesForms = recientes
.map((id) => formularios.find((f) => f.id === id))
.filter((f): f is GlmForm => f != null && f.activo)
.slice(0, 4);
const abrirFormulario = (form: GlmForm) => {
if (!form.activo) return;
registrarUso(form.id);
};
const abrirReciente = (event: MouseEvent<HTMLAnchorElement>, form: GlmForm) => {
abrirFormulario(form);
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
void recordFormOpened(form);
return;
}
event.preventDefault();
void router.navigate({
to: "/formularios/$formId",
params: { formId: form.id },
});
};
const guardarFormulario = async (form: GlmForm) => {
await guardar(form);
};
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="mx-auto w-full max-w-6xl flex-1 px-6">
{/* Hero */}
<section className="pt-14 pb-10">
<h1 className="max-w-3xl text-4xl leading-[1.1] font-bold text-glm-blue sm:text-5xl">
Cruces GLM
</h1>
<p className="mt-5 max-w-2xl text-[15px] leading-relaxed text-glm-body">
Un solo lugar para acceder a los cruces desarrollados para el área de administración.
Busca el formulario, ábrelo y ejecuta el cruce sin tener que buscar enlaces sueltos.
</p>
<dl className="mt-8 flex flex-wrap gap-x-12 gap-y-4">
{[
{ k: "Formularios", v: formularios.length },
{ k: "Disponibles", v: formularios.filter((f) => f.activo).length },
{ k: "Categorías", v: CATEGORIAS.length },
{ k: "Favoritos", v: favoritos.length },
].map((s) => (
<div key={s.k}>
<dd className="text-3xl font-bold text-glm-blue">{s.v}</dd>
<dt className="mt-1 text-[11px] tracking-[0.14em] text-muted-foreground uppercase">
{s.k}
</dt>
</div>
))}
</dl>
</section>
<div className="glm-rule" />
{/* Recientes */}
{listo && recientesForms.length > 0 && (
<section className="py-8">
<h2 className="glm-section-title inline-block text-xs">Usados recientemente</h2>
<div className="mt-4 flex flex-wrap gap-2">
{recientesForms.map((f) => (
<a
key={f.id}
href={f.url}
onClick={(event) => abrirReciente(event, f)}
className="rounded-full border border-border px-3.5 py-1.5 text-xs text-glm-blue transition-colors hover:border-glm-green hover:bg-glm-leaf-bg"
>
{f.nombre}
</a>
))}
</div>
</section>
)}
{/* Controles */}
<section className="py-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={busqueda}
onChange={(e) => {
setBusqueda(e.target.value);
setPaginaPorCategoria((prev) => ({ ...prev, [categoria]: 1 }));
}}
placeholder="Buscar formulario o proceso…"
className="pl-9"
/>
</div>
{canManageForms && (
<Button
size="sm"
onClick={() => {
setEditando(null);
setDialogo(true);
}}
>
<Plus className="size-4" /> Agregar formulario
</Button>
)}
</div>
<div className="mt-6 overflow-x-auto pb-1">
<div
role="tablist"
aria-label="Secciones de Cruces GLM"
className="grid w-full min-w-[640px] max-w-3xl grid-cols-4 gap-1 rounded-xl border border-border bg-muted/55 p-1 shadow-sm"
>
{CATEGORIAS_HUB.map((c) => {
const activa = c === categoria;
return (
<button
key={c}
type="button"
role="tab"
aria-selected={activa}
onClick={() => cambiarCategoria(c)}
className={
activa
? "relative w-full rounded-lg border border-glm-green/35 bg-card px-3 py-2.5 text-center text-xs font-bold tracking-[0.08em] text-glm-blue uppercase shadow-sm outline-none transition-all after:absolute after:right-5 after:bottom-0 after:left-5 after:h-0.5 after:rounded-full after:bg-glm-green focus:outline-none focus-visible:ring-2 focus-visible:ring-glm-green focus-visible:ring-offset-2 focus-visible:ring-offset-background"
: "w-full rounded-lg border border-transparent px-3 py-2.5 text-center text-xs font-semibold tracking-[0.08em] text-muted-foreground uppercase outline-none transition-all hover:bg-card/75 hover:text-glm-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-glm-green focus-visible:ring-offset-2 focus-visible:ring-offset-background"
}
>
{c}
</button>
);
})}
</div>
</div>
</section>
{/* Listado */}
<section className="pb-24 sm:pb-28">
{errorCarga && (
<div className="mb-5 rounded-md border border-border bg-glm-section-bg px-4 py-3 text-sm text-glm-blue">
No se pudieron cargar los formularios: {errorCarga}
</div>
)}
{filtrados.length === 0 ? (
<div className="glm-card flex flex-col items-center p-12 text-center">
<p className="text-sm text-glm-body">
No hay formularios que coincidan con esta sección o búsqueda.
</p>
</div>
) : (
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{formulariosPagina.map((f) => (
<FormCard
key={f.id}
form={f}
favorito={favoritos.includes(f.id)}
onFavorito={alternarFavorito}
canManageForms={canManageForms}
onEditar={(form) => {
if (!canManageForms) return;
setEditando(form);
setDialogo(true);
}}
onEliminar={(form) => {
if (!canManageForms) return;
setPorEliminar(form);
}}
onAbrir={abrirFormulario}
/>
))}
</div>
)}
{totalPaginas > 1 && (
<>
<nav
className="mt-10 flex flex-wrap items-center justify-center gap-2"
aria-label={`Paginación de ${categoria}`}
>
<button
type="button"
onClick={() => cambiarPagina(paginaActual - 1)}
disabled={paginaActual === 1}
className="inline-flex size-9 items-center justify-center rounded-md border border-border bg-card text-glm-blue transition-colors hover:border-glm-green hover:bg-glm-leaf-bg disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Página anterior"
>
<ChevronLeft className="size-4" />
</button>
{itemsPaginacion.map((item) =>
typeof item === "number" ? (
<button
key={item}
type="button"
onClick={() => cambiarPagina(item)}
aria-current={item === paginaActual ? "page" : undefined}
className={
item === paginaActual
? "inline-flex size-9 items-center justify-center rounded-md border border-glm-blue bg-glm-blue text-sm font-bold text-white shadow-sm"
: "inline-flex size-9 items-center justify-center rounded-md border border-border bg-card text-sm font-semibold text-glm-blue transition-colors hover:border-glm-green hover:bg-glm-leaf-bg"
}
>
{item}
</button>
) : (
<span
key={item}
className="inline-flex size-9 items-center justify-center text-sm text-muted-foreground"
aria-hidden="true"
>
</span>
),
)}
<button
type="button"
onClick={() => cambiarPagina(paginaActual + 1)}
disabled={paginaActual === totalPaginas}
className="inline-flex size-9 items-center justify-center rounded-md border border-border bg-card text-glm-blue transition-colors hover:border-glm-green hover:bg-glm-leaf-bg disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Página siguiente"
>
<ChevronRight className="size-4" />
</button>
</nav>
<p className="mt-3 text-center text-[11px] text-muted-foreground">
Página {paginaActual} de {totalPaginas} · {filtrados.length} formulario
{filtrados.length === 1 ? "" : "s"}
</p>
</>
)}
</section>
<ReportIssue />
</main>
{canManageForms && (
<>
<FormDialog
open={dialogo}
onOpenChange={setDialogo}
inicial={editando}
onGuardar={guardarFormulario}
/>
<AlertDialog open={Boolean(porEliminar)} onOpenChange={(o) => !o && setPorEliminar(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-glm-blue">
¿Eliminar {porEliminar?.nombre}?
</AlertDialogTitle>
<AlertDialogDescription>
Se quitará este formulario del hub. Si lo necesitas nuevamente, podrás volver a
agregarlo desde Agregar formulario.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
const objetivo = porEliminar;
setPorEliminar(null);
if (objetivo) void eliminar(objetivo.id);
}}
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
);
}
+279
View File
@@ -0,0 +1,279 @@
@import "tailwindcss" source(none);
@source "../src";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/*
* GLM — GomezLee Marketing design system.
* Arial only. White surfaces, blue hierarchy (#4F758B), green accents (#6CC24A).
* Color appears as thin strips, lines, icons and text — never as large blocks.
*/
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--font-sans: Arial, Helvetica, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-ring-offset-background: var(--background);
/* GLM brand tokens */
--color-glm-green: var(--glm-green);
--color-glm-green-light: var(--glm-green-light);
--color-glm-lime: var(--glm-lime);
--color-glm-mint: var(--glm-mint);
--color-glm-blue: var(--glm-blue);
--color-glm-blue-mid: var(--glm-blue-mid);
--color-glm-steel: var(--glm-steel);
--color-glm-teal: var(--glm-teal);
--color-glm-orange: var(--glm-orange);
--color-glm-gold: var(--glm-gold);
--color-glm-leaf-bg: var(--glm-leaf-bg);
--color-glm-section-bg: var(--glm-section-bg);
--color-glm-body: var(--glm-body);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.5rem;
/* Brand palette (converted from the GLM Pantone/HEX system) */
--glm-green: oklch(0.75 0.171 141.2); /* #6CC24A */
--glm-green-light: oklch(0.82 0.148 130.5); /* #A4D65E */
--glm-lime: oklch(0.83 0.183 116.5); /* #C4D600 */
--glm-mint: oklch(0.79 0.114 165.4); /* #68D2A0 */
--glm-blue: oklch(0.55 0.043 233.4); /* #4F758B */
--glm-blue-mid: oklch(0.59 0.04 232.6); /* #5B7F95 */
--glm-steel: oklch(0.64 0.037 231.9); /* #6B8FA3 */
--glm-teal: oklch(0.47 0.055 190.3); /* #2C6E6F */
--glm-orange: oklch(0.67 0.216 41.1); /* #FF6A13 */
--glm-gold: oklch(0.84 0.121 87.5); /* #F0C75E */
--glm-leaf-bg: oklch(0.966 0.026 133.8); /* #EEF6E8 */
--glm-section-bg: oklch(0.917 0.028 234.9); /* #D6E8F4 */
--glm-body: oklch(0.4 0 0); /* #4A4A4A */
--background: oklch(1 0 0);
--foreground: oklch(0.4 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.4 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.4 0 0);
--primary: var(--glm-blue);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.968 0 0);
--secondary-foreground: var(--glm-blue);
--muted: oklch(0.968 0 0);
--muted-foreground: var(--glm-steel);
--accent: var(--glm-leaf-bg);
--accent-foreground: var(--glm-blue);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9 0 0);
--input: oklch(0.88 0 0);
--ring: var(--glm-green);
--chart-1: var(--glm-green);
--chart-2: var(--glm-blue);
--chart-3: var(--glm-lime);
--chart-4: var(--glm-orange);
--chart-5: var(--glm-steel);
--sidebar: oklch(1 0 0);
--sidebar-foreground: var(--glm-body);
--sidebar-primary: var(--glm-blue);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: var(--glm-leaf-bg);
--sidebar-accent-foreground: var(--glm-blue);
--sidebar-border: oklch(0.9 0 0);
--sidebar-ring: var(--glm-green);
--shadow-card:
0 1px 2px oklch(0.55 0.043 233.4 / 0.06), 0 8px 24px -12px oklch(0.55 0.043 233.4 / 0.18);
--shadow-card-hover:
0 2px 4px oklch(0.55 0.043 233.4 / 0.08), 0 18px 40px -18px oklch(0.55 0.043 233.4 / 0.3);
}
.dark {
--glm-green: oklch(0.75 0.171 141.2);
--glm-green-light: oklch(0.82 0.148 130.5);
--glm-lime: oklch(0.83 0.183 116.5);
--glm-mint: oklch(0.79 0.114 165.4);
--glm-blue: oklch(0.72 0.045 233.4);
--glm-blue-mid: oklch(0.68 0.042 232.6);
--glm-steel: oklch(0.74 0.036 231.9);
--glm-teal: oklch(0.72 0.055 190.3);
--glm-orange: oklch(0.72 0.19 41.1);
--glm-gold: oklch(0.84 0.121 87.5);
--glm-leaf-bg: oklch(0.27 0.045 141.2);
--glm-section-bg: oklch(0.29 0.035 233.4);
--glm-body: oklch(0.9 0.008 230);
--background: oklch(0.18 0.015 230);
--foreground: oklch(0.94 0.004 230);
--card: oklch(0.22 0.016 230);
--card-foreground: oklch(0.94 0.004 230);
--popover: oklch(0.22 0.016 230);
--popover-foreground: oklch(0.94 0.004 230);
--primary: oklch(0.56 0.043 233.4);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.27 0.015 230);
--secondary-foreground: oklch(0.92 0.006 230);
--muted: oklch(0.27 0.015 230);
--muted-foreground: oklch(0.7 0.025 232);
--accent: oklch(0.28 0.04 141.2);
--accent-foreground: oklch(0.94 0.004 230);
--destructive: oklch(0.58 0.2 27.3);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.33 0.018 230);
--input: oklch(0.36 0.018 230);
--ring: var(--glm-green);
--sidebar: var(--card);
--sidebar-foreground: var(--foreground);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: var(--accent);
--sidebar-accent-foreground: var(--accent-foreground);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
--shadow-card:
0 1px 2px oklch(0 0 0 / 0.28), 0 10px 28px -16px oklch(0 0 0 / 0.7);
--shadow-card-hover:
0 2px 4px oklch(0 0 0 / 0.32), 0 18px 42px -18px oklch(0 0 0 / 0.78);
}
@layer base {
* {
border-color: var(--color-border);
}
html {
font-family: Arial, Helvetica, sans-serif;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
transition: background-color 180ms ease, color 180ms ease;
}
}
/* GLM section title: blue caps with a thin green underline */
@utility glm-section-title {
color: var(--glm-blue);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding-bottom: 0.35rem;
border-bottom: 2px solid var(--glm-green);
}
@utility glm-card {
background-color: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
transition:
box-shadow 220ms ease,
transform 220ms ease,
border-color 220ms ease;
&:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
border-color: color-mix(in oklab, var(--glm-green) 45%, transparent);
}
}
@utility glm-strip {
height: 4px;
width: 100%;
background: linear-gradient(90deg, var(--glm-green), var(--glm-lime));
}
@utility glm-rule {
height: 1px;
width: 100%;
background: var(--color-border);
}
/* Login de Cruces GLM — inspirado en la pantalla de acceso de CDC Brief. */
.glm-login-background {
display: grid;
place-items: center;
background-color: #f8faf8;
background-image:
radial-gradient(circle at 10% 14%, rgba(164, 214, 94, 0.13), transparent 28rem),
radial-gradient(circle at 91% 19%, rgba(91, 127, 149, 0.18), transparent 31rem),
linear-gradient(rgba(79, 117, 139, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(79, 117, 139, 0.035) 1px, transparent 1px);
background-size:
auto,
auto,
48px 48px,
48px 48px;
}
.glm-login-card {
position: relative;
width: min(100%, 390px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 31px;
background: rgba(255, 255, 255, 0.94);
box-shadow:
0 26px 65px rgba(79, 117, 139, 0.14),
0 2px 8px rgba(79, 117, 139, 0.05);
backdrop-filter: blur(12px);
}
.glm-login-topline {
position: absolute;
inset: 0 0 auto;
height: 4px;
background: linear-gradient(90deg, #6cc24a 0%, #68d2a0 28%, #4f758b 100%);
}
/* Formularios no disponibles: visibles para referencia/edición, pero claramente desactivados. */
.glm-card-disabled {
filter: grayscale(1);
opacity: 0.62;
}
.glm-card-disabled:hover {
transform: none;
border-color: var(--color-border);
box-shadow: var(--shadow-card);
}
+977
View File
@@ -0,0 +1,977 @@
-- ============================================================================
-- CRUCES GLM — SUPABASE COMPLETO
-- GomezLee Marketing
-- ============================================================================
-- Ejecutar una vez en SQL Editor.
--
-- Supabase queda como fuente de verdad para:
-- 1) Usuarios autorizados para iniciar sesión en Cruces GLM.
-- 2) Permiso para Agregar / Editar / Eliminar formularios.
-- 3) Datos de todos los formularios/cruces del Hub.
-- 4) Sumatorias globales: formularios creados, aperturas y logueos.
-- 5) Métricas por formulario y por usuario.
-- 6) Historial de eventos.
-- 7) Consultas y mutaciones mediante RPC; el frontend no necesita acceso
-- directo a las tablas.
--
-- IMPORTANTE:
-- Google/Supabase puede completar el OAuth, pero Cruces GLM consulta
-- cruces_glm_get_my_access() inmediatamente. Si is_allowed = false o el correo
-- no existe en cruces_glm_permissions, la app cierra esa sesión y no permite
-- entrar. Todas las RPC de datos vuelven a validar el acceso en servidor.
-- ============================================================================
begin;
-- --------------------------------------------------------------------------
-- 1. ACCESOS / PERMISOS
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_permissions (
email text primary key,
display_name text,
is_allowed boolean not null default true,
can_manage_forms boolean not null default false,
updated_at timestamptz not null default now(),
constraint cruces_glm_permissions_email_lowercase check (email = lower(email))
);
-- Compatibilidad con versiones anteriores del script.
alter table public.cruces_glm_permissions
add column if not exists display_name text;
alter table public.cruces_glm_permissions
add column if not exists is_allowed boolean not null default true;
alter table public.cruces_glm_permissions
add column if not exists can_manage_forms boolean not null default false;
alter table public.cruces_glm_permissions
add column if not exists updated_at timestamptz not null default now();
alter table public.cruces_glm_permissions enable row level security;
create or replace function public.cruces_glm_set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists cruces_glm_permissions_set_updated_at
on public.cruces_glm_permissions;
create trigger cruces_glm_permissions_set_updated_at
before update on public.cruces_glm_permissions
for each row execute function public.cruces_glm_set_updated_at();
-- Lista inicial autorizada. Solo Isaac administra formularios inicialmente.
-- ON CONFLICT preserva is_allowed/can_manage_forms para no deshacer cambios
-- manuales posteriores si actualizas solamente nombres.
insert into public.cruces_glm_permissions (
email,
display_name,
is_allowed,
can_manage_forms
)
values
('mgomez@gomezleemarketing.com', 'Máximo Gómez', true, false),
('iaracena@gomezleemarketing.com', 'Isaac Aracena', true, true),
('jgomez@gomezleemarketing.com', 'José Leopoldo Gómez', true, false),
('lmatos@gomezleemarketing.com', 'Luis Matos', true, false),
('ymadera@gomezleemarketing.com', 'Yanelly Madera', true, false),
('iherrera@gomezleemarketing.com', 'Iveth Herrera', true, false),
('msoto@gomezleemarketing.com', 'Mati Soto', true, false),
('vparamo@gomezleemarketing.com', 'Viviana Páramo', true, false),
('asrodriguez@gomezleemarketing.com', 'Ada Rodríguez', true, false),
('administrativonicaragua@gomezleemarketing.com', 'Estefani Dayana Orozco Dávila', true, false),
('administrativo@gomezleemarketing.com', 'Seyling Zambrana', true, false),
('adminregional@gomezleemarketing.com', 'Liliana Benítez', true, false)
on conflict (email) do update
set display_name = excluded.display_name;
-- --------------------------------------------------------------------------
-- 2. FORMULARIOS / CRUCES — FUENTE DE VERDAD
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_forms (
id text primary key,
nombre text not null,
descripcion text not null,
categoria text not null,
url text not null,
activo boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
created_by text,
updated_by text,
constraint cruces_glm_forms_categoria_check
check (categoria in ('Seguridad Social', 'IR', 'Reportes')),
constraint cruces_glm_forms_nombre_not_blank check (length(trim(nombre)) > 0),
constraint cruces_glm_forms_descripcion_not_blank check (length(trim(descripcion)) > 0),
constraint cruces_glm_forms_url_not_blank check (length(trim(url)) > 0)
);
create index if not exists cruces_glm_forms_categoria_idx
on public.cruces_glm_forms (categoria, nombre);
create index if not exists cruces_glm_forms_activo_idx
on public.cruces_glm_forms (activo, categoria);
alter table public.cruces_glm_forms enable row level security;
drop trigger if exists cruces_glm_forms_set_updated_at
on public.cruces_glm_forms;
create trigger cruces_glm_forms_set_updated_at
before update on public.cruces_glm_forms
for each row execute function public.cruces_glm_set_updated_at();
-- --------------------------------------------------------------------------
-- 3. MÉTRICAS GLOBALES
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_metrics_summary (
id smallint primary key default 1,
total_formularios_colocados bigint not null default 0 check (total_formularios_colocados >= 0),
total_formularios_abiertos bigint not null default 0 check (total_formularios_abiertos >= 0),
total_logueos bigint not null default 0 check (total_logueos >= 0),
updated_at timestamptz not null default now(),
constraint cruces_glm_metrics_summary_single_row check (id = 1)
);
insert into public.cruces_glm_metrics_summary (id)
values (1)
on conflict (id) do nothing;
alter table public.cruces_glm_metrics_summary enable row level security;
-- --------------------------------------------------------------------------
-- 4. MÉTRICAS POR FORMULARIO
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_form_metrics (
form_id text primary key,
form_name text not null,
category text,
total_publicaciones bigint not null default 1 check (total_publicaciones >= 0),
total_aperturas bigint not null default 0 check (total_aperturas >= 0),
first_published_at timestamptz not null default now(),
last_published_at timestamptz not null default now(),
last_opened_at timestamptz
);
alter table public.cruces_glm_form_metrics enable row level security;
-- --------------------------------------------------------------------------
-- 5. MÉTRICAS POR USUARIO
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_user_metrics (
email text primary key,
total_logueos bigint not null default 0 check (total_logueos >= 0),
total_aperturas bigint not null default 0 check (total_aperturas >= 0),
last_login_at timestamptz,
last_opened_at timestamptz,
constraint cruces_glm_user_metrics_email_lowercase check (email = lower(email))
);
alter table public.cruces_glm_user_metrics enable row level security;
-- --------------------------------------------------------------------------
-- 6. HISTORIAL DE EVENTOS
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_metric_events (
id bigint generated by default as identity primary key,
event_type text not null check (event_type in ('login', 'form_created', 'form_opened')),
user_email text not null,
form_id text,
form_name text,
category text,
created_at timestamptz not null default now()
);
create index if not exists cruces_glm_metric_events_type_idx
on public.cruces_glm_metric_events (event_type, created_at desc);
create index if not exists cruces_glm_metric_events_user_idx
on public.cruces_glm_metric_events (user_email, created_at desc);
create index if not exists cruces_glm_metric_events_form_idx
on public.cruces_glm_metric_events (form_id, created_at desc);
alter table public.cruces_glm_metric_events enable row level security;
-- --------------------------------------------------------------------------
-- 7. SEGURIDAD BASE — SIN ACCESO DIRECTO DESDE EL FRONTEND
-- --------------------------------------------------------------------------
-- El navegador usa exclusivamente RPCs SECURITY DEFINER. Esto evita depender
-- de filtros construidos por el cliente para permisos o CRUD.
revoke all on table public.cruces_glm_permissions from anon, authenticated;
revoke all on table public.cruces_glm_forms from anon, authenticated;
revoke all on table public.cruces_glm_metrics_summary from anon, authenticated;
revoke all on table public.cruces_glm_form_metrics from anon, authenticated;
revoke all on table public.cruces_glm_user_metrics from anon, authenticated;
revoke all on table public.cruces_glm_metric_events from anon, authenticated;
-- Quitar policies antiguas conocidas; el acceso normal será solo vía RPC.
drop policy if exists "Cruces GLM - leer permiso propio"
on public.cruces_glm_permissions;
drop policy if exists "Cruces GLM - administradores leen resumen"
on public.cruces_glm_metrics_summary;
drop policy if exists "Cruces GLM - administradores leen metricas formularios"
on public.cruces_glm_form_metrics;
drop policy if exists "Cruces GLM - administradores leen metricas usuarios"
on public.cruces_glm_user_metrics;
drop policy if exists "Cruces GLM - administradores leen eventos"
on public.cruces_glm_metric_events;
-- --------------------------------------------------------------------------
-- 8. HELPERS DE AUTORIZACIÓN
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_current_email()
returns text
language sql
stable
as $$
select lower(coalesce(auth.jwt() ->> 'email', ''));
$$;
create or replace function public.cruces_glm_is_allowed()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.cruces_glm_permissions p
where p.email = public.cruces_glm_current_email()
and p.is_allowed = true
);
$$;
create or replace function public.cruces_glm_can_manage_forms()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.cruces_glm_permissions p
where p.email = public.cruces_glm_current_email()
and p.is_allowed = true
and p.can_manage_forms = true
);
$$;
revoke all on function public.cruces_glm_current_email() from public;
revoke all on function public.cruces_glm_is_allowed() from public;
revoke all on function public.cruces_glm_can_manage_forms() from public;
-- --------------------------------------------------------------------------
-- 9. RPC — ACCESO DEL USUARIO ACTUAL
-- --------------------------------------------------------------------------
-- Esta es la fuente de verdad del login de Cruces GLM. No existe allowlist
-- hardcodeada en el frontend.
create or replace function public.cruces_glm_get_my_access()
returns table (
allowed boolean,
can_manage_forms boolean,
display_name text
)
language plpgsql
stable
security definer
set search_path = public
as $$
declare
v_email text := public.cruces_glm_current_email();
begin
return query
select
coalesce(p.is_allowed, false) as allowed,
(coalesce(p.is_allowed, false) and coalesce(p.can_manage_forms, false)) as can_manage_forms,
p.display_name
from (select v_email as email) current_user_email
left join public.cruces_glm_permissions p
on p.email = current_user_email.email;
end;
$$;
revoke all on function public.cruces_glm_get_my_access() from public;
grant execute on function public.cruces_glm_get_my_access() to authenticated;
-- --------------------------------------------------------------------------
-- 10. RPC — LISTAR FORMULARIOS
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_get_forms()
returns table (
id text,
nombre text,
descripcion text,
categoria text,
url text,
activo boolean,
created_at timestamptz,
updated_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_is_allowed() then
raise exception 'Usuario no autorizado para Cruces GLM';
end if;
return query
select
f.id,
f.nombre,
f.descripcion,
f.categoria,
f.url,
f.activo,
f.created_at,
f.updated_at
from public.cruces_glm_forms f
order by f.nombre asc;
end;
$$;
revoke all on function public.cruces_glm_get_forms() from public;
grant execute on function public.cruces_glm_get_forms() to authenticated;
-- --------------------------------------------------------------------------
-- 11. RPC — CONSULTAR UN FORMULARIO
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_get_form(p_form_id text)
returns table (
id text,
nombre text,
descripcion text,
categoria text,
url text,
activo boolean,
created_at timestamptz,
updated_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_is_allowed() then
raise exception 'Usuario no autorizado para Cruces GLM';
end if;
return query
select
f.id,
f.nombre,
f.descripcion,
f.categoria,
f.url,
f.activo,
f.created_at,
f.updated_at
from public.cruces_glm_forms f
where f.id = trim(coalesce(p_form_id, ''))
limit 1;
end;
$$;
revoke all on function public.cruces_glm_get_form(text) from public;
grant execute on function public.cruces_glm_get_form(text) to authenticated;
-- --------------------------------------------------------------------------
-- 12. RPC — CREAR / EDITAR FORMULARIO
-- --------------------------------------------------------------------------
-- Registra total_formularios_colocados únicamente cuando el ID no existía.
-- Editar un formulario no incrementa esa sumatoria.
drop function if exists public.cruces_glm_record_form_created(text, text, text);
create or replace function public.cruces_glm_save_form(
p_form_id text,
p_nombre text,
p_descripcion text,
p_categoria text,
p_url text,
p_activo boolean
)
returns table (
id text,
nombre text,
descripcion text,
categoria text,
url text,
activo boolean,
created_at timestamptz,
updated_at timestamptz
)
language plpgsql
security definer
set search_path = public
as $$
declare
v_email text := public.cruces_glm_current_email();
v_id text := trim(coalesce(p_form_id, ''));
v_nombre text := trim(coalesce(p_nombre, ''));
v_descripcion text := trim(coalesce(p_descripcion, ''));
v_categoria text := trim(coalesce(p_categoria, ''));
v_url text := trim(coalesce(p_url, ''));
v_activo boolean := coalesce(p_activo, false);
v_existed boolean;
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para administrar formularios';
end if;
if v_id = '' or v_nombre = '' or v_descripcion = '' or v_categoria = '' or v_url = '' then
raise exception 'Todos los campos del formulario son obligatorios';
end if;
if v_categoria not in ('Seguridad Social', 'IR', 'Reportes') then
raise exception 'Categoría no válida';
end if;
if v_url !~* '^https?://' then
raise exception 'La URL debe comenzar con http:// o https://';
end if;
select exists (
select 1 from public.cruces_glm_forms f where f.id = v_id
) into v_existed;
insert into public.cruces_glm_forms (
id,
nombre,
descripcion,
categoria,
url,
activo,
created_by,
updated_by
)
values (
v_id,
v_nombre,
v_descripcion,
v_categoria,
v_url,
v_activo,
v_email,
v_email
)
on conflict (id) do update
set nombre = excluded.nombre,
descripcion = excluded.descripcion,
categoria = excluded.categoria,
url = excluded.url,
activo = excluded.activo,
updated_by = excluded.updated_by;
if not v_existed then
insert into public.cruces_glm_metrics_summary (
id,
total_formularios_colocados,
total_formularios_abiertos,
total_logueos,
updated_at
)
values (1, 1, 0, 0, now())
on conflict (id) do update
set total_formularios_colocados = public.cruces_glm_metrics_summary.total_formularios_colocados + 1,
updated_at = now();
insert into public.cruces_glm_form_metrics (
form_id,
form_name,
category,
total_publicaciones,
total_aperturas,
first_published_at,
last_published_at
)
values (v_id, v_nombre, v_categoria, 1, 0, now(), now())
on conflict (form_id) do update
set form_name = excluded.form_name,
category = excluded.category,
total_publicaciones = public.cruces_glm_form_metrics.total_publicaciones + 1,
last_published_at = now();
insert into public.cruces_glm_metric_events (
event_type,
user_email,
form_id,
form_name,
category
)
values ('form_created', v_email, v_id, v_nombre, v_categoria);
else
update public.cruces_glm_form_metrics
set form_name = v_nombre,
category = v_categoria
where form_id = v_id;
end if;
return query
select
f.id,
f.nombre,
f.descripcion,
f.categoria,
f.url,
f.activo,
f.created_at,
f.updated_at
from public.cruces_glm_forms f
where f.id = v_id;
end;
$$;
revoke all on function public.cruces_glm_save_form(text, text, text, text, text, boolean) from public;
grant execute on function public.cruces_glm_save_form(text, text, text, text, text, boolean) to authenticated;
-- --------------------------------------------------------------------------
-- 13. RPC — ELIMINAR FORMULARIO
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_delete_form(p_form_id text)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_id text := trim(coalesce(p_form_id, ''));
v_deleted_count integer;
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para administrar formularios';
end if;
delete from public.cruces_glm_forms f
where f.id = v_id;
get diagnostics v_deleted_count = row_count;
return v_deleted_count > 0;
end;
$$;
revoke all on function public.cruces_glm_delete_form(text) from public;
grant execute on function public.cruces_glm_delete_form(text) to authenticated;
-- --------------------------------------------------------------------------
-- 14. RPC — REGISTRAR LOGIN
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_record_login()
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_email text := public.cruces_glm_current_email();
begin
if not public.cruces_glm_is_allowed() then
raise exception 'Usuario no autorizado para Cruces GLM';
end if;
insert into public.cruces_glm_metrics_summary (
id,
total_formularios_colocados,
total_formularios_abiertos,
total_logueos,
updated_at
)
values (1, 0, 0, 1, now())
on conflict (id) do update
set total_logueos = public.cruces_glm_metrics_summary.total_logueos + 1,
updated_at = now();
insert into public.cruces_glm_user_metrics (
email,
total_logueos,
total_aperturas,
last_login_at
)
values (v_email, 1, 0, now())
on conflict (email) do update
set total_logueos = public.cruces_glm_user_metrics.total_logueos + 1,
last_login_at = now();
insert into public.cruces_glm_metric_events (event_type, user_email)
values ('login', v_email);
end;
$$;
revoke all on function public.cruces_glm_record_login() from public;
grant execute on function public.cruces_glm_record_login() to authenticated;
-- --------------------------------------------------------------------------
-- 15. RPC — REGISTRAR APERTURA
-- --------------------------------------------------------------------------
-- Solo recibe el ID. Nombre/categoría/estado salen de la tabla real para evitar
-- manipulación desde el navegador. Un formulario inactivo no se contabiliza.
drop function if exists public.cruces_glm_record_form_open(text, text, text);
create or replace function public.cruces_glm_record_form_open(p_form_id text)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_email text := public.cruces_glm_current_email();
v_form_id text := trim(coalesce(p_form_id, ''));
v_form_name text;
v_category text;
v_active boolean;
begin
if not public.cruces_glm_is_allowed() then
raise exception 'Usuario no autorizado para Cruces GLM';
end if;
select f.nombre, f.categoria, f.activo
into v_form_name, v_category, v_active
from public.cruces_glm_forms f
where f.id = v_form_id;
if not found then
raise exception 'Formulario no encontrado';
end if;
if not v_active then
raise exception 'Formulario no disponible';
end if;
insert into public.cruces_glm_metrics_summary (
id,
total_formularios_colocados,
total_formularios_abiertos,
total_logueos,
updated_at
)
values (1, 0, 1, 0, now())
on conflict (id) do update
set total_formularios_abiertos = public.cruces_glm_metrics_summary.total_formularios_abiertos + 1,
updated_at = now();
insert into public.cruces_glm_user_metrics (
email,
total_logueos,
total_aperturas,
last_opened_at
)
values (v_email, 0, 1, now())
on conflict (email) do update
set total_aperturas = public.cruces_glm_user_metrics.total_aperturas + 1,
last_opened_at = now();
insert into public.cruces_glm_form_metrics (
form_id,
form_name,
category,
total_publicaciones,
total_aperturas,
first_published_at,
last_published_at,
last_opened_at
)
values (v_form_id, v_form_name, v_category, 0, 1, now(), now(), now())
on conflict (form_id) do update
set form_name = excluded.form_name,
category = excluded.category,
total_aperturas = public.cruces_glm_form_metrics.total_aperturas + 1,
last_opened_at = now();
insert into public.cruces_glm_metric_events (
event_type,
user_email,
form_id,
form_name,
category
)
values ('form_opened', v_email, v_form_id, v_form_name, v_category);
end;
$$;
revoke all on function public.cruces_glm_record_form_open(text) from public;
grant execute on function public.cruces_glm_record_form_open(text) to authenticated;
-- --------------------------------------------------------------------------
-- 16. RPC — CONSULTAS DE MÉTRICAS PARA FUTURO DASHBOARD
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_get_metrics_summary()
returns table (
total_formularios_colocados bigint,
total_formularios_abiertos bigint,
total_logueos bigint,
updated_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para consultar métricas';
end if;
return query
select
m.total_formularios_colocados,
m.total_formularios_abiertos,
m.total_logueos,
m.updated_at
from public.cruces_glm_metrics_summary m
where m.id = 1;
end;
$$;
create or replace function public.cruces_glm_get_form_metrics()
returns table (
form_id text,
form_name text,
category text,
total_publicaciones bigint,
total_aperturas bigint,
first_published_at timestamptz,
last_published_at timestamptz,
last_opened_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para consultar métricas';
end if;
return query
select
m.form_id,
m.form_name,
m.category,
m.total_publicaciones,
m.total_aperturas,
m.first_published_at,
m.last_published_at,
m.last_opened_at
from public.cruces_glm_form_metrics m
order by m.total_aperturas desc, m.form_name asc;
end;
$$;
create or replace function public.cruces_glm_get_user_metrics()
returns table (
email text,
total_logueos bigint,
total_aperturas bigint,
last_login_at timestamptz,
last_opened_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para consultar métricas';
end if;
return query
select
m.email,
m.total_logueos,
m.total_aperturas,
m.last_login_at,
m.last_opened_at
from public.cruces_glm_user_metrics m
order by m.total_logueos desc, m.email asc;
end;
$$;
create or replace function public.cruces_glm_get_recent_events(p_limit integer default 100)
returns table (
id bigint,
event_type text,
user_email text,
form_id text,
form_name text,
category text,
created_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para consultar métricas';
end if;
return query
select
e.id,
e.event_type,
e.user_email,
e.form_id,
e.form_name,
e.category,
e.created_at
from public.cruces_glm_metric_events e
order by e.created_at desc
limit least(greatest(coalesce(p_limit, 100), 1), 500);
end;
$$;
revoke all on function public.cruces_glm_get_metrics_summary() from public;
revoke all on function public.cruces_glm_get_form_metrics() from public;
revoke all on function public.cruces_glm_get_user_metrics() from public;
revoke all on function public.cruces_glm_get_recent_events(integer) from public;
grant execute on function public.cruces_glm_get_metrics_summary() to authenticated;
grant execute on function public.cruces_glm_get_form_metrics() to authenticated;
grant execute on function public.cruces_glm_get_user_metrics() to authenticated;
grant execute on function public.cruces_glm_get_recent_events(integer) to authenticated;
-- --------------------------------------------------------------------------
-- 17. RPC — ADMINISTRACIÓN DE ACCESOS
-- --------------------------------------------------------------------------
-- No hay UI para esto todavía, pero queda listo para una futura pantalla.
-- Hoy puedes administrar cruces_glm_permissions directamente en Table Editor.
create or replace function public.cruces_glm_admin_get_access_list()
returns table (
email text,
display_name text,
is_allowed boolean,
can_manage_forms boolean,
updated_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para administrar accesos';
end if;
return query
select
p.email,
p.display_name,
p.is_allowed,
p.can_manage_forms,
p.updated_at
from public.cruces_glm_permissions p
order by p.display_name nulls last, p.email;
end;
$$;
create or replace function public.cruces_glm_admin_save_access(
p_email text,
p_display_name text,
p_is_allowed boolean,
p_can_manage_forms boolean
)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_email text := lower(trim(coalesce(p_email, '')));
v_name text := nullif(trim(coalesce(p_display_name, '')), '');
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para administrar accesos';
end if;
if v_email = '' or position('@' in v_email) <= 1 then
raise exception 'Correo no válido';
end if;
insert into public.cruces_glm_permissions (
email,
display_name,
is_allowed,
can_manage_forms
)
values (
v_email,
v_name,
coalesce(p_is_allowed, false),
coalesce(p_can_manage_forms, false)
)
on conflict (email) do update
set display_name = excluded.display_name,
is_allowed = excluded.is_allowed,
can_manage_forms = excluded.can_manage_forms;
end;
$$;
revoke all on function public.cruces_glm_admin_get_access_list() from public;
revoke all on function public.cruces_glm_admin_save_access(text, text, boolean, boolean) from public;
grant execute on function public.cruces_glm_admin_get_access_list() to authenticated;
grant execute on function public.cruces_glm_admin_save_access(text, text, boolean, boolean) to authenticated;
commit;
-- ============================================================================
-- ADMINISTRACIÓN RÁPIDA DESDE SQL EDITOR / TABLE EDITOR
-- ============================================================================
-- Dar acceso a Cruces GLM:
-- update public.cruces_glm_permissions
-- set is_allowed = true
-- where email = 'correo@gomezleemarketing.com';
--
-- Quitar acceso a Cruces GLM sin borrar historial:
-- update public.cruces_glm_permissions
-- set is_allowed = false
-- where email = 'correo@gomezleemarketing.com';
--
-- Dar permiso de Agregar / Editar / Eliminar:
-- update public.cruces_glm_permissions
-- set can_manage_forms = true
-- where email = 'correo@gomezleemarketing.com';
--
-- Quitar permiso de administración:
-- update public.cruces_glm_permissions
-- set can_manage_forms = false
-- where email = 'correo@gomezleemarketing.com';
--
-- Formularios actualmente guardados:
-- select * from public.cruces_glm_forms order by nombre;
--
-- Métricas globales:
-- select * from public.cruces_glm_metrics_summary;
--
-- Métricas por formulario:
-- select * from public.cruces_glm_form_metrics order by total_aperturas desc;
--
-- Métricas por usuario:
-- select * from public.cruces_glm_user_metrics order by total_logueos desc;
--
-- Eventos recientes:
-- select * from public.cruces_glm_metric_events order by created_at desc limit 100;
-- ============================================================================
+30
View File
@@ -0,0 +1,30 @@
{
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "eslint.config.js"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"noEmit": true,
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*": ["./src/*"]
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import tailwindcss from "@tailwindcss/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";
export default defineConfig({
base: "/cruces-glm/",
plugins: [tsConfigPaths(), tailwindcss(), viteReact()],
build: {
outDir: "dist",
emptyOutDir: true,
},
});