400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
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);
|
|
}
|