import { useEffect, useState } from "react"; import { supabase } from "@/lib/supabase"; import type { ColorId, Status } from "@/data/lists"; export interface Project { id: string; nombre: string; color: ColorId; cliente: string; bu: string; cm: string; // autocompletado desde BU marca: string; solicitante: string; comentarios: string; briefLink: string; propuestaLinks: string[]; afLinks: string[]; status: Status | ""; monto: number | null; // solo Marrero mes: string; anio: number; createdAt: number; } export type SheetSyncStatus = "synced" | "skipped" | "failed" | "timeout"; export type SaveProjectResult = { projectId: string; sheetSyncStatus: SheetSyncStatus; sheetSyncMessage: string; }; type DbProjectLink = { id?: string; link_type: string | null; url: string | null; label?: string | null; }; type DbProject = { id: string; title: string | null; client: string | null; brand: string | null; country: string | null; requested_by: string | null; country_manager: string | null; description: string | null; status: string | null; internal_amount?: number | string | null; brief_link: string | null; created_at: string | null; extra_data: Record | null; project_links?: DbProjectLink[] | null; }; const DEFAULT_COLOR: ColorId = "blue"; const OPEN_STATUS = "Activo"; const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com"; let listeners: Array<() => void> = []; let cache: Project[] = []; let loading = false; let initialized = false; let errorMessage: string | null = null; let projectsRealtimeChannel: ReturnType | null = null; let realtimeRefreshTimeout: ReturnType | null = null; let activeHookInstances = 0; function notify() { listeners.forEach((listener) => listener()); } function asString(value: unknown, fallback = "") { return typeof value === "string" ? value : fallback; } function asNumber(value: unknown, fallback: number) { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } function toUiStatus(status: string | null | undefined): Status | "" { // En base de datos usamos "Activo" para proyectos abiertos. // En la UI histórica, proyecto abierto = status vacío. if (!status || status === OPEN_STATUS) return ""; return status as Status; } function toDbStatus(status: Status | "" | undefined | null): string { return status && status.trim() ? status : OPEN_STATUS; } function mapDbProject(row: DbProject): Project { const extra = row.extra_data || {}; const links = row.project_links || []; const propuestaLinks = links .filter((link) => link.link_type === "proposal") .map((link) => link.url || "") .filter(Boolean); const afLinks = links .filter((link) => link.link_type === "final_art") .map((link) => link.url || "") .filter(Boolean); const createdAt = row.created_at ? new Date(row.created_at).getTime() : Date.now(); const date = Number.isFinite(createdAt) ? new Date(createdAt) : new Date(); return { id: row.id, nombre: row.title || "", color: asString(extra.color, DEFAULT_COLOR) as ColorId, cliente: row.client || "", bu: row.country || "", cm: row.country_manager || "", marca: row.brand || "", solicitante: row.requested_by || "", comentarios: row.description || "", briefLink: row.brief_link || "", propuestaLinks, afLinks, status: toUiStatus(row.status), monto: row.internal_amount == null ? null : Number(row.internal_amount), mes: asString(extra.mes, "") || date.toLocaleString("es-DO", { month: "long" }), anio: asNumber(extra.anio, date.getFullYear()), createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(), }; } async function getCurrentUser() { const { data, error } = await supabase.auth.getUser(); if (error || !data.user) { throw new Error("No hay una sesión activa de Supabase."); } return data.user; } async function getCurrentUserId(): Promise { const user = await getCurrentUser(); return user.id; } async function currentUserIsGerardo(): Promise { const user = await getCurrentUser(); return user.email?.toLowerCase() === GERARDO_EMAIL; } function toProjectRow(project: Omit | Partial) { return { title: project.nombre, client: project.cliente, brand: project.marca, country: project.bu, requested_by: project.solicitante, country_manager: project.cm, description: project.comentarios, status: toDbStatus(project.status), internal_amount: project.monto ?? null, brief_link: project.briefLink, extra_data: { color: project.color ?? DEFAULT_COLOR, mes: project.mes, anio: project.anio, }, }; } function toLinkRows(projectId: string, project: Pick) { const proposalLinks = project.propuestaLinks .map((url) => url.trim()) .filter(Boolean) .map((url, index) => ({ project_id: projectId, link_type: "proposal", url, label: `Propuesta ${index + 1}`, })); const finalArtLinks = project.afLinks .map((url) => url.trim()) .filter(Boolean) .map((url, index) => ({ project_id: projectId, link_type: "final_art", url, label: `AF ${index + 1}`, })); return [...proposalLinks, ...finalArtLinks]; } function setCache(next: Project[]) { cache = next; notify(); } function setError(message: string | null) { errorMessage = message; notify(); } function getSyncWebhookUrl() { return import.meta.env.VITE_WEBHOOK_URL?.trim() || ""; } async function syncProjectToSheet( projectId: string, action: "create" | "update", ): Promise> { const webhookUrl = getSyncWebhookUrl(); if (!webhookUrl) { console.info("No se configuró VITE_WEBHOOK_URL; se omite sincronización con Google Sheet."); return { sheetSyncStatus: "skipped", sheetSyncMessage: "Proyecto guardado. No hay webhook configurado para sincronizar el Sheet.", }; } const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), 15000); try { const response = await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ project_id: projectId, action, source: "tablero-cdc", sent_at: new Date().toISOString(), }), signal: controller.signal, }); if (!response.ok) { const errorText = await response.text().catch(() => ""); const message = errorText || `${response.status} ${response.statusText}`.trim(); console.warn("El proyecto se guardó en Supabase, pero n8n no respondió correctamente.", { status: response.status, statusText: response.statusText, errorText, }); return { sheetSyncStatus: "failed", sheetSyncMessage: `Proyecto guardado, pero el Sheet no se pudo sincronizar: ${message}`, }; } return { sheetSyncStatus: "synced", sheetSyncMessage: "Proyecto guardado y sincronizado con el Google Sheet.", }; } catch (error) { const isTimeout = error instanceof DOMException && error.name === "AbortError"; console.warn("El proyecto se guardó en Supabase, pero no se pudo sincronizar con Google Sheet.", error); return { sheetSyncStatus: isTimeout ? "timeout" : "failed", sheetSyncMessage: isTimeout ? "Proyecto guardado, pero n8n tardó demasiado en responder. Revisa el workflow si el Sheet no se actualizó." : "Proyecto guardado, pero no se pudo sincronizar con Google Sheet.", }; } finally { window.clearTimeout(timeout); } } function scheduleRealtimeRefresh() { if (realtimeRefreshTimeout) { clearTimeout(realtimeRefreshTimeout); } realtimeRefreshTimeout = setTimeout(() => { realtimeRefreshTimeout = null; if (!loading) { void loadProjects(); } }, 350); } function startProjectsRealtime() { if (projectsRealtimeChannel) return; projectsRealtimeChannel = supabase .channel("tablero-cdc-projects") .on( "postgres_changes", { event: "*", schema: "public", table: "tablero_cdc_projects" }, () => scheduleRealtimeRefresh(), ) .on( "postgres_changes", { event: "*", schema: "public", table: "tablero_cdc_project_links" }, () => scheduleRealtimeRefresh(), ) .subscribe((status) => { if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { console.warn("Supabase Realtime no pudo suscribirse a proyectos:", status); } }); } function stopProjectsRealtime() { if (realtimeRefreshTimeout) { clearTimeout(realtimeRefreshTimeout); realtimeRefreshTimeout = null; } if (!projectsRealtimeChannel) return; void supabase.removeChannel(projectsRealtimeChannel); projectsRealtimeChannel = null; } async function replaceProjectLinks(projectId: string, project: Pick) { const { error: deleteError } = await supabase .from("tablero_cdc_project_links") .delete() .eq("project_id", projectId); if (deleteError) throw deleteError; const linkRows = toLinkRows(projectId, project); if (linkRows.length === 0) return; const { error: insertError } = await supabase.from("tablero_cdc_project_links").insert(linkRows); if (insertError) throw insertError; } async function loadProjects() { loading = true; setError(null); notify(); try { const isGerardo = await currentUserIsGerardo(); const selectColumns = ` id, title, client, brand, country, requested_by, country_manager, description, status, ${isGerardo ? "internal_amount," : ""} brief_link, created_at, extra_data, project_links:tablero_cdc_project_links ( id, link_type, url, label ) `; const { data, error } = await supabase .from("tablero_cdc_projects") .select(selectColumns) .order("created_at", { ascending: false }); if (error) throw error; cache = (data || []).map((row) => mapDbProject(row as unknown as DbProject)); initialized = true; } catch (error) { console.error("Error cargando proyectos desde Supabase:", error); errorMessage = error instanceof Error ? error.message : "No se pudieron cargar los proyectos desde Supabase."; } finally { loading = false; notify(); } } async function addProject(project: Project) { setError(null); const userId = await getCurrentUserId(); const insertPayload = { ...toProjectRow(project), created_by: userId, updated_by: userId, }; const { data, error } = await supabase .from("tablero_cdc_projects") .insert(insertPayload) .select("id") .single(); if (error) { setError(error.message); throw error; } await replaceProjectLinks(data.id, project); const sheetSync = await syncProjectToSheet(data.id, "create"); await loadProjects(); return { projectId: data.id, ...sheetSync, } satisfies SaveProjectResult; } async function updateProject(id: string, patch: Partial) { setError(null); const userId = await getCurrentUserId(); const isGerardo = await currentUserIsGerardo(); const current = cache.find((project) => project.id === id); const nextProject = current ? { ...current, ...patch } : ({ ...patch, id } as Project); const rowPayload = toProjectRow(nextProject); const updatePayload = isGerardo ? { ...rowPayload, updated_by: userId, } : { title: rowPayload.title, client: rowPayload.client, brand: rowPayload.brand, country: rowPayload.country, requested_by: rowPayload.requested_by, country_manager: rowPayload.country_manager, description: rowPayload.description, status: rowPayload.status, brief_link: rowPayload.brief_link, extra_data: rowPayload.extra_data, updated_by: userId, }; const { error } = await supabase.from("tablero_cdc_projects").update(updatePayload).eq("id", id); if (error) { setError(error.message); throw error; } await replaceProjectLinks(id, nextProject); const sheetSync = await syncProjectToSheet(id, "update"); await loadProjects(); return { projectId: id, ...sheetSync, } satisfies SaveProjectResult; } export function useProjects() { const [, force] = useState(0); useEffect(() => { const listener = () => force((n) => n + 1); listeners.push(listener); activeHookInstances += 1; startProjectsRealtime(); if (!initialized && !loading) { void loadProjects(); } return () => { listeners = listeners.filter((x) => x !== listener); activeHookInstances = Math.max(0, activeHookInstances - 1); if (activeHookInstances === 0) { stopProjectsRealtime(); } }; }, []); return { projects: cache, loading, error: errorMessage, refresh: loadProjects, add: addProject, update: updateProject, }; } // NOTE: useRole() was removed in favour of useAuth() from AuthContext. // Role logic (isGerardo) is now derived from the real Supabase authenticated user.