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
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),
}}
+186 -67
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>
<Select
value={form.status || "_none"}
onValueChange={(v) => set("status", (v === "_none" ? "" : v) as Status | "")}
disabled={!isGerardo}
>
<SelectTrigger className={cn("h-10", !isGerardo && "opacity-70")}>
<SelectValue placeholder="Sin estatus" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Sin estatus</SelectItem>
{STATUS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
{isGerardo ? (
<Select
value={form.status || "_none"}
onValueChange={(v) => set("status", (v === "_none" ? "" : v) as Status | "")}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="Sin estatus" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Sin estatus</SelectItem>
{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>
{(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>
<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"
>
<Trash2 className="w-4 h-4 mr-1.5" /> Eliminar
</Button>
)}
</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>
+109 -40
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);
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(() => {
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
if (firebaseUser) {
// Validate domain on every session restore (page reload, etc.)
if (!isAllowedEmail(firebaseUser.email)) {
await signOut(auth);
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("Solo se permite acceso con correos corporativos de GomezLee Marketing.");
} else {
setUser(firebaseUser);
setError(null);
setError("No se pudo recuperar la sesión. Intenta iniciar sesión de nuevo.");
return;
}
} else {
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,
},
},
);
+283 -53
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,58 +191,86 @@ 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>
<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 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>
</div>
<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 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 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