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
+2 -6
View File
@@ -1,7 +1,3 @@
VITE_FIREBASE_API_KEY=""
VITE_FIREBASE_AUTH_DOMAIN=""
VITE_FIREBASE_PROJECT_ID=""
VITE_FIREBASE_STORAGE_BUCKET=""
VITE_FIREBASE_MESSAGING_SENDER_ID=""
VITE_FIREBASE_APP_ID=""
VITE_SUPABASE_URL="https://ecngyelpabyqbdxhftdy.supabase.co"
VITE_SUPABASE_ANON_KEY="TU_ANON_PUBLIC_KEY"
VITE_WEBHOOK_URL=""
+78 -69
View File
@@ -6,63 +6,43 @@ El objetivo de esta app es simplificar el flujo de trabajo del equipo creativo m
---
## Descripción general
**Tablero CDC** permite crear, visualizar y gestionar proyectos creativos en una interfaz sencilla, clara y colaborativa.
Cada proyecto se representa como una tarjeta dentro del tablero. Al abrir una tarjeta, el usuario puede consultar o completar información como cliente, marca, país, solicitante, brief, enlaces de propuestas, artes finales, estatus y otros datos relevantes para el proceso interno.
La app está pensada para alimentar posteriormente el archivo de aprobaciones utilizado por el dashboard de Power BI del departamento creativo.
---
## Objetivo del proyecto
Reducir el trabajo manual y desordenado que actualmente se realiza en hojas colaborativas, reemplazándolo por una interfaz más amigable para el equipo.
La app busca:
- Centralizar la información de cada proyecto creativo.
- Facilitar el seguimiento de propuestas y artes finales.
- Permitir que el equipo agregue enlaces de trabajo de forma organizada.
- Mantener proyectos cerrados visibles, pero diferenciados visualmente.
- Preparar la información para ser enviada al Sheet original que alimenta Power BI.
- Mejorar la colaboración del equipo creativo.
---
## Estado actual
Esta versión corresponde a la **base inicial limpia** del proyecto.
Esta versión incluye:
Incluye:
- React + Vite puro.
- Configuración de `base` en `vite.config.ts`.
- Proyecto React + Vite limpio.
- Limpieza del ecosistema Lovable.
- Corrección de `County Manager` a `Country Manager`.
- Configuración de `base: "/tablero-cdc/"` en `vite.config.ts`.
- Favicon personalizado para la pestaña del navegador.
- Login corporativo con Firebase.
- Diseño visual aprobado como base del producto.
- Login corporativo con Supabase Auth y Google Sign-In.
- Restricción de acceso a correos `@gomezleemarketing.com`.
- Usuario autenticado real en el encabezado.
- Botón de cerrar sesión.
- Rol `isGerardo` para Gerardo Marrero.
- Permisos visuales iniciales: solo Gerardo Marrero puede ver/editar monto interno y editar estatus.
- Paginación local en las vistas de Todos, Activos y Cerrados con 12 proyectos por página.
- Persistencia de proyectos en Supabase.
- Sincronización multiusuario con Supabase Realtime.
- Desplegables dinámicos desde Supabase `app_lists`, con fallback local mientras se termina la sincronización del Sheet.
---
## Funcionalidades principales del prototipo
## Funcionalidades principales
- Tablero visual de proyectos.
- Tarjetas por proyecto.
- Filtros por estado:
- Todos
- Activos
- Cerrados
- Filtros por estado: Todos, Activos y Cerrados.
- Buscador por datos del proyecto.
- Modal de creación y edición de proyecto.
- Campos para cliente, marca, país y solicitante.
- Campo de `Country Manager`.
- Links de brief.
- Links de propuestas.
- Links de artes finales.
- Campos para cliente, marca, país, solicitante y `Country Manager`.
- Link de brief.
- Links múltiples de propuestas.
- Links múltiples de artes finales.
- Etiquetas de color por proyecto.
- Visualización de estatus.
- Preparación para control de permisos por usuario.
- Estatus visible para todos los usuarios.
- Monto interno visible solo para Gerardo Marrero.
- Paginación local para manejar tableros con muchos proyectos.
---
@@ -70,28 +50,22 @@ Incluye:
Las siguientes funcionalidades se implementarán en próximas fases:
1. Control de permisos por usuario.
2. Ocultar monto interno a usuarios que no sean Gerardo Marrero.
3. Bloquear edición de estatus para usuarios que no sean Gerardo Marrero.
4. Almacenamiento persistente.
5. Sincronización multiusuario.
6. Paginación en las vistas de Todos, Activos y Cerrados.
7. Sincronización automática de desplegables con la hoja `listas` del Google Sheet original.
8. Escritura automática de datos en el Sheet original mediante n8n u otra integración definida.
9. Agregar una nueva columna final para enlace de brief en el Sheet original.
1. Sincronización automática de la hoja `listas` del Google Sheet original hacia Supabase `app_lists`.
2. Escritura automática de datos en el Sheet original mediante n8n u otra integración definida.
3. Agregar una nueva columna final para enlace de brief en el Sheet original.
4. Reglas de seguridad definitivas con Supabase RLS.
5. Optimización futura de carga con RPC si el volumen de datos lo requiere.
---
## Arquitectura prevista
La arquitectura esperada para las próximas fases es:
```text
Frontend React/Vite
Firebase Auth
Supabase Auth
Almacenamiento persistente
Supabase Database/Postgres + Realtime + RLS
n8n como capa de integración
@@ -134,23 +108,17 @@ npm run preview
## Variables de entorno
El proyecto utiliza variables de entorno para Firebase y futuras integraciones.
Crear un archivo `.env` local basado en `.env.example`.
Estructura esperada:
```env
VITE_FIREBASE_API_KEY=""
VITE_FIREBASE_AUTH_DOMAIN=""
VITE_FIREBASE_PROJECT_ID=""
VITE_FIREBASE_STORAGE_BUCKET=""
VITE_FIREBASE_MESSAGING_SENDER_ID=""
VITE_FIREBASE_APP_ID=""
VITE_SUPABASE_URL=""
VITE_SUPABASE_ANON_KEY=""
VITE_WEBHOOK_URL=""
```
El campo `VITE_WEBHOOK_URL` puede permanecer vacío hasta que se defina la integración con n8n.
El campo `VITE_WEBHOOK_URL` puede permanecer vacío hasta que se defina la integración con n8n. La `anon key` de Supabase es pública para el frontend; nunca subir claves privadas como `service_role`, secretos OAuth o contraseñas de base de datos.
---
@@ -178,16 +146,16 @@ El archivo `.env.example` sí puede subirse, siempre que no contenga valores rea
El proyecto está preparado para publicarse bajo la ruta:
```ts
base: "/tablero-cdc/"
base: "/tablero-cdc/";
```
Si la ruta final cambia, debe actualizarse `vite.config.ts`.
Si la ruta final cambia, debe actualizarse `vite.config.ts` y el `basename` del router si aplica.
---
## Notas importantes
- Este repositorio representa la versión inicial del proyecto.
- Este repositorio representa la base inicial del proyecto.
- El diseño base fue aprobado antes de iniciar las integraciones técnicas.
- Las próximas fases deben implementarse en commits separados.
- No se debe alterar la estructura del Google Sheet original sin validación previa.
@@ -199,3 +167,44 @@ Si la ruta final cambia, debe actualizarse `vite.config.ts`.
Proyecto interno de **GomezLee Marketing**.
Desarrollo y soporte técnico: **Isaac Aracena**.
## Persistencia en Supabase
Esta versión ya no depende de `localStorage` para los proyectos. El tablero lee y guarda la información en Supabase usando las tablas:
- `projects`
- `project_links`
- `profiles`
- `app_lists`
Notas importantes:
- Los proyectos abiertos se guardan en base de datos con `status = 'Activo'`, pero en la interfaz se muestran como proyectos sin estatus para mantener el comportamiento visual original.
- Los usuarios normales no ven ni actualizan el monto interno desde la app.
- Solo Gerardo Marrero puede modificar estatus, monto interno y eliminar proyectos desde la interfaz.
- Los links de propuestas y artes finales se guardan en `project_links`.
- Los desplegables se leen desde `app_lists`. Si una categoría todavía no existe en Supabase, la app usa una lista local de respaldo para no bloquear el formulario.
## Supabase Realtime
Para que la sincronización multiusuario funcione, habilita Realtime para las tablas `projects`, `project_links` y `app_lists` ejecutando en Supabase SQL Editor el archivo:
```text
supabase_realtime_projects.sql
```
Luego abre la app en dos pestañas o dos navegadores: al crear, editar o eliminar un proyecto en una ventana, la otra debe actualizarse automáticamente.
## Desplegables dinámicos
La app intenta cargar estos desplegables desde `public.app_lists`:
- `client` / `cliente` / `clientes`
- `brand` / `marca` / `marcas`
- `country` / `pais` / `bu`
- `status` / `estatus`
- `country_manager` / `cm` / `bu_cm` para mapear BU → Country Manager
Si una categoría aún no existe en Supabase, se usa el respaldo local del frontend. Más adelante n8n sincronizará la hoja `listas` del Sheet original hacia `app_lists`.
-1411
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
[install]
# 24h supply-chain guard: skip package versions published less than a day ago.
minimumReleaseAge = 86400
# Each entry bypasses the 24h guard for one package — confirm with the user
# before adding any.
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config"]
+265 -1150
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -37,13 +37,13 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/supabase-js": "^2.87.1",
"@tailwindcss/vite": "^4.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"firebase": "^12.13.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
+2
View File
@@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider, useAuth } from "@/context/AuthContext";
import { LoginScreen } from "@/components/LoginScreen";
import BoardPage from "./pages/BoardPage";
import { Toaster } from "@/components/ui/sonner";
// ─── Error Boundary ──────────────────────────────────────────────────────────
@@ -94,6 +95,7 @@ export function App() {
<AuthProvider>
<BrowserRouter basename="/tablero-cdc/">
<AuthGate />
<Toaster richColors position="top-right" />
</BrowserRouter>
</AuthProvider>
</ErrorBoundary>
-5
View File
@@ -15,16 +15,11 @@ export function LoginScreen() {
</div>
<div className="text-center leading-tight">
<h1 className="text-2xl font-bold tracking-tight">Tablero CDC</h1>
<p className="text-sm text-muted-foreground mt-0.5">Aprobaciones de Proyectos</p>
</div>
</div>
{/* Card */}
<div className="bg-card border border-border rounded-2xl p-8 shadow-sm">
<p className="text-sm text-muted-foreground text-center mb-6">
Accede con tu correo corporativo de GomezLee Marketing.
</p>
<Button
onClick={loginWithGoogle}
disabled={loading}
+2 -2
View File
@@ -13,7 +13,7 @@ export function ProjectCard({ project, onClick }: { project: Project; onClick: (
<button
onClick={onClick}
className={cn(
"group text-left w-full rounded-2xl bg-card overflow-hidden border border-border",
"group block text-left w-full rounded-2xl bg-card overflow-hidden p-0 ring-1 ring-border",
"transition-all duration-200 hover:-translate-y-0.5",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
closed && "opacity-65 hover:opacity-90",
@@ -32,7 +32,7 @@ export function ProjectCard({ project, onClick }: { project: Project; onClick: (
>
{/* Pestaña de color con título */}
<div
className="px-4 py-3"
className="min-h-14 rounded-t-2xl px-4 py-3"
style={{
backgroundColor: closed ? "#9AA0A6" : colorHex(project.color),
}}
+170 -51
View File
@@ -20,14 +20,22 @@ import {
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Lock, Trash2, DollarSign, Calendar } from "lucide-react";
import { AlertCircle, Loader2, Lock, DollarSign, Calendar } from "lucide-react";
import { ColorPicker } from "./ColorPicker";
import { LinkList } from "./LinkList";
import { CLIENTES, BUS, BU_CM, MARCAS, STATUS, COLORS, MESES, type Status } from "@/data/lists";
import { MESES, type Status } from "@/data/lists";
import { useAppLists } from "@/lib/appLists";
import { useProjects, type Project } from "@/lib/store";
import { useAuth } from "@/context/AuthContext";
import { colorHex } from "@/lib/colors";
import { cn } from "@/lib/utils";
import {
canonicalOptionLabel,
dedupeOptions,
ensureOption,
normalizeOptionKey,
} from "@/lib/optionUtils";
import { toast } from "sonner";
interface Props {
open: boolean;
@@ -57,16 +65,31 @@ const empty = (): Omit<Project, "id" | "createdAt"> => {
};
export function ProjectDialog({ open, onOpenChange, project }: Props) {
const { add, update, remove } = useProjects();
const { add, update } = useProjects();
const { lists, loading: listsLoading, error: listsError, refresh: refreshLists } = useAppLists();
const { isGerardo } = useAuth();
const isEdit = !!project;
const [form, setForm] = useState<Omit<Project, "id" | "createdAt">>(empty());
const [saving, setSaving] = useState(false);
const [savingStep, setSavingStep] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const clienteOptions = dedupeOptions(ensureOption(lists.clientes, form.cliente));
const marcaOptions = dedupeOptions(ensureOption(lists.marcas, form.marca));
const buOptions = dedupeOptions(ensureOption(lists.bus, form.bu));
const statusOptions = dedupeOptions(ensureOption(lists.status, form.status)) as Status[];
useEffect(() => {
setFormError(null);
setSavingStep(null);
if (project) {
const { id: _i, createdAt: _c, ...rest } = project;
setForm(rest);
setForm({
...rest,
bu: canonicalOptionLabel(rest.bu),
});
} else if (open) {
setForm(empty());
}
@@ -75,26 +98,82 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
const set = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
setForm((f) => ({ ...f, [k]: v }));
// BU → CM automático
const onBU = (bu: string) => setForm((f) => ({ ...f, bu, cm: BU_CM[bu] ?? "" }));
const getCountryManager = (bu: string) => {
const direct = lists.buCm[bu];
if (direct) return direct;
const buKey = normalizeOptionKey(bu);
const match = Object.entries(lists.buCm).find(([key]) => normalizeOptionKey(key) === buKey);
return match?.[1] ?? "";
};
// BU → Country Manager automático desde Supabase app_lists, con fallback local.
const onBU = (bu: string) => {
const cleanBu = canonicalOptionLabel(bu);
setForm((f) => ({ ...f, bu: cleanBu, cm: getCountryManager(cleanBu) }));
};
const requiredOk =
form.nombre.trim() && form.cliente && form.bu && form.marca && form.solicitante.trim();
const submit = () => {
if (!requiredOk) return;
if (isEdit && project) {
update(project.id, form);
} else {
add({ ...form, id: crypto.randomUUID(), createdAt: Date.now() });
const showSaveToast = (result: Awaited<ReturnType<typeof add>>) => {
if (result.sheetSyncStatus === "synced") {
toast.success(isEdit ? "Proyecto actualizado" : "Proyecto creado", {
description: "Se guardó y se sincronizó con el Google Sheet.",
});
return;
}
onOpenChange(false);
if (result.sheetSyncStatus === "skipped") {
toast.success(isEdit ? "Proyecto actualizado" : "Proyecto creado", {
description: "Se guardó. La sincronización con Google Sheet no está configurada.",
});
return;
}
toast.warning("Proyecto guardado con aviso", {
description: result.sheetSyncMessage,
});
};
const onDelete = () => {
if (project && confirm("¿Eliminar este proyecto?")) {
remove(project.id);
const submit = async () => {
if (!requiredOk || saving) return;
const safeForm = isGerardo
? form
: {
...form,
status: project?.status ?? "",
monto: project?.monto ?? null,
};
try {
setSaving(true);
setFormError(null);
setSavingStep("Guardando y sincronizando con Google Sheet…");
const result =
isEdit && project
? await update(project.id, safeForm)
: await add({ ...safeForm, id: crypto.randomUUID(), createdAt: Date.now() });
showSaveToast(result);
onOpenChange(false);
} catch (error) {
console.error("Error guardando proyecto:", error);
const message =
error instanceof Error
? error.message
: "No se pudo guardar el proyecto. Revisa los permisos o intenta de nuevo.";
setFormError(message);
toast.error("No se pudo guardar el proyecto", {
description: message,
});
} finally {
setSaving(false);
setSavingStep(null);
}
};
@@ -151,6 +230,41 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
<Separator className="md:col-span-2 my-1" />
{(listsLoading || listsError) && (
<div
className={cn(
"md:col-span-2 flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-xs",
listsError
? "border-amber-300/50 bg-amber-50 text-amber-900"
: "border-border bg-muted/30 text-muted-foreground",
)}
>
<span className="flex items-center gap-2">
{listsLoading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Cargando listas
</>
) : (
<>
<AlertCircle className="h-3.5 w-3.5" />
No se pudieron cargar las listas. Se usaron opciones locales.
</>
)}
</span>
{listsError && (
<button
type="button"
onClick={() => void refreshLists()}
className="font-medium text-amber-950 underline-offset-4 hover:underline"
>
Reintentar
</button>
)}
</div>
)}
{/* Cliente */}
<div className="space-y-1.5">
<Label>
@@ -161,7 +275,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
<SelectValue placeholder="Seleccionar…" />
</SelectTrigger>
<SelectContent>
{CLIENTES.map((c) => (
{clienteOptions.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
@@ -180,7 +294,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
<SelectValue placeholder="Seleccionar…" />
</SelectTrigger>
<SelectContent>
{MARCAS.map((m) => (
{marcaOptions.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
@@ -199,7 +313,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
<SelectValue placeholder="Seleccionar…" />
</SelectTrigger>
<SelectContent>
{BUS.map((b) => (
{buOptions.map((b) => (
<SelectItem key={b} value={b}>
{b}
</SelectItem>
@@ -270,7 +384,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
label="Links de Propuestas"
links={form.propuestaLinks}
onChange={(v) => set("propuestaLinks", v)}
emptyText="Aún no hay propuestas. Pegá tantos links como necesites."
emptyText="Aún no hay propuestas. Pega tantos links como necesites."
/>
</div>
@@ -287,38 +401,45 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
<Separator className="md:col-span-2 my-1" />
{/* Status (solo Marrero edita) */}
{/* Status: Gerardo edita; el equipo solo visualiza */}
<div className="space-y-1.5">
<Label className="flex items-center gap-1.5">
Estatus
{!isGerardo && <Lock className="w-3 h-3 text-muted-foreground" />}
</Label>
{isGerardo ? (
<Select
value={form.status || "_none"}
onValueChange={(v) => set("status", (v === "_none" ? "" : v) as Status | "")}
disabled={!isGerardo}
>
<SelectTrigger className={cn("h-10", !isGerardo && "opacity-70")}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Sin estatus" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Sin estatus</SelectItem>
{STATUS.map((s) => (
{statusOptions.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="h-10 px-3 flex items-center rounded-md border border-border bg-muted/30 text-sm text-foreground">
{form.status || "Sin estatus"}
</div>
)}
{!isGerardo && (
<p className="text-[11px] text-muted-foreground">
Solo Marrero puede cambiar el estatus.
Solo Gerardo Marrero puede cambiar el estatus.
</p>
)}
</div>
{/* Monto: SOLO visible para Marrero */}
{isGerardo ? (
{/* Monto: SOLO visible para Gerardo Marrero */}
{isGerardo && (
<div className="space-y-1.5">
<Label className="flex items-center gap-1.5">
<DollarSign className="w-3.5 h-3.5" /> Interno Cargado
@@ -332,40 +453,38 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
placeholder="0.00"
className="h-10"
/>
<p className="text-[11px] text-muted-foreground">Privado · solo lo ves vos.</p>
</div>
) : (
<div className="space-y-1.5">
<Label className="text-muted-foreground flex items-center gap-1.5">
<Lock className="w-3.5 h-3.5" /> Interno Cargado
</Label>
<div className="h-10 px-3 flex items-center rounded-md border border-dashed border-border bg-muted/30 text-xs text-muted-foreground italic">
Solo visible para Marrero
</div>
<p className="text-[11px] text-muted-foreground">Privado · solo Gerardo Marrero.</p>
</div>
)}
</div>
</div>
<DialogFooter className="px-7 py-4 border-t bg-muted/30 rounded-b-lg flex-row justify-between sm:justify-between">
<div>
{isEdit && (
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
{(formError || savingStep) && (
<div
className={cn(
"mt-5 flex items-start gap-2 rounded-xl border px-3 py-2 text-sm",
formError
? "border-destructive/30 bg-destructive/5 text-destructive"
: "border-primary/20 bg-primary/5 text-primary",
)}
>
<Trash2 className="w-4 h-4 mr-1.5" /> Eliminar
</Button>
{formError ? (
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
) : (
<Loader2 className="mt-0.5 h-4 w-4 flex-shrink-0 animate-spin" />
)}
<span>{formError || savingStep}</span>
</div>
)}
</div>
<DialogFooter className="px-7 py-4 border-t bg-muted/30 rounded-b-lg flex-row justify-end sm:justify-end">
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
Cancelar
</Button>
<Button onClick={submit} disabled={!requiredOk}>
{isEdit ? "Guardar cambios" : "Crear proyecto"}
<Button onClick={submit} disabled={!requiredOk || saving}>
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
{saving ? "Guardando…" : isEdit ? "Guardar cambios" : "Crear proyecto"}
</Button>
</div>
</DialogFooter>
+108 -39
View File
@@ -2,12 +2,13 @@ import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
useCallback,
type ReactNode,
} from "react";
import { onAuthStateChanged, signInWithPopup, signOut, type User } from "firebase/auth";
import { auth, googleProvider } from "@/lib/firebase";
import type { User as SupabaseUser } from "@supabase/supabase-js";
import { supabase } from "@/lib/supabase";
const ALLOWED_DOMAIN = "gomezleemarketing.com";
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
@@ -17,8 +18,44 @@ function isAllowedEmail(email: string | null | undefined): boolean {
return email.toLowerCase().endsWith(`@${ALLOWED_DOMAIN}`);
}
function getRedirectTo(): string {
if (typeof window === "undefined") return "/tablero-cdc/";
return new URL(import.meta.env.BASE_URL || "/", window.location.origin).toString();
}
export interface AuthUser {
id: string;
uid: string;
email: string | null;
displayName: string | null;
photoURL: string | null;
raw: SupabaseUser;
}
function normalizeSupabaseUser(user: SupabaseUser): AuthUser {
const metadata = user.user_metadata || {};
const displayName =
metadata.full_name ||
metadata.name ||
metadata.display_name ||
user.email?.split("@")[0] ||
null;
const photoURL = metadata.avatar_url || metadata.picture || null;
return {
id: user.id,
uid: user.id,
email: user.email ?? null,
displayName,
photoURL,
raw: user,
};
}
interface AuthContextValue {
user: User | null;
user: AuthUser | null;
loading: boolean;
error: string | null;
isGerardo: boolean;
@@ -29,67 +66,99 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
if (firebaseUser) {
// Validate domain on every session restore (page reload, etc.)
if (!isAllowedEmail(firebaseUser.email)) {
await signOut(auth);
const applyUser = useCallback(async (supabaseUser: SupabaseUser | null) => {
if (!supabaseUser) {
setUser(null);
return;
}
if (!isAllowedEmail(supabaseUser.email)) {
await supabase.auth.signOut();
setUser(null);
setError("Solo se permite acceso con correos corporativos de GomezLee Marketing.");
} else {
setUser(firebaseUser);
return;
}
setUser(normalizeSupabaseUser(supabaseUser));
setError(null);
}
} else {
}, []);
useEffect(() => {
let mounted = true;
supabase.auth
.getSession()
.then(async ({ data, error: sessionError }) => {
if (!mounted) return;
if (sessionError) {
console.error("Error al recuperar sesión de Supabase:", sessionError);
setUser(null);
setError("No se pudo recuperar la sesión. Intenta iniciar sesión de nuevo.");
return;
}
await applyUser(data.session?.user ?? null);
})
.catch((err: unknown) => {
if (!mounted) return;
console.error("Error inesperado al recuperar sesión:", err);
setUser(null);
setError("No se pudo recuperar la sesión. Intenta iniciar sesión de nuevo.");
})
.finally(() => {
if (mounted) setLoading(false);
});
const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
void applyUser(session?.user ?? null);
setLoading(false);
});
return () => unsubscribe();
}, []);
return () => {
mounted = false;
listener.subscription.unsubscribe();
};
}, [applyUser]);
const loginWithGoogle = useCallback(async () => {
setError(null);
try {
const result = await signInWithPopup(auth, googleProvider);
const email = result.user.email?.toLowerCase() ?? "";
if (!isAllowedEmail(email)) {
await signOut(auth);
setUser(null);
setError("Solo se permite acceso con correos corporativos de GomezLee Marketing.");
return;
}
const { error: loginError } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: getRedirectTo(),
queryParams: {
prompt: "select_account",
hd: ALLOWED_DOMAIN,
},
},
});
setUser(result.user);
} catch (err: unknown) {
// User closed popup or other cancellable error — don't treat as fatal
if (
err &&
typeof err === "object" &&
"code" in err &&
(err as { code: string }).code === "auth/popup-closed-by-user"
) {
return;
}
console.error("Error al iniciar sesión con Google:", err);
if (loginError) {
console.error("Error al iniciar sesión con Google:", loginError);
setError("No se pudo iniciar sesión. Intenta de nuevo.");
}
}, []);
const logout = useCallback(async () => {
await signOut(auth);
const { error: logoutError } = await supabase.auth.signOut();
if (logoutError) {
console.error("Error al cerrar sesión:", logoutError);
setError("No se pudo cerrar sesión. Intenta de nuevo.");
return;
}
setUser(null);
setError(null);
}, []);
const isGerardo = !!user && user.email?.toLowerCase() === GERARDO_EMAIL;
const isGerardo = useMemo(() => !!user && user.email?.toLowerCase() === GERARDO_EMAIL, [user]);
return (
<AuthContext.Provider value={{ user, loading, error, isGerardo, loginWithGoogle, logout }}>
+62 -10
View File
@@ -94,20 +94,72 @@ export const BUS = Object.keys(BU_CM).sort();
export const MARCAS = [
"Carnation",
"La Lechera",
"Nestum",
"Nido",
"Coffee Mate",
"Nescafé",
"GLM RRHH",
"La Lechera",
"Malher",
"US Meat",
"Coffee Mate y Nescafé",
"Nestum y Nido",
"Whirlpool",
"Motorola",
"Philip Morris",
"Malher",
"Bacardí",
"Coca Cola",
"Otros",
"US Meat Guatemala",
"KitKat, Crunch, Choco Trío",
"Colgate",
"Kit Kat",
"UMA",
"Purina",
"Maggi e Ideal",
"Nestlé",
"Jimador",
"Multimarcas",
"Maggi",
"Covo",
"El Machetazo",
"Grupo Bocel",
"Supply Chain",
"Carnation y La Lechera",
"GLM Administración",
"Nestlé Bice",
"GLM",
"Nescafé",
"Idea, Qué Rico y Maggi",
"EY",
"Nesquik",
"Maggi, Ketchup, Qué Rico",
"Ideal",
"Claro Empresas",
"Nido",
"Presto",
"Nestlé RD",
"GLM Colombia",
"Motorola RD",
"GLM Caribe",
"Mandino",
"Coffee Mate",
"GLM Latam",
"RRHH RD",
"Philip Morris TT",
"GLM RD",
"Smirnoff",
"Kaspersky",
"US Pork",
"Qué Rico y Maggi",
"Claro y Motorola",
"Sedal",
"Maytag",
"Qué Rico, Maggi y Ketchup",
"Multimarca",
"Aviva y Princesa",
"KitchenAid",
"Enredé",
"Ascenda",
"Lipton Té",
"Heiniken",
"Oral B",
"Aafresh",
"IQOS",
"Natures Heart",
];
export const STATUS = [
@@ -120,7 +172,7 @@ export const STATUS = [
"Sin respuesta",
] as const;
export type Status = (typeof STATUS)[number];
export type Status = string;
// 10 colores primarios suaves estilo Google Material
export const COLORS = [
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { canonicalOptionLabel, dedupeOptions } from "@/lib/optionUtils";
import {
BUS as FALLBACK_BUS,
BU_CM as FALLBACK_BU_CM,
CLIENTES as FALLBACK_CLIENTES,
MARCAS as FALLBACK_MARCAS,
STATUS as FALLBACK_STATUS,
type Status,
} from "@/data/lists";
type AppListRow = {
category: string | null;
value: string | null;
label: string | null;
sort_order: number | null;
is_active: boolean | null;
};
export type AppLists = {
clientes: string[];
marcas: string[];
bus: string[];
buCm: Record<string, string>;
status: Status[];
};
const FALLBACK_LISTS: AppLists = {
clientes: FALLBACK_CLIENTES,
marcas: FALLBACK_MARCAS,
bus: FALLBACK_BUS,
buCm: FALLBACK_BU_CM,
status: [...FALLBACK_STATUS],
};
let listeners: Array<() => void> = [];
let cache: AppLists = FALLBACK_LISTS;
let loading = false;
let initialized = false;
let errorMessage: string | null = null;
let appListsRealtimeChannel: ReturnType<typeof supabase.channel> | null = null;
let realtimeRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
let activeHookInstances = 0;
function notify() {
listeners.forEach((listener) => listener());
}
function uniqOptions(values: string[]) {
return dedupeOptions(values);
}
function normalizeCategory(value: string | null | undefined) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
}
function canonicalCategory(value: string | null | undefined) {
const category = normalizeCategory(value);
if (["client", "clients", "cliente", "clientes"].includes(category)) return "client";
if (["brand", "brands", "marca", "marcas"].includes(category)) return "brand";
if (
["country", "countries", "pais", "paises", "bu", "business_unit", "business unit"].includes(
category,
)
) {
return "country";
}
if (["status", "estatus", "estado", "estados"].includes(category)) return "status";
if (
["country_manager", "country manager", "cm", "bu_cm", "bu cm", "manager", "managers"].includes(
category,
)
) {
return "country_manager";
}
return category;
}
function rowDisplay(row: AppListRow) {
return canonicalOptionLabel(String(row.label || row.value || ""));
}
function buildLists(rows: AppListRow[]): AppLists {
const clientes: string[] = [];
const marcas: string[] = [];
const bus: string[] = [];
const status: string[] = [];
const buCm: Record<string, string> = { ...FALLBACK_BU_CM };
for (const row of rows) {
if (row.is_active === false) continue;
const category = canonicalCategory(row.category);
const display = rowDisplay(row);
const value = canonicalOptionLabel(String(row.value || ""));
if (!display && !value) continue;
if (category === "client") clientes.push(display || value);
if (category === "brand") marcas.push(display || value);
if (category === "country") bus.push(display || value);
if (category === "status") status.push(display || value);
if (category === "country_manager" && value) {
const manager = display || value;
if (manager && manager !== value) {
buCm[value] = manager;
}
}
}
return {
clientes: clientes.length ? uniqOptions(clientes) : uniqOptions(FALLBACK_LISTS.clientes),
marcas: marcas.length ? uniqOptions(marcas) : uniqOptions(FALLBACK_LISTS.marcas),
bus: bus.length ? uniqOptions(bus) : uniqOptions(FALLBACK_LISTS.bus),
buCm,
status: (status.length ? uniqOptions(status) : uniqOptions(FALLBACK_LISTS.status)) as Status[],
};
}
function setError(message: string | null) {
errorMessage = message;
notify();
}
export async function loadAppLists() {
loading = true;
setError(null);
notify();
try {
const { data, error } = await supabase
.from("app_lists")
.select("category, value, label, sort_order, is_active")
.eq("is_active", true)
.order("category", { ascending: true })
.order("sort_order", { ascending: true })
.order("label", { ascending: true });
if (error) throw error;
cache = buildLists((data || []) as AppListRow[]);
initialized = true;
} catch (error) {
console.error("Error cargando listas desde Supabase:", error);
errorMessage =
error instanceof Error
? error.message
: "No se pudieron cargar los desplegables desde Supabase.";
cache = FALLBACK_LISTS;
} finally {
loading = false;
notify();
}
}
function scheduleRealtimeRefresh() {
if (realtimeRefreshTimeout) {
clearTimeout(realtimeRefreshTimeout);
}
realtimeRefreshTimeout = setTimeout(() => {
realtimeRefreshTimeout = null;
if (!loading) {
void loadAppLists();
}
}, 350);
}
function startAppListsRealtime() {
if (appListsRealtimeChannel) return;
appListsRealtimeChannel = supabase
.channel("tablero-cdc-app-lists")
.on("postgres_changes", { event: "*", schema: "public", table: "app_lists" }, () =>
scheduleRealtimeRefresh(),
)
.subscribe((status) => {
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
console.warn("Supabase Realtime no pudo suscribirse a app_lists:", status);
}
});
}
function stopAppListsRealtime() {
if (realtimeRefreshTimeout) {
clearTimeout(realtimeRefreshTimeout);
realtimeRefreshTimeout = null;
}
if (!appListsRealtimeChannel) return;
void supabase.removeChannel(appListsRealtimeChannel);
appListsRealtimeChannel = null;
}
export function useAppLists() {
const [, force] = useState(0);
useEffect(() => {
const listener = () => force((n) => n + 1);
listeners.push(listener);
activeHookInstances += 1;
startAppListsRealtime();
if (!initialized && !loading) {
void loadAppLists();
}
return () => {
listeners = listeners.filter((x) => x !== listener);
activeHookInstances = Math.max(0, activeHookInstances - 1);
if (activeHookInstances === 0) {
stopAppListsRealtime();
}
};
}, []);
return {
lists: cache,
loading,
error: errorMessage,
refresh: loadAppLists,
};
}
-34
View File
@@ -1,34 +0,0 @@
import { initializeApp } from "firebase/app";
import { getAuth, GoogleAuthProvider } from "firebase/auth";
const requiredVars = [
"VITE_FIREBASE_API_KEY",
"VITE_FIREBASE_AUTH_DOMAIN",
"VITE_FIREBASE_PROJECT_ID",
"VITE_FIREBASE_STORAGE_BUCKET",
"VITE_FIREBASE_MESSAGING_SENDER_ID",
"VITE_FIREBASE_APP_ID",
] as const;
const missing = requiredVars.filter((key) => !import.meta.env[key]);
if (missing.length > 0) {
throw new Error(
`Faltan variables de entorno de Firebase. Revisa tu archivo .env.\nVariables faltantes: ${missing.join(", ")}`,
);
}
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
export const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();
googleProvider.setCustomParameters({ prompt: "select_account" });
+54
View File
@@ -0,0 +1,54 @@
export function cleanOptionLabel(value: string | null | undefined) {
return String(value || "")
.replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200D\uFEFF]/g, "")
.replace(/\s+/g, " ")
.trim();
}
export function normalizeOptionKey(value: string | null | undefined) {
return cleanOptionLabel(value)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
}
export function canonicalOptionLabel(value: string | null | undefined) {
const cleanValue = cleanOptionLabel(value);
const key = normalizeOptionKey(cleanValue);
if (key === "republica dominicana") return "Republica Dominicana";
return cleanValue;
}
export function dedupeOptions(values: Array<string | null | undefined>) {
const unique = new Map<string, string>();
for (const value of values) {
const cleanValue = canonicalOptionLabel(value);
if (!cleanValue) continue;
const key = normalizeOptionKey(cleanValue);
if (!key) continue;
if (!unique.has(key)) {
unique.set(key, cleanValue);
}
}
return Array.from(unique.values());
}
export function ensureOption(options: string[], value: string | null | undefined) {
const uniqueOptions = dedupeOptions(options);
const current = canonicalOptionLabel(value);
if (!current) return uniqueOptions;
const currentKey = normalizeOptionKey(current);
const exists = uniqueOptions.some((option) => normalizeOptionKey(option) === currentKey);
return exists ? uniqueOptions : [current, ...uniqueOptions];
}
+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.
+23
View File
@@ -0,0 +1,23 @@
import { createClient } from "@supabase/supabase-js";
const requiredVars = ["VITE_SUPABASE_URL", "VITE_SUPABASE_ANON_KEY"] as const;
const missing = requiredVars.filter((key) => !import.meta.env[key]);
if (missing.length > 0) {
throw new Error(
`Faltan variables de entorno de Supabase. Revisa tu archivo .env.\nVariables faltantes: ${missing.join(", ")}`,
);
}
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY,
{
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
},
);
+261 -31
View File
@@ -1,5 +1,15 @@
import React, { useMemo, useState } from "react";
import { Plus, Search, LayoutGrid, ChevronDown, LogOut } from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
AlertCircle,
LayoutGrid,
LogOut,
Plus,
RefreshCw,
Search,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -16,6 +26,8 @@ import { useProjects, type Project } from "@/lib/store";
import { useAuth } from "@/context/AuthContext";
/** Extract up-to-2-letter initials from a display name or email */
const PROJECTS_PER_PAGE = 12;
function initials(name: string | null | undefined, email: string | null | undefined): string {
if (name) {
const parts = name.trim().split(/\s+/);
@@ -27,17 +39,26 @@ function initials(name: string | null | undefined, email: string | null | undefi
}
export default function BoardPage() {
const { projects } = useProjects();
const { projects, loading, error, refresh } = useProjects();
const { user, isGerardo, logout } = useAuth();
const projectsTopRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Project | null>(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<"todos" | "abiertos" | "cerrados">("todos");
const [pageByFilter, setPageByFilter] = useState({
todos: 1,
abiertos: 1,
cerrados: 1,
});
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return projects.filter((p) => {
const orderedProjects = [...projects].sort((a, b) => b.createdAt - a.createdAt);
return orderedProjects.filter((p) => {
if (filter === "abiertos" && p.status) return false;
if (filter === "cerrados" && !p.status) return false;
if (!q) return true;
@@ -48,6 +69,33 @@ export default function BoardPage() {
});
}, [projects, query, filter]);
const currentPage = pageByFilter[filter];
const totalPages = Math.max(1, Math.ceil(filtered.length / PROJECTS_PER_PAGE));
const safePage = Math.min(currentPage, totalPages);
const pageStart = (safePage - 1) * PROJECTS_PER_PAGE;
const paginatedProjects = filtered.slice(pageStart, pageStart + PROJECTS_PER_PAGE);
const visibleStart = filtered.length === 0 ? 0 : pageStart + 1;
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, filtered.length);
useEffect(() => {
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
}, [query, filter]);
useEffect(() => {
if (currentPage > totalPages) {
setPageByFilter((prev) => ({ ...prev, [filter]: totalPages }));
}
}, [currentPage, filter, totalPages]);
const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), totalPages);
setPageByFilter((prev) => ({ ...prev, [filter]: nextPage }));
window.requestAnimationFrame(() => {
projectsTopRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
};
const openNew = () => {
setEditing(null);
setOpen(true);
@@ -143,14 +191,13 @@ export default function BoardPage() {
</header>
{/* Filters */}
<div className="max-w-[1400px] mx-auto px-6 pt-6 pb-3 flex items-center justify-between gap-4 flex-wrap">
<div ref={projectsTopRef} className="max-w-[1400px] mx-auto px-6 pt-6 pb-3 space-y-3">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h2 className="text-2xl font-semibold tracking-tight">Proyectos</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{filtered.length} de {projects.length}{" "}
{projects.length === 1 ? "proyecto" : "proyectos"}
</p>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex bg-muted/60 rounded-full p-1 text-xs">
{(
[
@@ -173,28 +220,57 @@ export default function BoardPage() {
))}
</div>
</div>
</div>
<div className="relative md:hidden">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar proyecto, cliente, marca…"
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
/>
</div>
</div>
{/* Grid */}
<main className="max-w-[1400px] mx-auto px-6 pb-16">
{filtered.length === 0 ? (
<EmptyState onNew={openNew} hasProjects={projects.length > 0} />
{error && (
<div className="mb-4 flex flex-col gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive sm:flex-row sm:items-center sm:justify-between">
<span className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<span className="font-medium">No se pudieron cargar los proyectos.</span> {error}
</span>
</span>
<Button variant="outline" size="sm" onClick={() => void refresh()} className="gap-1.5">
<RefreshCw className="h-3.5 w-3.5" /> Reintentar
</Button>
</div>
)}
{loading && projects.length === 0 ? (
<LoadingState />
) : filtered.length === 0 ? (
<EmptyState onNew={openNew} hasProjects={projects.length > 0} filter={filter} query={query} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map((p) => (
{paginatedProjects.map((p) => (
<ProjectCard key={p.id} project={p} onClick={() => openEdit(p)} />
))}
{/* Add tile */}
<button
onClick={openNew}
className="min-h-[230px] rounded-2xl border-2 border-dashed border-border hover:border-primary/50 hover:bg-primary/5 transition-colors flex flex-col items-center justify-center gap-2 text-muted-foreground hover:text-primary"
>
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<Plus className="w-5 h-5" />
</div>
<span className="text-sm font-medium">Nuevo proyecto</span>
</button>
</div>
)}
{filtered.length > PROJECTS_PER_PAGE && (
<PaginationControls
currentPage={safePage}
totalPages={totalPages}
visibleStart={visibleStart}
visibleEnd={visibleEnd}
totalItems={filtered.length}
onPageChange={goToPage}
/>
)}
</main>
<ProjectDialog open={open} onOpenChange={setOpen} project={editing} />
@@ -202,20 +278,174 @@ export default function BoardPage() {
);
}
function EmptyState({ onNew, hasProjects }: { onNew: () => void; hasProjects: boolean }) {
function PaginationControls({
currentPage,
totalPages,
visibleStart,
visibleEnd,
totalItems,
onPageChange,
}: {
currentPage: number;
totalPages: number;
visibleStart: number;
visibleEnd: number;
totalItems: number;
onPageChange: (page: number) => void;
}) {
const pages = getVisiblePages(currentPage, totalPages);
return (
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-3 rounded-2xl border border-border bg-card/70 px-4 py-3">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{visibleStart}-{visibleEnd}
</span>{" "}
de <span className="font-medium text-foreground">{totalItems}</span> proyectos
</p>
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 rounded-full px-2.5"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">Anterior</span>
</Button>
<div className="flex items-center gap-1">
{pages.map((page, index) =>
page === "ellipsis" ? (
<span
key={`ellipsis-${index}`}
className="flex h-8 w-8 items-center justify-center text-xs text-muted-foreground"
>
</span>
) : (
<Button
key={page}
type="button"
variant={page === currentPage ? "default" : "ghost"}
size="sm"
className="h-8 w-8 rounded-full p-0 text-xs"
onClick={() => onPageChange(page)}
aria-current={page === currentPage ? "page" : undefined}
>
{page}
</Button>
),
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 rounded-full px-2.5"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<span className="hidden sm:inline">Siguiente</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
function getVisiblePages(currentPage: number, totalPages: number): Array<number | "ellipsis"> {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, index) => index + 1);
const pages = new Set<number>([1, totalPages, currentPage]);
if (currentPage > 1) pages.add(currentPage - 1);
if (currentPage < totalPages) pages.add(currentPage + 1);
if (currentPage <= 3) {
pages.add(2);
pages.add(3);
pages.add(4);
}
if (currentPage >= totalPages - 2) {
pages.add(totalPages - 3);
pages.add(totalPages - 2);
pages.add(totalPages - 1);
}
const sortedPages = Array.from(pages)
.filter((page) => page >= 1 && page <= totalPages)
.sort((a, b) => a - b);
return sortedPages.reduce<Array<number | "ellipsis">>((acc, page) => {
const previous = acc[acc.length - 1];
if (typeof previous === "number" && page - previous > 1) acc.push("ellipsis");
acc.push(page);
return acc;
}, []);
}
function LoadingState() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="min-h-[230px] overflow-hidden rounded-2xl border border-border bg-card"
>
<div className="h-14 bg-muted animate-pulse" />
<div className="space-y-3 p-4">
<div className="h-3 w-3/4 rounded-full bg-muted animate-pulse" />
<div className="h-3 w-2/3 rounded-full bg-muted animate-pulse" />
<div className="h-3 w-1/2 rounded-full bg-muted animate-pulse" />
<div className="mt-6 h-8 rounded-full bg-muted animate-pulse" />
</div>
</div>
))}
</div>
);
}
function EmptyState({
onNew,
hasProjects,
filter,
query,
}: {
onNew: () => void;
hasProjects: boolean;
filter: "todos" | "abiertos" | "cerrados";
query: string;
}) {
const hasSearch = query.trim().length > 0;
let title = "Tu tablero está vacío";
let message = "Crea tu primer proyecto para que el equipo pueda darle seguimiento.";
if (hasProjects && hasSearch) {
title = "Nada coincide con la búsqueda";
message = "Intenta cambiar la búsqueda o borrar el texto escrito.";
} else if (hasProjects && filter === "abiertos") {
title = "No hay proyectos activos";
message = "Cuando se cree un proyecto nuevo, aparecerá en esta pestaña.";
} else if (hasProjects && filter === "cerrados") {
title = "No hay proyectos cerrados";
message = "Cuando Gerardo asigne un estatus a un proyecto, aparecerá aquí.";
} else if (hasProjects) {
title = "Nada coincide con el filtro";
message = "Intenta quitar el filtro o cambiar la búsqueda.";
}
return (
<div className="text-center py-24 rounded-2xl border border-dashed border-border bg-card/40">
<div className="w-14 h-14 rounded-2xl bg-primary/10 mx-auto flex items-center justify-center mb-4">
<LayoutGrid className="w-6 h-6 text-primary" />
</div>
<h3 className="text-lg font-semibold">
{hasProjects ? "Nada coincide con el filtro" : "Tu tablero está vacío"}
</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">
{hasProjects
? "Probá quitar el filtro o cambiar la búsqueda."
: "Creá tu primer proyecto. Cuando entre un brief nuevo, abrís una caja con su nombre y ya todo el equipo lo ve."}
</p>
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">{message}</p>
{!hasProjects && (
<Button onClick={onNew} className="mt-5 gap-1.5 rounded-full">
<Plus className="w-4 h-4" /> Crear proyecto
+38
View File
@@ -0,0 +1,38 @@
-- =========================================================
-- TABLERO CDC - HABILITAR SUPABASE REALTIME
-- Ejecutar una sola vez en Supabase SQL Editor.
-- Incluye proyectos, links y listas dinámicas.
-- =========================================================
do $$
begin
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'projects'
) then
alter publication supabase_realtime add table public.projects;
end if;
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'project_links'
) then
alter publication supabase_realtime add table public.project_links;
end if;
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'app_lists'
) then
alter publication supabase_realtime add table public.app_lists;
end if;
end $$;