Files
tablero-cdc/src/lib/store.ts
T

1593 lines
45 KiB
TypeScript

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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown> | 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<ProjectFilterOptions> | 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<typeof supabase.channel> | null = null;
let realtimeRefreshTimeout: ReturnType<typeof setTimeout> | 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<string, unknown>, fallbackDate: Date) {
return (
asString(extra.legacy_month, "") ||
asString(extra.mes, "") ||
fallbackDate.toLocaleString("es-DO", { month: "long" })
);
}
function getDisplayYear(extra: Record<string, unknown>, 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>): 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<ReturnType<typeof getCurrentUser>>) {
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<string> {
const user = await getCurrentUser();
return user.id;
}
async function currentUserIsGerardo(): Promise<boolean> {
const access = await getCurrentTableroCdcAccess();
return access.role === "director_creativo";
}
async function currentUserCanManageInternalPricing(): Promise<boolean> {
const access = await getCurrentTableroCdcAccess();
return access.canManageInternalPricing;
}
async function currentUserCanDeleteProjects(): Promise<boolean> {
const access = await getCurrentTableroCdcAccess();
return access.canDeleteProjects;
}
function toProjectRow(project: Omit<Project, "id" | "createdAt"> | Partial<Project>) {
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<Project, "propuestaLinks" | "afLinks">) {
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<Omit<SaveProjectResult, "projectId">> {
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<Project, "propuestaLinks" | "afLinks">,
) {
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<ProjectPricingItem[]> {
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<ProjectActivity[]> {
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<ProjectLoadResult> {
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<ProjectLoadResult> {
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<ProjectQueryParams>) {
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<Project> & { 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<ProjectPricingSummary> {
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<ProjectPricingSummary> {
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<ProjectQueryParams>,
): Promise<ProjectPricingSummary> {
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<ProjectPricingSummary>({
totalAmount: 0,
projectCount: 0,
loadedAt: null,
});
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(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.