1593 lines
45 KiB
TypeScript
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.
|