Initial commit without README
This commit is contained in:
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
bun.lock
|
||||||
|
routeTree.gen.ts
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
Generated
+4742
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -0,0 +1,14 @@
|
|||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bingbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Twitterbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: facebookexternalhit
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DEMO_AUTH_ENABLED,
|
||||||
|
DEMO_USER,
|
||||||
|
GLM_LOGO_URL,
|
||||||
|
initializeAuth,
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 ? (
|
||||||
|
<Link
|
||||||
|
to="/formularios/$formId"
|
||||||
|
params={{ formId: form.id }}
|
||||||
|
onClick={() => onAbrir(form)}
|
||||||
|
className="text-base leading-snug font-bold text-glm-blue transition-colors hover:text-glm-teal"
|
||||||
|
>
|
||||||
|
{form.nombre}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<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 ? (
|
||||||
|
<Link
|
||||||
|
to="/formularios/$formId"
|
||||||
|
params={{ formId: form.id }}
|
||||||
|
onClick={() => onAbrir(form)}
|
||||||
|
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" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+354
@@ -0,0 +1,354 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 refreshSession(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) {
|
||||||
|
clearSession();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 refreshSession(session);
|
||||||
|
if (!session) return { status: "unauthenticated" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await getUser(session.accessToken);
|
||||||
|
if (!user) {
|
||||||
|
const refreshed = await refreshSession(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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { DEMO_AUTH_ENABLED, getStoredAccessToken } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRealSupabaseSession() {
|
||||||
|
return !DEMO_AUTH_ENABLED && Boolean(getStoredAccessToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = getStoredAccessToken();
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error("No hay una sesión autenticada activa.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
parseErrorMessage(parsed, `Supabase rechazó la operación ${functionName} (${response.status}).`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as T;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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>()
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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-6xl flex-1 px-6 py-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-6 overflow-hidden rounded-md border border-border">
|
||||||
|
<div className="glm-strip" />
|
||||||
|
<iframe
|
||||||
|
key={recarga}
|
||||||
|
src={form.url}
|
||||||
|
title={form.nombre}
|
||||||
|
className="h-[72vh] min-h-[520px] w-full bg-background"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useMemo, useState } 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";
|
||||||
|
|
||||||
|
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 { 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 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) => (
|
||||||
|
<Link
|
||||||
|
key={f.id}
|
||||||
|
to="/formularios/$formId"
|
||||||
|
params={{ formId: f.id }}
|
||||||
|
onClick={() => abrirFormulario(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}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
-- ============================================================================
|
||||||
@@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user