feat: subir versión final Tablero CDC
This commit is contained in:
+2
-6
@@ -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=""
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
Generated
+265
-1150
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
{isGerardo ? (
|
||||||
<Select
|
<Select
|
||||||
value={form.status || "_none"}
|
value={form.status || "_none"}
|
||||||
onValueChange={(v) => set("status", (v === "_none" ? "" : v) as Status | "")}
|
onValueChange={(v) => set("status", (v === "_none" ? "" : v) as Status | "")}
|
||||||
disabled={!isGerardo}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className={cn("h-10", !isGerardo && "opacity-70")}>
|
<SelectTrigger className="h-10">
|
||||||
<SelectValue placeholder="Sin estatus" />
|
<SelectValue placeholder="Sin estatus" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="_none">Sin estatus</SelectItem>
|
<SelectItem value="_none">Sin estatus</SelectItem>
|
||||||
{STATUS.map((s) => (
|
{statusOptions.map((s) => (
|
||||||
<SelectItem key={s} value={s}>
|
<SelectItem key={s} value={s}>
|
||||||
{s}
|
{s}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="px-7 py-4 border-t bg-muted/30 rounded-b-lg flex-row justify-between sm:justify-between">
|
{(formError || savingStep) && (
|
||||||
<div>
|
<div
|
||||||
{isEdit && (
|
className={cn(
|
||||||
<Button
|
"mt-5 flex items-start gap-2 rounded-xl border px-3 py-2 text-sm",
|
||||||
variant="ghost"
|
formError
|
||||||
size="sm"
|
? "border-destructive/30 bg-destructive/5 text-destructive"
|
||||||
onClick={onDelete}
|
: "border-primary/20 bg-primary/5 text-primary",
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-1.5" /> Eliminar
|
{formError ? (
|
||||||
</Button>
|
<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-end sm:justify-end">
|
||||||
<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>
|
||||||
|
|||||||
+108
-39
@@ -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);
|
||||||
|
|
||||||
useEffect(() => {
|
const applyUser = useCallback(async (supabaseUser: SupabaseUser | null) => {
|
||||||
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
|
if (!supabaseUser) {
|
||||||
if (firebaseUser) {
|
setUser(null);
|
||||||
// Validate domain on every session restore (page reload, etc.)
|
return;
|
||||||
if (!isAllowedEmail(firebaseUser.email)) {
|
}
|
||||||
await signOut(auth);
|
|
||||||
|
if (!isAllowedEmail(supabaseUser.email)) {
|
||||||
|
await supabase.auth.signOut();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setError("Solo se permite acceso con correos corporativos de GomezLee Marketing.");
|
setError("Solo se permite acceso con correos corporativos de GomezLee Marketing.");
|
||||||
} else {
|
return;
|
||||||
setUser(firebaseUser);
|
}
|
||||||
|
|
||||||
|
setUser(normalizeSupabaseUser(supabaseUser));
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}, []);
|
||||||
} else {
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
supabase.auth
|
||||||
|
.getSession()
|
||||||
|
.then(async ({ data, error: sessionError }) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (sessionError) {
|
||||||
|
console.error("Error al recuperar sesión de Supabase:", sessionError);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setError("No se pudo recuperar la sesión. Intenta iniciar sesión de nuevo.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await applyUser(data.session?.user ?? null);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
console.error("Error inesperado al recuperar sesión:", err);
|
||||||
|
setUser(null);
|
||||||
|
setError("No se pudo recuperar la sesión. Intenta iniciar sesión de nuevo.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
void applyUser(session?.user ?? null);
|
||||||
setLoading(false);
|
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
@@ -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 = [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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" });
|
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
+458
-111
@@ -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";
|
||||||
|
|
||||||
function load(): Project[] {
|
export type SaveProjectResult = {
|
||||||
if (typeof window === "undefined") return [];
|
projectId: string;
|
||||||
try {
|
sheetSyncStatus: SheetSyncStatus;
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
sheetSyncMessage: string;
|
||||||
if (!raw) return seed();
|
};
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return seed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function seed(): Project[] {
|
type DbProjectLink = {
|
||||||
const now = Date.now();
|
id?: string;
|
||||||
const mesIdx = new Date().getMonth();
|
link_type: string | null;
|
||||||
const mes = [
|
url: string | null;
|
||||||
"Enero",
|
label?: string | null;
|
||||||
"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 save(p: Project[]) {
|
type DbProject = {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
|
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 listeners: Array<() => void> = [];
|
||||||
let cache: Project[] | null = null;
|
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 getAll(): Project[] {
|
function notify() {
|
||||||
if (cache === null) cache = load();
|
listeners.forEach((listener) => listener());
|
||||||
return cache;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAll(next: Project[]) {
|
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;
|
cache = next;
|
||||||
save(next);
|
notify();
|
||||||
listeners.forEach((l) => l());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 scheduleRealtimeRefresh() {
|
||||||
|
if (realtimeRefreshTimeout) {
|
||||||
|
clearTimeout(realtimeRefreshTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
realtimeRefreshTimeout = setTimeout(() => {
|
||||||
|
realtimeRefreshTimeout = null;
|
||||||
|
|
||||||
|
if (!loading) {
|
||||||
|
void loadProjects();
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopProjectsRealtime() {
|
||||||
|
if (realtimeRefreshTimeout) {
|
||||||
|
clearTimeout(realtimeRefreshTimeout);
|
||||||
|
realtimeRefreshTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectsRealtimeChannel) return;
|
||||||
|
|
||||||
|
void supabase.removeChannel(projectsRealtimeChannel);
|
||||||
|
projectsRealtimeChannel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
const requiredVars = ["VITE_SUPABASE_URL", "VITE_SUPABASE_ANON_KEY"] as const;
|
||||||
|
|
||||||
|
const missing = requiredVars.filter((key) => !import.meta.env[key]);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Faltan variables de entorno de Supabase. Revisa tu archivo .env.\nVariables faltantes: ${missing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabase = createClient(
|
||||||
|
import.meta.env.VITE_SUPABASE_URL,
|
||||||
|
import.meta.env.VITE_SUPABASE_ANON_KEY,
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
persistSession: true,
|
||||||
|
autoRefreshToken: true,
|
||||||
|
detectSessionInUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
+261
-31
@@ -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,14 +191,13 @@ 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 className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">Proyectos</h2>
|
<h2 className="text-2xl font-semibold tracking-tight">Proyectos</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
|
||||||
{filtered.length} de {projects.length}{" "}
|
|
||||||
{projects.length === 1 ? "proyecto" : "proyectos"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className="inline-flex bg-muted/60 rounded-full p-1 text-xs">
|
<div className="inline-flex bg-muted/60 rounded-full p-1 text-xs">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
@@ -173,28 +220,57 @@ export default function BoardPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative md:hidden">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Buscar proyecto, cliente, marca…"
|
||||||
|
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* 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
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
Reference in New Issue
Block a user