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_SUPABASE_URL="https://ecngyelpabyqbdxhftdy.supabase.co"
VITE_FIREBASE_AUTH_DOMAIN="" VITE_SUPABASE_ANON_KEY="TU_ANON_PUBLIC_KEY"
VITE_FIREBASE_PROJECT_ID=""
VITE_FIREBASE_STORAGE_BUCKET=""
VITE_FIREBASE_MESSAGING_SENDER_ID=""
VITE_FIREBASE_APP_ID=""
VITE_WEBHOOK_URL="" 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 ## Estado actual
Esta versión corresponde a la **base inicial limpia** del proyecto. Esta versión incluye:
Incluye: - Proyecto React + Vite limpio.
- Limpieza del ecosistema Lovable.
- React + Vite puro. - Corrección de `County Manager` a `Country Manager`.
- Configuración de `base` en `vite.config.ts`. - Configuración de `base: "/tablero-cdc/"` en `vite.config.ts`.
- Favicon personalizado para la pestaña del navegador. - Favicon personalizado para la pestaña del navegador.
- Login corporativo con Firebase. - Login corporativo con Supabase Auth y Google Sign-In.
- Diseño visual aprobado como base del producto. - 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. - Tablero visual de proyectos.
- Tarjetas por proyecto. - Tarjetas por proyecto.
- Filtros por estado: - Filtros por estado: Todos, Activos y Cerrados.
- Todos
- Activos
- Cerrados
- Buscador por datos del proyecto. - Buscador por datos del proyecto.
- Modal de creación y edición de proyecto. - Modal de creación y edición de proyecto.
- Campos para cliente, marca, país y solicitante. - Campos para cliente, marca, país, solicitante y `Country Manager`.
- Campo de `Country Manager`. - Link de brief.
- Links de brief. - Links múltiples de propuestas.
- Links de propuestas. - Links múltiples de artes finales.
- Links de artes finales.
- Etiquetas de color por proyecto. - Etiquetas de color por proyecto.
- Visualización de estatus. - Estatus visible para todos los usuarios.
- Preparación para control de permisos por usuario. - 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: Las siguientes funcionalidades se implementarán en próximas fases:
1. Control de permisos por usuario. 1. Sincronización automática de la hoja `listas` del Google Sheet original hacia Supabase `app_lists`.
2. Ocultar monto interno a usuarios que no sean Gerardo Marrero. 2. Escritura automática de datos en el Sheet original mediante n8n u otra integración definida.
3. Bloquear edición de estatus para usuarios que no sean Gerardo Marrero. 3. Agregar una nueva columna final para enlace de brief en el Sheet original.
4. Almacenamiento persistente. 4. Reglas de seguridad definitivas con Supabase RLS.
5. Sincronización multiusuario. 5. Optimización futura de carga con RPC si el volumen de datos lo requiere.
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.
--- ---
## Arquitectura prevista ## Arquitectura prevista
La arquitectura esperada para las próximas fases es:
```text ```text
Frontend React/Vite Frontend React/Vite
Firebase Auth Supabase Auth
Almacenamiento persistente Supabase Database/Postgres + Realtime + RLS
n8n como capa de integración n8n como capa de integración
@@ -134,23 +108,17 @@ npm run preview
## Variables de entorno ## Variables de entorno
El proyecto utiliza variables de entorno para Firebase y futuras integraciones.
Crear un archivo `.env` local basado en `.env.example`. Crear un archivo `.env` local basado en `.env.example`.
Estructura esperada: Estructura esperada:
```env ```env
VITE_FIREBASE_API_KEY="" VITE_SUPABASE_URL=""
VITE_FIREBASE_AUTH_DOMAIN="" VITE_SUPABASE_ANON_KEY=""
VITE_FIREBASE_PROJECT_ID=""
VITE_FIREBASE_STORAGE_BUCKET=""
VITE_FIREBASE_MESSAGING_SENDER_ID=""
VITE_FIREBASE_APP_ID=""
VITE_WEBHOOK_URL="" 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: El proyecto está preparado para publicarse bajo la ruta:
```ts ```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 ## 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. - El diseño base fue aprobado antes de iniciar las integraciones técnicas.
- Las próximas fases deben implementarse en commits separados. - Las próximas fases deben implementarse en commits separados.
- No se debe alterar la estructura del Google Sheet original sin validación previa. - 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**. 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": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@supabase/supabase-js": "^2.87.1",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"firebase": "^12.13.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"react": "^19.2.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 { AuthProvider, useAuth } from "@/context/AuthContext";
import { LoginScreen } from "@/components/LoginScreen"; import { LoginScreen } from "@/components/LoginScreen";
import BoardPage from "./pages/BoardPage"; import BoardPage from "./pages/BoardPage";
import { Toaster } from "@/components/ui/sonner";
// ─── Error Boundary ────────────────────────────────────────────────────────── // ─── Error Boundary ──────────────────────────────────────────────────────────
@@ -94,6 +95,7 @@ export function App() {
<AuthProvider> <AuthProvider>
<BrowserRouter basename="/tablero-cdc/"> <BrowserRouter basename="/tablero-cdc/">
<AuthGate /> <AuthGate />
<Toaster richColors position="top-right" />
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
</ErrorBoundary> </ErrorBoundary>
-5
View File
@@ -15,16 +15,11 @@ export function LoginScreen() {
</div> </div>
<div className="text-center leading-tight"> <div className="text-center leading-tight">
<h1 className="text-2xl font-bold tracking-tight">Tablero CDC</h1> <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>
</div> </div>
{/* Card */} {/* Card */}
<div className="bg-card border border-border rounded-2xl p-8 shadow-sm"> <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 <Button
onClick={loginWithGoogle} onClick={loginWithGoogle}
disabled={loading} disabled={loading}
+2 -2
View File
@@ -13,7 +13,7 @@ export function ProjectCard({ project, onClick }: { project: Project; onClick: (
<button <button
onClick={onClick} onClick={onClick}
className={cn( 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", "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", "focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
closed && "opacity-65 hover:opacity-90", 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 */} {/* Pestaña de color con título */}
<div <div
className="px-4 py-3" className="min-h-14 rounded-t-2xl px-4 py-3"
style={{ style={{
backgroundColor: closed ? "#9AA0A6" : colorHex(project.color), backgroundColor: closed ? "#9AA0A6" : colorHex(project.color),
}} }}
+186 -67
View File
@@ -20,14 +20,22 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; 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 { ColorPicker } from "./ColorPicker";
import { LinkList } from "./LinkList"; 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 { useProjects, type Project } from "@/lib/store";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { colorHex } from "@/lib/colors"; import { colorHex } from "@/lib/colors";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
canonicalOptionLabel,
dedupeOptions,
ensureOption,
normalizeOptionKey,
} from "@/lib/optionUtils";
import { toast } from "sonner";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -57,16 +65,31 @@ const empty = (): Omit<Project, "id" | "createdAt"> => {
}; };
export function ProjectDialog({ open, onOpenChange, project }: Props) { 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 { isGerardo } = useAuth();
const isEdit = !!project; const isEdit = !!project;
const [form, setForm] = useState<Omit<Project, "id" | "createdAt">>(empty()); 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(() => { useEffect(() => {
setFormError(null);
setSavingStep(null);
if (project) { if (project) {
const { id: _i, createdAt: _c, ...rest } = project; const { id: _i, createdAt: _c, ...rest } = project;
setForm(rest); setForm({
...rest,
bu: canonicalOptionLabel(rest.bu),
});
} else if (open) { } else if (open) {
setForm(empty()); 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]) => const set = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
setForm((f) => ({ ...f, [k]: v })); setForm((f) => ({ ...f, [k]: v }));
// BU → CM automático const getCountryManager = (bu: string) => {
const onBU = (bu: string) => setForm((f) => ({ ...f, bu, cm: BU_CM[bu] ?? "" })); 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 = const requiredOk =
form.nombre.trim() && form.cliente && form.bu && form.marca && form.solicitante.trim(); form.nombre.trim() && form.cliente && form.bu && form.marca && form.solicitante.trim();
const submit = () => { const showSaveToast = (result: Awaited<ReturnType<typeof add>>) => {
if (!requiredOk) return; if (result.sheetSyncStatus === "synced") {
if (isEdit && project) { toast.success(isEdit ? "Proyecto actualizado" : "Proyecto creado", {
update(project.id, form); description: "Se guardó y se sincronizó con el Google Sheet.",
} else { });
add({ ...form, id: crypto.randomUUID(), createdAt: Date.now() }); 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 = () => { const submit = async () => {
if (project && confirm("¿Eliminar este proyecto?")) { if (!requiredOk || saving) return;
remove(project.id);
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); 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" /> <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 */} {/* Cliente */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label> <Label>
@@ -161,7 +275,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
<SelectValue placeholder="Seleccionar…" /> <SelectValue placeholder="Seleccionar…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{CLIENTES.map((c) => ( {clienteOptions.map((c) => (
<SelectItem key={c} value={c}> <SelectItem key={c} value={c}>
{c} {c}
</SelectItem> </SelectItem>
@@ -180,7 +294,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
<SelectValue placeholder="Seleccionar…" /> <SelectValue placeholder="Seleccionar…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{MARCAS.map((m) => ( {marcaOptions.map((m) => (
<SelectItem key={m} value={m}> <SelectItem key={m} value={m}>
{m} {m}
</SelectItem> </SelectItem>
@@ -199,7 +313,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
<SelectValue placeholder="Seleccionar…" /> <SelectValue placeholder="Seleccionar…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{BUS.map((b) => ( {buOptions.map((b) => (
<SelectItem key={b} value={b}> <SelectItem key={b} value={b}>
{b} {b}
</SelectItem> </SelectItem>
@@ -270,7 +384,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
label="Links de Propuestas" label="Links de Propuestas"
links={form.propuestaLinks} links={form.propuestaLinks}
onChange={(v) => set("propuestaLinks", v)} 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> </div>
@@ -287,38 +401,45 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
<Separator className="md:col-span-2 my-1" /> <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"> <div className="space-y-1.5">
<Label className="flex items-center gap-1.5"> <Label className="flex items-center gap-1.5">
Estatus Estatus
{!isGerardo && <Lock className="w-3 h-3 text-muted-foreground" />} {!isGerardo && <Lock className="w-3 h-3 text-muted-foreground" />}
</Label> </Label>
<Select
value={form.status || "_none"} {isGerardo ? (
onValueChange={(v) => set("status", (v === "_none" ? "" : v) as Status | "")} <Select
disabled={!isGerardo} value={form.status || "_none"}
> onValueChange={(v) => set("status", (v === "_none" ? "" : v) as Status | "")}
<SelectTrigger className={cn("h-10", !isGerardo && "opacity-70")}> >
<SelectValue placeholder="Sin estatus" /> <SelectTrigger className="h-10">
</SelectTrigger> <SelectValue placeholder="Sin estatus" />
<SelectContent> </SelectTrigger>
<SelectItem value="_none">Sin estatus</SelectItem> <SelectContent>
{STATUS.map((s) => ( <SelectItem value="_none">Sin estatus</SelectItem>
<SelectItem key={s} value={s}> {statusOptions.map((s) => (
{s} <SelectItem key={s} value={s}>
</SelectItem> {s}
))} </SelectItem>
</SelectContent> ))}
</Select> </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 && ( {!isGerardo && (
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
Solo Marrero puede cambiar el estatus. Solo Gerardo Marrero puede cambiar el estatus.
</p> </p>
)} )}
</div> </div>
{/* Monto: SOLO visible para Marrero */} {/* Monto: SOLO visible para Gerardo Marrero */}
{isGerardo ? ( {isGerardo && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="flex items-center gap-1.5"> <Label className="flex items-center gap-1.5">
<DollarSign className="w-3.5 h-3.5" /> Interno Cargado <DollarSign className="w-3.5 h-3.5" /> Interno Cargado
@@ -332,40 +453,38 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
placeholder="0.00" placeholder="0.00"
className="h-10" className="h-10"
/> />
<p className="text-[11px] text-muted-foreground">Privado · solo lo ves vos.</p> <p className="text-[11px] text-muted-foreground">Privado · solo Gerardo Marrero.</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>
</div> </div>
)} )}
</div> </div>
{(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",
)}
>
{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> </div>
<DialogFooter className="px-7 py-4 border-t bg-muted/30 rounded-b-lg flex-row justify-between sm:justify-between"> <DialogFooter className="px-7 py-4 border-t bg-muted/30 rounded-b-lg flex-row justify-end sm:justify-end">
<div>
{isEdit && (
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-4 h-4 mr-1.5" /> Eliminar
</Button>
)}
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}> <Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
Cancelar Cancelar
</Button> </Button>
<Button onClick={submit} disabled={!requiredOk}> <Button onClick={submit} disabled={!requiredOk || saving}>
{isEdit ? "Guardar cambios" : "Crear proyecto"} {saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
{saving ? "Guardando…" : isEdit ? "Guardar cambios" : "Crear proyecto"}
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>
+109 -40
View File
@@ -2,12 +2,13 @@ import React, {
createContext, createContext,
useContext, useContext,
useEffect, useEffect,
useMemo,
useState, useState,
useCallback, useCallback,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { onAuthStateChanged, signInWithPopup, signOut, type User } from "firebase/auth"; import type { User as SupabaseUser } from "@supabase/supabase-js";
import { auth, googleProvider } from "@/lib/firebase"; import { supabase } from "@/lib/supabase";
const ALLOWED_DOMAIN = "gomezleemarketing.com"; const ALLOWED_DOMAIN = "gomezleemarketing.com";
const GERARDO_EMAIL = "gmarrero@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}`); 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 { interface AuthContextValue {
user: User | null; user: AuthUser | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
isGerardo: boolean; isGerardo: boolean;
@@ -29,67 +66,99 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) { 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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
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.");
return;
}
setUser(normalizeSupabaseUser(supabaseUser));
setError(null);
}, []);
useEffect(() => { useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => { let mounted = true;
if (firebaseUser) {
// Validate domain on every session restore (page reload, etc.) supabase.auth
if (!isAllowedEmail(firebaseUser.email)) { .getSession()
await signOut(auth); .then(async ({ data, error: sessionError }) => {
if (!mounted) return;
if (sessionError) {
console.error("Error al recuperar sesión de Supabase:", sessionError);
setUser(null); setUser(null);
setError("Solo se permite acceso con correos corporativos de GomezLee Marketing."); setError("No se pudo recuperar la sesión. Intenta iniciar sesión de nuevo.");
} else { return;
setUser(firebaseUser);
setError(null);
} }
} else {
await applyUser(data.session?.user ?? null);
})
.catch((err: unknown) => {
if (!mounted) return;
console.error("Error inesperado al recuperar sesión:", err);
setUser(null); 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); setLoading(false);
}); });
return () => unsubscribe(); return () => {
}, []); mounted = false;
listener.subscription.unsubscribe();
};
}, [applyUser]);
const loginWithGoogle = useCallback(async () => { const loginWithGoogle = useCallback(async () => {
setError(null); setError(null);
try {
const result = await signInWithPopup(auth, googleProvider);
const email = result.user.email?.toLowerCase() ?? "";
if (!isAllowedEmail(email)) { const { error: loginError } = await supabase.auth.signInWithOAuth({
await signOut(auth); provider: "google",
setUser(null); options: {
setError("Solo se permite acceso con correos corporativos de GomezLee Marketing."); redirectTo: getRedirectTo(),
return; queryParams: {
} prompt: "select_account",
hd: ALLOWED_DOMAIN,
},
},
});
setUser(result.user); if (loginError) {
} catch (err: unknown) { console.error("Error al iniciar sesión con Google:", loginError);
// 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);
setError("No se pudo iniciar sesión. Intenta de nuevo."); setError("No se pudo iniciar sesión. Intenta de nuevo.");
} }
}, []); }, []);
const logout = useCallback(async () => { 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); setUser(null);
setError(null); setError(null);
}, []); }, []);
const isGerardo = !!user && user.email?.toLowerCase() === GERARDO_EMAIL; const isGerardo = useMemo(() => !!user && user.email?.toLowerCase() === GERARDO_EMAIL, [user]);
return ( return (
<AuthContext.Provider value={{ user, loading, error, isGerardo, loginWithGoogle, logout }}> <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 = [ export const MARCAS = [
"Carnation", "Carnation",
"La Lechera",
"Nestum",
"Nido",
"Coffee Mate",
"Nescafé",
"GLM RRHH", "GLM RRHH",
"La Lechera",
"Malher",
"US Meat", "US Meat",
"Coffee Mate y Nescafé",
"Nestum y Nido",
"Whirlpool", "Whirlpool",
"Motorola", "Motorola",
"Philip Morris", "Philip Morris",
"Malher", "US Meat Guatemala",
"Bacardí", "KitKat, Crunch, Choco Trío",
"Coca Cola", "Colgate",
"Otros", "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 = [ export const STATUS = [
@@ -120,7 +172,7 @@ export const STATUS = [
"Sin respuesta", "Sin respuesta",
] as const; ] as const;
export type Status = (typeof STATUS)[number]; export type Status = string;
// 10 colores primarios suaves estilo Google Material // 10 colores primarios suaves estilo Google Material
export const COLORS = [ 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 { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import type { ColorId, Status } from "@/data/lists"; import type { ColorId, Status } from "@/data/lists";
export interface Project { export interface Project {
@@ -21,136 +22,482 @@ export interface Project {
createdAt: number; 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 { try {
const raw = localStorage.getItem(STORAGE_KEY); const response = await fetch(webhookUrl, {
if (!raw) return seed(); method: "POST",
return JSON.parse(raw); headers: {
} catch { "Content-Type": "application/json",
return seed(); },
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[] { function scheduleRealtimeRefresh() {
const now = Date.now(); if (realtimeRefreshTimeout) {
const mesIdx = new Date().getMonth(); clearTimeout(realtimeRefreshTimeout);
const mes = [ }
"Enero",
"Febrero", realtimeRefreshTimeout = setTimeout(() => {
"Marzo", realtimeRefreshTimeout = null;
"Abril",
"Mayo", if (!loading) {
"Junio", void loadProjects();
"Julio", }
"Agosto", }, 350);
"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 save(p: Project[]) { function startProjectsRealtime() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); 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> = []; function stopProjectsRealtime() {
let cache: Project[] | null = null; if (realtimeRefreshTimeout) {
clearTimeout(realtimeRefreshTimeout);
realtimeRefreshTimeout = null;
}
function getAll(): Project[] { if (!projectsRealtimeChannel) return;
if (cache === null) cache = load();
return cache; void supabase.removeChannel(projectsRealtimeChannel);
projectsRealtimeChannel = null;
} }
function setAll(next: Project[]) { async function replaceProjectLinks(projectId: string, project: Pick<Project, "propuestaLinks" | "afLinks">) {
cache = next; const { error: deleteError } = await supabase
save(next); .from("project_links")
listeners.forEach((l) => l()); .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() { export function useProjects() {
const [, force] = useState(0); const [, force] = useState(0);
useEffect(() => { useEffect(() => {
const l = () => force((n) => n + 1); const listener = () => force((n) => n + 1);
listeners.push(l); listeners.push(listener);
activeHookInstances += 1;
startProjectsRealtime();
if (!initialized && !loading) {
void loadProjects();
}
return () => { return () => {
listeners = listeners.filter((x) => x !== l); listeners = listeners.filter((x) => x !== listener);
activeHookInstances = Math.max(0, activeHookInstances - 1);
if (activeHookInstances === 0) {
stopProjectsRealtime();
}
}; };
}, []); }, []);
return { return {
projects: getAll(), projects: cache,
add: (p: Project) => setAll([p, ...getAll()]), loading,
update: (id: string, patch: Partial<Project>) => error: errorMessage,
setAll(getAll().map((x) => (x.id === id ? { ...x, ...patch } : x))), refresh: loadProjects,
remove: (id: string) => setAll(getAll().filter((x) => x.id !== id)), add: addProject,
update: updateProject,
}; };
} }
// NOTE: useRole() was removed in favour of useAuth() from AuthContext. // 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,
},
},
);
+283 -53
View File
@@ -1,5 +1,15 @@
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Plus, Search, LayoutGrid, ChevronDown, LogOut } from "lucide-react"; import {
ChevronDown,
ChevronLeft,
ChevronRight,
AlertCircle,
LayoutGrid,
LogOut,
Plus,
RefreshCw,
Search,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -16,6 +26,8 @@ import { useProjects, type Project } from "@/lib/store";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
/** Extract up-to-2-letter initials from a display name or email */ /** 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 { function initials(name: string | null | undefined, email: string | null | undefined): string {
if (name) { if (name) {
const parts = name.trim().split(/\s+/); const parts = name.trim().split(/\s+/);
@@ -27,17 +39,26 @@ function initials(name: string | null | undefined, email: string | null | undefi
} }
export default function BoardPage() { export default function BoardPage() {
const { projects } = useProjects(); const { projects, loading, error, refresh } = useProjects();
const { user, isGerardo, logout } = useAuth(); const { user, isGerardo, logout } = useAuth();
const projectsTopRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Project | null>(null); const [editing, setEditing] = useState<Project | null>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [filter, setFilter] = useState<"todos" | "abiertos" | "cerrados">("todos"); const [filter, setFilter] = useState<"todos" | "abiertos" | "cerrados">("todos");
const [pageByFilter, setPageByFilter] = useState({
todos: 1,
abiertos: 1,
cerrados: 1,
});
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = query.trim().toLowerCase(); 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 === "abiertos" && p.status) return false;
if (filter === "cerrados" && !p.status) return false; if (filter === "cerrados" && !p.status) return false;
if (!q) return true; if (!q) return true;
@@ -48,6 +69,33 @@ export default function BoardPage() {
}); });
}, [projects, query, filter]); }, [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 = () => { const openNew = () => {
setEditing(null); setEditing(null);
setOpen(true); setOpen(true);
@@ -143,58 +191,86 @@ export default function BoardPage() {
</header> </header>
{/* Filters */} {/* 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> <div className="flex items-center justify-between gap-4 flex-wrap">
<h2 className="text-2xl font-semibold tracking-tight">Proyectos</h2> <div>
<p className="text-sm text-muted-foreground mt-0.5"> <h2 className="text-2xl font-semibold tracking-tight">Proyectos</h2>
{filtered.length} de {projects.length}{" "} </div>
{projects.length === 1 ? "proyecto" : "proyectos"}
</p> <div className="flex items-center gap-2">
<div className="inline-flex bg-muted/60 rounded-full p-1 text-xs">
{(
[
["todos", "Todos"],
["abiertos", "Activos"],
["cerrados", "Cerrados"],
] as const
).map(([k, l]) => (
<button
key={k}
onClick={() => setFilter(k)}
className={`px-3.5 py-1.5 rounded-full transition-colors font-medium ${
filter === k
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{l}
</button>
))}
</div>
</div>
</div> </div>
<div className="inline-flex bg-muted/60 rounded-full p-1 text-xs">
{( <div className="relative md:hidden">
[ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
["todos", "Todos"], <Input
["abiertos", "Activos"], value={query}
["cerrados", "Cerrados"], onChange={(e) => setQuery(e.target.value)}
] as const placeholder="Buscar proyecto, cliente, marca…"
).map(([k, l]) => ( className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
<button />
key={k}
onClick={() => setFilter(k)}
className={`px-3.5 py-1.5 rounded-full transition-colors font-medium ${
filter === k
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{l}
</button>
))}
</div> </div>
</div> </div>
{/* Grid */} {/* Grid */}
<main className="max-w-[1400px] mx-auto px-6 pb-16"> <main className="max-w-[1400px] mx-auto px-6 pb-16">
{filtered.length === 0 ? ( {error && (
<EmptyState onNew={openNew} hasProjects={projects.length > 0} /> <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"> <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)} /> <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> </div>
)} )}
{filtered.length > PROJECTS_PER_PAGE && (
<PaginationControls
currentPage={safePage}
totalPages={totalPages}
visibleStart={visibleStart}
visibleEnd={visibleEnd}
totalItems={filtered.length}
onPageChange={goToPage}
/>
)}
</main> </main>
<ProjectDialog open={open} onOpenChange={setOpen} project={editing} /> <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 ( return (
<div className="text-center py-24 rounded-2xl border border-dashed border-border bg-card/40"> <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"> <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" /> <LayoutGrid className="w-6 h-6 text-primary" />
</div> </div>
<h3 className="text-lg font-semibold"> <h3 className="text-lg font-semibold">{title}</h3>
{hasProjects ? "Nada coincide con el filtro" : "Tu tablero está vacío"} <p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">{message}</p>
</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>
{!hasProjects && ( {!hasProjects && (
<Button onClick={onNew} className="mt-5 gap-1.5 rounded-full"> <Button onClick={onNew} className="mt-5 gap-1.5 rounded-full">
<Plus className="w-4 h-4" /> Crear proyecto <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 $$;