feat: subir versión final Tablero CDC
This commit is contained in:
+455
-108
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user