feat: subir versión final Tablero CDC

This commit is contained in:
2026-05-28 13:26:32 -04:00
parent b4d3abc9fc
commit eb461ee134
19 changed files with 1795 additions and 2962 deletions
+455 -108
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import type { ColorId, Status } from "@/data/lists";
export interface Project {
@@ -21,136 +22,482 @@ export interface Project {
createdAt: number;
}
const STORAGE_KEY = "cdc-board-v1";
export type SheetSyncStatus = "synced" | "skipped" | "failed" | "timeout";
export type SaveProjectResult = {
projectId: string;
sheetSyncStatus: SheetSyncStatus;
sheetSyncMessage: string;
};
type DbProjectLink = {
id?: string;
link_type: string | null;
url: string | null;
label?: string | null;
};
type DbProject = {
id: string;
title: string | null;
client: string | null;
brand: string | null;
country: string | null;
requested_by: string | null;
country_manager: string | null;
description: string | null;
status: string | null;
internal_amount?: number | string | null;
brief_link: string | null;
created_at: string | null;
extra_data: Record<string, unknown> | null;
project_links?: DbProjectLink[] | null;
};
const DEFAULT_COLOR: ColorId = "blue";
const OPEN_STATUS = "Activo";
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
let listeners: Array<() => void> = [];
let cache: Project[] = [];
let loading = false;
let initialized = false;
let errorMessage: string | null = null;
let projectsRealtimeChannel: ReturnType<typeof supabase.channel> | null = null;
let realtimeRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
let activeHookInstances = 0;
function notify() {
listeners.forEach((listener) => listener());
}
function asString(value: unknown, fallback = "") {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback: number) {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function toUiStatus(status: string | null | undefined): Status | "" {
// En base de datos usamos "Activo" para proyectos abiertos.
// En la UI histórica, proyecto abierto = status vacío.
if (!status || status === OPEN_STATUS) return "";
return status as Status;
}
function toDbStatus(status: Status | "" | undefined | null): string {
return status && status.trim() ? status : OPEN_STATUS;
}
function mapDbProject(row: DbProject): Project {
const extra = row.extra_data || {};
const links = row.project_links || [];
const propuestaLinks = links
.filter((link) => link.link_type === "proposal")
.map((link) => link.url || "")
.filter(Boolean);
const afLinks = links
.filter((link) => link.link_type === "final_art")
.map((link) => link.url || "")
.filter(Boolean);
const createdAt = row.created_at ? new Date(row.created_at).getTime() : Date.now();
const date = Number.isFinite(createdAt) ? new Date(createdAt) : new Date();
return {
id: row.id,
nombre: row.title || "",
color: asString(extra.color, DEFAULT_COLOR) as ColorId,
cliente: row.client || "",
bu: row.country || "",
cm: row.country_manager || "",
marca: row.brand || "",
solicitante: row.requested_by || "",
comentarios: row.description || "",
briefLink: row.brief_link || "",
propuestaLinks,
afLinks,
status: toUiStatus(row.status),
monto: row.internal_amount == null ? null : Number(row.internal_amount),
mes: asString(extra.mes, "") || date.toLocaleString("es-DO", { month: "long" }),
anio: asNumber(extra.anio, date.getFullYear()),
createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
};
}
async function getCurrentUser() {
const { data, error } = await supabase.auth.getUser();
if (error || !data.user) {
throw new Error("No hay una sesión activa de Supabase.");
}
return data.user;
}
async function getCurrentUserId(): Promise<string> {
const user = await getCurrentUser();
return user.id;
}
async function currentUserIsGerardo(): Promise<boolean> {
const user = await getCurrentUser();
return user.email?.toLowerCase() === GERARDO_EMAIL;
}
function toProjectRow(project: Omit<Project, "id" | "createdAt"> | Partial<Project>) {
return {
title: project.nombre,
client: project.cliente,
brand: project.marca,
country: project.bu,
requested_by: project.solicitante,
country_manager: project.cm,
description: project.comentarios,
status: toDbStatus(project.status),
internal_amount: project.monto ?? null,
brief_link: project.briefLink,
extra_data: {
color: project.color ?? DEFAULT_COLOR,
mes: project.mes,
anio: project.anio,
},
};
}
function toLinkRows(projectId: string, project: Pick<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 setError(message: string | null) {
errorMessage = message;
notify();
}
function getSyncWebhookUrl() {
return import.meta.env.VITE_WEBHOOK_URL?.trim() || "";
}
async function syncProjectToSheet(
projectId: string,
action: "create" | "update",
): Promise<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: "Proyecto guardado. No hay webhook configurado para sincronizar el Sheet.",
};
}
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 15000);
function load(): Project[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return seed();
return JSON.parse(raw);
} catch {
return seed();
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
project_id: projectId,
action,
source: "tablero-cdc",
sent_at: new Date().toISOString(),
}),
signal: controller.signal,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
const message = errorText || `${response.status} ${response.statusText}`.trim();
console.warn("El proyecto se guardó en Supabase, pero n8n no respondió correctamente.", {
status: response.status,
statusText: response.statusText,
errorText,
});
return {
sheetSyncStatus: "failed",
sheetSyncMessage: `Proyecto guardado, pero el Sheet no se pudo sincronizar: ${message}`,
};
}
return {
sheetSyncStatus: "synced",
sheetSyncMessage: "Proyecto guardado y sincronizado con el Google Sheet.",
};
} catch (error) {
const isTimeout = error instanceof DOMException && error.name === "AbortError";
console.warn("El proyecto se guardó en Supabase, pero no se pudo sincronizar con Google Sheet.", error);
return {
sheetSyncStatus: isTimeout ? "timeout" : "failed",
sheetSyncMessage: isTimeout
? "Proyecto guardado, pero n8n tardó demasiado en responder. Revisa el workflow si el Sheet no se actualizó."
: "Proyecto guardado, pero no se pudo sincronizar con Google Sheet.",
};
} finally {
window.clearTimeout(timeout);
}
}
function seed(): Project[] {
const now = Date.now();
const mesIdx = new Date().getMonth();
const mes = [
"Enero",
"Febrero",
"Marzo",
"Abril",
"Mayo",
"Junio",
"Julio",
"Agosto",
"Septiembre",
"Octubre",
"Noviembre",
"Diciembre",
][mesIdx];
const anio = new Date().getFullYear();
return [
{
id: crypto.randomUUID(),
nombre: "Promoción Día de las Madres",
color: "pink",
cliente: "Nestlé",
bu: "Republica Dominicana",
cm: "Liz",
marca: "Carnation",
solicitante: "Luiscar",
comentarios: "Solicitado por Luiscar",
briefLink: "https://drive.google.com/drive/folders/ejemplo-brief",
propuestaLinks: ["https://docs.google.com/presentation/d/ejemplo-1"],
afLinks: [],
status: "",
monto: null,
mes,
anio,
createdAt: now,
},
{
id: crypto.randomUUID(),
nombre: "KV Coffee Mate y Nescafé",
color: "blue",
cliente: "Nestlé",
bu: "Republica Dominicana",
cm: "Liz",
marca: "Coffee Mate",
solicitante: "Luiscar",
comentarios: "Dupla que eleva el momento",
briefLink: "",
propuestaLinks: [
"https://docs.google.com/presentation/d/ejemplo-kv-1",
"https://docs.google.com/presentation/d/ejemplo-kv-2",
],
afLinks: ["https://drive.google.com/drive/af-coffee"],
status: "Pre aprobado",
monto: 1250,
mes,
anio,
createdAt: now - 1000,
},
{
id: crypto.randomUUID(),
nombre: "Stand KitchenAid",
color: "green",
cliente: "KitchenAid",
bu: "Republica Dominicana",
cm: "Liz",
marca: "Whirlpool",
solicitante: "Luiscar",
comentarios: "",
briefLink: "https://drive.google.com/drive/brief-kitchen",
propuestaLinks: ["https://drive.google.com/file/stand-kitchen"],
afLinks: ["https://drive.google.com/af-stand"],
status: "Aprobado",
monto: 850,
mes,
anio,
createdAt: now - 2000,
},
];
function scheduleRealtimeRefresh() {
if (realtimeRefreshTimeout) {
clearTimeout(realtimeRefreshTimeout);
}
realtimeRefreshTimeout = setTimeout(() => {
realtimeRefreshTimeout = null;
if (!loading) {
void loadProjects();
}
}, 350);
}
function save(p: Project[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
function startProjectsRealtime() {
if (projectsRealtimeChannel) return;
projectsRealtimeChannel = supabase
.channel("tablero-cdc-projects")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "projects" },
() => scheduleRealtimeRefresh(),
)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "project_links" },
() => scheduleRealtimeRefresh(),
)
.subscribe((status) => {
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
console.warn("Supabase Realtime no pudo suscribirse a proyectos:", status);
}
});
}
let listeners: Array<() => void> = [];
let cache: Project[] | null = null;
function stopProjectsRealtime() {
if (realtimeRefreshTimeout) {
clearTimeout(realtimeRefreshTimeout);
realtimeRefreshTimeout = null;
}
function getAll(): Project[] {
if (cache === null) cache = load();
return cache;
if (!projectsRealtimeChannel) return;
void supabase.removeChannel(projectsRealtimeChannel);
projectsRealtimeChannel = null;
}
function setAll(next: Project[]) {
cache = next;
save(next);
listeners.forEach((l) => l());
async function replaceProjectLinks(projectId: string, project: Pick<Project, "propuestaLinks" | "afLinks">) {
const { error: deleteError } = await supabase
.from("project_links")
.delete()
.eq("project_id", projectId);
if (deleteError) throw deleteError;
const linkRows = toLinkRows(projectId, project);
if (linkRows.length === 0) return;
const { error: insertError } = await supabase.from("project_links").insert(linkRows);
if (insertError) throw insertError;
}
async function loadProjects() {
loading = true;
setError(null);
notify();
try {
const isGerardo = await currentUserIsGerardo();
const selectColumns = `
id,
title,
client,
brand,
country,
requested_by,
country_manager,
description,
status,
${isGerardo ? "internal_amount," : ""}
brief_link,
created_at,
extra_data,
project_links (
id,
link_type,
url,
label
)
`;
const { data, error } = await supabase
.from("projects")
.select(selectColumns)
.order("created_at", { ascending: false });
if (error) throw error;
cache = (data || []).map((row) => mapDbProject(row as unknown as DbProject));
initialized = true;
} catch (error) {
console.error("Error cargando proyectos desde Supabase:", error);
errorMessage =
error instanceof Error
? error.message
: "No se pudieron cargar los proyectos desde Supabase.";
} finally {
loading = false;
notify();
}
}
async function addProject(project: Project) {
setError(null);
const userId = await getCurrentUserId();
const insertPayload = {
...toProjectRow(project),
created_by: userId,
updated_by: userId,
};
const { data, error } = await supabase
.from("projects")
.insert(insertPayload)
.select("id")
.single();
if (error) {
setError(error.message);
throw error;
}
await replaceProjectLinks(data.id, project);
const sheetSync = await syncProjectToSheet(data.id, "create");
await loadProjects();
return {
projectId: data.id,
...sheetSync,
} satisfies SaveProjectResult;
}
async function updateProject(id: string, patch: Partial<Project>) {
setError(null);
const userId = await getCurrentUserId();
const isGerardo = await currentUserIsGerardo();
const current = cache.find((project) => project.id === id);
const nextProject = current ? { ...current, ...patch } : ({ ...patch, id } as Project);
const rowPayload = toProjectRow(nextProject);
const updatePayload = isGerardo
? {
...rowPayload,
updated_by: userId,
}
: {
title: rowPayload.title,
client: rowPayload.client,
brand: rowPayload.brand,
country: rowPayload.country,
requested_by: rowPayload.requested_by,
country_manager: rowPayload.country_manager,
description: rowPayload.description,
status: rowPayload.status,
brief_link: rowPayload.brief_link,
extra_data: rowPayload.extra_data,
updated_by: userId,
};
const { error } = await supabase.from("projects").update(updatePayload).eq("id", id);
if (error) {
setError(error.message);
throw error;
}
await replaceProjectLinks(id, nextProject);
const sheetSync = await syncProjectToSheet(id, "update");
await loadProjects();
return {
projectId: id,
...sheetSync,
} satisfies SaveProjectResult;
}
export function useProjects() {
const [, force] = useState(0);
useEffect(() => {
const l = () => force((n) => n + 1);
listeners.push(l);
const listener = () => force((n) => n + 1);
listeners.push(listener);
activeHookInstances += 1;
startProjectsRealtime();
if (!initialized && !loading) {
void loadProjects();
}
return () => {
listeners = listeners.filter((x) => x !== l);
listeners = listeners.filter((x) => x !== listener);
activeHookInstances = Math.max(0, activeHookInstances - 1);
if (activeHookInstances === 0) {
stopProjectsRealtime();
}
};
}, []);
return {
projects: getAll(),
add: (p: Project) => setAll([p, ...getAll()]),
update: (id: string, patch: Partial<Project>) =>
setAll(getAll().map((x) => (x.id === id ? { ...x, ...patch } : x))),
remove: (id: string) => setAll(getAll().filter((x) => x.id !== id)),
projects: cache,
loading,
error: errorMessage,
refresh: loadProjects,
add: addProject,
update: updateProject,
};
}
// NOTE: useRole() was removed in favour of useAuth() from AuthContext.
// Role logic (isGerardo) is now derived from the real Firebase authenticated user.
// Role logic (isGerardo) is now derived from the real Supabase authenticated user.