import { useEffect, useState } from "react"; import { supabase } from "@/lib/supabase"; import { getCurrentTableroCdcAccess } from "@/lib/accessControl"; 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; extraData?: Record; } export type ProjectActivityType = "created" | "updated" | "note"; export interface ProjectActivity { id: string; projectId: string; type: ProjectActivityType; title: string; description: string; actorName: string; actorEmail: string; createdAt: number; extraData?: Record; } export type ProjectPricingSource = "tariff" | "manual"; export interface ProjectPricingItemInput { id?: string; source: ProjectPricingSource; category: string; serviceName: string; workType?: string; complexityLevel?: string; referenceLabel?: string; referenceMin?: number | null; referenceMax?: number | null; amount: number; description?: string; } export interface ProjectPricingItem extends ProjectPricingItemInput { id: string; projectId: string; createdAt: number; } export type SheetSyncStatus = "synced" | "skipped" | "failed" | "timeout"; export type SaveProjectResult = { projectId: string; sheetSyncStatus: SheetSyncStatus; sheetSyncMessage: string; }; export type ProjectTab = "todos" | "abiertos" | "cerrados"; export type ProjectFilterOptions = { countries: string[]; brands: string[]; clients: string[]; cms: string[]; }; export type ProjectPricingSummary = { totalAmount: number; projectCount: number; loadedAt: number | null; }; export type ProjectQueryParams = { tab: ProjectTab; search: string; page: number; pageSize: number; country?: string; brand?: string; client?: string; cm?: string; }; type ProjectLoadResult = { projects: Project[]; totalProjects: number; filterOptions: ProjectFilterOptions; }; 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; }; type DbProjectActivity = { id: string; project_id: string; activity_type: string | null; title: string | null; description: string | null; actor_name: string | null; actor_email: string | null; created_at: string | null; }; type DbProjectPricingItem = { id: string; project_id: string; source: string | null; category: string | null; service_name: string | null; work_type: string | null; complexity_level: string | null; reference_label: string | null; reference_min: number | string | null; reference_max: number | string | null; amount: number | string | null; description: string | null; created_at: string | null; }; type RpcProjectsResponse = { projects?: DbProject[] | null; total_count?: number | string | null; filter_options?: Partial | null; }; type RpcPricingSummaryResponse = { total_amount?: number | string | null; project_count?: number | string | null; }; type DirectProjectQuery = { or: (filters: string) => DirectProjectQuery; not: (column: string, operator: string, value: unknown) => DirectProjectQuery; neq: (column: string, value: unknown) => DirectProjectQuery; range: ( from: number, to: number, ) => Promise<{ data: unknown[] | null; error: { message: string } | null; count: number | null }>; }; type DirectProjectSummaryRow = { id?: string | null; internal_amount?: number | string | null; }; const DEFAULT_COLOR: ColorId = "blue"; const OPEN_STATUS = "Activo"; const DEFAULT_PROJECT_PARAMS: ProjectQueryParams = { tab: "todos", search: "", page: 1, pageSize: 24, country: "", brand: "", client: "", cm: "", }; let listeners: Array<() => void> = []; let cache: Project[] = []; let totalProjects = 0; let filterOptions: ProjectFilterOptions = { countries: [], brands: [], clients: [], cms: [] }; let loading = false; let initialized = false; let errorMessage: string | null = null; let projectsRealtimeChannel: ReturnType | null = null; let realtimeRefreshTimeout: ReturnType | null = null; let activeHookInstances = 0; let currentParams: ProjectQueryParams = { ...DEFAULT_PROJECT_PARAMS }; let writeInProgress = 0; let loadRequestSeq = 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 asYear(value: unknown, fallback: number) { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { const parsed = Number(value.trim()); if (Number.isFinite(parsed)) return parsed; } return fallback; } function getDisplayMonth(extra: Record, fallbackDate: Date) { return ( asString(extra.legacy_month, "") || asString(extra.mes, "") || fallbackDate.toLocaleString("es-DO", { month: "long" }) ); } function getDisplayYear(extra: Record, fallbackDate: Date) { return asYear(extra.legacy_year, asNumber(extra.anio, fallbackDate.getFullYear())); } 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 normalizeProjectParams(params?: Partial): ProjectQueryParams { return { tab: params?.tab ?? currentParams.tab ?? DEFAULT_PROJECT_PARAMS.tab, search: String(params?.search ?? currentParams.search ?? DEFAULT_PROJECT_PARAMS.search).trim(), page: Math.max( 1, Math.floor(Number(params?.page ?? currentParams.page ?? DEFAULT_PROJECT_PARAMS.page)), ), pageSize: Math.max( 1, Math.min( 100, Math.floor( Number(params?.pageSize ?? currentParams.pageSize ?? DEFAULT_PROJECT_PARAMS.pageSize), ), ), ), country: String( params?.country ?? currentParams.country ?? DEFAULT_PROJECT_PARAMS.country, ).trim(), brand: String(params?.brand ?? currentParams.brand ?? DEFAULT_PROJECT_PARAMS.brand).trim(), client: String(params?.client ?? currentParams.client ?? DEFAULT_PROJECT_PARAMS.client).trim(), cm: String(params?.cm ?? currentParams.cm ?? DEFAULT_PROJECT_PARAMS.cm).trim(), }; } function paramsEqual(a: ProjectQueryParams, b: ProjectQueryParams) { return ( a.tab === b.tab && a.search === b.search && a.page === b.page && a.pageSize === b.pageSize && (a.country || "") === (b.country || "") && (a.brand || "") === (b.brand || "") && (a.client || "") === (b.client || "") && (a.cm || "") === (b.cm || "") ); } 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: getDisplayMonth(extra, date), anio: getDisplayYear(extra, date), createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(), extraData: extra, }; } function mapDbProjectActivity(row: DbProjectActivity): ProjectActivity { const createdAt = row.created_at ? new Date(row.created_at).getTime() : Date.now(); return { id: row.id, projectId: row.project_id, type: (row.activity_type || "note") as ProjectActivityType, title: row.title || "Actividad registrada", description: row.description || "", actorName: row.actor_name || row.actor_email || "Usuario CDC", actorEmail: row.actor_email || "", createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(), }; } function mapDbProjectPricingItem(row: DbProjectPricingItem): ProjectPricingItem { const createdAt = row.created_at ? new Date(row.created_at).getTime() : Date.now(); const amount = Number(row.amount ?? 0); const referenceMin = row.reference_min == null ? null : Number(row.reference_min); const referenceMax = row.reference_max == null ? null : Number(row.reference_max); return { id: row.id, projectId: row.project_id, source: row.source === "manual" ? "manual" : "tariff", category: row.category || "", serviceName: row.service_name || "", workType: row.work_type || "", complexityLevel: row.complexity_level || "", referenceLabel: row.reference_label || "", referenceMin: Number.isFinite(referenceMin) ? referenceMin : null, referenceMax: Number.isFinite(referenceMax) ? referenceMax : null, amount: Number.isFinite(amount) ? amount : 0, description: row.description || "", createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(), }; } function getUserDisplayName(user: Awaited>) { const metadata = user.user_metadata || {}; return ( metadata.full_name || metadata.name || metadata.display_name || user.email?.split("@")[0] || "Usuario CDC" ); } function publicProjectStatus(value: Status | "" | null | undefined) { return value && value.trim() ? value : OPEN_STATUS; } function listCountLabel(count: number, singular: string, plural: string) { return `${count} ${count === 1 ? singular : plural}`; } function normalizeComparable(value: unknown) { if (Array.isArray(value)) return value .map((item) => String(item || "").trim()) .filter(Boolean) .join(" | "); return String(value ?? "").trim(); } function appendChange( changes: string[], label: string, beforeValue: unknown, afterValue: unknown, options?: { privateValue?: boolean }, ) { const before = normalizeComparable(beforeValue); const after = normalizeComparable(afterValue); if (before === after) return; if (options?.privateValue) { changes.push(`${label} actualizado.`); return; } changes.push(`${label}: ${before || "—"} → ${after || "—"}`); } function buildProjectChangeSummary(before: Project, after: Project, includePrivateFields: boolean) { const changes: string[] = []; appendChange(changes, "Nombre", before.nombre, after.nombre); appendChange(changes, "Cliente", before.cliente, after.cliente); appendChange(changes, "Marca", before.marca, after.marca); appendChange(changes, "País/BU", before.bu, after.bu); appendChange(changes, "CM", before.cm, after.cm); appendChange(changes, "Solicitado por", before.solicitante, after.solicitante); appendChange( changes, "Estatus", publicProjectStatus(before.status), publicProjectStatus(after.status), ); if (before.color !== after.color) { changes.push("Etiqueta de color actualizada."); } if ((before.comentarios || "").trim() !== (after.comentarios || "").trim()) { changes.push("Comentarios actualizados."); } if ((before.briefLink || "").trim() !== (after.briefLink || "").trim()) { changes.push("Link del brief actualizado."); } if (before.propuestaLinks.join("|") !== after.propuestaLinks.join("|")) { changes.push( `Links de propuestas: ${listCountLabel(before.propuestaLinks.length, "link", "links")} → ${listCountLabel(after.propuestaLinks.length, "link", "links")}.`, ); } if (before.afLinks.join("|") !== after.afLinks.join("|")) { changes.push( `Links de artes finales: ${listCountLabel(before.afLinks.length, "link", "links")} → ${listCountLabel(after.afLinks.length, "link", "links")}.`, ); } if (includePrivateFields && Number(before.monto ?? 0) !== Number(after.monto ?? 0)) { appendChange(changes, "Monto interno", before.monto, after.monto, { privateValue: true }); } return changes; } 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 access = await getCurrentTableroCdcAccess(); return access.role === "director_creativo"; } async function currentUserCanManageInternalPricing(): Promise { const access = await getCurrentTableroCdcAccess(); return access.canManageInternalPricing; } async function currentUserCanDeleteProjects(): Promise { const access = await getCurrentTableroCdcAccess(); return access.canDeleteProjects; } function toProjectRow(project: Omit | Partial) { const existingExtra = project.extraData && typeof project.extraData === "object" && !Array.isArray(project.extraData) ? project.extraData : {}; 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: { ...existingExtra, color: project.color ?? asString(existingExtra.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 cleanFilterOptionArray(values: unknown): string[] { if (!Array.isArray(values)) return []; return Array.from( new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean)), ).sort((a, b) => a.localeCompare(b, "es", { sensitivity: "base" })); } function buildFilterOptionsFromProjects(projects: Project[]): ProjectFilterOptions { return { countries: cleanFilterOptionArray(projects.map((project) => project.bu)), brands: cleanFilterOptionArray(projects.map((project) => project.marca)), clients: cleanFilterOptionArray(projects.map((project) => project.cliente)), cms: cleanFilterOptionArray(projects.map((project) => project.cm)), }; } function setError(message: string | null) { errorMessage = message; notify(); } function replaceProjectInCache(project: Project) { const exists = cache.some((current) => current.id === project.id); if (!exists) return; setCache(cache.map((current) => (current.id === project.id ? project : current))); } function removeProjectFromCache(projectId: string) { if (!cache.some((project) => project.id === projectId)) return; setCache(cache.filter((project) => project.id !== projectId)); totalProjects = Math.max(0, totalProjects - 1); notify(); } function getSyncWebhookUrl() { return import.meta.env.VITE_WEBHOOK_URL?.trim() || ""; } async function syncProjectToSheet( projectId: string, action: "create" | "update" | "delete", ): 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: action === "delete" ? "No hay webhook configurado para eliminar el proyecto del Google Sheet." : "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: action === "delete" ? `No se pudo eliminar el proyecto del Google Sheet: ${message}` : `Proyecto guardado, pero el Sheet no se pudo sincronizar: ${message}`, }; } return { sheetSyncStatus: "synced", sheetSyncMessage: action === "delete" ? "Proyecto eliminado del Google Sheet." : "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 ? action === "delete" ? "n8n tardó demasiado en responder. No se eliminó el proyecto para evitar inconsistencias." : "Proyecto guardado, pero n8n tardó demasiado en responder. Revisa el workflow si el Sheet no se actualizó." : action === "delete" ? "No se pudo eliminar el proyecto del Google Sheet. No se eliminó el proyecto para evitar inconsistencias." : "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 && writeInProgress === 0) { void loadProjects(currentParams); } }, 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 linkRows = toLinkRows(projectId, project); try { const { error: rpcError } = await supabase.rpc("tablero_cdc_replace_project_links", { p_project_id: projectId, p_links: linkRows.map(({ link_type, url, label }) => ({ link_type, url, label })), }); if (rpcError) throw rpcError; return; } catch (error) { if (!isMissingReplaceLinksRpc(error)) throw error; console.warn( "RPC tablero_cdc_replace_project_links no existe todavía. Usando reemplazo de links por delete+insert.", error, ); } const { error: deleteError } = await supabase .from("tablero_cdc_project_links") .delete() .eq("project_id", projectId); if (deleteError) throw deleteError; if (linkRows.length === 0) return; const { error: insertError } = await supabase.from("tablero_cdc_project_links").insert(linkRows); if (insertError) throw insertError; } async function replaceProjectPricingItems(projectId: string, items?: ProjectPricingItemInput[]) { if (!Array.isArray(items)) return; const { error: deleteError } = await supabase .from("tablero_cdc_project_pricing_items") .delete() .eq("project_id", projectId); if (deleteError) throw deleteError; const cleanItems = items .map((item, index) => ({ item, index })) .filter(({ item }) => Number.isFinite(Number(item.amount)) && Number(item.amount) > 0) .map(({ item, index }) => ({ project_id: projectId, source: item.source, category: item.category || "", service_name: item.serviceName || "Costo manual", work_type: item.workType || "", complexity_level: item.complexityLevel || "", reference_label: item.referenceLabel || "", reference_min: item.referenceMin ?? null, reference_max: item.referenceMax ?? null, amount: Number(item.amount), description: item.description || "", sort_order: index, })); if (cleanItems.length === 0) return; const { error: insertError } = await supabase .from("tablero_cdc_project_pricing_items") .insert(cleanItems); if (insertError) throw insertError; } export async function loadProjectPricingItems(projectId: string): Promise { const { data, error } = await supabase .from("tablero_cdc_project_pricing_items") .select( "id, project_id, source, category, service_name, work_type, complexity_level, reference_label, reference_min, reference_max, amount, description, created_at", ) .eq("project_id", projectId) .order("sort_order", { ascending: true }) .order("created_at", { ascending: true }); if (error) throw error; return ((data || []) as DbProjectPricingItem[]).map(mapDbProjectPricingItem); } export async function loadProjectActivities( projectId: string, limit = 20, offset = 0, ): Promise { const safeLimit = Math.max(1, Math.min(50, Math.floor(limit))); const safeOffset = Math.max(0, Math.floor(offset)); const { data, error } = await supabase .from("tablero_cdc_project_activity") .select( "id, project_id, activity_type, title, description, actor_name, actor_email, created_at", ) .eq("project_id", projectId) .order("created_at", { ascending: false }) .range(safeOffset, safeOffset + safeLimit - 1); if (error) throw error; return ((data || []) as DbProjectActivity[]).map(mapDbProjectActivity); } function formatCommentForSheet( description: string, actorName: string, actorEmail: string, date: Date, ) { const timestamp = new Intl.DateTimeFormat("es-DO", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }).format(date); const actor = actorEmail ? `${actorName} (${actorEmail})` : actorName; return `[${timestamp}] ${actor}:\n${description}`; } async function appendActivityNoteToProjectComments( projectId: string, description: string, actorName: string, actorEmail: string, actorId: string, createdAt: Date, ) { const { data, error: readError } = await supabase .from("tablero_cdc_projects") .select("description") .eq("id", projectId) .single(); if (readError) throw readError; const existingDescription = String(data?.description || "").trim(); const noteForSheet = formatCommentForSheet(description, actorName, actorEmail, createdAt); const nextDescription = existingDescription ? `${existingDescription}\n\n${noteForSheet}` : noteForSheet; const { error: updateError } = await supabase .from("tablero_cdc_projects") .update({ description: nextDescription, updated_by: actorId, }) .eq("id", projectId); if (updateError) throw updateError; const current = cache.find((project) => project.id === projectId); if (current) { replaceProjectInCache({ ...current, comentarios: nextDescription }); } return nextDescription; } export async function addProjectActivityNote(projectId: string, description: string) { const cleanDescription = description.trim(); if (!cleanDescription) { throw new Error("Escribe una nota antes de agregarla al historial."); } const user = await getCurrentUser(); const actorName = getUserDisplayName(user); const actorEmail = user.email || ""; const createdAt = new Date(); const { error: activityError } = await supabase.from("tablero_cdc_project_activity").insert({ project_id: projectId, activity_type: "note", title: "Comentario agregado", description: cleanDescription, actor_id: user.id, actor_email: actorEmail || null, actor_name: actorName, }); if (activityError) throw activityError; const comments = await appendActivityNoteToProjectComments( projectId, cleanDescription, actorName, actorEmail, user.id, createdAt, ); const sheetSync = await syncProjectToSheet(projectId, "update"); return { comments, ...sheetSync, }; } async function logProjectActivity( projectId: string, type: ProjectActivityType, title: string, description = "", shouldThrow = false, ) { try { const user = await getCurrentUser(); const actorName = getUserDisplayName(user); const { error } = await supabase.from("tablero_cdc_project_activity").insert({ project_id: projectId, activity_type: type, title, description, actor_id: user.id, actor_email: user.email || null, actor_name: actorName, }); if (error) throw error; } catch (error) { console.warn("No se pudo registrar la actividad del proyecto:", error); if (shouldThrow) { throw error instanceof Error ? error : new Error("No se pudo registrar la actividad del proyecto."); } } } function getSelectColumns(includeInternalAmount: boolean) { return ` id, title, client, brand, country, requested_by, country_manager, description, status, ${includeInternalAmount ? "internal_amount," : ""} brief_link, created_at, extra_data, project_links:tablero_cdc_project_links ( id, link_type, url, label ) `; } function getErrorMessage(error: unknown) { if (error instanceof Error) return error.message; if (error && typeof error === "object" && "message" in error) { return String((error as { message?: unknown }).message || ""); } return String(error || ""); } function isMissingRpcFunction(error: unknown) { const message = getErrorMessage(error); return ( message.includes("tablero_cdc_get_projects_paginated") || message.includes("Could not find the function") ); } function isMissingReplaceLinksRpc(error: unknown) { const message = getErrorMessage(error); return ( message.includes("tablero_cdc_replace_project_links") || message.includes("Could not find the function") ); } function isMissingPricingSummaryRpc(error: unknown) { const message = getErrorMessage(error); return ( message.includes("tablero_cdc_get_pricing_summary") || message.includes("Could not find the function") ); } function escapePostgrestFilterValue(value: string) { return value.replace(/[%_]/g, "\\$&").replace(/,/g, " "); } function applyDirectFilters(query: DirectProjectQuery, params: ProjectQueryParams) { let nextQuery = query; if (params.tab === "abiertos") { nextQuery = nextQuery.or("status.is.null,status.eq.,status.eq.Activo"); } if (params.tab === "cerrados") { nextQuery = nextQuery.not("status", "is", null).neq("status", "").neq("status", OPEN_STATUS); } const search = params.search.trim(); if (search) { const escapedSearch = escapePostgrestFilterValue(search); const pattern = `%${escapedSearch}%`; nextQuery = nextQuery.or( `title.ilike.${pattern},client.ilike.${pattern},brand.ilike.${pattern},country.ilike.${pattern},requested_by.ilike.${pattern},country_manager.ilike.${pattern},description.ilike.${pattern}`, ); } if (params.country) { const pattern = `%${escapePostgrestFilterValue(params.country)}%`; nextQuery = nextQuery.or(`country.ilike.${pattern}`); } if (params.brand) { const pattern = `%${escapePostgrestFilterValue(params.brand)}%`; nextQuery = nextQuery.or(`brand.ilike.${pattern}`); } if (params.client) { const pattern = `%${escapePostgrestFilterValue(params.client)}%`; nextQuery = nextQuery.or(`client.ilike.${pattern}`); } if (params.cm) { const pattern = `%${escapePostgrestFilterValue(params.cm)}%`; nextQuery = nextQuery.or(`country_manager.ilike.${pattern}`); } return nextQuery; } async function loadProjectsDirect( params: ProjectQueryParams, includeInternalAmount: boolean, ): Promise { const from = (params.page - 1) * params.pageSize; const to = from + params.pageSize - 1; let query = supabase .from("tablero_cdc_projects") .select(getSelectColumns(includeInternalAmount), { count: "exact" }) .order("created_at", { ascending: false }) as unknown as DirectProjectQuery; query = applyDirectFilters(query, params); const { data, error, count } = await query.range(from, to); if (error) throw error; const projects = (data || []).map((row: unknown) => mapDbProject(row as DbProject)); return { projects, totalProjects: count ?? 0, filterOptions: buildFilterOptionsFromProjects(projects), }; } async function loadProjectsFromRpc(params: ProjectQueryParams): Promise { const { data, error } = await supabase.rpc("tablero_cdc_get_projects_paginated", { p_tab: params.tab, p_search: params.search, p_page: params.page, p_page_size: params.pageSize, p_country: params.country || "", p_brand: params.brand || "", p_client: params.client || "", p_cm: params.cm || "", }); if (error) throw error; const result = (data || {}) as RpcProjectsResponse; const rows = Array.isArray(result.projects) ? result.projects : []; return { totalProjects: Number(result.total_count || 0), projects: rows.map((row) => mapDbProject(row)), filterOptions: { countries: cleanFilterOptionArray(result.filter_options?.countries), brands: cleanFilterOptionArray(result.filter_options?.brands), clients: cleanFilterOptionArray(result.filter_options?.clients), cms: cleanFilterOptionArray(result.filter_options?.cms), }, }; } async function loadProjects(params?: Partial) { const nextParams = normalizeProjectParams(params); const requestId = (loadRequestSeq += 1); currentParams = nextParams; loading = true; setError(null); notify(); try { const canManageInternalPricing = await currentUserCanManageInternalPricing(); let result: ProjectLoadResult; try { result = await loadProjectsFromRpc(nextParams); } catch (rpcError) { if (!isMissingRpcFunction(rpcError)) throw rpcError; console.warn( "RPC tablero_cdc_get_projects_paginated no existe todavía. Usando consulta directa como respaldo temporal.", rpcError, ); result = await loadProjectsDirect(nextParams, canManageInternalPricing); } if (requestId !== loadRequestSeq) return; totalProjects = result.totalProjects; cache = result.projects; filterOptions = result.filterOptions; initialized = true; } catch (error) { if (requestId !== loadRequestSeq) return; console.error("Error cargando proyectos desde Supabase:", error); errorMessage = error instanceof Error ? error.message : "No se pudieron cargar los proyectos desde Supabase."; } finally { if (requestId === loadRequestSeq) { loading = false; notify(); } } } async function addProject(project: Project & { pricingItems?: ProjectPricingItemInput[] }) { setError(null); writeInProgress += 1; try { 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); await replaceProjectPricingItems(data.id, project.pricingItems); await logProjectActivity( data.id, "created", "Proyecto creado", `${project.nombre || "Proyecto sin título"} quedó registrado en el tablero.`, ); const sheetSync = await syncProjectToSheet(data.id, "create"); await loadProjects(currentParams); return { projectId: data.id, ...sheetSync, } satisfies SaveProjectResult; } finally { writeInProgress = Math.max(0, writeInProgress - 1); } } async function updateProject( id: string, patch: Partial & { pricingItems?: ProjectPricingItemInput[] }, ) { setError(null); writeInProgress += 1; try { const userId = await getCurrentUserId(); const isGerardo = await currentUserIsGerardo(); const canManageInternalPricing = await currentUserCanManageInternalPricing(); const current = cache.find((project) => project.id === id); if (!current) { const message = "No se encontró el proyecto en la página actual. Recarga el tablero antes de guardar para evitar sobrescribir datos."; setError(message); throw new Error(message); } const nextProject = { ...current, ...patch }; const rowPayload = toProjectRow(nextProject); const updatePayload = isGerardo ? { ...rowPayload, updated_by: userId, } : canManageInternalPricing ? { 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: current.status, internal_amount: rowPayload.internal_amount, brief_link: rowPayload.brief_link, extra_data: rowPayload.extra_data, 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; } replaceProjectInCache(nextProject); await replaceProjectLinks(id, nextProject); if (canManageInternalPricing) { await replaceProjectPricingItems(id, patch.pricingItems); } const changes = buildProjectChangeSummary(current, nextProject, canManageInternalPricing); if (changes.length > 0) { await logProjectActivity( id, "updated", "Proyecto actualizado", changes.slice(0, 8).join("\n"), ); } const sheetSync = await syncProjectToSheet(id, "update"); await loadProjects(currentParams); return { projectId: id, ...sheetSync, } satisfies SaveProjectResult; } finally { writeInProgress = Math.max(0, writeInProgress - 1); } } async function deleteProject(id: string) { setError(null); writeInProgress += 1; try { const canDelete = await currentUserCanDeleteProjects(); if (!canDelete) { const message = "No tienes permiso para eliminar proyectos."; setError(message); throw new Error(message); } const sheetSync = await syncProjectToSheet(id, "delete"); if (sheetSync.sheetSyncStatus !== "synced") { const message = sheetSync.sheetSyncMessage || "No se pudo eliminar el proyecto del Google Sheet."; setError(message); throw new Error(message); } const { error: linksError } = await supabase .from("tablero_cdc_project_links") .delete() .eq("project_id", id); if (linksError) { setError(linksError.message); throw linksError; } const { error: projectError } = await supabase .from("tablero_cdc_projects") .delete() .eq("id", id); if (projectError) { setError(projectError.message); throw projectError; } removeProjectFromCache(id); await loadProjects(currentParams); } finally { writeInProgress = Math.max(0, writeInProgress - 1); } } async function loadProjectPricingSummaryFromRpc( params: ProjectQueryParams, ): Promise { const { data, error } = await supabase.rpc("tablero_cdc_get_pricing_summary", { p_tab: params.tab, p_search: params.search, p_country: params.country || "", p_brand: params.brand || "", p_client: params.client || "", p_cm: params.cm || "", }); if (error) throw error; const result = (data || {}) as RpcPricingSummaryResponse; const totalAmount = Number(result.total_amount || 0); const projectCount = Number(result.project_count || 0); return { totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0, projectCount: Number.isFinite(projectCount) ? projectCount : 0, loadedAt: Date.now(), }; } async function loadProjectPricingSummaryDirect( params: ProjectQueryParams, ): Promise { const pageSize = 1000; let from = 0; let totalCount: number | null = null; let totalAmount = 0; while (totalCount === null || from < totalCount) { let query = supabase .from("tablero_cdc_projects") .select("id, internal_amount", { count: "exact" }) .order("created_at", { ascending: false }) as unknown as DirectProjectQuery; query = applyDirectFilters(query, params); const { data, error, count } = await query.range(from, from + pageSize - 1); if (error) throw error; if (totalCount === null) { totalCount = count ?? (data || []).length; } const rows = (data || []) as DirectProjectSummaryRow[]; rows.forEach((row) => { const amount = Number(row.internal_amount ?? 0); if (Number.isFinite(amount) && amount > 0) { totalAmount += amount; } }); if (rows.length < pageSize) break; from += pageSize; } return { totalAmount, projectCount: totalCount ?? 0, loadedAt: Date.now(), }; } async function loadProjectPricingSummary( params?: Partial, ): Promise { const nextParams = normalizeProjectParams({ ...params, page: 1, pageSize: 1000, }); try { return await loadProjectPricingSummaryFromRpc(nextParams); } catch (rpcError) { if (!isMissingPricingSummaryRpc(rpcError)) throw rpcError; console.warn( "RPC tablero_cdc_get_pricing_summary no existe todavía. Usando suma directa como respaldo temporal.", rpcError, ); return loadProjectPricingSummaryDirect(nextParams); } } export function useProjectPricingSummary(params: ProjectQueryParams, enabled: boolean) { const [summary, setSummary] = useState({ totalAmount: 0, projectCount: 0, loadedAt: null, }); const [summaryLoading, setSummaryLoading] = useState(false); const [summaryError, setSummaryError] = useState(null); const paramsTab = params.tab; const paramsSearch = params.search; const paramsCountry = params.country; const paramsBrand = params.brand; const paramsClient = params.client; const paramsCm = params.cm; useEffect(() => { if (!enabled) { setSummary({ totalAmount: 0, projectCount: 0, loadedAt: null }); setSummaryError(null); setSummaryLoading(false); return; } let cancelled = false; setSummaryLoading(true); setSummaryError(null); loadProjectPricingSummary({ tab: paramsTab, search: paramsSearch, country: paramsCountry, brand: paramsBrand, client: paramsClient, cm: paramsCm, page: 1, pageSize: 1000, }) .then((result) => { if (!cancelled) setSummary(result); }) .catch((error) => { if (cancelled) return; console.error("No se pudo calcular el total tarifado filtrado:", error); setSummaryError( error instanceof Error ? error.message : "No se pudo calcular el total tarifado filtrado.", ); }) .finally(() => { if (!cancelled) setSummaryLoading(false); }); return () => { cancelled = true; }; }, [enabled, paramsTab, paramsSearch, paramsCountry, paramsBrand, paramsClient, paramsCm]); return { summary, loading: summaryLoading, error: summaryError, refresh: () => loadProjectPricingSummary(params).then((result) => { setSummary(result); return result; }), }; } export function useProjects(params?: ProjectQueryParams) { const [, force] = useState(0); const paramsTab = params?.tab; const paramsSearch = params?.search; const paramsPage = params?.page; const paramsPageSize = params?.pageSize; const paramsCountry = params?.country; const paramsBrand = params?.brand; const paramsClient = params?.client; const paramsCm = params?.cm; useEffect(() => { const listener = () => force((n) => n + 1); listeners.push(listener); activeHookInstances += 1; startProjectsRealtime(); return () => { listeners = listeners.filter((x) => x !== listener); activeHookInstances = Math.max(0, activeHookInstances - 1); if (activeHookInstances === 0) { stopProjectsRealtime(); } }; }, []); useEffect(() => { if (paramsTab && paramsPage && paramsPageSize) { void loadProjects({ tab: paramsTab, search: paramsSearch ?? "", page: paramsPage, pageSize: paramsPageSize, country: paramsCountry ?? "", brand: paramsBrand ?? "", client: paramsClient ?? "", cm: paramsCm ?? "", }); return; } if (!initialized && !loading) { void loadProjects(); } }, [ paramsTab, paramsSearch, paramsPage, paramsPageSize, paramsCountry, paramsBrand, paramsClient, paramsCm, ]); return { projects: cache, totalProjects, filterOptions, loading, error: errorMessage, refresh: () => loadProjects(params ?? currentParams), add: addProject, update: updateProject, remove: deleteProject, }; } // NOTE: useRole() was removed in favour of useAuth() from AuthContext. // Role logic (isGerardo) is now derived from the real Supabase authenticated user.