feat: actualizar Tablero CDC con administracion de listas

This commit is contained in:
2026-08-13 18:21:34 -04:00
parent f4f6ee23df
commit 0567c666d3
65 changed files with 8120 additions and 1863 deletions
+1
View File
@@ -1,3 +1,4 @@
VITE_SUPABASE_URL="https://dbit.digitalcompass.agency" VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
VITE_SUPABASE_ANON_KEY="TU_ANON_PUBLIC_KEY" VITE_SUPABASE_ANON_KEY="TU_ANON_PUBLIC_KEY"
VITE_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-proyecto" VITE_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-proyecto"
VITE_LISTS_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-listas-admin"
+8
View File
@@ -0,0 +1,8 @@
# Corrección aplicada
- **Build:** corregido el `TS2322` de `src/lib/tariffCatalog.ts` sin cambiar la lógica funcional.
- **Producción/XAMPP:** el modal del tarifario ya no se monta dentro del encabezado; se abre en `document.body` mediante portal para impedir el cuadro blanco o recortado.
- **Caché:** el `dist/index.html` utiliza nombres nuevos de assets y un identificador de versión.
- **Compatibilidad:** se mantienen copias con los nombres anteriores de los assets.
Para probar el paquete, elimina primero el contenido anterior de `C:\xampp\htdocs\tablero-cdc` y copia allí el contenido de la carpeta `dist` incluida.
+30
View File
@@ -0,0 +1,30 @@
# Administración instantánea de listas — Tablero CDC
## Objetivo
Permitir que los administradores actualicen desde Tablero CDC las listas utilizadas por el formulario sin dejar de usar Google Sheet como fuente oficial.
## Fuente oficial
- Spreadsheet: `CD_Latam_Registro Horas_2024`
- Hoja: `Listas`
- La app nunca escribe directamente en `tablero_cdc_app_lists`.
- La app llama n8n, n8n modifica la hoja `Listas` y luego sincroniza Supabase.
- El workflow programado se mantiene como reconciliación de respaldo.
## Listas administrables
- Cliente → columna A (`Cliente`)
- Marca → columna R (`Marca`). En la exportación recibida esa columna está vacía y no tiene referencias; el workflow la inicializa de forma segura conservando las marcas activas de Supabase y el catálogo de respaldo antes de sincronizar.
- País / BU + Country Manager → columnas C:D (`Pais / BU`, `CM`)
- Estatus → columna N (`estatus`)
## Seguridad
- El botón `Listas` usa el mismo permiso existente `can_manage_tariff_catalog`.
- El webhook valida la sesión de Supabase y vuelve a comprobar ese permiso antes de permitir cualquier escritura.
- Los cambios no eliminan proyectos históricos; solo modifican opciones disponibles para nuevas ediciones/selecciones.
## Actualización inmediata
1. El administrador crea, edita o elimina una opción.
2. n8n actualiza únicamente la columna correspondiente del Google Sheet.
3. n8n ejecuta el mismo RPC existente `tablero_cdc_sync_app_lists`.
4. Supabase Realtime notifica a las sesiones abiertas y `useAppLists()` recarga las listas.
También existe `Sincronizar desde Sheet` para aplicar manualmente cambios hechos directamente en Google Sheet sin esperar el Schedule Trigger. El flujo programado conserva su intervalo existente de 3 horas.
+76
View File
@@ -0,0 +1,76 @@
# Módulo de administración del tarifario — Tablero CDC
## Qué incorpora
- Botón **Tarifario** inmediatamente antes de **+ Nuevo**.
- El botón depende exclusivamente de `tablero_cdc_allowed_users.can_manage_tariff_catalog`.
- Administración de las secciones generales existentes:
- Tarifario Gráfico CDC.
- Estrategia y Creatividad.
- Creación de nuevas secciones generales.
- Creación de tarifarios especiales vinculados a cualquier cliente activo del Tablero CDC.
- Edición, desactivación y reactivación de secciones y tarifas.
- Las tarifas especiales se muestran únicamente cuando el proyecto tiene seleccionado el cliente asociado.
- Cada tarifario especial utiliza selección múltiple de piezas con montos fijos y puede permitir un costo manual adicional.
- Los proyectos históricos no se modifican.
## Orden de instalación
1. Ejecutar completo `supabase_tariff_admin_module.sql` en Supabase.
2. Confirmar que las consultas de verificación muestran a Isaac, Gerardo y Alicia con `can_manage_tariff_catalog = true`.
3. Ejecutar `npm run build` o utilizar el `dist` incluido, que ya contiene el módulo.
4. Probar localmente con XAMPP.
5. Publicar el `dist` validado.
## Dar acceso a otra persona
La persona debe existir primero como usuario activo del Tablero CDC. Luego ejecutar:
```sql
update public.tablero_cdc_allowed_users
set can_manage_tariff_catalog = true,
updated_at = now()
where lower(trim(email)) = lower(trim('correo@gomezleemarketing.com'));
```
## Quitar acceso al módulo
```sql
update public.tablero_cdc_allowed_users
set can_manage_tariff_catalog = false,
updated_at = now()
where lower(trim(email)) = lower(trim('correo@gomezleemarketing.com'));
```
Después del cambio, el usuario debe recargar la aplicación. Si tenía una sesión abierta desde antes, puede cerrar sesión y volver a entrar para que el permiso se lea nuevamente.
## Verificar quién tiene acceso
```sql
select
email,
full_name,
is_active,
can_manage_tariff_catalog
from public.tablero_cdc_allowed_users
where can_manage_tariff_catalog = true
order by full_name, email;
```
## Pruebas recomendadas
1. Isaac, Gerardo y Alicia ven el botón **Tarifario**.
2. Un usuario sin el permiso no ve el botón y tampoco puede escribir en las tablas por RLS.
3. Editar una tarifa de una sección general y verificarla dentro de un proyecto de un cliente normal.
4. Crear una sección general nueva, agregar una tarifa y verificar que aparezca en el estimador interno.
5. Crear un tarifario por cliente, asociarlo a un cliente de prueba y agregar dos piezas con montos fijos.
6. Abrir un proyecto de ese cliente y verificar selección múltiple, subtotal y costo manual.
7. Abrir un proyecto de otro cliente y confirmar que el tarifario especial no aparezca.
8. Desactivar una tarifa y comprobar que deja de ofrecerse sin borrar costos históricos.
## Compatibilidad con el sincronizador del Sheet
- Las tarifas administradas desde la app se marcan con `managed_by = 'app'`.
- El sincronizador de n8n no puede sobrescribir tarifas que ya hayan sido administradas desde la app.
- La desactivación masiva del Sheet solo afecta las tarifas gestionadas por el Sheet en las secciones originales.
- Las nuevas secciones y los tarifarios especiales permanecen protegidos.
+44
View File
@@ -0,0 +1,44 @@
# Notificaciones personales — CDC Brief / Nuevos
Actualización: 2026-08-12
## Comportamiento
La navegación queda en este orden:
`Todos → Activos → Cerrados → Nuevos → No aprobados`
El apartado **Nuevos** muestra un badge rojo únicamente cuando el usuario autenticado tiene briefs pendientes que todavía no ha visto.
- Cada usuario mantiene su propio estado de lectura en Supabase.
- Entrar a **Nuevos** marca como vistos, para ese usuario, todos los briefs que estén pendientes en ese momento y el badge desaparece.
- El hecho de que un usuario entre a **Nuevos** no borra la notificación de los demás usuarios.
- Si otra persona aprueba o no aprueba un brief, deja de contar como pendiente para todos y los contadores se actualizan.
- Un brief que llegue después vuelve a incrementar el badge de quienes todavía no lo hayan visto.
- Mientras el usuario permanece en **Nuevos**, los briefs nuevos que aparecen en la cola visible se marcan como vistos para esa persona.
## Supabase
El estado por usuario se guarda en:
`public.tablero_cdc_brief_inbox_seen`
RPCs nuevas:
- `tablero_cdc_get_unseen_brief_count()`
- `tablero_cdc_mark_briefs_seen()`
La solución usa una relación `(user_id, inbox_id)` y no un timestamp global, evitando que un brief que llegue concurrentemente quede marcado como visto por accidente.
## SQL a ejecutar
- Si todavía **no** se había ejecutado el SQL anterior de la bandeja: ejecutar `supabase_cdc_brief_review_inbox.sql` completo.
- Si el SQL anterior **ya** estaba instalado: ejecutar solamente `supabase_cdc_brief_unread_notifications.sql`.
## Producción / dist
Se conserva el bundle principal ya probado. El nuevo comportamiento de la bandeja se carga desde:
`dist/assets/brief-review-inbox-v2.js`
En el código fuente React la actualización usa Supabase Realtime y un fallback periódico. El módulo de `dist` conserva un refresco periódico y por foco/visibilidad para mantener compatibilidad con el build híbrido existente sin reemplazar el bundle principal.
+20
View File
@@ -0,0 +1,20 @@
# Paginación del módulo de tarifario
Fecha: 2026-07-28
## Implementación
- Lista lateral de secciones: **6 secciones por página**.
- Lista principal de tarifas/piezas: **10 registros por página**.
- Los controles muestran el rango visible, el total y la página actual.
- Al cambiar entre **Generales** y **Por cliente**, la lista vuelve a la primera página.
- Al seleccionar otra sección, buscar o activar **Mostrar inactivas**, las tarifas vuelven a la primera página.
- Si se crean, desactivan o filtran registros y disminuye el número de páginas, la página actual se ajusta automáticamente.
## Alcance
No se modificaron tablas, políticas, permisos, consultas de Supabase, formularios de edición ni la lógica de creación/desactivación. El cambio se limita a la presentación paginada del módulo administrativo.
## Distribución
La carpeta `dist` ya contiene la versión lista para probar en XAMPP bajo `/tablero-cdc/`.
+166 -560
View File
@@ -1,282 +1,104 @@
# Tablero CDC — Project Management # Tablero CDC
> Aplicación interna de GomezLee Marketing para centralizar la gestión de proyectos creativos del CDC, sus aprobaciones, enlaces, estimación interna y tarifarios en una sola interfaz web. Aplicación interna para la gestión visual de aprobaciones de proyectos creativos del equipo CDC de **GomezLee Marketing**.
El objetivo de esta app es simplificar el flujo de trabajo del equipo creativo mediante un tablero tipo Trello, donde cada tarjeta representa un proyecto y centraliza sus datos principales, enlaces y estado de aprobación.
--- ---
## INFORMACIÓN GENERAL ## Estado actual
| Campo | Detalle | Esta versión incluye:
|---|---|
| **Proyecto** | Tablero CDC / CDC Project Management | - Proyecto React + Vite limpio.
| **Área** | Creatividad y Diseño — CDC | - Limpieza del ecosistema Lovable.
| **Developer Principal** | Isaac Aracena | - Corrección de `County Manager` a `Country Manager`.
| **IT Manager** | Luis Matos | - Configuración de `base: "/tablero-cdc/"` en `vite.config.ts`.
- Favicon personalizado para la pestaña del navegador.
- Login corporativo con Supabase Auth y Google Sign-In.
- Restricción de acceso a correos `@gomezleemarketing.com`.
- Usuario autenticado real en el encabezado.
- Botón de cerrar sesión.
- Rol `isGerardo` para Gerardo Marrero.
- Permisos visuales iniciales: solo Gerardo Marrero puede ver/editar monto interno y editar estatus.
- Paginación local en las vistas de Todos, Activos y Cerrados con 12 proyectos por página.
- Persistencia de proyectos en Supabase.
- Sincronización multiusuario con Supabase Realtime.
- Desplegables dinámicos desde Supabase `tablero_cdc_app_lists`, con fallback local mientras se termina la sincronización del Sheet.
--- ---
## OBJETIVO ## Funcionalidades principales
### Problema que resuelve - Tablero visual de proyectos.
- Tarjetas por proyecto.
La gestión de proyectos del CDC requiere centralizar información que de otro modo queda distribuida entre hojas de cálculo, enlaces, comunicaciones y seguimientos manuales. Esto dificulta consultar rápidamente el estado de un proyecto, sus responsables, propuestas, artes finales, costos internos y actividad reciente. - Filtros por estado: Todos, Activos y Cerrados.
- Buscador por datos del proyecto.
### Solución implementada - Modal de creación y edición de proyecto.
- Campos para cliente, marca, país, solicitante y `Country Manager`.
El Tablero CDC ofrece una aplicación web donde los usuarios autorizados pueden: - Link de brief.
- Links múltiples de propuestas.
- crear, consultar, editar y cerrar proyectos; - Links múltiples de artes finales.
- filtrar y buscar proyectos; - Etiquetas de color por proyecto.
- manejar links de brief, propuestas y artes finales; - Estatus visible para todos los usuarios.
- visualizar actividad e información clave de cada proyecto; - Monto interno visible solo para Gerardo Marrero.
- calcular y guardar el costo interno mediante tarifarios; - Paginación local para manejar tableros con muchos proyectos.
- usar tarifarios generales o tarifarios especiales por cliente;
- administrar el catálogo de tarifarios cuando el usuario tiene permiso;
- consultar el total tarifado global o filtrado según permisos;
- trabajar con datos persistidos en Supabase y sincronizados entre usuarios.
### Usuarios / Beneficiarios
- Equipo de Creatividad y Diseño / CDC.
- Director Creativo.
- Usuarios internos autorizados de GomezLee Marketing.
- IT, para soporte, mantenimiento y administración técnica.
- Áreas que consumen la información consolidada posteriormente mediante Google Sheets / Power BI.
--- ---
## ARQUITECTURA ## Funcionalidades pendientes
### Diagrama de flujo principal Las siguientes funcionalidades se implementarán en próximas fases:
1. Sincronización automática de la hoja `listas` del Google Sheet original hacia Supabase `tablero_cdc_app_lists`.
2. Escritura automática de datos en el Sheet original mediante n8n u otra integración definida.
3. Agregar una nueva columna final para enlace de brief en el Sheet original.
4. Reglas de seguridad definitivas con Supabase RLS.
5. Optimización futura de carga con RPC si el volumen de datos lo requiere.
---
## Arquitectura prevista
```text ```text
Usuario autorizado Frontend React/Vite
|
v Supabase Auth
React + TypeScript + Vite
| Supabase Database/Postgres + Realtime + RLS
+--> Supabase Auth (Google OAuth)
| n8n como capa de integración
+--> tablero_cdc_allowed_users
| | Google Sheets original
| +--> permisos funcionales
|
+--> Supabase Postgres
| |
| +--> Proyectos
| +--> Links
| +--> Actividad
| +--> Costos internos
| +--> Listas dinámicas
| +--> Tarifarios
| +--> RPCs de paginación y totales
|
+--> Supabase Realtime
|
+--> n8n Webhook
|
v
Google Sheets
|
v
Power BI Power BI
``` ```
### Flujo del tarifario La app no debe modificar la estructura existente del Sheet original, ya que ese archivo alimenta un dashboard de Power BI. Cualquier columna nueva debe agregarse al final y validarse previamente.
```text
Google Sheet del tarifario
|
v
n8n
|
v
Supabase tariff catalog
|
+--> Secciones generales
|
+--> Tarifarios por cliente
|
v
Estimador interno del proyecto
|
v
tablero_cdc_project_pricing_items
```
Las tarifas administradas directamente desde la aplicación se identifican con `managed_by = 'app'` para protegerlas frente al sincronizador del Sheet.
### Stack tecnológico
| Componente | Tecnología | Propósito |
|---|---|---|
| Frontend | React 19 + TypeScript | Interfaz y lógica de la aplicación |
| Build / Dev Server | Vite 6 | Desarrollo y compilación |
| UI | Tailwind CSS 4 + Radix UI | Diseño y componentes |
| Base de datos | Supabase / PostgreSQL | Persistencia, RLS, RPCs y configuración |
| Autenticación | Supabase Auth + Google OAuth | Inicio de sesión corporativo |
| Tiempo real | Supabase Realtime | Sincronización multiusuario |
| Automatización | n8n | Integración con Google Sheets |
| Fuente / salida operativa | Google Sheets | Sincronización con procesos existentes |
| Reporting | Power BI | Consumo posterior de la información |
| Repositorio | Gitea | Control de versiones |
### Integraciones externas
| Sistema | Tipo de integración | Datos que fluyen |
|---|---|---|
| Supabase | SDK / REST / RPC / Realtime | Usuarios, proyectos, permisos, listas, links, actividad, tarifas y costos |
| Google OAuth | OAuth 2.0 vía Supabase | Identidad del usuario |
| n8n | Webhook HTTPS | Sincronización de proyectos y catálogo |
| Google Sheets | n8n | Datos operativos y catálogo de tarifarios |
| Power BI | Fuente existente basada en Sheets | Reporting / visualización |
--- ---
## REGLAS DE NEGOCIO ## Instalación local
1. **El acceso no depende solamente del dominio del correo.** El usuario debe autenticarse con Google y existir activo en `public.tablero_cdc_allowed_users`. Instalar dependencias:
2. **Los permisos especiales se controlan desde Supabase**, principalmente mediante:
- `can_delete_projects`
- `can_manage_internal_pricing`
- `can_control_pricing_summary`
- `can_manage_tariff_catalog`
3. **No se deben hardcodear administradores nuevos en el frontend.** Los accesos y permisos se administran en Supabase.
4. **Los tarifarios generales** están disponibles para los clientes que no dependen de un tarifario especial.
5. **Los tarifarios por cliente** solo aparecen al seleccionar el cliente asociado a esa sección.
6. **Walmart Connect WMC** utiliza un tarifario especial con 12 piezas de precio fijo y mantiene la posibilidad de agregar un costo manual para propuestas o conceptos no contemplados.
7. **Las tarifas administradas desde la app** quedan marcadas con `managed_by = 'app'`. El sincronizador del Sheet no debe sobrescribirlas.
8. **Las tarifas históricas no deben eliminarse como operación normal.** Se deben desactivar para conservar integridad histórica.
9. **Los costos seleccionados en un proyecto** se guardan en `tablero_cdc_project_pricing_items` y alimentan el total interno del proyecto.
10. **El Total Tarifado Global** se calcula mediante la RPC `tablero_cdc_get_pricing_summary` y respeta los filtros activos. Los proyectos creados con tarifarios actuales o futuros siguen formando parte del total al guardar su costo interno.
11. **La visibilidad del Total Tarifado** se controla mediante configuración y permisos de Supabase.
12. **La estructura del Google Sheet original debe mantenerse estable**, especialmente cuando es consumida por Power BI. Cualquier cambio estructural debe validarse previamente.
13. **Paginación actual:**
- proyectos: `24` por página;
- secciones del módulo de tarifario: `6` por página;
- tarifas / piezas del módulo: `10` por página.
14. Al cambiar de sección, buscar, cambiar entre **Generales / Por cliente** o activar **Mostrar inactivas**, el módulo de tarifario reajusta la página automáticamente.
---
## CONFIGURACIÓN Y SETUP
### Prerrequisitos
- Git.
- Node.js + npm.
- Acceso al repositorio interno en Gitea.
- Proyecto Supabase configurado.
- Google OAuth configurado en Supabase Auth.
- Acceso a SQL Editor de Supabase para instalaciones o migraciones.
- Acceso al workflow n8n correspondiente si se requiere sincronización con Google Sheets.
### Variables de entorno
Crear `.env` a partir de `.env.example`.
| Variable | Descripción | Dónde se obtiene |
|---|---|---|
| `VITE_SUPABASE_URL` | URL pública del proyecto Supabase | Supabase / infraestructura GLM |
| `VITE_SUPABASE_ANON_KEY` | Anon/Public Key usada por el frontend | Supabase |
| `VITE_WEBHOOK_URL` | Webhook de sincronización de proyectos | n8n |
Ejemplo:
```env
VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
VITE_SUPABASE_ANON_KEY="TU_ANON_PUBLIC_KEY"
VITE_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-proyecto"
```
> **Nunca commitear `.env`, `service_role`, secretos OAuth, contraseñas, tokens privados ni credenciales administrativas.**
La `anon key` del frontend no debe sustituirse por una `service_role`.
### Esquema de base de datos / SQL incluidos
El repositorio mantiene scripts SQL separados para las distintas capacidades:
```text
supabase_tablero_cdc_access_control.sql
supabase_tablero_cdc_alicia_permissions.sql
supabase_project_activity.sql
supabase_project_pricing_items.sql
supabase_realtime_projects.sql
supabase_rpc_projects_paginated.sql
supabase_pricing_summary_visibility.sql
supabase_tariff_catalog_and_safe_links.sql
supabase_walmart_connect_tariff.sql
supabase_tariff_admin_module.sql
```
#### Script principal del módulo de tarifario
Para una instalación que ya tenga la base anterior del Tablero CDC, el archivo:
```text
supabase_tariff_admin_module.sql
```
incorpora de forma idempotente el tarifario Walmart Connect y el módulo de administración del catálogo, incluyendo `can_manage_tariff_catalog`, las secciones dinámicas y sus políticas RLS.
Después de ejecutarlo, verificar que los usuarios administradores esperados tengan:
```text
can_manage_tariff_catalog = true
```
### Instalación local
```bash ```bash
git clone https://git.digitalcompass.agency/Isaac_Aracena/cdc-project-management.git
cd cdc-project-management
npm install npm install
cp .env.example .env
``` ```
Editar `.env` con los valores correctos. Ejecutar en modo desarrollo:
Ejecutar en desarrollo:
```bash ```bash
npm run dev npm run dev
``` ```
La configuración actual de Vite usa: Generar build de producción:
```text
http://localhost:3000/tablero-cdc/
```
### Build
```bash ```bash
npm run build npm run build
``` ```
El script ejecuta: Vista previa del build:
```text
tsc && vite build
```
Por lo tanto, un error de TypeScript detiene el build y debe corregirse antes de publicar.
### Preview
```bash ```bash
npm run preview npm run preview
@@ -284,352 +106,136 @@ npm run preview
--- ---
## INSTALACIÓN / DEPLOY ## Variables de entorno
La aplicación está compilada para funcionar debajo de: Crear un archivo `.env` local basado en `.env.example`.
```text Estructura esperada:
/tablero-cdc/
```env
VITE_SUPABASE_URL=""
VITE_SUPABASE_ANON_KEY=""
VITE_WEBHOOK_URL=""
``` ```
Configuración en `vite.config.ts`: 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.
---
## Seguridad
No se deben subir al repositorio:
- `.env`
- `.env.local`
- `.env.production`
- `.env.development`
- `node_modules`
- `dist`
- tokens
- credenciales
- archivos con secretos
- logs innecesarios
El archivo `.env.example` sí puede subirse, siempre que no contenga valores reales.
---
## Despliegue
El proyecto está preparado para publicarse bajo la ruta:
```ts ```ts
base: "/tablero-cdc/"; base: "/tablero-cdc/";
``` ```
### Deploy manual mediante `dist` Si la ruta final cambia, debe actualizarse `vite.config.ts` y el `basename` del router si aplica.
Este repositorio **sí mantiene el `dist` validado** porque el flujo actual de despliegue utiliza esa carpeta directamente en el servidor. ---
El `dist` debe contener como mínimo: ## Notas importantes
- Este repositorio representa la base inicial del proyecto.
- El diseño base fue aprobado antes de iniciar las integraciones técnicas.
- Las próximas fases deben implementarse en commits separados.
- No se debe alterar la estructura del Google Sheet original sin validación previa.
- La integración con Power BI depende de mantener estable la estructura del Sheet.
---
## Equipo
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:
- `tablero_cdc_projects`
- `tablero_cdc_project_links`
- `tablero_cdc_profiles`
- `tablero_cdc_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 `tablero_cdc_project_links`.
- Los desplegables se leen desde `tablero_cdc_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 `tablero_cdc_projects`, `tablero_cdc_project_links` y `tablero_cdc_app_lists` ejecutando en Supabase SQL Editor el archivo:
```text ```text
dist/ supabase_realtime_projects.sql
├── assets/
├── index.html
├── favicon.ico
└── demás archivos públicos generados
``` ```
> `dist/assets/` es obligatorio. Sin esa carpeta, `index.html` no podrá cargar correctamente el JavaScript y CSS compilados. 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.
Flujo recomendado: ## Desplegables dinámicos
1. Probar la aplicación con `npm run dev`. La app intenta cargar estos desplegables desde `public.tablero_cdc_app_lists`:
2. Generar el build con `npm run build`.
3. Probar **ese mismo `dist`** en XAMPP bajo `/tablero-cdc/`.
4. No regenerar el build después de la validación si se desea desplegar exactamente la versión probada.
5. Subir a Gitea el mismo `dist`, incluyendo `dist/assets/`.
6. Copiar ese `dist` validado al servidor.
7. Probar login, tablero, tarifario y una apertura directa sin recargar la página.
--- - `client` / `cliente` / `clientes`
- `brand` / `marca` / `marcas`
- `country` / `pais` / `bu`
- `status` / `estatus`
- `country_manager` / `cm` / `bu_cm` para mapear BU → Country Manager
## CÓMO FUNCIONA 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 `tablero_cdc_app_lists`.
### Flujo paso a paso ## Actualización: Tarifario dinámico desde Sheet/n8n, permisos internos y total tarifado por RPC
1. El usuario entra a la aplicación. Esta versión mantiene la app como consumidora del tarifario, pero elimina la administración manual dentro de la app:
2. Supabase Auth inicia o recupera la sesión Google.
3. La app consulta `tablero_cdc_allowed_users`.
4. Si el usuario no está activo/autorizado, la sesión se rechaza.
5. Si está autorizado, la app carga sus permisos.
6. El tablero consulta los proyectos desde Supabase mediante RPC paginada.
7. Los filtros se aplican desde la consulta y no requieren descargar toda la base.
8. Realtime mantiene sincronizadas las ventanas activas.
9. Al crear o editar un proyecto, la información se persiste en Supabase.
10. Si `VITE_WEBHOOK_URL` está configurado, la aplicación dispara la sincronización correspondiente hacia n8n.
11. Los costos internos se guardan como líneas de pricing por proyecto.
12. El total tarifado se calcula en Supabase mediante RPC.
13. El tarifario administrativo solo aparece a usuarios con `can_manage_tariff_catalog = true`.
### Tarifario general 1. **Tarifario desde Google Sheets/n8n**: ya no aparece la pantalla privada de “Administrar tarifario”. El catálogo visible en los proyectos se carga desde `tablero_cdc_tariff_catalog`, que deberá sincronizarse desde el Google Sheet del tarifario mediante n8n. La app conserva el tarifario base como respaldo si la tabla está vacía o aún no existe.
2. **Total tarifado global/filtrado por RPC**: la tarjeta de total tarifado ya no necesita traer todos los proyectos para sumar en el navegador. La app llama la RPC `tablero_cdc_get_pricing_summary`, que calcula en Supabase el total de `Interno Cargado` y la cantidad de proyectos incluidos según los filtros activos. Si no hay filtros, devuelve el total global.
3. **Permisos internos ajustados**: el total tarifado, `Interno Cargado` y eliminar proyecto quedan restringidos en frontend a `gmarrero@gomezleemarketing.com` e `iaracena@gomezleemarketing.com`.
El estimador puede utilizar secciones generales como: Antes de usar esta versión en producción, ejecutar en Supabase SQL Editor:
- **Tarifario Gráfico CDC** ```sql
- **Estrategia y Creatividad** -- Archivo incluido en este ZIP:
-- supabase_tariff_catalog_and_safe_links.sql
Cada tarifa puede incluir categoría, servicio, tipo de trabajo, nivel, rango de referencia, notas y orden.
### Tarifario por cliente
Las secciones con `scope = 'client'` se muestran únicamente cuando el proyecto tiene seleccionado el cliente asociado.
Ejemplo actual:
```text
Walmart Connect WMC
``` ```
El usuario puede seleccionar varias piezas y el subtotal se calcula automáticamente. Ese SQL crea/actualiza `tablero_cdc_tariff_catalog`, la función transaccional `tablero_cdc_replace_project_links`, la RPC `tablero_cdc_get_pricing_summary` y actualiza `tablero_cdc_get_projects_paginated` para que el monto interno solo viaje a Marrero e Isaac.
### Administración del tarifario ## Tarifario fijo Walmart Connect
Los usuarios autorizados pueden: Esta versión incluye el tarifario específico de Walmart Connect basado en `Tarifario_XCDC_WMC.xlsx`.
- crear secciones generales; - Al seleccionar `Walmart Connect WMC` como cliente, la estimación interna muestra únicamente:
- crear tarifarios por cliente; - selección múltiple de piezas WMC con montos fijos;
- crear tarifas / piezas; - costo/propuesta manual WMC para casos cotizados por proyecto.
- editar secciones y tarifas; - Para los demás clientes se conservan sin cambios el Tarifario Gráfico CDC, Estrategia y Creatividad y el costo manual.
- desactivar y reactivar; - Las piezas seleccionadas se totalizan automáticamente y se guardan en `tablero_cdc_project_pricing_items` como líneas normales del tarifario.
- buscar; - Antes de desplegar, ejecutar `supabase_walmart_connect_tariff.sql` en Supabase SQL Editor.
- mostrar inactivas;
- paginar listas extensas.
La administración no debe borrar costos ya guardados en proyectos históricos. ## Módulo de administración del tarifario
### Schedules / Triggers
| Trigger | Frecuencia | Descripción |
|---|---|---|
| Interacción del usuario | On demand | Crear, editar, filtrar o cotizar proyectos |
| Webhook de proyecto | On demand | La app envía cambios a n8n cuando `VITE_WEBHOOK_URL` está configurado |
| Realtime Supabase | Evento | Actualiza la app cuando cambian tablas suscritas |
| Sync de tarifario Sheet → Supabase | Según workflow n8n | Mantiene actualizado el catálogo administrado desde Sheet |
> La frecuencia exacta del sincronizador n8n debe consultarse en el workflow activo; no está definida por el frontend.
---
## TESTING
### Casos de prueba mínimos
| Caso | Input / Acción | Output esperado | Estado |
|---|---|---|---|
| Login autorizado | Usuario activo en `tablero_cdc_allowed_users` | Entra al tablero | Revalidar tras deploy |
| Login no autorizado | Usuario sin fila activa | Acceso rechazado | Revalidar tras cambios de acceso |
| Carga del tablero | Abrir Todos / Activos / Cerrados | Proyectos paginados correctamente | Revalidar tras deploy |
| Filtros | País, marca, cliente o CM | Resultado y total corresponden al filtro | Revalidar tras cambios SQL |
| Realtime | Dos ventanas abiertas | Cambios visibles sin recarga manual | Revalidar tras cambios de Realtime |
| Links | Editar propuestas / artes finales | Persisten al reabrir el proyecto | Revalidar tras cambios en store/RPC |
| Tarifario general | Cliente normal | Secciones generales disponibles | Revalidar tras cambios de catálogo |
| Walmart Connect | Cliente `Walmart Connect WMC` | Solo tarifario especial correspondiente + costo manual | Validado funcionalmente |
| WMC multiselección | Elegir varias piezas | Subtotal correcto | Validado funcionalmente |
| Administrar tarifario | Usuario con `can_manage_tariff_catalog = true` | Botón Tarifario visible y módulo accesible | Validado funcionalmente |
| Usuario sin permiso | `can_manage_tariff_catalog = false` | No ve módulo administrativo | Revalidar al modificar permisos |
| Paginación secciones | Más de 6 secciones | Controles de página sin perder selección | Implementado |
| Paginación tarifas | Más de 10 tarifas | Controles de página correctos | Implementado |
| Mostrar inactivas | Activar switch | Se muestran registros desactivados cuando existan | Implementado |
| Total tarifado | Crear/editar costos | Total global/filtrado se actualiza | Revalidar tras cambios de pricing |
| Build | `npm run build` | `tsc && vite build` sin errores | Requerido antes de generar nuevo dist |
| XAMPP | Abrir `dist` en `/tablero-cdc/` | App y módulo Tarifario abren sin recarga | Validado en la versión actual |
### Prueba de referencia Walmart Connect
La documentación técnica incluida define este caso:
- `Uniformes`
- `Photobooth`
- `Arco de entrada`
Subtotal esperado:
```text
$480.00
```
Agregando manualmente:
```text
Propuesta general Walmart Connect = $900.00
```
Total esperado:
```text
$1,380.00
```
Al guardar, cerrar y volver a abrir el proyecto, las líneas y el total deben persistir.
---
## ERRORES CONOCIDOS Y TROUBLESHOOTING
| Error / Síntoma | Causa probable | Solución |
|---|---|---|
| Pantalla en blanco al publicar | Falta `dist/assets` o las rutas del build no coinciden | Confirmar `dist/assets/` y `base: "/tablero-cdc/"` |
| Tarifario abre como modal blanco hasta recargar | Build antiguo / híbrido o assets desactualizados | Generar un build limpio desde el código fuente actual y desplegar exactamente el `dist` probado |
| `npm run build` falla en TypeScript | Error de tipos antes de ejecutar Vite | Corregir el error de `tsc`; no publicar un dist nuevo hasta que el build termine correctamente |
| Botón **Tarifario** no aparece | Falta permiso o SQL del módulo | Verificar `can_manage_tariff_catalog = true` y recargar sesión |
| Usuario válido no puede entrar | No existe como activo en `tablero_cdc_allowed_users` | Revisar fila, correo normalizado e `is_active` |
| Error / ausencia de RPC paginada | SQL no aplicado o schema cache desactualizado | Ejecutar `supabase_rpc_projects_paginated.sql` y revisar Supabase |
| Total tarifado no responde como esperado | RPC/configuración de visibilidad no aplicada | Revisar `supabase_pricing_summary_visibility.sql` y `tablero_cdc_get_pricing_summary` |
| Tarifario no carga desde Supabase | Tabla / SQL no aplicado | Revisar `tablero_cdc_tariff_catalog` y `tablero_cdc_tariff_sections` |
| Sync a Sheet no ocurre | `VITE_WEBHOOK_URL` vacío o workflow n8n inactivo | Revisar `.env`, webhook y ejecución de n8n |
| Cambios no aparecen en otra ventana | Realtime no habilitado | Ejecutar / revisar `supabase_realtime_projects.sql` |
| OAuth vuelve a una ruta incorrecta | Redirect URL no autorizada o base incorrecta | Revisar configuración OAuth y `/tablero-cdc/` |
---
## MONITOREO
- **Supabase:** revisar errores de Auth, RLS, RPC y consultas.
- **n8n Executions:** revisar ejecuciones fallidas del webhook y sincronizadores.
- **Browser DevTools:** revisar `Console` y `Network` ante errores de frontend o `404`.
- **Gitea:** confirmar que el commit de despliegue contiene `dist/index.html` y `dist/assets/`.
- **Prueba funcional:** abrir la app en una sesión limpia después de cada despliegue.
### Output esperado en operación normal
- usuarios autorizados ingresan con Google;
- usuarios no autorizados quedan fuera;
- proyectos cargan paginados;
- cambios persisten en Supabase;
- Realtime mantiene sincronización multiusuario;
- tarifarios muestran únicamente las secciones aplicables;
- costos guardados alimentan el total interno y el resumen global;
- el módulo administrativo solo aparece a quienes tienen permiso;
- el build publicado funciona directamente sin requerir recargar la página.
---
## ESTRUCTURA DEL REPOSITORIO
```text
cdc-project-management/
├── dist/ # Build probado para despliegue
│ ├── assets/ # JS/CSS compilado — obligatorio
│ └── index.html
├── public/ # Favicons y assets públicos
├── src/
│ ├── components/
│ │ ├── board/ # Tarjetas, diálogo y pricing
│ │ ├── tariff/ # Administración del tarifario
│ │ └── ui/ # Componentes de interfaz
│ ├── context/
│ │ └── AuthContext.tsx # Sesión y permisos
│ ├── data/ # Datos de respaldo
│ ├── hooks/
│ ├── lib/
│ │ ├── accessControl.ts
│ │ ├── appLists.ts
│ │ ├── pricingSummaryVisibility.ts
│ │ ├── store.ts
│ │ ├── supabase.ts
│ │ ├── tariffCatalog.ts
│ │ └── tariffSections.ts
│ ├── pages/
│ │ └── BoardPage.tsx
│ ├── App.tsx
│ ├── main.tsx
│ └── styles.css
├── .env.example
├── package.json
├── package-lock.json
├── vite.config.ts
├── tsconfig.json
├── supabase_*.sql # Migraciones / configuración
├── MODULO_TARIFARIO_ADMIN_IMPLEMENTACION.md
├── PAGINACION_MODULO_TARIFARIO.md
├── WMC_TARIFARIO_IMPLEMENTACION.md
├── VALIDACION_MODULO_TARIFARIO.md
├── CORRECCION_BUILD_Y_MODAL_TARIFARIO.md
└── README.md
```
---
## SEGURIDAD
### No commitear
```text
.env
.env.local
.env.production
service_role keys
tokens privados
secretos OAuth
contraseñas
node_modules/
logs con información sensible
```
### Sí se mantiene en este repositorio
```text
.env.example
dist/
dist/assets/
scripts SQL versionados
documentación técnica
```
La inclusión de `dist/` es intencional mientras el procedimiento de producción dependa de desplegar exactamente el build probado en XAMPP.
---
## CHANGELOG
### 2026-08-07 — Documentación
- README actualizado al estándar GLM IT.
- Se documenta arquitectura, permisos, setup, deploy, testing y troubleshooting.
- Se deja explícito que `dist/assets/` forma parte obligatoria del build desplegable.
### 2026-07-28 — Módulo de tarifario administrativo
- Incorporación del tarifario especial Walmart Connect.
- Administración de secciones generales y por cliente.
- Permiso `can_manage_tariff_catalog`.
- Creación, edición, desactivación y reactivación de tarifas.
- Compatibilidad con tarifas gestionadas por Sheet y por app.
- Corrección del build / renderizado del modal del tarifario.
- Paginación de secciones y tarifas:
- 6 secciones por página.
- 10 tarifas por página.
- `dist` validado mediante XAMPP bajo `/tablero-cdc/`.
---
## DECISIONS LOG
### DEC-001 — Acceso administrado desde Supabase
- **Contexto:** evitar listas rígidas de usuarios dentro del frontend.
- **Decisión:** utilizar `tablero_cdc_allowed_users`.
- **Razón:** permite habilitar, deshabilitar y asignar permisos sin recompilar la aplicación.
### DEC-002 — Permisos granulares
- **Contexto:** no todos los usuarios deben administrar costos, eliminar proyectos, controlar el resumen o editar tarifarios.
- **Decisión:** usar flags independientes en Supabase.
- **Razón:** mantener privilegio mínimo y separar responsabilidades.
### DEC-003 — Tarifario híbrido Sheet + App
- **Contexto:** las tarifas generales existentes se mantienen desde el proceso operativo, mientras nuevos tarifarios especiales pueden administrarse desde la app.
- **Decisión:** utilizar `managed_by` para distinguir origen y proteger registros administrados en la aplicación.
- **Razón:** evitar que el sincronizador del Sheet sobrescriba cambios creados desde el módulo administrativo.
### DEC-004 — Desactivar en lugar de eliminar tarifas históricas
- **Contexto:** una tarifa puede estar referenciada por proyectos anteriores.
- **Decisión:** usar `is_active = false` como operación habitual.
- **Razón:** conservar trazabilidad e integridad histórica.
### DEC-005 — Base de Vite fija en `/tablero-cdc/`
- **Contexto:** la aplicación se publica dentro de una subcarpeta.
- **Decisión:** configurar `base: "/tablero-cdc/"`.
- **Razón:** generar rutas correctas para JS, CSS y assets en producción.
### DEC-006 — Publicar exactamente el `dist` validado
- **Contexto:** el build de producción debe comportarse igual que la versión probada antes del despliegue.
- **Decisión:** validar el `dist` en XAMPP y subir ese mismo build al repositorio / servidor.
- **Razón:** evitar diferencias entre el artefacto probado y el artefacto publicado.
---
## CONTACTOS DEL PROYECTO
| Rol | Nombre | Contacto |
|---|---|---|
| IT Manager | Luis Matos | `lmatos@gomezleemarketing.com` |
| Developer Principal | Isaac Aracena | `iaracena@gomezleemarketing.com` |
La versión incluye un módulo de tarifario controlado por el permiso de Supabase `can_manage_tariff_catalog`. Permite editar las secciones generales, crear nuevas secciones y crear tarifarios especiales por cliente. Consulta `MODULO_TARIFARIO_ADMIN_IMPLEMENTACION.md` y ejecuta `supabase_tariff_admin_module.sql` antes del despliegue.
+25
View File
@@ -0,0 +1,25 @@
# Reaprobar briefs rechazados
Cambio puntual agregado sobre la versión con **Nuevos**, **Rechazados** y notificaciones por usuario.
## Comportamiento
- Un brief en `Nuevos` puede seguir siendo **Aprobado** o **Rechazado**.
- Un brief en `Rechazados` ahora muestra el botón **Aprobar**.
- Al aprobarlo desde `Rechazados`:
- se crea un proyecto normal en `tablero_cdc_projects`;
- el proyecto se crea con estado `Activo`;
- aparece en `Todos` y `Activos`;
- el brief desaparece de `Rechazados`;
- se limpia `rejection_reason`;
- se conserva la misma sincronización existente hacia n8n/Google Sheet.
- La RPC mantiene idempotencia para evitar duplicados por doble clic o reintentos.
- No se modifican los workflows de n8n.
## Supabase
Si la bandeja de briefs ya estaba instalada, ejecutar una vez:
`supabase_cdc_brief_reapprove_rejected.sql`
El archivo completo `supabase_cdc_brief_review_inbox.sql` también quedó actualizado para instalaciones nuevas.
+38
View File
@@ -0,0 +1,38 @@
# Tiempo dedicado por proyecto — implementación
## Qué agrega
- Botón **Tiempo** en cada tarjeta de proyecto.
- Modal independiente para registrar y consultar tiempo sin abrir/editar el proyecto.
- Registro por **país**, **tarea**, **horas/minutos**, **fecha** y **nota opcional**.
- Resumen automático:
- total del proyecto;
- desglose por país;
- desglose por tarea.
- Historial detallado con usuario y fecha de registro.
- Eliminación de registros con confirmación para corregir capturas equivocadas.
- Paginación del historial (10 registros por página).
## Antes de desplegar
Ejecutar en Supabase SQL Editor:
```text
supabase_project_time_entries.sql
```
El script es idempotente y no modifica las tablas de proyectos, tarifarios ni montos.
## Seguridad
Solo usuarios autenticados que estén activos en `tablero_cdc_allowed_users` pueden leer o modificar registros de tiempo.
## Persistencia
Tabla nueva:
```text
public.tablero_cdc_project_time_entries
```
La duración se guarda en minutos enteros para evitar redondeos.
+42
View File
@@ -0,0 +1,42 @@
# Validación final — módulo Tarifario
Fecha: 28 de julio de 2026
## Correcciones aplicadas
1. Se corrigió el error `TS2322` de `src/lib/tariffCatalog.ts` que impedía ejecutar `npm run build`.
- La respuesta de la consulta moderna y la consulta de compatibilidad ahora comparten explícitamente el tipo `Array<Record<string, unknown>> | null`.
- No se alteró la lógica de lectura, mezcla, respaldo ni guardado del catálogo.
2. Se corrigió el cuadro blanco del tarifario en el `dist`.
- El lanzador permanece junto al botón **Nuevo**.
- El modal ahora se renderiza mediante un portal directamente en `document.body`.
- Esto evita que el `header` sticky con `backdrop-filter` limite, recorte o altere el modal en producción/XAMPP.
3. Se generaron nombres de assets nuevos para evitar reutilizar JavaScript antiguo del caché:
- `tariff-admin-module-ready.js`
- `index-core-tariff-admin-ready.js`
- `index-ready.css`
4. Se conservaron alias compatibles con los nombres anteriores para evitar errores 404 si algún navegador conserva temporalmente un `index.html` anterior.
## Pruebas realizadas
- 74 archivos TypeScript/TSX transpilados: **0 errores de sintaxis**.
- Importaciones locales verificadas: **0 rutas faltantes**.
- JavaScript del `dist` validado con `node --check`: **correcto**.
- Prueba automatizada en Chromium con un encabezado sticky y `backdrop-filter`, equivalente al encabezado real:
- botón **Tarifario** visible con permiso activo;
- modal insertado directamente dentro de `BODY`;
- overlay cubriendo el viewport completo;
- tres ciclos consecutivos de abrir, cerrar y volver a abrir;
- secciones generales visibles;
- tarifarios por cliente visibles;
- editor de una nueva tarifa abierto y cerrado correctamente;
- **0 errores JavaScript durante la prueba**.
## Estado del paquete
El directorio `dist` incluido está listo para copiar dentro de `htdocs/tablero-cdc` o desplegar en el servidor bajo la ruta `/tablero-cdc/`.
El registro npm disponible en el entorno de preparación respondió `503 Service Temporarily Unavailable`, por lo que no fue posible reinstalar todas las dependencias y repetir aquí un build completo desde cero. No obstante, el error TypeScript exacto reportado quedó corregido en el código fuente y el `dist` incluido fue corregido y probado directamente en Chromium.
+25
View File
@@ -0,0 +1,25 @@
# Tablero CDC — Tarifario Walmart Connect
## Comportamiento
- Cliente `Walmart Connect WMC`:
- muestra selección múltiple de las 12 piezas WMC;
- cada pieza usa un monto fijo y se totaliza automáticamente;
- mantiene un bloque manual para propuestas generales o conceptos no contemplados.
- Cualquier otro cliente:
- mantiene sin cambios `Tarifario Gráfico CDC`;
- mantiene sin cambios `Estrategia y Creatividad`;
- mantiene el costo manual existente.
## Instalación
1. Ejecutar `supabase_walmart_connect_tariff.sql` en Supabase SQL Editor.
2. Confirmar que el primer resultado muestre `12` piezas activas y `2180.00` como suma de referencia.
3. Desplegar el contenido de `dist` incluido.
4. Probar un proyecto con cliente `Walmart Connect WMC` y otro con un cliente normal.
## Prueba mínima
- Seleccionar `Uniformes`, `Photobooth` y `Arco de entrada` debe producir un subtotal WMC de `$480.00`.
- Agregar manualmente `Propuesta general Walmart Connect` por `$900.00` debe producir un total de `$1,380.00`.
- Guardar, cerrar y volver a abrir el proyecto debe conservar las cuatro líneas y el total.
-433
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-479
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<link rel="icon" type="image/png" sizes="192x192" href="/tablero-cdc/icon-192.png?v=glm-tab-v12" /> <link rel="icon" type="image/png" sizes="192x192" href="/tablero-cdc/icon-192.png?v=glm-tab-v12" />
<link rel="shortcut icon" type="image/x-icon" href="/tablero-cdc/favicon.ico?v=glm-tab-v12" /> <link rel="shortcut icon" type="image/x-icon" href="/tablero-cdc/favicon.ico?v=glm-tab-v12" />
<link rel="apple-touch-icon" sizes="180x180" href="/tablero-cdc/apple-touch-icon.png?v=glm-tab-v12" /> <link rel="apple-touch-icon" sizes="180x180" href="/tablero-cdc/apple-touch-icon.png?v=glm-tab-v12" />
<script type="module" crossorigin src="/tablero-cdc/assets/index-DCvZdXJT.js"></script> <script type="module" crossorigin src="/tablero-cdc/assets/index-Deer85MW.js"></script>
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-D0h6tUNa.css"> <link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-Npcv_R3U.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+5 -3
View File
@@ -6,9 +6,11 @@
<title>Tablero CDC | GLM</title> <title>Tablero CDC | GLM</title>
<meta name="description" content="Herramienta interna de GomezLee Marketing para gestión de proyectos CDC." /> <meta name="description" content="Herramienta interna de GomezLee Marketing para gestión de proyectos CDC." />
<meta name="author" content="GomezLee Marketing" /> <meta name="author" content="GomezLee Marketing" />
<link rel="icon" type="image/png" href="%BASE_URL%glm-favicon.png?v=glm-logo-final-20260626" /> <link rel="icon" type="image/png" sizes="32x32" href="%BASE_URL%favicon-32.png?v=glm-tab-v12" />
<link rel="shortcut icon" type="image/png" href="%BASE_URL%glm-favicon.png?v=glm-logo-final-20260626" /> <link rel="icon" type="image/png" sizes="16x16" href="%BASE_URL%favicon-16.png?v=glm-tab-v12" />
<link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.png?v=glm-logo-final-20260626" /> <link rel="icon" type="image/png" sizes="192x192" href="%BASE_URL%icon-192.png?v=glm-tab-v12" />
<link rel="shortcut icon" type="image/x-icon" href="%BASE_URL%favicon.ico?v=glm-tab-v12" />
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%apple-touch-icon.png?v=glm-tab-v12" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+2 -3
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 302 B

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+1 -1
View File
@@ -59,7 +59,7 @@ class ErrorBoundary extends Component<EBProps, EBState> {
function AuthGate() { function AuthGate() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
if (loading) { if (loading && !user) {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background"> <div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
+2 -2
View File
@@ -9,9 +9,9 @@ export function LoginScreen() {
<div className="min-h-screen bg-background flex items-center justify-center px-4"> <div className="min-h-screen bg-background flex items-center justify-center px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
{/* Logo / marca */} {/* Logo / marca */}
<div className="flex flex-col items-center mb-8 gap-5"> <div className="flex flex-col items-center mb-8 gap-3">
<GLMLogo size="lg" /> <GLMLogo size="lg" />
<h1 className="text-2xl font-bold tracking-tight text-[#4F758B]">Tablero CDC</h1> <h1 className="text-2xl font-bold tracking-tight text-[#4F758B] text-center">CDC Project Management</h1>
</div> </div>
{/* Card */} {/* Card */}
+207
View File
@@ -0,0 +1,207 @@
import {
CalendarClock,
CheckCircle2,
ExternalLink,
FolderOpen,
MapPin,
Tag,
User,
XCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { BriefInboxItem } from "@/lib/briefInbox";
interface BriefReviewCardProps {
item: BriefInboxItem;
mode: "new" | "rejected";
busy?: boolean;
onApprove?: () => void;
onReject?: () => void;
}
function safeUrl(value: string) {
const clean = value.trim();
if (!/^https?:\/\//i.test(clean)) return "";
return clean;
}
function openUrl(value: string) {
const url = safeUrl(value);
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
}
function formatDate(value: number | null) {
if (!value || !Number.isFinite(value)) return "—";
return new Intl.DateTimeFormat("es-DO", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(value));
}
export function BriefReviewCard({
item,
mode,
busy = false,
onApprove,
onReject,
}: BriefReviewCardProps) {
const isRejected = mode === "rejected";
const mainBriefUrl = safeUrl(item.documentLink) || safeUrl(item.briefLink);
const folderUrl = safeUrl(item.briefLink);
return (
<article
className={cn(
"overflow-hidden rounded-2xl border bg-card shadow-sm transition-shadow hover:shadow-md",
isRejected ? "border-red-200/70" : "border-amber-200/80",
)}
>
<div
className={cn(
"flex min-h-14 items-start justify-between gap-3 px-4 py-3",
isRejected ? "bg-red-50" : "bg-amber-50",
)}
>
<div className="min-w-0">
<div className="mb-1.5 flex flex-wrap items-center gap-1.5">
<Badge
variant="secondary"
className={cn(
"h-5 px-2 text-[10px] font-semibold",
isRejected
? "bg-red-100 text-red-800 hover:bg-red-100"
: "bg-amber-100 text-amber-900 hover:bg-amber-100",
)}
>
{isRejected ? "Rechazado" : "Nuevo · CDC Brief"}
</Badge>
</div>
<h3 className="line-clamp-2 text-sm font-semibold leading-snug text-foreground">
{item.title || "Brief sin título"}
</h3>
</div>
</div>
<div className="space-y-3 p-4">
<div className="space-y-2.5">
<InfoRow icon={<Tag className="h-3.5 w-3.5" />} label="Cliente" value={item.client} />
<InfoRow icon={<Tag className="h-3.5 w-3.5" />} label="Marca" value={item.brand} />
<InfoRow icon={<MapPin className="h-3.5 w-3.5" />} label="País" value={item.country} />
<InfoRow
icon={<User className="h-3.5 w-3.5" />}
label="Solicita"
value={item.requestedBy}
/>
<InfoRow
icon={<CalendarClock className="h-3.5 w-3.5" />}
label="Creado"
value={formatDate(item.sourceCreatedAt)}
/>
</div>
{(item.requestType || item.deliverableType || item.deliveryDate) && (
<div className="rounded-xl border border-border/70 bg-muted/20 px-3 py-2.5 text-xs">
{item.requestType && (
<p>
<span className="text-muted-foreground">Requerimiento:</span>{" "}
<span className="font-medium text-foreground">{item.requestType}</span>
</p>
)}
{item.deliverableType && (
<p className={item.requestType ? "mt-1" : ""}>
<span className="text-muted-foreground">Entregable:</span>{" "}
<span className="font-medium text-foreground">{item.deliverableType}</span>
</p>
)}
{item.deliveryDate && (
<p className={item.requestType || item.deliverableType ? "mt-1" : ""}>
<span className="text-muted-foreground">Entrega:</span>{" "}
<span className="font-medium text-foreground">{item.deliveryDate}</span>
</p>
)}
</div>
)}
{item.summary && (
<p className="line-clamp-4 text-xs leading-relaxed text-muted-foreground">{item.summary}</p>
)}
{isRejected && (item.reviewedByName || item.reviewedAt) && (
<div className="rounded-xl border border-red-100 bg-red-50/60 px-3 py-2 text-[11px] text-red-800">
Revisado{item.reviewedByName ? ` por ${item.reviewedByName}` : ""}
{item.reviewedAt ? ` · ${formatDate(item.reviewedAt)}` : ""}
{item.rejectionReason ? ` · ${item.rejectionReason}` : ""}
</div>
)}
<div className="flex flex-wrap gap-2 border-t border-border/70 pt-3">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 rounded-full px-3 text-xs"
disabled={!mainBriefUrl || busy}
onClick={() => openUrl(mainBriefUrl)}
>
<ExternalLink className="h-3.5 w-3.5" />
Ver brief
</Button>
{folderUrl && folderUrl !== mainBriefUrl && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1.5 rounded-full px-3 text-xs"
disabled={busy}
onClick={() => openUrl(folderUrl)}
>
<FolderOpen className="h-3.5 w-3.5" />
Carpeta
</Button>
)}
<div className="ml-auto flex gap-2">
{!isRejected && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 rounded-full border-red-200 px-3 text-xs text-red-700 hover:bg-red-50 hover:text-red-800"
disabled={busy || !onReject}
onClick={onReject}
>
<XCircle className="h-3.5 w-3.5" />
No aprobar
</Button>
)}
<Button
type="button"
size="sm"
className="h-8 gap-1.5 rounded-full px-3 text-xs"
disabled={busy || !onApprove}
onClick={onApprove}
>
<CheckCircle2 className="h-3.5 w-3.5" />
{busy ? "Procesando…" : "Aprobar"}
</Button>
</div>
</div>
</div>
</article>
);
}
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="flex items-start gap-2 text-xs">
<span className="mt-0.5 flex-shrink-0 text-muted-foreground">{icon}</span>
<span className="w-14 flex-shrink-0 text-muted-foreground">{label}</span>
<span className="truncate font-medium text-foreground">{value || "—"}</span>
</div>
);
}
+49 -10
View File
@@ -1,19 +1,29 @@
import { MapPin, User, Tag, Link2, FileCheck2, CheckCircle2 } from "lucide-react"; import { MapPin, User, Tag, Link2, FileCheck2, CheckCircle2, Clock3 } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { colorHex } from "@/lib/colors"; import { colorHex } from "@/lib/colors";
import type { Project } from "@/lib/store"; import type { Project } from "@/lib/store";
import { useAuth } from "@/context/AuthContext";
interface ProjectCardProps { interface ProjectCardProps {
project: Project; project: Project;
onClick: () => void; onClick: () => void;
onTimeClick?: () => void;
showInternalAmount?: boolean;
} }
export function ProjectCard({ project, onClick }: ProjectCardProps) { export function ProjectCard({
project,
onClick,
onTimeClick,
showInternalAmount = false,
}: ProjectCardProps) {
const closed = !!project.status; const closed = !!project.status;
const { isGerardo } = useAuth(); const internalAmount = Number(project.monto);
const hasInternalAmount =
showInternalAmount &&
project.monto !== null &&
Number.isFinite(internalAmount) &&
internalAmount > 0;
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
event.preventDefault(); event.preventDefault();
@@ -23,6 +33,9 @@ export function ProjectCard({ project, onClick }: ProjectCardProps) {
return ( return (
<div <div
data-cdc-project-id={project.id}
data-cdc-project-country={project.bu}
data-cdc-project-name={project.nombre || "Sin título"}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={onClick} onClick={onClick}
@@ -65,7 +78,22 @@ export function ProjectCard({ project, onClick }: ProjectCardProps) {
<Row icon={<User className="w-3.5 h-3.5" />} label="Solicita" value={project.solicitante} /> <Row icon={<User className="w-3.5 h-3.5" />} label="Solicita" value={project.solicitante} />
<div className="flex items-center justify-between pt-2 mt-2 border-t border-border/70"> <div className="flex items-center justify-between pt-2 mt-2 border-t border-border/70">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground"> <div className="flex items-center gap-2 text-[11px] text-muted-foreground">
{onTimeClick && (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onTimeClick();
}}
onKeyDown={(event) => event.stopPropagation()}
className="inline-flex h-7 items-center gap-1 rounded-full border border-border bg-card px-2.5 font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Agregar o ver tiempo dedicado"
>
<Clock3 className="h-3.5 w-3.5" />
Tiempo
</button>
)}
{project.propuestaLinks.length > 0 && ( {project.propuestaLinks.length > 0 && (
<span className="flex items-center gap-1" title="Propuestas"> <span className="flex items-center gap-1" title="Propuestas">
<Link2 className="w-3 h-3" /> {project.propuestaLinks.length} <Link2 className="w-3 h-3" /> {project.propuestaLinks.length}
@@ -78,12 +106,16 @@ export function ProjectCard({ project, onClick }: ProjectCardProps) {
)} )}
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
{isGerardo && project.monto != null && ( {hasInternalAmount && (
<span className="text-[11px] font-medium text-foreground/70"> <span
${project.monto.toLocaleString()} className="text-[11px] font-semibold tabular-nums text-foreground"
title="Monto interno del proyecto"
>
{formatInternalAmount(internalAmount)}
</span> </span>
)} )}
{project.status && ( {project.status && (
<Badge <Badge
variant="secondary" variant="secondary"
@@ -105,6 +137,13 @@ export function ProjectCard({ project, onClick }: ProjectCardProps) {
); );
} }
function formatInternalAmount(amount: number): string {
return `$${amount.toLocaleString("en-US", {
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
maximumFractionDigits: 2,
})}`;
}
function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return ( return (
<div className="flex items-start gap-2 text-xs"> <div className="flex items-start gap-2 text-xs">
+41 -37
View File
@@ -1,4 +1,4 @@
import { useCallback, useState, useEffect } from "react"; import { useCallback, useRef, useState, useEffect } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -25,7 +25,6 @@ import {
AlertCircle, AlertCircle,
Loader2, Loader2,
Lock, Lock,
DollarSign,
Calendar, Calendar,
Trash2, Trash2,
Search, Search,
@@ -69,6 +68,7 @@ interface Props {
} }
const WALMART_CONNECT_CLIENT = "Walmart Connect WMC"; const WALMART_CONNECT_CLIENT = "Walmart Connect WMC";
const WMC_CREATE_EXCLUDED_BU = new Set(["wmc"]);
const ACTIVITY_PAGE_SIZE = 15; const ACTIVITY_PAGE_SIZE = 15;
function formatActivityDate(timestamp: number) { function formatActivityDate(timestamp: number) {
@@ -107,6 +107,8 @@ function toPricingInputItems(
referenceMax: item.referenceMax, referenceMax: item.referenceMax,
amount: item.amount, amount: item.amount,
description: item.description, description: item.description,
tariffSectionId: item.tariffSectionId,
tariffCatalogId: item.tariffCatalogId,
})); }));
} }
@@ -261,6 +263,7 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
const [pricingLoading, setPricingLoading] = useState(false); const [pricingLoading, setPricingLoading] = useState(false);
const [pricingError, setPricingError] = useState<string | null>(null); const [pricingError, setPricingError] = useState<string | null>(null);
const [internalAmountManuallyEdited, setInternalAmountManuallyEdited] = useState(false); const [internalAmountManuallyEdited, setInternalAmountManuallyEdited] = useState(false);
const saveInFlightRef = useRef(false);
const selectedBrands = splitBrandValues(form.marca, lists.marcas); const selectedBrands = splitBrandValues(form.marca, lists.marcas);
const selectedCountries = splitCountryValues(form.bu, lists.bus); const selectedCountries = splitCountryValues(form.bu, lists.bus);
@@ -268,9 +271,10 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
const marcaOptions = dedupeOptions([...lists.marcas, ...selectedBrands]); const marcaOptions = dedupeOptions([...lists.marcas, ...selectedBrands]);
const buOptions = dedupeOptions([...lists.bus, ...selectedCountries]); const buOptions = dedupeOptions([...lists.bus, ...selectedCountries]);
const statusOptions = dedupeOptions(ensureOption(lists.status, form.status)) as Status[]; const statusOptions = dedupeOptions(ensureOption(lists.status, form.status)) as Status[];
const filteredWmcBuOptions = buOptions.filter((b) => const filteredWmcBuOptions = buOptions.filter((b) => {
normalizeOptionKey(b).includes(normalizeOptionKey(wmcCountrySearch)), const key = normalizeOptionKey(b);
); return !WMC_CREATE_EXCLUDED_BU.has(key) && key.includes(normalizeOptionKey(wmcCountrySearch));
});
const pricingTotal = getPricingTotal(pricingItems); const pricingTotal = getPricingTotal(pricingItems);
useEffect(() => { useEffect(() => {
@@ -399,6 +403,19 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
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 }));
// El tarifario es la única fuente editable del monto interno.
// Conservamos overrides históricos al abrir un proyecto, pero en cuanto
// el usuario modifica las líneas del tarifario el total vuelve a ser la fuente de verdad.
const onPricingItemsChange = (nextItems: ProjectPricingItemInput[]) => {
setInternalAmountManuallyEdited(false);
setPricingItems(nextItems);
const nextAmount = Number(getPricingTotal(nextItems).toFixed(2));
setForm((current) =>
current.monto === nextAmount ? current : { ...current, monto: nextAmount },
);
};
useEffect(() => { useEffect(() => {
if (!canManageInternalPricing || !pricingItems.length || internalAmountManuallyEdited) return; if (!canManageInternalPricing || !pricingItems.length || internalAmountManuallyEdited) return;
@@ -540,7 +557,9 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
}; };
const submit = async () => { const submit = async () => {
if (!requiredOk || saving) return; if (!requiredOk || saving || saveInFlightRef.current) return;
saveInFlightRef.current = true;
const safeForm = isGerardo const safeForm = isGerardo
? form ? form
@@ -587,7 +606,7 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
if (failedSyncs.length === 0) { if (failedSyncs.length === 0) {
toast.success("Proyectos multipaís creados", { toast.success("Proyectos multipaís creados", {
description: `Se crearon ${countries.length} proyectos y ${countries.length} filas en Google Sheet. El Interno Cargado se aplicó por país.`, description: `Se crearon ${countries.length} proyectos y ${countries.length} filas en Google Sheet. El total de la estimación interna se aplicó por país.`,
}); });
} else { } else {
toast.warning("Proyectos multipaís creados con aviso", { toast.warning("Proyectos multipaís creados con aviso", {
@@ -624,6 +643,7 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
description: message, description: message,
}); });
} finally { } finally {
saveInFlightRef.current = false;
setSaving(false); setSaving(false);
setSavingStep(null); setSavingStep(null);
} }
@@ -632,7 +652,13 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
const closed = !!form.status; const closed = !!form.status;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
open={open}
onOpenChange={(nextOpen) => {
if (saving || saveInFlightRef.current) return;
onOpenChange(nextOpen);
}}
>
<DialogContent className="max-w-3xl max-h-[92vh] overflow-y-auto p-0 gap-0"> <DialogContent className="max-w-3xl max-h-[92vh] overflow-y-auto p-0 gap-0">
{/* Pestaña de color superior */} {/* Pestaña de color superior */}
<div <div
@@ -864,7 +890,7 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
{isMultiCountryCreate && canManageInternalPricing && ( {isMultiCountryCreate && canManageInternalPricing && (
<div className="md:col-span-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900"> <div className="md:col-span-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Si seleccionas varios países, el Interno Cargado se aplicará por cada país. La app Si seleccionas varios países, el total de la estimación interna se aplicará por cada país. La app
no divide el monto automáticamente. no divide el monto automáticamente.
</div> </div>
)} )}
@@ -940,8 +966,9 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
{canManageInternalPricing && ( {canManageInternalPricing && (
<ProjectPricingPanel <ProjectPricingPanel
client={form.cliente}
items={pricingItems} items={pricingItems}
onChange={setPricingItems} onChange={onPricingItemsChange}
loading={pricingLoading} loading={pricingLoading}
error={pricingError} error={pricingError}
/> />
@@ -986,32 +1013,6 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
)} )}
</div> </div>
{/* Monto: SOLO visible para usuarios internos autorizados */}
{canManageInternalPricing && (
<div className="space-y-1.5">
<Label className="flex items-center gap-1.5 pl-1">
Interno Cargado <DollarSign className="w-3.5 h-3.5 text-muted-foreground" />
</Label>
<Input
type="number"
value={form.monto ?? ""}
onChange={(e) => {
setInternalAmountManuallyEdited(true);
set("monto", e.target.value === "" ? null : Number(e.target.value));
}}
placeholder="0.00"
className="h-10"
/>
<p className="pl-1 text-[11px] text-muted-foreground">
Privado · solo usuarios internos autorizados.
{internalAmountManuallyEdited && pricingItems.length > 0
? " Monto ajustado manualmente; se respetará al guardar."
: pricingItems.length > 0
? " El total del tarifario se refleja aquí y puedes ajustarlo."
: ""}
</p>
</div>
)}
</div> </div>
{(formError || savingStep) && ( {(formError || savingStep) && (
@@ -1057,7 +1058,10 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onOpenChange(false)} onClick={() => {
if (saving || saveInFlightRef.current) return;
onOpenChange(false);
}}
disabled={saving || deleting} disabled={saving || deleting}
> >
Cancelar Cancelar
+316 -50
View File
@@ -1,26 +1,28 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Calculator, DollarSign, Plus, Trash2, Info } from "lucide-react"; import { Calculator, DollarSign, Info, Plus, Trash2 } from "lucide-react";
import { SearchableMultiSelect } from "@/components/board/SearchableMultiSelect";
import { SearchableSelect } from "@/components/board/SearchableSelect";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
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 { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { SearchableSelect } from "@/components/board/SearchableSelect";
import { import {
TARIFF_SECTIONS,
defaultTariffAmount, defaultTariffAmount,
formatTariffRange, formatTariffRange,
type TariffCatalogItem, type TariffCatalogItem,
type TariffLevel,
type TariffWorkType, type TariffWorkType,
} from "@/data/tariff"; } from "@/data/tariff";
import { useTariffCatalog } from "@/lib/tariffCatalog";
import type { ProjectPricingItemInput } from "@/lib/store"; import type { ProjectPricingItemInput } from "@/lib/store";
import { useTariffCatalog } from "@/lib/tariffCatalog";
import { useTariffSections, type TariffSection } from "@/lib/tariffSections";
import { normalizeOptionKey } from "@/lib/optionUtils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface Props { interface Props {
client: string;
items: ProjectPricingItemInput[]; items: ProjectPricingItemInput[];
onChange: (items: ProjectPricingItemInput[]) => void; onChange: (items: ProjectPricingItemInput[]) => void;
loading?: boolean; loading?: boolean;
@@ -52,39 +54,130 @@ function clean(value: string) {
return value.trim(); return value.trim();
} }
export function ProjectPricingPanel({ items, onChange, loading, error }: Props) { function getFixedLevel(item: TariffCatalogItem) {
const [section, setSection] = useState<string>("grafico"); const workType = item.workTypes[0] || null;
const [serviceId, setServiceId] = useState<string>(""); const level = workType?.levels[0] || null;
return { workType, level };
}
function getFixedAmount(item: TariffCatalogItem) {
const { level } = getFixedLevel(item);
const amount = Number(level?.min ?? level?.max ?? 0);
return Number.isFinite(amount) ? amount : 0;
}
function isLegacyWmcItem(item: ProjectPricingItemInput, section: TariffSection) {
return (
section.id === "wmc" &&
item.source === "tariff" &&
(normalizeOptionKey(item.workType) === normalizeOptionKey("Tarifa fija WMC") ||
normalizeOptionKey(item.category) === normalizeOptionKey("Walmart Connect"))
);
}
function itemBelongsToSpecialSection(item: ProjectPricingItemInput, section: TariffSection) {
if (item.source !== "tariff") return false;
if (item.tariffSectionId) return item.tariffSectionId === section.id;
return isLegacyWmcItem(item, section);
}
function createSpecialPricingItem(
catalogItem: TariffCatalogItem,
section: TariffSection,
): ProjectPricingItemInput | null {
const { workType, level } = getFixedLevel(catalogItem);
const amount = getFixedAmount(catalogItem);
if (!workType || !level || amount <= 0) return null;
return {
source: "tariff",
category: section.name,
serviceName: catalogItem.service,
workType: `Tarifa fija · ${section.name}`,
complexityLevel: "Precio fijo",
referenceLabel: formatTariffRange(level),
referenceMin: amount,
referenceMax: amount,
amount,
description: catalogItem.notes || "",
tariffSectionId: section.id,
tariffCatalogId: catalogItem.id,
};
}
export function ProjectPricingPanel({ client, items, onChange, loading, error }: Props) {
const [sectionId, setSectionId] = useState<string>("grafico");
const [serviceLabel, setServiceLabel] = useState<string>("");
const [workTypeId, setWorkTypeId] = useState<string>(""); const [workTypeId, setWorkTypeId] = useState<string>("");
const [levelId, setLevelId] = useState<string>(""); const [levelId, setLevelId] = useState<string>("");
const [amount, setAmount] = useState<string>(""); const [amount, setAmount] = useState<string>("");
const [description, setDescription] = useState<string>(""); const [description, setDescription] = useState<string>("");
const [manualName, setManualName] = useState<string>(""); const [manualName, setManualName] = useState<string>("");
const [manualDescription, setManualDescription] = useState<string>(""); const [manualDescription, setManualDescription] = useState<string>("");
const [manualAmount, setManualAmount] = useState<string>(""); const [manualAmount, setManualAmount] = useState<string>("");
const { catalog: tariffCatalog, loading: tariffLoading, error: tariffError } = useTariffCatalog();
const sectionOptions = TARIFF_SECTIONS.map((option) => option.label); const {
const selectedSectionLabel = TARIFF_SECTIONS.find((option) => option.id === section)?.label || ""; catalog: tariffCatalog,
loading: tariffLoading,
error: tariffError,
} = useTariffCatalog();
const {
sections,
loading: sectionsLoading,
error: sectionsError,
} = useTariffSections();
const generalSections = useMemo(
() =>
sections.filter(
(section) => section.isActive && section.scope === "general" && section.pricingMode === "guided",
),
[sections],
);
const specialSections = useMemo(() => {
const clientKey = normalizeOptionKey(client);
if (!clientKey) return [];
return sections.filter(
(section) =>
section.isActive &&
section.scope === "client" &&
section.pricingMode === "fixed_multi" &&
normalizeOptionKey(section.clientName) === clientKey,
);
}, [client, sections]);
const hasSpecialTariff = specialSections.length > 0;
const activeGeneralSection =
generalSections.find((section) => section.id === sectionId) || generalSections[0] || null;
useEffect(() => {
if (!activeGeneralSection && generalSections.length) {
setSectionId(generalSections[0].id);
} else if (activeGeneralSection && activeGeneralSection.id !== sectionId) {
setSectionId(activeGeneralSection.id);
}
}, [activeGeneralSection, generalSections, sectionId]);
const serviceOptions = useMemo( const serviceOptions = useMemo(
() => () =>
tariffCatalog tariffCatalog
.filter((item) => item.section === section) .filter((item) => item.section === activeGeneralSection?.id)
.map((item) => `${item.category} · ${item.service}`), .map((item) => `${item.category} · ${item.service}`),
[section, tariffCatalog], [activeGeneralSection?.id, tariffCatalog],
); );
const selectedItem = useMemo(() => { const selectedItem = useMemo(
const selectedLabel = serviceId; () =>
return (
tariffCatalog.find( tariffCatalog.find(
(item) => (item) =>
item.section === section && `${item.category} · ${item.service}` === selectedLabel, item.section === activeGeneralSection?.id &&
) || null `${item.category} · ${item.service}` === serviceLabel,
) || null,
[activeGeneralSection?.id, serviceLabel, tariffCatalog],
); );
}, [section, serviceId, tariffCatalog]);
const selectedWorkType = useMemo(() => { const selectedWorkType = useMemo(() => {
if (!selectedItem) return null; if (!selectedItem) return null;
@@ -99,24 +192,24 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
const total = items.reduce((sum, item) => sum + Number(item.amount || 0), 0); const total = items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const resetTariffSelection = () => { const resetTariffSelection = () => {
setServiceId(""); setServiceLabel("");
setWorkTypeId(""); setWorkTypeId("");
setLevelId(""); setLevelId("");
setAmount(""); setAmount("");
setDescription(""); setDescription("");
}; };
const onSectionChange = (label: string) => { const onSectionChange = (name: string) => {
const option = TARIFF_SECTIONS.find((item) => item.label === label); const section = generalSections.find((item) => item.name === name);
setSection(option?.id || "grafico"); setSectionId(section?.id || generalSections[0]?.id || "");
resetTariffSelection(); resetTariffSelection();
}; };
const onServiceChange = (label: string) => { const onServiceChange = (label: string) => {
setServiceId(label); setServiceLabel(label);
const item = tariffCatalog.find( const item = tariffCatalog.find(
(catalogItem) => (catalogItem) =>
catalogItem.section === section && catalogItem.section === activeGeneralSection?.id &&
`${catalogItem.category} · ${catalogItem.service}` === label, `${catalogItem.category} · ${catalogItem.service}` === label,
); );
const firstWorkType = findDefaultWorkType(item); const firstWorkType = findDefaultWorkType(item);
@@ -146,7 +239,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
}; };
const addTariffItem = () => { const addTariffItem = () => {
if (!selectedItem || !selectedWorkType || !selectedLevel) return; if (!selectedItem || !selectedWorkType || !selectedLevel || !activeGeneralSection) return;
const finalAmount = parseAmount(amount); const finalAmount = parseAmount(amount);
if (finalAmount <= 0) return; if (finalAmount <= 0) return;
@@ -164,6 +257,8 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
referenceMax: selectedLevel.max ?? null, referenceMax: selectedLevel.max ?? null,
amount: finalAmount, amount: finalAmount,
description: clean(description), description: clean(description),
tariffSectionId: activeGeneralSection.id,
tariffCatalogId: selectedItem.id,
}, },
]); ]);
@@ -171,9 +266,43 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
setAmount(defaultTariffAmount(selectedLevel)); setAmount(defaultTariffAmount(selectedLevel));
}; };
const onSpecialServicesChange = (section: TariffSection, services: string[]) => {
const sectionCatalog = tariffCatalog.filter(
(item) => item.section === section.id && getFixedAmount(item) > 0,
);
const existingItems = items.filter((item) => itemBelongsToSpecialSection(item, section));
const existingByService = new Map(
existingItems.map((item) => [normalizeOptionKey(item.serviceName), item] as const),
);
const catalogByService = new Map(
sectionCatalog.map((item) => [normalizeOptionKey(item.service), item] as const),
);
const nextSectionItems = services
.map((service) => {
const key = normalizeOptionKey(service);
return (
existingByService.get(key) ||
(catalogByService.get(key)
? createSpecialPricingItem(catalogByService.get(key)!, section)
: null)
);
})
.filter((item): item is ProjectPricingItemInput => Boolean(item));
const otherItems = items.filter((item) => !itemBelongsToSpecialSection(item, section));
onChange([...otherItems, ...nextSectionItems]);
};
const manualContext = specialSections[0] || null;
const allowManual = !hasSpecialTariff || specialSections.some((section) => section.allowManual);
const addManualItem = () => { const addManualItem = () => {
const finalAmount = parseAmount(manualAmount); const finalAmount = parseAmount(manualAmount);
const name = clean(manualName) || "Otros / costo manual"; const contextName = manualContext?.name || "Otros";
const name =
clean(manualName) ||
(manualContext ? `Propuesta general ${manualContext.clientName}` : "Otros / costo manual");
if (finalAmount <= 0) return; if (finalAmount <= 0) return;
@@ -181,7 +310,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
...items, ...items,
{ {
source: "manual", source: "manual",
category: "Otros", category: contextName,
serviceName: name, serviceName: name,
workType: "Manual", workType: "Manual",
complexityLevel: "", complexityLevel: "",
@@ -190,6 +319,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
referenceMax: null, referenceMax: null,
amount: finalAmount, amount: finalAmount,
description: clean(manualDescription), description: clean(manualDescription),
tariffSectionId: manualContext?.id,
}, },
]); ]);
@@ -204,6 +334,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
const selectedWorkTypeLabel = selectedWorkType?.label || ""; const selectedWorkTypeLabel = selectedWorkType?.label || "";
const selectedLevelLabel = selectedLevel?.label || ""; const selectedLevelLabel = selectedLevel?.label || "";
const combinedError = error || tariffError || sectionsError;
return ( return (
<div className="md:col-span-2 rounded-2xl border border-border bg-muted/20 p-4"> <div className="md:col-span-2 rounded-2xl border border-border bg-muted/20 p-4">
@@ -215,37 +346,139 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
<div> <div>
<h3 className="text-sm font-semibold">Tarifario / Estimación interna</h3> <h3 className="text-sm font-semibold">Tarifario / Estimación interna</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Agrega costos del tarifario o montos manuales. El total se guarda como Interno {hasSpecialTariff
Cargado. ? `Este cliente tiene ${specialSections.length === 1 ? "un tarifario especial" : "tarifarios especiales"}. Selecciona piezas o agrega un costo manual.`
: "Agrega costos del tarifario general o montos manuales. El total se guarda automáticamente como monto interno del proyecto."}
</p> </p>
</div> </div>
</div> </div>
<div className="rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-sm font-semibold text-primary"> <div className="flex w-full flex-shrink-0 items-center justify-between gap-3 rounded-xl border border-primary/20 bg-card px-3 py-2 shadow-sm sm:w-auto">
Total: {formatCurrency(total)} <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<DollarSign className="h-4 w-4" />
</span>
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total estimado
</p>
<p className="mt-1 whitespace-nowrap text-lg font-semibold leading-none tabular-nums text-primary">
{formatCurrency(total)}
</p>
</div>
</div> </div>
</div> </div>
{(error || tariffError) && ( {combinedError && (
<div className="mb-3 flex items-start gap-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900"> <div className="mb-3 flex items-start gap-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" /> <Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span>{error || tariffError}</span> <span>{combinedError}</span>
</div> </div>
)} )}
{(loading || tariffLoading) && ( {(loading || tariffLoading || sectionsLoading) && (
<div className="mb-3 rounded-xl border border-border bg-card px-3 py-2 text-xs text-muted-foreground"> <div className="mb-3 rounded-xl border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
{loading ? "Cargando costos guardados…" : "Cargando tarifario…"} {loading ? "Cargando costos guardados…" : "Cargando tarifario…"}
</div> </div>
)} )}
<div className="space-y-4"> <div className="space-y-4">
{hasSpecialTariff ? (
specialSections.map((specialSection) => {
const sectionCatalog = tariffCatalog.filter(
(item) => item.section === specialSection.id && getFixedAmount(item) > 0,
);
const sectionItems = items.filter((item) =>
itemBelongsToSpecialSection(item, specialSection),
);
const selectedServices = sectionItems.map((item) => item.serviceName);
const subtotal = sectionItems.reduce(
(sum, item) => sum + Number(item.amount || 0),
0,
);
return (
<div
key={specialSection.id}
className="space-y-4 rounded-xl border border-primary/20 bg-card p-4"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-sm font-medium">{specialSection.name}</p>
<p className="text-xs text-muted-foreground">
{specialSection.description ||
"Selecciona una o varias piezas. Cada pieza se carga una sola vez con su monto fijo."}
</p>
</div>
<Badge className="w-fit">{specialSection.clientName}</Badge>
</div>
<div className="space-y-1.5">
<Label>Piezas a cotizar</Label>
<SearchableMultiSelect
values={selectedServices}
options={sectionCatalog.map((item) => item.service)}
onValuesChange={(services) =>
onSpecialServicesChange(specialSection, services)
}
placeholder="Seleccionar una o varias piezas…"
searchPlaceholder="Buscar pieza…"
emptyText="No se encontró esa pieza en este tarifario."
disabled={tariffLoading || sectionCatalog.length === 0}
summaryLabel="piezas seleccionadas"
/>
</div>
<div className="rounded-xl border border-dashed border-border bg-muted/30 p-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-medium text-foreground">Piezas y montos fijos</p>
<p className="text-xs font-semibold text-primary">
Subtotal: {formatCurrency(subtotal)}
</p>
</div>
{sectionCatalog.length === 0 ? (
<p className="text-xs text-muted-foreground">
Este tarifario todavía no tiene piezas activas. Agrégalas desde el módulo
Tarifario.
</p>
) : (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{sectionCatalog.map((item) => {
const selected = selectedServices.some(
(service) =>
normalizeOptionKey(service) === normalizeOptionKey(item.service),
);
return (
<div
key={item.id}
className={cn(
"flex items-center justify-between gap-3 rounded-lg border px-2.5 py-2 text-xs",
selected
? "border-primary/30 bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground",
)}
>
<span className="min-w-0 truncate">{item.service}</span>
<span className="flex-shrink-0 font-semibold text-foreground">
{formatCurrency(getFixedAmount(item))}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
})
) : (
<div className="space-y-4 rounded-xl border border-border bg-card p-4"> <div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<p className="text-sm font-medium">Agregar desde tarifario</p> <p className="text-sm font-medium">Agregar desde tarifario general</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Referencia flexible. El Director Creativo decide el monto final. Referencia flexible. El usuario autorizado decide el monto final.
</p> </p>
</div> </div>
<Badge variant="secondary">Tarifario</Badge> <Badge variant="secondary">Tarifario</Badge>
@@ -255,23 +488,25 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Sección</Label> <Label>Sección</Label>
<SearchableSelect <SearchableSelect
value={selectedSectionLabel} value={activeGeneralSection?.name || ""}
options={sectionOptions} options={generalSections.map((section) => section.name)}
onValueChange={onSectionChange} onValueChange={onSectionChange}
placeholder="Seleccionar sección…" placeholder="Seleccionar sección…"
searchPlaceholder="Buscar sección…" searchPlaceholder="Buscar sección…"
emptyText="No hay secciones generales activas."
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Ítem a cobrar</Label> <Label>Ítem a cobrar</Label>
<SearchableSelect <SearchableSelect
value={serviceId} value={serviceLabel}
options={serviceOptions} options={serviceOptions}
onValueChange={onServiceChange} onValueChange={onServiceChange}
placeholder="Seleccionar ítem…" placeholder="Seleccionar ítem…"
searchPlaceholder="Buscar ítem…" searchPlaceholder="Buscar ítem…"
emptyText="No se encontró ese ítem." emptyText="No se encontró ese ítem."
disabled={!activeGeneralSection}
/> />
</div> </div>
@@ -354,12 +589,20 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
</Button> </Button>
</div> </div>
</div> </div>
)}
{allowManual && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4"> <div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div> <div>
<p className="text-sm font-medium">Agregar costo manual</p> <p className="text-sm font-medium">
{manualContext
? `Agregar propuesta o costo manual para ${manualContext.clientName}`
: "Agregar costo manual"}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Para otros, adicionales o casos fuera del tarifario. {manualContext
? "Para propuestas generales, proyectos por concepto o casos no contemplados por pieza."
: "Para otros, adicionales o casos fuera del tarifario."}
</p> </p>
</div> </div>
@@ -369,7 +612,11 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
<Input <Input
value={manualName} value={manualName}
onChange={(event) => setManualName(event.target.value)} onChange={(event) => setManualName(event.target.value)}
placeholder="Ej: Piezas adicionales" placeholder={
manualContext
? `Ej: Propuesta general ${manualContext.clientName}`
: "Ej: Piezas adicionales"
}
/> />
</div> </div>
@@ -391,7 +638,11 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
<Textarea <Textarea
value={manualDescription} value={manualDescription}
onChange={(event) => setManualDescription(event.target.value)} onChange={(event) => setManualDescription(event.target.value)}
placeholder="Ej: Cliente pidió 8 piezas adicionales fuera del paquete." placeholder={
manualContext
? "Ej: Compendio cotizado por proyecto, no por piezas."
: "Ej: Cliente pidió piezas adicionales fuera del paquete."
}
rows={4} rows={4}
className="min-h-[110px] resize-y" className="min-h-[110px] resize-y"
/> />
@@ -410,6 +661,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
</Button> </Button>
</div> </div>
</div> </div>
)}
</div> </div>
<Separator className="my-4" /> <Separator className="my-4" />
@@ -422,11 +674,20 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
{items.length === 0 ? ( {items.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-card/70 px-3 py-3 text-sm text-muted-foreground"> <div className="rounded-xl border border-dashed border-border bg-card/70 px-3 py-3 text-sm text-muted-foreground">
Aún no hay costos agregados. Puedes usar el tarifario, agregar otros manuales o ambos. Aún no hay costos agregados. Usa el tarifario disponible o agrega un monto manual.
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{items.map((item, index) => ( {items.map((item, index) => {
const specialSection = sections.find(
(section) => section.id === item.tariffSectionId && section.scope === "client",
);
const legacyWmc = !specialSection && specialSections.find((section) =>
isLegacyWmcItem(item, section),
);
const itemSpecialSection = specialSection || legacyWmc;
return (
<div <div
key={`${item.source}-${item.serviceName}-${index}`} key={`${item.source}-${item.serviceName}-${index}`}
className="flex flex-col gap-2 rounded-xl border border-border bg-card px-3 py-3 sm:flex-row sm:items-start sm:justify-between" className="flex flex-col gap-2 rounded-xl border border-border bg-card px-3 py-3 sm:flex-row sm:items-start sm:justify-between"
@@ -434,7 +695,11 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant={item.source === "manual" ? "outline" : "secondary"}> <Badge variant={item.source === "manual" ? "outline" : "secondary"}>
{item.source === "manual" ? "Manual" : "Tarifario"} {item.source === "manual"
? "Manual"
: itemSpecialSection
? itemSpecialSection.clientName
: "Tarifario"}
</Badge> </Badge>
<p className="text-sm font-medium">{item.serviceName}</p> <p className="text-sm font-medium">{item.serviceName}</p>
</div> </div>
@@ -469,7 +734,8 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
</Button> </Button>
</div> </div>
</div> </div>
))} );
})}
<div className="flex items-center justify-end gap-2 rounded-xl bg-primary/10 px-3 py-2 text-primary"> <div className="flex items-center justify-end gap-2 rounded-xl bg-primary/10 px-3 py-2 text-primary">
<DollarSign className="h-4 w-4" /> <DollarSign className="h-4 w-4" />
+710
View File
@@ -0,0 +1,710 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import {
CalendarDays,
ChevronLeft,
ChevronRight,
Clock3,
ListChecks,
Loader2,
MapPin,
Plus,
RefreshCw,
Trash2,
UserRound,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { SearchableSelect } from "./SearchableSelect";
import { useAppLists } from "@/lib/appLists";
import { dedupeOptions, normalizeOptionKey } from "@/lib/optionUtils";
import {
addProjectTimeEntry,
deleteProjectTimeEntry,
loadProjectTimeEntries,
type Project,
type ProjectTimeEntry,
} from "@/lib/store";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
interface ProjectTimeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
project: Project | null;
}
type BreakdownRow = {
key: string;
label: string;
minutes: number;
};
const ENTRIES_PER_PAGE = 10;
function todayLocal() {
const now = new Date();
const offset = now.getTimezoneOffset() * 60_000;
return new Date(now.getTime() - offset).toISOString().slice(0, 10);
}
function splitProjectCountries(value: string) {
return dedupeOptions(
String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean),
);
}
function formatDuration(totalMinutes: number, compact = false) {
const safeMinutes = Math.max(0, Math.round(Number(totalMinutes || 0)));
const hours = Math.floor(safeMinutes / 60);
const minutes = safeMinutes % 60;
if (compact) {
if (hours && minutes) return `${hours} h ${minutes} min`;
if (hours) return `${hours} h`;
return `${minutes} min`;
}
if (hours && minutes) return `${hours} ${hours === 1 ? "hora" : "horas"} ${minutes} min`;
if (hours) return `${hours} ${hours === 1 ? "hora" : "horas"}`;
return `${minutes} min`;
}
function formatWorkDate(value: string) {
if (!value) return "—";
const date = new Date(`${value}T12:00:00`);
if (!Number.isFinite(date.getTime())) return value;
return new Intl.DateTimeFormat("es-DO", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(date);
}
function formatCreatedAt(timestamp: number) {
return new Intl.DateTimeFormat("es-DO", {
day: "2-digit",
month: "short",
hour: "numeric",
minute: "2-digit",
}).format(new Date(timestamp));
}
function buildBreakdown(
entries: ProjectTimeEntry[],
selector: (entry: ProjectTimeEntry) => string,
): BreakdownRow[] {
const groups = new Map<string, BreakdownRow>();
for (const entry of entries) {
const label = selector(entry).trim() || "Sin especificar";
const key = normalizeOptionKey(label) || label.toLowerCase();
const current = groups.get(key);
if (current) {
current.minutes += entry.durationMinutes;
} else {
groups.set(key, { key, label, minutes: entry.durationMinutes });
}
}
return [...groups.values()].sort(
(a, b) => b.minutes - a.minutes || a.label.localeCompare(b.label, "es"),
);
}
function isMissingTimeTableError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
const normalized = message.toLowerCase();
return (
normalized.includes("tablero_cdc_project_time_entries") ||
normalized.includes("relation") ||
normalized.includes("schema cache")
);
}
export function ProjectTimeDialog({ open, onOpenChange, project }: ProjectTimeDialogProps) {
const { lists } = useAppLists();
const [entries, setEntries] = useState<ProjectTimeEntry[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [country, setCountry] = useState("");
const [taskName, setTaskName] = useState("");
const [hours, setHours] = useState("");
const [minutes, setMinutes] = useState("");
const [workDate, setWorkDate] = useState(todayLocal());
const [notes, setNotes] = useState("");
const [page, setPage] = useState(1);
const projectCountries = useMemo(
() => splitProjectCountries(project?.bu || ""),
[project?.bu],
);
const countryOptions = useMemo(
() => dedupeOptions([...projectCountries, ...lists.bus, country]),
[country, lists.bus, projectCountries],
);
const totalMinutes = useMemo(
() => entries.reduce((sum, entry) => sum + entry.durationMinutes, 0),
[entries],
);
const countryBreakdown = useMemo(
() => buildBreakdown(entries, (entry) => entry.country),
[entries],
);
const taskBreakdown = useMemo(
() => buildBreakdown(entries, (entry) => entry.taskName),
[entries],
);
const recentTasks = useMemo(
() => dedupeOptions(entries.map((entry) => entry.taskName)).slice(0, 8),
[entries],
);
const totalPages = Math.max(1, Math.ceil(entries.length / ENTRIES_PER_PAGE));
const safePage = Math.min(page, totalPages);
const pageStart = (safePage - 1) * ENTRIES_PER_PAGE;
const visibleEntries = entries.slice(pageStart, pageStart + ENTRIES_PER_PAGE);
const visibleStart = entries.length === 0 ? 0 : pageStart + 1;
const visibleEnd = Math.min(pageStart + ENTRIES_PER_PAGE, entries.length);
const loadEntries = useCallback(async () => {
if (!project?.id) return;
try {
setLoading(true);
setError(null);
const rows = await loadProjectTimeEntries(project.id);
setEntries(rows);
} catch (loadError) {
console.error("No se pudo cargar el tiempo del proyecto:", loadError);
setEntries([]);
setError(
isMissingTimeTableError(loadError)
? "El módulo de tiempo todavía no está instalado en Supabase. Ejecuta supabase_project_time_entries.sql."
: loadError instanceof Error
? loadError.message
: "No se pudo cargar el desglose de tiempo.",
);
} finally {
setLoading(false);
}
}, [project?.id]);
useEffect(() => {
if (!open || !project?.id) return;
setCountry(projectCountries[0] || "");
setTaskName("");
setHours("");
setMinutes("");
setWorkDate(todayLocal());
setNotes("");
setPage(1);
void loadEntries();
}, [loadEntries, open, project?.id, projectCountries]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const durationMinutes =
Math.max(0, Math.floor(Number(hours || 0))) * 60 +
Math.max(0, Math.floor(Number(minutes || 0)));
const validDuration = Number.isFinite(durationMinutes) && durationMinutes > 0;
const canSubmit = Boolean(country.trim() && taskName.trim() && workDate && validDuration);
const handleAdd = async () => {
if (!project?.id || !canSubmit || saving) return;
try {
setSaving(true);
setError(null);
const saved = await addProjectTimeEntry(project.id, {
country,
taskName,
durationMinutes,
workDate,
notes,
});
setEntries((current) => [saved, ...current]);
setTaskName("");
setHours("");
setMinutes("");
setNotes("");
setPage(1);
toast.success("Tiempo registrado", {
description: `${formatDuration(saved.durationMinutes, true)} · ${saved.country} · ${saved.taskName}`,
});
} catch (saveError) {
console.error("No se pudo registrar el tiempo:", saveError);
const message = isMissingTimeTableError(saveError)
? "Ejecuta supabase_project_time_entries.sql en Supabase antes de usar este módulo."
: saveError instanceof Error
? saveError.message
: "No se pudo registrar el tiempo.";
setError(message);
toast.error("No se pudo registrar el tiempo", { description: message });
} finally {
setSaving(false);
}
};
const handleDelete = async (entry: ProjectTimeEntry) => {
const confirmed = window.confirm(
`¿Eliminar este registro de ${formatDuration(entry.durationMinutes, true)} para “${entry.taskName}”?`,
);
if (!confirmed) return;
try {
setDeletingId(entry.id);
await deleteProjectTimeEntry(entry.id);
setEntries((current) => current.filter((item) => item.id !== entry.id));
toast.success("Registro de tiempo eliminado");
} catch (deleteError) {
console.error("No se pudo eliminar el registro de tiempo:", deleteError);
toast.error("No se pudo eliminar", {
description:
deleteError instanceof Error ? deleteError.message : "Intenta nuevamente.",
});
} finally {
setDeletingId(null);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[92vh] max-w-5xl overflow-y-auto p-0 gap-0">
<div className="border-b border-border bg-muted/20 px-6 py-5 sm:px-7">
<DialogHeader>
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock3 className="h-5 w-5" />
</span>
<div className="min-w-0">
<DialogTitle className="text-xl tracking-tight">Tiempo dedicado</DialogTitle>
<DialogDescription className="mt-1">
Desglose por país y tarea para <span className="font-medium text-foreground">{project?.nombre || "este proyecto"}</span>.
</DialogDescription>
</div>
</div>
</DialogHeader>
</div>
<div className="space-y-6 px-6 py-6 sm:px-7">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SummaryCard
icon={<Clock3 className="h-4 w-4" />}
label="Total dedicado"
value={formatDuration(totalMinutes, true)}
/>
<SummaryCard
icon={<MapPin className="h-4 w-4" />}
label="Países con tiempo"
value={String(countryBreakdown.length)}
/>
<SummaryCard
icon={<ListChecks className="h-4 w-4" />}
label="Tareas registradas"
value={String(taskBreakdown.length)}
/>
</div>
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-sm font-semibold">Agregar tiempo</h3>
<p className="mt-0.5 text-xs text-muted-foreground">
Cada registro queda asociado a este proyecto y al usuario que lo agrega.
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 self-start rounded-full px-2.5 text-xs text-muted-foreground"
onClick={() => void loadEntries()}
disabled={loading}
>
{loading ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
Actualizar
</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-12">
<div className="space-y-1.5 md:col-span-4">
<Label>País</Label>
<SearchableSelect
value={country}
options={countryOptions}
onValueChange={setCountry}
placeholder="Seleccionar país…"
searchPlaceholder="Buscar país…"
emptyText="No se encontró ese país."
/>
</div>
<div className="space-y-1.5 md:col-span-4">
<Label>Tarea</Label>
<Input
value={taskName}
onChange={(event) => setTaskName(event.target.value)}
placeholder="Ej: Diseño, adaptación, reunión…"
className="h-10"
maxLength={160}
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label>Horas</Label>
<Input
type="number"
min={0}
step={1}
inputMode="numeric"
value={hours}
onChange={(event) => setHours(event.target.value)}
placeholder="0"
className="h-10"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label>Minutos</Label>
<Input
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={minutes}
onChange={(event) => {
const value = event.target.value;
if (value === "") {
setMinutes("");
return;
}
setMinutes(String(Math.min(59, Math.max(0, Math.floor(Number(value) || 0)))));
}}
placeholder="0"
className="h-10"
/>
</div>
<div className="space-y-1.5 md:col-span-4">
<Label>Fecha</Label>
<Input
type="date"
value={workDate}
onChange={(event) => setWorkDate(event.target.value)}
className="h-10"
/>
</div>
<div className="space-y-1.5 md:col-span-8">
<Label>Nota <span className="font-normal text-muted-foreground">(opcional)</span></Label>
<Textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder="Detalle breve de lo realizado…"
rows={2}
maxLength={600}
className="min-h-[64px] resize-none"
/>
</div>
</div>
{recentTasks.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-[11px] text-muted-foreground">Tareas recientes:</span>
{recentTasks.map((task) => (
<button
type="button"
key={task}
onClick={() => setTaskName(task)}
className="rounded-full border border-border bg-muted/30 px-2.5 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{task}
</button>
))}
</div>
)}
<div className="mt-4 flex flex-col gap-2 border-t border-border/70 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
Duración a registrar: <span className="font-semibold text-foreground">{formatDuration(durationMinutes, true)}</span>
</p>
<Button
type="button"
onClick={() => void handleAdd()}
disabled={!canSubmit || saving || loading}
className="gap-1.5 rounded-full"
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{saving ? "Guardando…" : "Agregar registro"}
</Button>
</div>
{error && (
<div className="mt-4 rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
</section>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<BreakdownCard
icon={<MapPin className="h-4 w-4" />}
title="Desglose por país"
rows={countryBreakdown}
totalMinutes={totalMinutes}
emptyText="Aún no hay países con tiempo registrado."
/>
<BreakdownCard
icon={<ListChecks className="h-4 w-4" />}
title="Desglose por tarea"
rows={taskBreakdown}
totalMinutes={totalMinutes}
emptyText="Aún no hay tareas con tiempo registrado."
/>
</div>
<section className="overflow-hidden rounded-2xl border border-border bg-card">
<div className="flex flex-col gap-2 border-b border-border bg-muted/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold">Registros detallados</h3>
<p className="text-xs text-muted-foreground">
{entries.length === 0
? "Sin registros todavía."
: `Mostrando ${visibleStart}-${visibleEnd} de ${entries.length}.`}
</p>
</div>
{entries.length > ENTRIES_PER_PAGE && (
<span className="text-xs text-muted-foreground">
Página {safePage} de {totalPages}
</span>
)}
</div>
{loading && entries.length === 0 ? (
<div className="flex items-center justify-center gap-2 px-4 py-12 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Cargando tiempo
</div>
) : entries.length === 0 ? (
<div className="px-4 py-10 text-center">
<Clock3 className="mx-auto h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm font-medium">Aún no hay tiempo registrado</p>
<p className="mt-1 text-xs text-muted-foreground">
Agrega el primer registro usando el formulario de arriba.
</p>
</div>
) : (
<div className="divide-y divide-border/70">
{visibleEntries.map((entry) => (
<TimeEntryRow
key={entry.id}
entry={entry}
deleting={deletingId === entry.id}
onDelete={() => void handleDelete(entry)}
/>
))}
</div>
)}
{entries.length > ENTRIES_PER_PAGE && (
<div className="flex items-center justify-between border-t border-border bg-muted/10 px-4 py-3">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1 rounded-full"
disabled={safePage <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
<ChevronLeft className="h-3.5 w-3.5" />
Anterior
</Button>
<span className="text-xs text-muted-foreground">
{safePage} / {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1 rounded-full"
disabled={safePage >= totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
>
Siguiente
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
</section>
</div>
</DialogContent>
</Dialog>
);
}
function SummaryCard({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card px-4 py-3.5 shadow-sm">
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
{icon}
</span>
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-0.5 truncate text-lg font-semibold tabular-nums text-foreground">{value}</p>
</div>
</div>
);
}
function BreakdownCard({
icon,
title,
rows,
totalMinutes,
emptyText,
}: {
icon: ReactNode;
title: string;
rows: BreakdownRow[];
totalMinutes: number;
emptyText: string;
}) {
return (
<section className="rounded-2xl border border-border bg-card p-4">
<div className="mb-3 flex items-center gap-2">
<span className="text-primary">{icon}</span>
<h3 className="text-sm font-semibold">{title}</h3>
</div>
{rows.length === 0 ? (
<p className="rounded-xl border border-dashed border-border bg-muted/20 px-3 py-5 text-center text-xs text-muted-foreground">
{emptyText}
</p>
) : (
<div className="space-y-3">
{rows.map((row) => {
const percentage = totalMinutes > 0 ? (row.minutes / totalMinutes) * 100 : 0;
return (
<div key={row.key}>
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="min-w-0 truncate font-medium text-foreground" title={row.label}>
{row.label}
</span>
<span className="flex-shrink-0 font-semibold tabular-nums text-foreground">
{formatDuration(row.minutes, true)}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/70 transition-[width]"
style={{ width: `${Math.max(2, Math.min(100, percentage))}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</section>
);
}
function TimeEntryRow({
entry,
deleting,
onDelete,
}: {
entry: ProjectTimeEntry;
deleting: boolean;
onDelete: () => void;
}) {
return (
<div className="grid grid-cols-1 gap-3 px-4 py-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-sm text-foreground">{entry.taskName}</span>
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-normal">
{entry.country}
</Badge>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold tabular-nums text-primary">
{formatDuration(entry.durationMinutes, true)}
</span>
</div>
{entry.notes && (
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
{entry.notes}
</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<CalendarDays className="h-3 w-3" />
{formatWorkDate(entry.workDate)}
</span>
<span className="inline-flex items-center gap-1" title={entry.actorEmail || entry.actorName}>
<UserRound className="h-3 w-3" />
{entry.actorName}
</span>
<span>Registrado {formatCreatedAt(entry.createdAt)}</span>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onDelete}
disabled={deleting}
className={cn(
"h-8 w-8 justify-self-end rounded-full text-muted-foreground hover:bg-destructive/10 hover:text-destructive",
deleting && "text-destructive",
)}
title="Eliminar registro"
>
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
</div>
);
}
+28 -9
View File
@@ -13,6 +13,7 @@ import {
} from "@/components/ui/command"; } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { canonicalOptionLabel, dedupeOptions, normalizeOptionKey } from "@/lib/optionUtils";
function handleDropdownWheel(event: WheelEvent<HTMLDivElement>) { function handleDropdownWheel(event: WheelEvent<HTMLDivElement>) {
event.preventDefault(); event.preventDefault();
@@ -43,10 +44,23 @@ export function SearchableSelect({
}: SearchableSelectProps) { }: SearchableSelectProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const selectedLabel = useMemo( const normalizedOptions = useMemo(() => {
() => options.find((option) => option === value) ?? value, const unique = new Map<string, string>();
[options, value],
); for (const option of dedupeOptions(options)) {
const label = canonicalOptionLabel(option);
const key = normalizeOptionKey(label);
if (!key || unique.has(key)) continue;
unique.set(key, label);
}
return Array.from(unique.values());
}, [options]);
const selectedLabel = useMemo(() => {
const valueKey = normalizeOptionKey(value);
return normalizedOptions.find((option) => normalizeOptionKey(option) === valueKey) ?? value;
}, [normalizedOptions, value]);
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@@ -88,21 +102,26 @@ export function SearchableSelect({
<Check className={cn("mr-2 h-4 w-4", !value ? "opacity-100" : "opacity-0")} /> <Check className={cn("mr-2 h-4 w-4", !value ? "opacity-100" : "opacity-0")} />
<span className="whitespace-normal break-words font-medium">{clearLabel}</span> <span className="whitespace-normal break-words font-medium">{clearLabel}</span>
</CommandItem> </CommandItem>
{options.length > 0 && <CommandSeparator className="my-1" />} {normalizedOptions.length > 0 && <CommandSeparator className="my-1" />}
</> </>
)} )}
{options.map((option) => ( {normalizedOptions.map((option) => (
<CommandItem <CommandItem
key={option} key={normalizeOptionKey(option)}
value={option} value={`${normalizeOptionKey(option)} ${option}`}
onSelect={() => { onSelect={() => {
onValueChange(option); onValueChange(option);
setOpen(false); setOpen(false);
}} }}
> >
<Check <Check
className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")} className={cn(
"mr-2 h-4 w-4",
normalizeOptionKey(value) === normalizeOptionKey(option)
? "opacity-100"
: "opacity-0",
)}
/> />
<span className="whitespace-normal break-words leading-snug">{option}</span> <span className="whitespace-normal break-words leading-snug">{option}</span>
</CommandItem> </CommandItem>
+429
View File
@@ -0,0 +1,429 @@
import { useEffect, useMemo, useState } from "react";
import {
Check,
ExternalLink,
ListChecks,
Loader2,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAppLists } from "@/lib/appLists";
import {
mutateManagedList,
type ManagedListCategory,
} from "@/lib/listAdmin";
import { normalizeOptionKey } from "@/lib/optionUtils";
import { cn } from "@/lib/utils";
const SHEET_URL =
"https://docs.google.com/spreadsheets/d/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/edit?gid=1709890592#gid=1709890592";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
type EditableRow = {
value: string;
manager?: string;
};
const CATEGORY_META: Record<
ManagedListCategory,
{ label: string; singular: string; placeholder: string; description: string }
> = {
client: {
label: "Clientes",
singular: "cliente",
placeholder: "Ej: PepsiCo",
description: "Opciones disponibles en el campo Cliente de los proyectos.",
},
brand: {
label: "Marcas",
singular: "marca",
placeholder: "Ej: Nescafé",
description: "Opciones disponibles en la selección de una o varias marcas.",
},
country: {
label: "País / BU",
singular: "País / BU",
placeholder: "Ej: Guatemala",
description: "Cada País / BU puede tener un Country Manager asociado.",
},
status: {
label: "Estatus",
singular: "estatus",
placeholder: "Ej: Stand by",
description: "Estatus disponibles para cerrar o clasificar proyectos.",
},
};
function sortRows(rows: EditableRow[]) {
return [...rows].sort((a, b) => a.value.localeCompare(b.value, "es", { sensitivity: "base" }));
}
export function ListManagerDialog({ open, onOpenChange }: Props) {
const [category, setCategory] = useState<ManagedListCategory>("client");
const [query, setQuery] = useState("");
const [value, setValue] = useState("");
const [manager, setManager] = useState("");
const [editingOriginal, setEditingOriginal] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deletingValue, setDeletingValue] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const { lists, loading, error, refresh } = useAppLists();
const rows = useMemo<EditableRow[]>(() => {
if (category === "client") return sortRows(lists.clientes.map((item) => ({ value: item })));
if (category === "brand") return sortRows(lists.marcas.map((item) => ({ value: item })));
if (category === "status") return sortRows(lists.status.map((item) => ({ value: item })));
return sortRows(
lists.bus.map((item) => ({
value: item,
manager:
lists.buCm[item] ||
Object.entries(lists.buCm).find(
([key]) => normalizeOptionKey(key) === normalizeOptionKey(item),
)?.[1] ||
"",
})),
);
}, [category, lists]);
const filteredRows = useMemo(() => {
const cleanQuery = normalizeOptionKey(query);
if (!cleanQuery) return rows;
return rows.filter((row) =>
normalizeOptionKey(`${row.value} ${row.manager || ""}`).includes(cleanQuery),
);
}, [query, rows]);
const resetDraft = () => {
setValue("");
setManager("");
setEditingOriginal(null);
};
useEffect(() => {
if (!open) return;
void refresh();
}, [open, refresh]);
useEffect(() => {
resetDraft();
setQuery("");
}, [category]);
const beginEdit = (row: EditableRow) => {
setEditingOriginal(row.value);
setValue(row.value);
setManager(row.manager || "");
};
const save = async () => {
const cleanValue = value.trim();
const cleanManager = manager.trim();
if (!cleanValue) {
toast.error(`Escribe el ${CATEGORY_META[category].singular}.`);
return;
}
setSaving(true);
try {
await mutateManagedList({
action: editingOriginal ? "update" : "create",
category,
value: cleanValue,
previousValue: editingOriginal || undefined,
manager: category === "country" ? cleanManager : undefined,
});
await refresh();
toast.success(editingOriginal ? "Lista actualizada." : "Opción agregada.", {
description: "Google Sheet y Supabase quedaron sincronizados.",
});
resetDraft();
} catch (saveError) {
console.error("Error guardando lista:", saveError);
toast.error("No se pudo guardar la lista.", {
description:
saveError instanceof Error ? saveError.message : "Revisa n8n e intenta nuevamente.",
});
} finally {
setSaving(false);
}
};
const remove = async (row: EditableRow) => {
if (
!window.confirm(
`¿Eliminar "${row.value}" de ${CATEGORY_META[category].label}?\n\nEl cambio también se hará en el Google Sheet. Los proyectos históricos no se borrarán.`,
)
) {
return;
}
setDeletingValue(row.value);
try {
await mutateManagedList({
action: "delete",
category,
value: row.value,
previousValue: row.value,
});
await refresh();
if (editingOriginal && normalizeOptionKey(editingOriginal) === normalizeOptionKey(row.value)) {
resetDraft();
}
toast.success("Opción eliminada.", {
description: "Google Sheet y Supabase quedaron sincronizados.",
});
} catch (deleteError) {
console.error("Error eliminando lista:", deleteError);
toast.error("No se pudo eliminar la opción.", {
description:
deleteError instanceof Error ? deleteError.message : "Revisa n8n e intenta nuevamente.",
});
} finally {
setDeletingValue(null);
}
};
const syncFromSheet = async () => {
setSyncing(true);
try {
await mutateManagedList({ action: "refresh" });
await refresh();
toast.success("Listas actualizadas desde el Sheet.");
} catch (syncError) {
console.error("Error sincronizando listas:", syncError);
toast.error("No se pudieron sincronizar las listas.", {
description:
syncError instanceof Error ? syncError.message : "Revisa n8n e intenta nuevamente.",
});
} finally {
setSyncing(false);
}
};
const meta = CATEGORY_META[category];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[88vh] max-h-[860px] w-[96vw] max-w-[1050px] overflow-hidden p-0">
<div className="flex h-full min-h-0 flex-col">
<div className="border-b border-border px-6 py-5 pr-14">
<DialogHeader>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<DialogTitle className="flex items-center gap-2 text-xl">
<ListChecks className="h-5 w-5 text-primary" />
Administración de listas
</DialogTitle>
<DialogDescription className="mt-1 max-w-2xl">
Los cambios se escriben primero en la hoja <strong>Listas</strong> y se
sincronizan inmediatamente con Supabase para que aparezcan en toda la app.
</DialogDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => window.open(SHEET_URL, "_blank", "noopener,noreferrer")}
>
<ExternalLink className="h-3.5 w-3.5" /> Abrir Sheet
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void syncFromSheet()}
disabled={syncing}
>
<RefreshCw className={cn("h-3.5 w-3.5", syncing && "animate-spin")} />
Sincronizar desde Sheet
</Button>
</div>
</div>
</DialogHeader>
</div>
<div className="border-b border-border px-6 py-4">
<Tabs value={category} onValueChange={(next) => setCategory(next as ManagedListCategory)}>
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4">
{(Object.keys(CATEGORY_META) as ManagedListCategory[]).map((key) => (
<TabsTrigger key={key} value={key}>
{CATEGORY_META[key].label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[360px_minmax(0,1fr)]">
<section className="border-b border-border bg-muted/15 p-5 lg:border-b-0 lg:border-r">
<div className="rounded-2xl border border-border bg-card p-4 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold">
{editingOriginal ? `Editar ${meta.singular}` : `Agregar ${meta.singular}`}
</h3>
<p className="mt-1 text-xs text-muted-foreground">{meta.description}</p>
</div>
{editingOriginal && (
<Button type="button" variant="ghost" size="icon" onClick={resetDraft}>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="mt-4 space-y-3">
<div className="space-y-1.5">
<Label>{meta.singular.charAt(0).toUpperCase() + meta.singular.slice(1)}</Label>
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={meta.placeholder}
disabled={saving}
/>
</div>
{category === "country" && (
<div className="space-y-1.5">
<Label>Country Manager</Label>
<Input
value={manager}
onChange={(event) => setManager(event.target.value)}
placeholder="Ej: Poul"
disabled={saving}
/>
</div>
)}
<Button
type="button"
className="w-full gap-1.5"
onClick={() => void save()}
disabled={saving || !value.trim()}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : editingOriginal ? (
<Check className="h-4 w-4" />
) : (
<Plus className="h-4 w-4" />
)}
{editingOriginal ? "Guardar cambios" : "Agregar"}
</Button>
</div>
</div>
</section>
<section className="flex min-h-0 flex-col p-5">
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={`Buscar en ${meta.label.toLowerCase()}`}
className="pl-9"
/>
</div>
{error && (
<div className="mb-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
No se pudieron cargar las listas desde Supabase: {error}
</div>
)}
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{filteredRows.length} opción(es)</span>
{loading && <span>Cargando</span>}
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-border bg-card">
{loading && rows.length === 0 ? (
<div className="flex h-40 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Cargando listas
</div>
) : filteredRows.length === 0 ? (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
No hay opciones que coincidan.
</div>
) : (
<div className="divide-y divide-border">
{filteredRows.map((row) => (
<div
key={normalizeOptionKey(`${category}:${row.value}`)}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{row.value}</p>
{category === "country" && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
CM: {row.manager || "Sin asignar"}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 px-2"
onClick={() => beginEdit(row)}
disabled={Boolean(deletingValue) || saving}
>
<Pencil className="h-3.5 w-3.5" /> Editar
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 px-2 text-muted-foreground hover:text-destructive"
onClick={() => void remove(row)}
disabled={Boolean(deletingValue) || saving}
>
{deletingValue === row.value ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Eliminar
</Button>
</div>
</div>
))}
</div>
)}
</div>
</section>
</div>
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -55,6 +55,7 @@ interface AuthContextValue {
canDeleteProjects: boolean; canDeleteProjects: boolean;
canManageInternalPricing: boolean; canManageInternalPricing: boolean;
canControlPricingSummary: boolean; canControlPricingSummary: boolean;
canManageTariffCatalog: boolean;
loginWithGoogle: () => Promise<void>; loginWithGoogle: () => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
} }
@@ -190,6 +191,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => access?.canControlPricingSummary === true, () => access?.canControlPricingSummary === true,
[access?.canControlPricingSummary], [access?.canControlPricingSummary],
); );
const canManageTariffCatalog = useMemo(
() => access?.canManageTariffCatalog === true,
[access?.canManageTariffCatalog],
);
return ( return (
<AuthContext.Provider <AuthContext.Provider
@@ -201,6 +206,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
canDeleteProjects, canDeleteProjects,
canManageInternalPricing, canManageInternalPricing,
canControlPricingSummary, canControlPricingSummary,
canManageTariffCatalog,
loginWithGoogle, loginWithGoogle,
logout, logout,
}} }}
+1
View File
@@ -88,6 +88,7 @@ export const BU_CM: Record<string, string> = {
Vozez: "Nathalia", Vozez: "Nathalia",
"GLM Digital": "Lil", "GLM Digital": "Lil",
IQM: "Lil", IQM: "Lil",
WMC: "Willy",
}; };
export const BUS = Object.keys(BU_CM).sort(); export const BUS = Object.keys(BU_CM).sort();
+167 -1
View File
@@ -17,7 +17,7 @@ export type TariffWorkType = {
export type TariffCatalogItem = { export type TariffCatalogItem = {
id: string; id: string;
section: "grafico" | "estrategia"; section: string;
category: string; category: string;
service: string; service: string;
notes?: string; notes?: string;
@@ -32,6 +32,9 @@ export const TARIFF_SECTIONS = [
export const WORK_TYPE_DESIGN = "design_final_art"; export const WORK_TYPE_DESIGN = "design_final_art";
export const WORK_TYPE_ADAPTATION = "adaptation_final_art"; export const WORK_TYPE_ADAPTATION = "adaptation_final_art";
export const WORK_TYPE_REFERENCE = "reference"; export const WORK_TYPE_REFERENCE = "reference";
export const WMC_TARIFF_SECTION = "wmc" as const;
export const WMC_FIXED_WORK_TYPE = "wmc_fixed";
export const WMC_FIXED_LEVEL = "fixed";
function level( function level(
id: string, id: string,
@@ -78,6 +81,56 @@ function graphicWorkTypes({
return workTypes; return workTypes;
} }
function wmcFixedItem({
id,
service,
amount,
description,
equivalent,
observations,
}: {
id: string;
service: string;
amount: number;
description: string;
equivalent: string;
observations: string;
}): TariffCatalogItem {
const notes = [
description,
equivalent ? `Equivalencia CDC: ${equivalent}.` : "",
observations,
]
.filter(Boolean)
.join(" ");
return {
id,
section: WMC_TARIFF_SECTION,
category: "Walmart Connect",
service,
notes,
workTypes: [
{
id: WMC_FIXED_WORK_TYPE,
label: "Tarifa fija WMC",
shortLabel: "WMC",
hourReference: "35 - 80",
levels: [
level(
WMC_FIXED_LEVEL,
"Precio fijo",
`${amount} USD`,
amount,
amount,
observations,
),
],
},
],
};
}
export const TARIFF_CATALOG: TariffCatalogItem[] = [ export const TARIFF_CATALOG: TariffCatalogItem[] = [
{ {
id: "pdv-materiales-basicos", id: "pdv-materiales-basicos",
@@ -510,6 +563,119 @@ export const TARIFF_CATALOG: TariffCatalogItem[] = [
}, },
], ],
}, },
wmcFixedItem({
id: "wmc-uniformes",
service: "Uniformes",
amount: 80,
description:
"Uniforme completo para personal de impulso en piso (playera/top, jogger, falda, chaleco), con 4 variantes de diseño exploradas.",
equivalent: "Materiales de PDV estándar y otros materiales básicos > Uniformes",
observations: "Incluye diseño en alta resolución y 3 rondas de cambios.",
}),
wmcFixedItem({
id: "wmc-uniformes-supervisor",
service: "Uniformes Supervisor",
amount: 100,
description:
"Uniforme para supervisor de piso (polo, chumpa/track jacket, camisa formal), con 3 variantes de diseño exploradas.",
equivalent: "Materiales de PDV estándar y otros materiales básicos > Uniformes",
observations: "Incluye diseño en alta resolución y 3 rondas de cambios.",
}),
wmcFixedItem({
id: "wmc-glorificador-mano",
service: "Glorificador de mano",
amount: 150,
description:
"Exhibidor portátil pequeño para presentar el producto: bandeja iluminada, aro LED, estuche o base acrílica.",
equivalent: "Stands, Muebles y Exhibidores > Estándar (Displays básicos)",
observations: "Incluye diseño 3D, troqueles y 3 rondas de cambios.",
}),
wmcFixedItem({
id: "wmc-carritos-intervenidos",
service: "Carritos intervenidos",
amount: 200,
description:
"Vinil o calcomanía de marca aplicada a la canasta y al mango del carrito de compras.",
equivalent:
"Materiales de PDV estándar y otros materiales básicos (análogo a Backings/Rompetráficos)",
observations:
"Pieza análoga a material gráfico estándar aplicado sobre una superficie existente.",
}),
wmcFixedItem({
id: "wmc-dummies-exhibicion",
service: "Dummies de exhibición",
amount: 200,
description:
"Réplica escultórica a gran escala de pasta y cepillo dental como punto focal decorativo o táctil.",
equivalent:
"Proyectos especiales > Diseño de muebles / proyectos con planos y troqueles desde cero",
observations: "Requiere diseño estructural y visualización 3D; no es solo arte de impresión.",
}),
wmcFixedItem({
id: "wmc-photobooth",
service: "Photobooth",
amount: 200,
description:
"Estructura tipo arco con iluminación LED y espacio fotográfico de marca para activaciones.",
equivalent: "Proyectos especiales > Diseño de Stands Creativos para ferias o eventos",
observations: "Estructura autoportante con iluminación, ambientación y 4 rondas de cambios.",
}),
wmcFixedItem({
id: "wmc-play-station",
service: "Play Station",
amount: 200,
description: "Kiosco interactivo con pantalla táctil y mecánica de juego.",
equivalent: "Proyectos especiales > Diseño de muebles / proyectos especiales",
observations:
"Incluye diseño de mueble e interfaz gráfica; el desarrollo funcional del juego se cotiza aparte.",
}),
wmcFixedItem({
id: "wmc-isla-wm",
service: "Isla WM",
amount: 150,
description:
"Mueble isla completo multinivel con iluminación, gráficos y espacio de exhibición de la línea completa de producto.",
equivalent: "Proyectos especiales > Diseño de Stands Creativos para ferias o eventos",
observations: "Mayor escala y complejidad estructural dentro del set de piezas.",
}),
wmcFixedItem({
id: "wmc-exhibicion-especial",
service: "Exhibición Especial",
amount: 250,
description: "Propuesta de muebles de exhibición.",
equivalent: "Proyectos especiales > Diseño de muebles con planos y troqueles desde cero",
observations:
"Costo de referencia por concepto; las variantes adicionales se cobran como adicionales.",
}),
wmcFixedItem({
id: "wmc-punta-gondola",
service: "Punta de góndola",
amount: 250,
description:
"Cabecera de góndola personalizada con iluminación, gráficos y espacio para producto.",
equivalent: "Proyectos especiales > Diseño de muebles con planos y troqueles desde cero",
observations:
"Costo de referencia por concepto; las variantes adicionales se cobran como adicionales.",
}),
wmcFixedItem({
id: "wmc-estacion-prueba-producto",
service: "Estación para prueba de producto",
amount: 200,
description:
"Carrito móvil con lavamanos funcional, grifo y desagüe para pruebas de producto en piso.",
equivalent: "Proyectos especiales > Diseño de Stands Creativos para ferias o eventos",
observations:
"La complejidad funcional puede requerir una cotización especial fuera del tarifario estándar.",
}),
wmcFixedItem({
id: "wmc-arco-entrada",
service: "Arco de entrada",
amount: 200,
description:
"Estructura de entrada de tienda a gran formato con iluminación, efecto de niebla y gráficos de marca.",
equivalent: "Proyectos especiales > Diseño de Stands Creativos para ferias o eventos",
observations: "La instalación en sitio se cotiza aparte.",
}),
]; ];
export function formatTariffRange(level?: TariffLevel | null) { export function formatTariffRange(level?: TariffLevel | null) {
+31 -5
View File
@@ -8,6 +8,7 @@ export type TableroCdcUserAccess = {
canDeleteProjects: boolean; canDeleteProjects: boolean;
canManageInternalPricing: boolean; canManageInternalPricing: boolean;
canControlPricingSummary: boolean; canControlPricingSummary: boolean;
canManageTariffCatalog: boolean;
}; };
type AccessRow = { type AccessRow = {
@@ -18,6 +19,7 @@ type AccessRow = {
can_delete_projects?: boolean | null; can_delete_projects?: boolean | null;
can_manage_internal_pricing?: boolean | null; can_manage_internal_pricing?: boolean | null;
can_control_pricing_summary?: boolean | null; can_control_pricing_summary?: boolean | null;
can_manage_tariff_catalog?: boolean | null;
}; };
function normalizeEmail(email: string | null | undefined): string { function normalizeEmail(email: string | null | undefined): string {
@@ -37,6 +39,7 @@ function normalizeAccessRow(row: AccessRow | null | undefined): TableroCdcUserAc
canDeleteProjects: row.can_delete_projects === true, canDeleteProjects: row.can_delete_projects === true,
canManageInternalPricing: row.can_manage_internal_pricing === true, canManageInternalPricing: row.can_manage_internal_pricing === true,
canControlPricingSummary: row.can_control_pricing_summary === true, canControlPricingSummary: row.can_control_pricing_summary === true,
canManageTariffCatalog: row.can_manage_tariff_catalog === true,
}; };
} }
@@ -47,7 +50,32 @@ export async function getActiveTableroCdcAccess(
if (!normalizedEmail) return null; if (!normalizedEmail) return null;
const { data, error } = await supabase const queryWithCatalogPermission = await supabase
.from("tablero_cdc_allowed_users")
.select(
"email, full_name, role, is_active, can_delete_projects, can_manage_internal_pricing, can_control_pricing_summary, can_manage_tariff_catalog",
)
.eq("email", normalizedEmail)
.eq("is_active", true)
.maybeSingle();
if (!queryWithCatalogPermission.error) {
return normalizeAccessRow(queryWithCatalogPermission.data as AccessRow | null);
}
const message = queryWithCatalogPermission.error.message || "";
const isMissingCatalogPermission =
message.includes("can_manage_tariff_catalog") ||
message.includes("column") ||
message.includes("schema cache");
if (!isMissingCatalogPermission) {
throw queryWithCatalogPermission.error;
}
// Compatibilidad durante el despliegue: la app sigue funcionando aunque el SQL nuevo
// todavía no se haya ejecutado. El botón Tarifario permanecerá oculto hasta entonces.
const fallbackQuery = await supabase
.from("tablero_cdc_allowed_users") .from("tablero_cdc_allowed_users")
.select( .select(
"email, full_name, role, is_active, can_delete_projects, can_manage_internal_pricing, can_control_pricing_summary", "email, full_name, role, is_active, can_delete_projects, can_manage_internal_pricing, can_control_pricing_summary",
@@ -56,11 +84,9 @@ export async function getActiveTableroCdcAccess(
.eq("is_active", true) .eq("is_active", true)
.maybeSingle(); .maybeSingle();
if (error) { if (fallbackQuery.error) throw fallbackQuery.error;
throw error;
}
return normalizeAccessRow(data as AccessRow | null); return normalizeAccessRow(fallbackQuery.data as AccessRow | null);
} }
export async function getCurrentTableroCdcAccess(): Promise<TableroCdcUserAccess> { export async function getCurrentTableroCdcAccess(): Promise<TableroCdcUserAccess> {
+15 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import { canonicalOptionLabel, dedupeOptions } from "@/lib/optionUtils"; import { canonicalOptionLabel, dedupeOptions, normalizeOptionKey } from "@/lib/optionUtils";
import { import {
BUS as FALLBACK_BUS, BUS as FALLBACK_BUS,
BU_CM as FALLBACK_BU_CM, BU_CM as FALLBACK_BU_CM,
@@ -51,6 +51,16 @@ function uniqOptions(values: string[]) {
return dedupeOptions(values); return dedupeOptions(values);
} }
function cleanCountryOptions(values: string[]) {
const fallbackByKey = new Map(
FALLBACK_BUS.map((country) => [normalizeOptionKey(country), country]),
);
return dedupeOptions(values)
.map((value) => fallbackByKey.get(normalizeOptionKey(value)) || canonicalOptionLabel(value))
.filter((value): value is string => Boolean(value));
}
function normalizeCategory(value: string | null | undefined) { function normalizeCategory(value: string | null | undefined) {
return String(value || "") return String(value || "")
.normalize("NFD") .normalize("NFD")
@@ -92,7 +102,7 @@ function buildLists(rows: AppListRow[]): AppLists {
const marcas: string[] = []; const marcas: string[] = [];
const bus: string[] = []; const bus: string[] = [];
const status: string[] = []; const status: string[] = [];
const buCm: Record<string, string> = { ...FALLBACK_BU_CM }; const dynamicBuCm: Record<string, string> = {};
for (const row of rows) { for (const row of rows) {
if (row.is_active === false) continue; if (row.is_active === false) continue;
@@ -111,7 +121,7 @@ function buildLists(rows: AppListRow[]): AppLists {
if (category === "country_manager" && value) { if (category === "country_manager" && value) {
const manager = display || value; const manager = display || value;
if (manager && manager !== value) { if (manager && manager !== value) {
buCm[value] = manager; dynamicBuCm[value] = manager;
} }
} }
} }
@@ -119,8 +129,8 @@ function buildLists(rows: AppListRow[]): AppLists {
return { return {
clientes: clientes.length ? uniqOptions(clientes) : uniqOptions(FALLBACK_LISTS.clientes), clientes: clientes.length ? uniqOptions(clientes) : uniqOptions(FALLBACK_LISTS.clientes),
marcas: marcas.length ? uniqOptions(marcas) : uniqOptions(FALLBACK_LISTS.marcas), marcas: marcas.length ? uniqOptions(marcas) : uniqOptions(FALLBACK_LISTS.marcas),
bus: bus.length ? uniqOptions(bus) : uniqOptions(FALLBACK_LISTS.bus), bus: bus.length ? cleanCountryOptions(bus) : uniqOptions(FALLBACK_LISTS.bus),
buCm, buCm: bus.length ? dynamicBuCm : { ...FALLBACK_LISTS.buCm },
status: (status.length ? uniqOptions(status) : uniqOptions(FALLBACK_LISTS.status)) as Status[], status: (status.length ? uniqOptions(status) : uniqOptions(FALLBACK_LISTS.status)) as Status[],
}; };
} }
+491
View File
@@ -0,0 +1,491 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { supabase } from "@/lib/supabase";
import type { SheetSyncStatus } from "@/lib/store";
export type BriefReviewStatus = "new" | "rejected";
export interface BriefInboxItem {
id: string;
briefId: string;
sourceCreatedAt: number;
title: string;
client: string;
brand: string;
country: string;
requestedBy: string;
requestedByEmail: string;
deliveryDate: string;
briefLink: string;
documentLink: string;
summary: string;
requestType: string;
deliverableType: string;
reviewStatus: "new" | "approved" | "rejected";
rejectionReason: string;
approvedProjectId: string;
reviewedByName: string;
reviewedByEmail: string;
reviewedAt: number | null;
createdAt: number;
}
type DbBriefInboxItem = {
id: string;
brief_id: string | null;
source_created_at: string | null;
title: string | null;
client: string | null;
brand: string | null;
country: string | null;
requested_by: string | null;
requested_by_email: string | null;
delivery_date: string | null;
brief_link: string | null;
document_link: string | null;
summary: string | null;
request_type: string | null;
deliverable_type: string | null;
review_status: string | null;
rejection_reason: string | null;
approved_project_id: string | null;
reviewed_by_name: string | null;
reviewed_by_email: string | null;
reviewed_at: string | null;
created_at: string | null;
};
type RpcApprovalResponse = {
ok?: boolean;
already_approved?: boolean;
project_id?: string | null;
brief_id?: string | null;
};
type RpcRejectResponse = {
ok?: boolean;
already_rejected?: boolean;
brief_id?: string | null;
};
export type BriefApprovalResult = {
projectId: string;
sheetSyncStatus: SheetSyncStatus;
sheetSyncMessage: string;
};
export type BriefInboxQuery = {
status: BriefReviewStatus;
search: string;
page: number;
pageSize: number;
enabled?: boolean;
};
function asTimestamp(value: string | null | undefined, fallback = Date.now()) {
if (!value) return fallback;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : fallback;
}
function mapDbBrief(row: DbBriefInboxItem): BriefInboxItem {
const createdAt = asTimestamp(row.created_at);
return {
id: row.id,
briefId: row.brief_id || "",
sourceCreatedAt: asTimestamp(row.source_created_at, createdAt),
title: row.title || "",
client: row.client || "",
brand: row.brand || "",
country: row.country || "",
requestedBy: row.requested_by || "",
requestedByEmail: row.requested_by_email || "",
deliveryDate: row.delivery_date || "",
briefLink: row.brief_link || "",
documentLink: row.document_link || "",
summary: row.summary || "",
requestType: row.request_type || "",
deliverableType: row.deliverable_type || "",
reviewStatus:
row.review_status === "approved"
? "approved"
: row.review_status === "rejected"
? "rejected"
: "new",
rejectionReason: row.rejection_reason || "",
approvedProjectId: row.approved_project_id || "",
reviewedByName: row.reviewed_by_name || "",
reviewedByEmail: row.reviewed_by_email || "",
reviewedAt: row.reviewed_at ? asTimestamp(row.reviewed_at) : null,
createdAt,
};
}
function cleanSearch(value: string) {
return value.trim().replace(/[%_,]/g, " ").replace(/\s+/g, " ");
}
function getSyncWebhookUrl() {
return import.meta.env.VITE_WEBHOOK_URL?.trim() || "";
}
async function syncApprovedProject(projectId: string): Promise<{
sheetSyncStatus: SheetSyncStatus;
sheetSyncMessage: string;
}> {
const webhookUrl = getSyncWebhookUrl();
if (!webhookUrl) {
return {
sheetSyncStatus: "skipped",
sheetSyncMessage:
"Proyecto aprobado y creado en Supabase. No hay webhook configurado para sincronizar Google 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: "create",
source: "tablero-cdc-brief-review",
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();
return {
sheetSyncStatus: "failed",
sheetSyncMessage: `El proyecto quedó Activo en el Tablero, pero el Sheet no se pudo sincronizar: ${message}`,
};
}
return {
sheetSyncStatus: "synced",
sheetSyncMessage: "Brief aprobado, proyecto Activo creado y Google Sheet sincronizado.",
};
} catch (error) {
const isTimeout = error instanceof DOMException && error.name === "AbortError";
return {
sheetSyncStatus: isTimeout ? "timeout" : "failed",
sheetSyncMessage: isTimeout
? "El proyecto quedó Activo en el Tablero, pero n8n tardó demasiado en responder. Revisa el Sheet si fuera necesario."
: "El proyecto quedó Activo en el Tablero, pero no se pudo sincronizar con Google Sheet.",
};
} finally {
window.clearTimeout(timeout);
}
}
export function useBriefInbox({ status, search, page, pageSize, enabled = true }: BriefInboxQuery) {
const [items, setItems] = useState<BriefInboxItem[]>([]);
const [totalItems, setTotalItems] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestSeq = useRef(0);
const normalizedSearch = useMemo(() => cleanSearch(search), [search]);
const load = useCallback(async () => {
if (!enabled) return;
const requestId = (requestSeq.current += 1);
setLoading(true);
setError(null);
try {
const from = Math.max(0, (page - 1) * pageSize);
const to = from + pageSize - 1;
let query = supabase
.from("tablero_cdc_brief_inbox")
.select(
"id, brief_id, source_created_at, title, client, brand, country, requested_by, requested_by_email, delivery_date, brief_link, document_link, summary, request_type, deliverable_type, review_status, rejection_reason, approved_project_id, reviewed_by_name, reviewed_by_email, reviewed_at, created_at",
{ count: "exact" },
)
.eq("review_status", status)
.order("created_at", { ascending: false });
if (normalizedSearch) {
const pattern = `%${normalizedSearch}%`;
query = query.or(
`title.ilike.${pattern},client.ilike.${pattern},brand.ilike.${pattern},country.ilike.${pattern},requested_by.ilike.${pattern},requested_by_email.ilike.${pattern},request_type.ilike.${pattern},deliverable_type.ilike.${pattern},summary.ilike.${pattern}`,
);
}
const { data, error: queryError, count } = await query.range(from, to);
if (queryError) throw queryError;
if (requestId !== requestSeq.current) return;
setItems(((data || []) as DbBriefInboxItem[]).map(mapDbBrief));
setTotalItems(count ?? 0);
} catch (loadError) {
if (requestId !== requestSeq.current) return;
console.error("Error cargando bandeja de CDC Brief:", loadError);
setError(
loadError instanceof Error
? loadError.message
: "No se pudieron cargar los briefs pendientes de revisión.",
);
setItems([]);
setTotalItems(0);
} finally {
if (requestId === requestSeq.current) setLoading(false);
}
}, [enabled, normalizedSearch, page, pageSize, status]);
useEffect(() => {
if (!enabled) return;
void load();
}, [enabled, load]);
useEffect(() => {
if (!enabled) return;
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
const channel = supabase
.channel(`tablero-cdc-brief-inbox-${status}`)
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "tablero_cdc_brief_inbox" },
() => {
if (refreshTimeout) clearTimeout(refreshTimeout);
refreshTimeout = setTimeout(() => void load(), 300);
},
)
.subscribe((subscriptionStatus) => {
if (subscriptionStatus === "CHANNEL_ERROR" || subscriptionStatus === "TIMED_OUT") {
console.warn(
"Supabase Realtime no pudo suscribirse a tablero_cdc_brief_inbox:",
subscriptionStatus,
);
}
});
return () => {
if (refreshTimeout) clearTimeout(refreshTimeout);
void supabase.removeChannel(channel);
};
}, [enabled, load, status]);
const approve = useCallback(
async (id: string): Promise<BriefApprovalResult> => {
const { data, error: rpcError } = await supabase.rpc("tablero_cdc_approve_brief", {
p_inbox_id: id,
});
if (rpcError) throw rpcError;
const result = (data || {}) as RpcApprovalResponse;
const projectId = String(result.project_id || "").trim();
if (!projectId) {
throw new Error("Supabase aprobó el brief, pero no devolvió el Project ID.");
}
const syncResult = await syncApprovedProject(projectId);
await load();
return {
projectId,
...syncResult,
};
},
[load],
);
const reject = useCallback(
async (id: string, reason = ""): Promise<void> => {
const { data, error: rpcError } = await supabase.rpc("tablero_cdc_reject_brief", {
p_inbox_id: id,
p_reason: reason.trim() || null,
});
if (rpcError) throw rpcError;
const result = (data || {}) as RpcRejectResponse;
if (result.ok === false) {
throw new Error("Supabase no pudo mover el brief a Rechazados.");
}
await load();
},
[load],
);
return {
items,
totalItems,
loading,
error,
refresh: load,
approve,
reject,
};
}
export type BriefUnreadState = {
unreadCount: number;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
markAllSeen: () => Promise<number>;
};
type RpcMarkSeenResponse = {
ok?: boolean;
marked_count?: number;
unseen_count?: number;
};
/**
* Per-user notification counter for the "Nuevos" brief inbox.
*
* A brief counts as unread only while it is still review_status='new' and the
* authenticated user has not marked it as seen. The seen state lives in
* Supabase, so every teammate has an independent counter. Changes made by
* another teammate (approve/reject/new brief) refresh this counter through
* Realtime, with focus/interval refreshes as a safe fallback.
*/
export function useBriefUnreadCount(enabled = true): BriefUnreadState {
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestSeq = useRef(0);
const refresh = useCallback(async () => {
if (!enabled) {
setUnreadCount(0);
return;
}
const requestId = (requestSeq.current += 1);
setLoading(true);
try {
const { data, error: rpcError } = await supabase.rpc(
"tablero_cdc_get_unseen_brief_count",
);
if (rpcError) throw rpcError;
if (requestId !== requestSeq.current) return;
const nextCount = Number(data ?? 0);
setUnreadCount(Number.isFinite(nextCount) && nextCount > 0 ? nextCount : 0);
setError(null);
} catch (refreshError) {
if (requestId !== requestSeq.current) return;
console.error("Error cargando notificaciones de briefs nuevos:", refreshError);
setError(
refreshError instanceof Error
? refreshError.message
: "No se pudo actualizar el contador de briefs nuevos.",
);
} finally {
if (requestId === requestSeq.current) setLoading(false);
}
}, [enabled]);
const markAllSeen = useCallback(async (): Promise<number> => {
if (!enabled) return 0;
// Clear immediately in the UI; the RPC persists this only for the current
// authenticated user. If it fails, refresh restores the server truth.
setUnreadCount(0);
try {
const { data, error: rpcError } = await supabase.rpc("tablero_cdc_mark_briefs_seen");
if (rpcError) throw rpcError;
const result = (data || {}) as RpcMarkSeenResponse;
const remaining = Number(result.unseen_count ?? 0);
const normalizedRemaining =
Number.isFinite(remaining) && remaining > 0 ? remaining : 0;
setUnreadCount(normalizedRemaining);
setError(null);
return normalizedRemaining;
} catch (markError) {
await refresh();
throw markError;
}
}, [enabled, refresh]);
useEffect(() => {
if (!enabled) {
setUnreadCount(0);
return;
}
void refresh();
}, [enabled, refresh]);
useEffect(() => {
if (!enabled) return;
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
const scheduleRefresh = () => {
if (refreshTimeout) clearTimeout(refreshTimeout);
refreshTimeout = setTimeout(() => void refresh(), 250);
};
const channel = supabase
.channel("tablero-cdc-brief-unread-counter")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "tablero_cdc_brief_inbox" },
scheduleRefresh,
)
.subscribe((subscriptionStatus) => {
if (subscriptionStatus === "CHANNEL_ERROR" || subscriptionStatus === "TIMED_OUT") {
console.warn(
"Supabase Realtime no pudo actualizar el contador de briefs:",
subscriptionStatus,
);
}
});
// Fallback: mantiene el contador correcto incluso si Realtime pierde conexión.
const interval = window.setInterval(() => {
if (document.visibilityState === "visible") void refresh();
}, 30000);
const handleFocus = () => void refresh();
const handleVisibility = () => {
if (document.visibilityState === "visible") void refresh();
};
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleVisibility);
return () => {
if (refreshTimeout) clearTimeout(refreshTimeout);
window.clearInterval(interval);
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibility);
void supabase.removeChannel(channel);
};
}, [enabled, refresh]);
return {
unreadCount,
loading,
error,
refresh,
markAllSeen,
};
}
+246
View File
@@ -0,0 +1,246 @@
import type { Project } from "@/lib/store";
import { canonicalOptionLabel, dedupeOptions, normalizeOptionKey } from "@/lib/optionUtils";
const COUNTRY_ALIASES: Record<string, string> = {
cr: "Costa Rica",
"costa rica": "Costa Rica",
costarica: "Costa Rica",
gt: "Guatemala",
gua: "Guatemala",
guate: "Guatemala",
guatemala: "Guatemala",
hn: "Honduras",
honduras: "Honduras",
ni: "Nicaragua",
nicaragua: "Nicaragua",
sv: "El Salvador",
es: "El Salvador",
"el salvador": "El Salvador",
elsalvador: "El Salvador",
pa: "Panama",
pty: "Panama",
panama: "Panama",
rd: "Republica Dominicana",
repdom: "Republica Dominicana",
"republica dominicana": "Republica Dominicana",
republicadominicana: "Republica Dominicana",
do: "Republica Dominicana",
mx: "Mexico",
mexico: "Mexico",
pr: "Puerto Rico",
"puerto rico": "Puerto Rico",
puertorico: "Puerto Rico",
co: "Colombia",
colombia: "Colombia",
jm: "Jamaica",
jamaica: "Jamaica",
tt: "Trinidad",
trinidad: "Trinidad",
ve: "Venezuela",
venezuela: "Venezuela",
latam: "Latam",
wmc: "WMC",
};
const COUNTRY_CODE_PATTERNS: Array<[RegExp, string]> = [
[/(^|[_\-\s])cr([_\-\s]|$)/i, "Costa Rica"],
[/(^|[_\-\s])(gt|gua|guate)([_\-\s]|$)/i, "Guatemala"],
[/(^|[_\-\s])hn([_\-\s]|$)/i, "Honduras"],
[/(^|[_\-\s])ni([_\-\s]|$)/i, "Nicaragua"],
[/(^|[_\-\s])(sv|es)([_\-\s]|$)/i, "El Salvador"],
[/(^|[_\-\s])(pa|pty|panama)([_\-\s]|$)/i, "Panama"],
[/(^|[_\-\s])(rd|repdom|do)([_\-\s]|$)/i, "Republica Dominicana"],
[/(^|[_\-\s])mx([_\-\s]|$)/i, "Mexico"],
[/(^|[_\-\s])pr([_\-\s]|$)/i, "Puerto Rico"],
[/(^|[_\-\s])co([_\-\s]|$)/i, "Colombia"],
[/(^|[_\-\s])jm([_\-\s]|$)/i, "Jamaica"],
[/(^|[_\-\s])tt([_\-\s]|$)/i, "Trinidad"],
[/(^|[_\-\s])ve([_\-\s]|$)/i, "Venezuela"],
[/(^|[_\-\s])latam([_\-\s]|$)/i, "Latam"],
[/(^|[_\-\s])wmc([_\-\s]|$)/i, "WMC"],
];
function clean(value: unknown) {
return canonicalOptionLabel(String(value ?? ""));
}
function addCanonicalToken(tokens: string[], value: unknown, knownOptions: string[] = []) {
const label = clean(value);
if (!label) return;
const known = knownOptions.find(
(option) => normalizeOptionKey(option) === normalizeOptionKey(label),
);
tokens.push(known || label);
}
function addKnownOptionsContained(tokens: string[], value: unknown, knownOptions: string[]) {
const label = clean(value);
const valueKey = normalizeOptionKey(label);
if (!valueKey) return;
knownOptions.forEach((option) => {
const optionKey = normalizeOptionKey(option);
if (!optionKey) return;
if (valueKey === optionKey || valueKey.includes(optionKey)) {
tokens.push(option);
}
});
}
export function splitMultiValueTokens(value: unknown, knownOptions: string[] = []) {
const label = clean(value);
if (!label) return [];
const tokens: string[] = [];
addKnownOptionsContained(tokens, label, knownOptions);
label
.split(/[,;|/]+/)
.map((part) => clean(part))
.filter(Boolean)
.forEach((part) => addCanonicalToken(tokens, part, knownOptions));
if (tokens.length === 0) addCanonicalToken(tokens, label, knownOptions);
return dedupeOptions(tokens);
}
export function getCountryTokensFromText(value: unknown, knownCountries: string[] = []) {
const label = clean(value);
if (!label) return [];
const tokens: string[] = [];
addKnownOptionsContained(tokens, label, knownCountries);
label
.split(/[,;|/]+/)
.map((part) => clean(part))
.filter(Boolean)
.forEach((part) => {
const alias = COUNTRY_ALIASES[normalizeOptionKey(part)];
const known = knownCountries.find(
(country) => normalizeOptionKey(country) === normalizeOptionKey(part),
);
if (alias) tokens.push(alias);
else if (known) tokens.push(known);
});
COUNTRY_CODE_PATTERNS.forEach(([pattern, country]) => {
if (pattern.test(label)) tokens.push(country);
});
// Importante: para país NO hacemos fallback al texto completo.
// La data histórica contiene títulos/observaciones en campos mezclados;
// si agregamos el texto bruto como opción, el dropdown de País se contamina
// con frases como "AF 03...", "Rep Dom Lizbeth", etc.
return dedupeOptions(tokens);
}
export function getProjectCountryTokens(project: Project, knownCountries: string[] = []) {
const tokens = [
...getCountryTokensFromText(project.bu, knownCountries),
...getCountryTokensFromText(project.nombre, knownCountries),
];
const clientKey = normalizeOptionKey(project.cliente);
const titleKey = normalizeOptionKey(project.nombre);
if (
clientKey.includes("walmartconnect") ||
clientKey.includes("wmc") ||
titleKey.includes("wmc")
) {
tokens.push("WMC");
}
return dedupeOptions(tokens);
}
export function getProjectBrandTokens(project: Project, knownBrands: string[] = []) {
return splitMultiValueTokens(project.marca, knownBrands);
}
export function getProjectClientTokens(project: Project, knownClients: string[] = []) {
return splitMultiValueTokens(project.cliente, knownClients);
}
export function getProjectCmTokens(project: Project, knownCms: string[] = []) {
return splitMultiValueTokens(project.cm, knownCms);
}
function tokenMatches(tokens: string[], filter: string) {
const filterKey = normalizeOptionKey(filter);
if (!filterKey) return true;
return tokens.some((token) => {
const tokenKey = normalizeOptionKey(token);
return tokenKey === filterKey || tokenKey.includes(filterKey) || filterKey.includes(tokenKey);
});
}
export function projectMatchesSmartFilters(
project: Project,
filters: { country?: string; brand?: string; client?: string; cm?: string },
options: { countries?: string[]; brands?: string[]; clients?: string[]; cms?: string[] } = {},
) {
if (filters.country) {
const countryTokens = getProjectCountryTokens(project, options.countries || []);
if (!tokenMatches(countryTokens, filters.country)) return false;
}
if (filters.brand) {
const brandTokens = getProjectBrandTokens(project, options.brands || []);
if (!tokenMatches(brandTokens, filters.brand)) return false;
}
if (filters.client) {
const clientTokens = getProjectClientTokens(project, options.clients || []);
if (!tokenMatches(clientTokens, filters.client)) return false;
}
if (filters.cm) {
const cmTokens = getProjectCmTokens(project, options.cms || []);
if (!tokenMatches(cmTokens, filters.cm)) return false;
}
return true;
}
export function expandCountryOptions(values: string[], knownCountries: string[] = []) {
return dedupeOptions(values.flatMap((value) => getCountryTokensFromText(value, knownCountries)));
}
export function expandMultiValueOptions(values: string[], knownOptions: string[] = []) {
return dedupeOptions(values.flatMap((value) => splitMultiValueTokens(value, knownOptions)));
}
export function buildSmartFilterOptionsFromProjects(
projects: Project[],
options: { countries?: string[]; brands?: string[]; clients?: string[]; cms?: string[] } = {},
) {
return {
countries: dedupeOptions(
projects.flatMap((project) => getProjectCountryTokens(project, options.countries || [])),
),
brands: dedupeOptions(
projects.flatMap((project) => getProjectBrandTokens(project, options.brands || [])),
),
clients: dedupeOptions(
projects.flatMap((project) => getProjectClientTokens(project, options.clients || [])),
),
cms: dedupeOptions(
projects.flatMap((project) => getProjectCmTokens(project, options.cms || [])),
),
};
}
export function hasSmartFilter(filters: {
country?: string;
brand?: string;
client?: string;
cm?: string;
}) {
return Boolean(filters.country || filters.brand || filters.client || filters.cm);
}
+84
View File
@@ -0,0 +1,84 @@
import { supabase } from "@/lib/supabase";
export type ManagedListCategory = "client" | "brand" | "country" | "status";
export type ManagedListAction = "create" | "update" | "delete" | "refresh";
export type ManagedListMutation = {
action: ManagedListAction;
category?: ManagedListCategory;
value?: string;
previousValue?: string;
manager?: string;
};
export type ManagedListMutationResult = {
ok?: boolean;
message?: string;
action?: string;
category?: string;
totalRecords?: number;
};
const DEFAULT_LISTS_WEBHOOK_URL =
"https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-listas-admin";
function getListsWebhookUrl() {
return import.meta.env.VITE_LISTS_WEBHOOK_URL?.trim() || DEFAULT_LISTS_WEBHOOK_URL;
}
export async function mutateManagedList(
mutation: ManagedListMutation,
): Promise<ManagedListMutationResult> {
const { data, error } = await supabase.auth.getSession();
if (error || !data.session?.access_token) {
throw new Error("No hay una sesión activa. Recarga la página e inicia sesión nuevamente.");
}
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 20000);
try {
const response = await fetch(getListsWebhookUrl(), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
action: mutation.action,
category: mutation.category || "",
value: mutation.value || "",
previous_value: mutation.previousValue || "",
manager: mutation.manager || "",
source: "tablero-cdc",
access_token: data.session.access_token,
sent_at: new Date().toISOString(),
}),
signal: controller.signal,
});
const text = await response.text();
let payload: ManagedListMutationResult | null = null;
if (text) {
try {
payload = JSON.parse(text) as ManagedListMutationResult;
} catch {
payload = { message: text };
}
}
if (!response.ok) {
throw new Error(payload?.message || `${response.status} ${response.statusText}`.trim());
}
return payload || { ok: true };
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("n8n tardó demasiado en responder. Revisa el workflow de sincronización de listas.");
}
throw error;
} finally {
window.clearTimeout(timeout);
}
}
+8 -2
View File
@@ -1,12 +1,13 @@
/* eslint-disable no-control-regex */ /* eslint-disable no-control-regex */
const INVISIBLE_CONTROL_CHARS = new RegExp( const INVISIBLE_CONTROL_CHARS = new RegExp(
"[\\u0000-\\u001F\\u007F-\\u009F\\u200B-\\u200D\\uFEFF]", "[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u061C\\u180E\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u206F\\uFEFF]",
"g", "g",
); );
export function cleanOptionLabel(value: string | null | undefined) { export function cleanOptionLabel(value: string | null | undefined) {
return String(value || "") return String(value || "")
.replace(INVISIBLE_CONTROL_CHARS, "") .replace(INVISIBLE_CONTROL_CHARS, "")
.replace(/\u00A0/g, " ")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
} }
@@ -24,7 +25,12 @@ export function canonicalOptionLabel(value: string | null | undefined) {
const cleanValue = cleanOptionLabel(value); const cleanValue = cleanOptionLabel(value);
const key = normalizeOptionKey(cleanValue); const key = normalizeOptionKey(cleanValue);
if (key === "republica dominicana") return "Republica Dominicana"; if (
["republica dominicana", "rep dominicana", "rep dom", "rep dom lizbeth", "dominicana"].includes(
key,
)
)
return "Republica Dominicana";
return cleanValue; return cleanValue;
} }
+309 -6
View File
@@ -1,6 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import { getCurrentTableroCdcAccess } from "@/lib/accessControl"; import { getCurrentTableroCdcAccess } from "@/lib/accessControl";
import {
buildSmartFilterOptionsFromProjects,
hasSmartFilter,
projectMatchesSmartFilters,
} from "@/lib/filterNormalization";
import type { ColorId, Status } from "@/data/lists"; import type { ColorId, Status } from "@/data/lists";
export interface Project { export interface Project {
@@ -38,6 +43,23 @@ export interface ProjectActivity {
extraData?: Record<string, unknown>; extraData?: Record<string, unknown>;
} }
export interface ProjectTimeEntryInput {
country: string;
taskName: string;
durationMinutes: number;
workDate: string;
notes?: string;
}
export interface ProjectTimeEntry extends ProjectTimeEntryInput {
id: string;
projectId: string;
actorName: string;
actorEmail: string;
createdAt: number;
updatedAt: number;
}
export type ProjectPricingSource = "tariff" | "manual"; export type ProjectPricingSource = "tariff" | "manual";
export interface ProjectPricingItemInput { export interface ProjectPricingItemInput {
@@ -52,6 +74,8 @@ export interface ProjectPricingItemInput {
referenceMax?: number | null; referenceMax?: number | null;
amount: number; amount: number;
description?: string; description?: string;
tariffSectionId?: string;
tariffCatalogId?: string;
} }
export interface ProjectPricingItem extends ProjectPricingItemInput { export interface ProjectPricingItem extends ProjectPricingItemInput {
@@ -135,6 +159,20 @@ type DbProjectActivity = {
created_at: string | null; created_at: string | null;
}; };
type DbProjectTimeEntry = {
id: string;
project_id: string;
country: string | null;
task_name: string | null;
duration_minutes: number | string | null;
work_date: string | null;
notes: string | null;
created_by_name: string | null;
created_by_email: string | null;
created_at: string | null;
updated_at: string | null;
};
type DbProjectPricingItem = { type DbProjectPricingItem = {
id: string; id: string;
project_id: string; project_id: string;
@@ -148,6 +186,8 @@ type DbProjectPricingItem = {
reference_max: number | string | null; reference_max: number | string | null;
amount: number | string | null; amount: number | string | null;
description: string | null; description: string | null;
tariff_section_id?: string | null;
tariff_catalog_id?: string | null;
created_at: string | null; created_at: string | null;
}; };
@@ -341,6 +381,26 @@ function mapDbProjectActivity(row: DbProjectActivity): ProjectActivity {
}; };
} }
function mapDbProjectTimeEntry(row: DbProjectTimeEntry): ProjectTimeEntry {
const createdAt = row.created_at ? new Date(row.created_at).getTime() : Date.now();
const updatedAt = row.updated_at ? new Date(row.updated_at).getTime() : createdAt;
const durationMinutes = Number(row.duration_minutes ?? 0);
return {
id: row.id,
projectId: row.project_id,
country: row.country || "",
taskName: row.task_name || "",
durationMinutes: Number.isFinite(durationMinutes) ? Math.max(0, durationMinutes) : 0,
workDate: row.work_date || new Date().toISOString().slice(0, 10),
notes: row.notes || "",
actorName: row.created_by_name || row.created_by_email || "Usuario CDC",
actorEmail: row.created_by_email || "",
createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now(),
};
}
function mapDbProjectPricingItem(row: DbProjectPricingItem): ProjectPricingItem { function mapDbProjectPricingItem(row: DbProjectPricingItem): ProjectPricingItem {
const createdAt = row.created_at ? new Date(row.created_at).getTime() : Date.now(); const createdAt = row.created_at ? new Date(row.created_at).getTime() : Date.now();
const amount = Number(row.amount ?? 0); const amount = Number(row.amount ?? 0);
@@ -360,6 +420,8 @@ function mapDbProjectPricingItem(row: DbProjectPricingItem): ProjectPricingItem
referenceMax: Number.isFinite(referenceMax) ? referenceMax : null, referenceMax: Number.isFinite(referenceMax) ? referenceMax : null,
amount: Number.isFinite(amount) ? amount : 0, amount: Number.isFinite(amount) ? amount : 0,
description: row.description || "", description: row.description || "",
tariffSectionId: row.tariff_section_id || "",
tariffCatalogId: row.tariff_catalog_id || "",
createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(), createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
}; };
} }
@@ -754,6 +816,15 @@ async function replaceProjectLinks(
if (insertError) throw insertError; if (insertError) throw insertError;
} }
function isMissingTariffTraceColumns(error: { message?: string } | null | undefined) {
const message = String(error?.message || "");
return (
message.includes("tariff_section_id") ||
message.includes("tariff_catalog_id") ||
message.includes("schema cache")
);
}
async function replaceProjectPricingItems(projectId: string, items?: ProjectPricingItemInput[]) { async function replaceProjectPricingItems(projectId: string, items?: ProjectPricingItemInput[]) {
if (!Array.isArray(items)) return; if (!Array.isArray(items)) return;
@@ -779,20 +850,47 @@ async function replaceProjectPricingItems(projectId: string, items?: ProjectPric
reference_max: item.referenceMax ?? null, reference_max: item.referenceMax ?? null,
amount: Number(item.amount), amount: Number(item.amount),
description: item.description || "", description: item.description || "",
tariff_section_id: item.tariffSectionId || null,
tariff_catalog_id: item.tariffCatalogId || null,
sort_order: index, sort_order: index,
})); }));
if (cleanItems.length === 0) return; if (cleanItems.length === 0) return;
const { error: insertError } = await supabase const insertResult = await supabase
.from("tablero_cdc_project_pricing_items") .from("tablero_cdc_project_pricing_items")
.insert(cleanItems); .insert(cleanItems);
if (insertError) throw insertError; if (!insertResult.error) return;
if (!isMissingTariffTraceColumns(insertResult.error)) throw insertResult.error;
const legacyItems = cleanItems.map(({ tariff_section_id: _section, tariff_catalog_id: _catalog, ...item }) => item);
const legacyInsert = await supabase
.from("tablero_cdc_project_pricing_items")
.insert(legacyItems);
if (legacyInsert.error) throw legacyInsert.error;
} }
export async function loadProjectPricingItems(projectId: string): Promise<ProjectPricingItem[]> { export async function loadProjectPricingItems(projectId: string): Promise<ProjectPricingItem[]> {
const { data, error } = await supabase const query = supabase
.from("tablero_cdc_project_pricing_items")
.select(
"id, project_id, source, category, service_name, work_type, complexity_level, reference_label, reference_min, reference_max, amount, description, tariff_section_id, tariff_catalog_id, created_at",
)
.eq("project_id", projectId)
.order("sort_order", { ascending: true })
.order("created_at", { ascending: true });
const result = await query;
if (!result.error) {
return ((result.data || []) as DbProjectPricingItem[]).map(mapDbProjectPricingItem);
}
if (!isMissingTariffTraceColumns(result.error)) throw result.error;
const legacyResult = await supabase
.from("tablero_cdc_project_pricing_items") .from("tablero_cdc_project_pricing_items")
.select( .select(
"id, project_id, source, category, service_name, work_type, complexity_level, reference_label, reference_min, reference_max, amount, description, created_at", "id, project_id, source, category, service_name, work_type, complexity_level, reference_label, reference_min, reference_max, amount, description, created_at",
@@ -801,9 +899,9 @@ export async function loadProjectPricingItems(projectId: string): Promise<Projec
.order("sort_order", { ascending: true }) .order("sort_order", { ascending: true })
.order("created_at", { ascending: true }); .order("created_at", { ascending: true });
if (error) throw error; if (legacyResult.error) throw legacyResult.error;
return ((data || []) as DbProjectPricingItem[]).map(mapDbProjectPricingItem); return ((legacyResult.data || []) as DbProjectPricingItem[]).map(mapDbProjectPricingItem);
} }
export async function loadProjectActivities( export async function loadProjectActivities(
@@ -828,6 +926,100 @@ export async function loadProjectActivities(
return ((data || []) as DbProjectActivity[]).map(mapDbProjectActivity); return ((data || []) as DbProjectActivity[]).map(mapDbProjectActivity);
} }
const PROJECT_TIME_PAGE_SIZE = 500;
export async function loadProjectTimeEntries(projectId: string): Promise<ProjectTimeEntry[]> {
const cleanProjectId = String(projectId || "").trim();
if (!cleanProjectId) return [];
const allRows: DbProjectTimeEntry[] = [];
let offset = 0;
while (true) {
const { data, error } = await supabase
.from("tablero_cdc_project_time_entries")
.select(
"id, project_id, country, task_name, duration_minutes, work_date, notes, created_by_name, created_by_email, created_at, updated_at",
)
.eq("project_id", cleanProjectId)
.order("work_date", { ascending: false })
.order("created_at", { ascending: false })
.range(offset, offset + PROJECT_TIME_PAGE_SIZE - 1);
if (error) throw error;
const rows = (data || []) as DbProjectTimeEntry[];
allRows.push(...rows);
if (rows.length < PROJECT_TIME_PAGE_SIZE) break;
offset += PROJECT_TIME_PAGE_SIZE;
}
return allRows.map(mapDbProjectTimeEntry);
}
export async function addProjectTimeEntry(
projectId: string,
input: ProjectTimeEntryInput,
): Promise<ProjectTimeEntry> {
const cleanProjectId = String(projectId || "").trim();
const country = String(input.country || "").trim();
const taskName = String(input.taskName || "").trim();
const durationMinutes = Math.round(Number(input.durationMinutes || 0));
const workDate = String(input.workDate || "").trim();
const notes = String(input.notes || "").trim();
if (!cleanProjectId) throw new Error("No se encontró el proyecto.");
if (!country) throw new Error("Selecciona el país.");
if (!taskName) throw new Error("Escribe la tarea realizada.");
if (!Number.isFinite(durationMinutes) || durationMinutes <= 0) {
throw new Error("La duración debe ser mayor que 0 minutos.");
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(workDate)) {
throw new Error("Selecciona una fecha válida.");
}
const user = await getCurrentUser();
const actorName = getUserDisplayName(user);
const actorEmail = user.email || "";
const { data, error } = await supabase
.from("tablero_cdc_project_time_entries")
.insert({
project_id: cleanProjectId,
country,
task_name: taskName,
duration_minutes: durationMinutes,
work_date: workDate,
notes,
created_by: user.id,
created_by_email: actorEmail || null,
created_by_name: actorName,
})
.select(
"id, project_id, country, task_name, duration_minutes, work_date, notes, created_by_name, created_by_email, created_at, updated_at",
)
.single();
if (error) throw error;
return mapDbProjectTimeEntry(data as DbProjectTimeEntry);
}
export async function deleteProjectTimeEntry(id: string): Promise<void> {
const cleanId = String(id || "").trim();
if (!cleanId) throw new Error("No se encontró el registro de tiempo.");
const { error } = await supabase
.from("tablero_cdc_project_time_entries")
.delete()
.eq("id", cleanId);
if (error) throw error;
}
function formatCommentForSheet( function formatCommentForSheet(
description: string, description: string,
actorName: string, actorName: string,
@@ -1023,7 +1215,7 @@ function escapePostgrestFilterValue(value: string) {
return value.replace(/[%_]/g, "\\$&").replace(/,/g, " "); return value.replace(/[%_]/g, "\\$&").replace(/,/g, " ");
} }
function applyDirectFilters(query: DirectProjectQuery, params: ProjectQueryParams) { function applyDirectBaseFilters(query: DirectProjectQuery, params: ProjectQueryParams) {
let nextQuery = query; let nextQuery = query;
if (params.tab === "abiertos") { if (params.tab === "abiertos") {
@@ -1044,6 +1236,12 @@ function applyDirectFilters(query: DirectProjectQuery, params: ProjectQueryParam
); );
} }
return nextQuery;
}
function applyDirectFilters(query: DirectProjectQuery, params: ProjectQueryParams) {
let nextQuery = applyDirectBaseFilters(query, params);
if (params.country) { if (params.country) {
const pattern = `%${escapePostgrestFilterValue(params.country)}%`; const pattern = `%${escapePostgrestFilterValue(params.country)}%`;
nextQuery = nextQuery.or(`country.ilike.${pattern}`); nextQuery = nextQuery.or(`country.ilike.${pattern}`);
@@ -1094,6 +1292,52 @@ async function loadProjectsDirect(
}; };
} }
async function loadProjectsSmartDirect(
params: ProjectQueryParams,
includeInternalAmount: boolean,
): Promise<ProjectLoadResult> {
const pageSize = 1000;
let from = 0;
let totalCount: number | null = null;
const allProjects: Project[] = [];
while (totalCount === null || from < totalCount) {
let query = supabase
.from("tablero_cdc_projects")
.select(getSelectColumns(includeInternalAmount), { count: "exact" })
.order("created_at", { ascending: false }) as unknown as DirectProjectQuery;
query = applyDirectBaseFilters(query, params);
const { data, error, count } = await query.range(from, from + pageSize - 1);
if (error) throw error;
if (totalCount === null) {
totalCount = count ?? (data || []).length;
}
const rows = (data || []).map((row: unknown) => mapDbProject(row as DbProject));
allProjects.push(...rows);
if (rows.length < pageSize) break;
from += pageSize;
}
const smartOptions = buildSmartFilterOptionsFromProjects(allProjects);
const filteredProjects = allProjects.filter((project) =>
projectMatchesSmartFilters(project, params, smartOptions),
);
const pageFrom = (params.page - 1) * params.pageSize;
const pageTo = pageFrom + params.pageSize;
return {
projects: filteredProjects.slice(pageFrom, pageTo),
totalProjects: filteredProjects.length,
filterOptions: smartOptions,
};
}
async function loadProjectsFromRpc(params: ProjectQueryParams): Promise<ProjectLoadResult> { async function loadProjectsFromRpc(params: ProjectQueryParams): Promise<ProjectLoadResult> {
const { data, error } = await supabase.rpc("tablero_cdc_get_projects_paginated", { const { data, error } = await supabase.rpc("tablero_cdc_get_projects_paginated", {
p_tab: params.tab, p_tab: params.tab,
@@ -1136,6 +1380,9 @@ async function loadProjects(params?: Partial<ProjectQueryParams>) {
const canManageInternalPricing = await currentUserCanManageInternalPricing(); const canManageInternalPricing = await currentUserCanManageInternalPricing();
let result: ProjectLoadResult; let result: ProjectLoadResult;
if (hasSmartFilter(nextParams) || canManageInternalPricing) {
result = await loadProjectsSmartDirect(nextParams, canManageInternalPricing);
} else {
try { try {
result = await loadProjectsFromRpc(nextParams); result = await loadProjectsFromRpc(nextParams);
} catch (rpcError) { } catch (rpcError) {
@@ -1147,6 +1394,7 @@ async function loadProjects(params?: Partial<ProjectQueryParams>) {
); );
result = await loadProjectsDirect(nextParams, canManageInternalPricing); result = await loadProjectsDirect(nextParams, canManageInternalPricing);
} }
}
if (requestId !== loadRequestSeq) return; if (requestId !== loadRequestSeq) return;
@@ -1425,6 +1673,57 @@ async function loadProjectPricingSummaryDirect(
}; };
} }
async function loadProjectPricingSummarySmartDirect(
params: ProjectQueryParams,
): Promise<ProjectPricingSummary> {
const pageSize = 1000;
let from = 0;
let totalCount: number | null = null;
const allProjects: Project[] = [];
while (totalCount === null || from < totalCount) {
let query = supabase
.from("tablero_cdc_projects")
.select(
"id, title, client, brand, country, requested_by, country_manager, description, status, internal_amount, brief_link, created_at, extra_data",
{ count: "exact" },
)
.order("created_at", { ascending: false }) as unknown as DirectProjectQuery;
query = applyDirectBaseFilters(query, params);
const { data, error, count } = await query.range(from, from + pageSize - 1);
if (error) throw error;
if (totalCount === null) {
totalCount = count ?? (data || []).length;
}
const rows = (data || []).map((row: unknown) => mapDbProject(row as DbProject));
allProjects.push(...rows);
if (rows.length < pageSize) break;
from += pageSize;
}
const smartOptions = buildSmartFilterOptionsFromProjects(allProjects);
const filteredProjects = allProjects.filter((project) =>
projectMatchesSmartFilters(project, params, smartOptions),
);
const totalAmount = filteredProjects.reduce((sum, project) => {
const amount = Number(project.monto ?? 0);
return Number.isFinite(amount) && amount > 0 ? sum + amount : sum;
}, 0);
return {
totalAmount,
projectCount: filteredProjects.length,
loadedAt: Date.now(),
};
}
async function loadProjectPricingSummary( async function loadProjectPricingSummary(
params?: Partial<ProjectQueryParams>, params?: Partial<ProjectQueryParams>,
): Promise<ProjectPricingSummary> { ): Promise<ProjectPricingSummary> {
@@ -1434,6 +1733,10 @@ async function loadProjectPricingSummary(
pageSize: 1000, pageSize: 1000,
}); });
if (hasSmartFilter(nextParams)) {
return loadProjectPricingSummarySmartDirect(nextParams);
}
try { try {
return await loadProjectPricingSummaryFromRpc(nextParams); return await loadProjectPricingSummaryFromRpc(nextParams);
} catch (rpcError) { } catch (rpcError) {
+85 -5
View File
@@ -13,7 +13,7 @@ export type TariffCatalogSource = "supabase" | "fallback";
export type TariffCatalogRow = { export type TariffCatalogRow = {
id: string; id: string;
catalog_item_id: string; catalog_item_id: string;
section: "grafico" | "estrategia"; section: string;
category: string; category: string;
service: string; service: string;
notes: string; notes: string;
@@ -29,6 +29,7 @@ export type TariffCatalogRow = {
level_hint: string; level_hint: string;
sort_order: number; sort_order: number;
is_active: boolean; is_active: boolean;
managed_by?: "sheet" | "app" | "system" | string;
created_at?: string | null; created_at?: string | null;
updated_at?: string | null; updated_at?: string | null;
}; };
@@ -88,7 +89,7 @@ function rowId(itemId: string, workTypeId: string, levelId: string) {
} }
function normalizeDbRow(row: Record<string, unknown>): TariffCatalogRow { function normalizeDbRow(row: Record<string, unknown>): TariffCatalogRow {
const section = String(row.section || "grafico") === "estrategia" ? "estrategia" : "grafico"; const section = String(row.section || "grafico").trim().toLowerCase() || "grafico";
const category = String(row.category || "").trim(); const category = String(row.category || "").trim();
const service = String(row.service || "").trim(); const service = String(row.service || "").trim();
const catalogItemId = String( const catalogItemId = String(
@@ -120,6 +121,7 @@ function normalizeDbRow(row: Record<string, unknown>): TariffCatalogRow {
level_hint: String(row.level_hint || "").trim(), level_hint: String(row.level_hint || "").trim(),
sort_order: Number(row.sort_order || 0), sort_order: Number(row.sort_order || 0),
is_active: row.is_active !== false, is_active: row.is_active !== false,
managed_by: String(row.managed_by || "sheet").trim() || "sheet",
created_at: typeof row.created_at === "string" ? row.created_at : null, created_at: typeof row.created_at === "string" ? row.created_at : null,
updated_at: typeof row.updated_at === "string" ? row.updated_at : null, updated_at: typeof row.updated_at === "string" ? row.updated_at : null,
}; };
@@ -208,6 +210,7 @@ export function flattenTariffCatalog(catalog: TariffCatalogItem[]): TariffCatalo
level_hint: level.hint || "", level_hint: level.hint || "",
sort_order: itemIndex * 1000 + workTypeIndex * 100 + levelIndex, sort_order: itemIndex * 1000 + workTypeIndex * 100 + levelIndex,
is_active: true, is_active: true,
managed_by: "system",
}); });
}); });
}); });
@@ -240,7 +243,19 @@ export async function loadTariffCatalog(force = false): Promise<TariffCatalogSta
notify(); notify();
try { try {
const { data, error } = await supabase const queryWithManagementSource = await supabase
.from(TABLE_NAME)
.select(
"id, catalog_item_id, section, category, service, notes, work_type_id, work_type_label, work_type_short_label, hour_reference, level_id, level_label, reference, reference_min, reference_max, level_hint, sort_order, is_active, managed_by, created_at, updated_at",
)
.order("sort_order", { ascending: true })
.order("category", { ascending: true });
let data = queryWithManagementSource.data as Array<Record<string, unknown>> | null;
let error = queryWithManagementSource.error;
if (error && String(error.message || "").includes("managed_by")) {
const legacyQuery = await supabase
.from(TABLE_NAME) .from(TABLE_NAME)
.select( .select(
"id, catalog_item_id, section, category, service, notes, work_type_id, work_type_label, work_type_short_label, hour_reference, level_id, level_label, reference, reference_min, reference_max, level_hint, sort_order, is_active, created_at, updated_at", "id, catalog_item_id, section, category, service, notes, work_type_id, work_type_label, work_type_short_label, hour_reference, level_id, level_label, reference, reference_min, reference_max, level_hint, sort_order, is_active, created_at, updated_at",
@@ -248,6 +263,10 @@ export async function loadTariffCatalog(force = false): Promise<TariffCatalogSta
.order("sort_order", { ascending: true }) .order("sort_order", { ascending: true })
.order("category", { ascending: true }); .order("category", { ascending: true });
data = legacyQuery.data as Array<Record<string, unknown>> | null;
error = legacyQuery.error;
}
if (error) throw error; if (error) throw error;
const dbRows = (data || []).map((row) => normalizeDbRow(row as Record<string, unknown>)); const dbRows = (data || []).map((row) => normalizeDbRow(row as Record<string, unknown>));
@@ -321,6 +340,7 @@ export async function upsertTariffCatalogRow(input: TariffCatalogUpsertInput) {
level_hint: row.level_hint, level_hint: row.level_hint,
sort_order: row.sort_order, sort_order: row.sort_order,
is_active: row.is_active, is_active: row.is_active,
managed_by: "app",
}, },
{ onConflict: "id" }, { onConflict: "id" },
); );
@@ -331,10 +351,70 @@ export async function upsertTariffCatalogRow(input: TariffCatalogUpsertInput) {
await loadTariffCatalog(true); await loadTariffCatalog(true);
} }
export async function saveTariffCatalogRow(
input: TariffCatalogUpsertInput,
original?: TariffCatalogRow | null,
) {
const row = normalizeDbRow(input as unknown as Record<string, unknown>);
const id = row.id || buildTariffRowId(row);
if (original?.catalog_item_id) {
const metadataChanged =
original.category !== row.category ||
original.service !== row.service ||
original.notes !== row.notes;
if (metadataChanged) {
const { error: metadataError } = await supabase
.from(TABLE_NAME)
.update({
category: row.category,
service: row.service,
notes: row.notes,
managed_by: "app",
})
.eq("catalog_item_id", original.catalog_item_id);
if (metadataError) throw metadataError;
}
}
const { error } = await supabase.from(TABLE_NAME).upsert(
{
id,
catalog_item_id: row.catalog_item_id,
section: row.section,
category: row.category,
service: row.service,
notes: row.notes,
work_type_id: row.work_type_id,
work_type_label: row.work_type_label,
work_type_short_label: row.work_type_short_label,
hour_reference: row.hour_reference,
level_id: row.level_id,
level_label: row.level_label,
reference: row.reference,
reference_min: row.reference_min,
reference_max: row.reference_max,
level_hint: row.level_hint,
sort_order: row.sort_order,
is_active: row.is_active,
managed_by: "app",
},
{ onConflict: "id" },
);
if (error) throw error;
initialized = false;
return loadTariffCatalog(true);
}
export async function setTariffCatalogRowActive(row: TariffCatalogRow, isActive: boolean) { export async function setTariffCatalogRowActive(row: TariffCatalogRow, isActive: boolean) {
const { error } = await supabase const { error } = await supabase
.from(TABLE_NAME) .from(TABLE_NAME)
.update({ is_active: isActive }) .update({ is_active: isActive, managed_by: "app" })
.eq("id", row.id); .eq("id", row.id);
if (error) throw error; if (error) throw error;
@@ -361,6 +441,6 @@ export function useTariffCatalog() {
return { return {
...cache, ...cache,
refresh: () => loadTariffCatalog(true), refresh: loadTariffCatalog,
}; };
} }
+264
View File
@@ -0,0 +1,264 @@
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
export type TariffSectionScope = "general" | "client";
export type TariffPricingMode = "guided" | "fixed_multi";
export type TariffSection = {
id: string;
name: string;
scope: TariffSectionScope;
clientName: string;
pricingMode: TariffPricingMode;
allowManual: boolean;
description: string;
sortOrder: number;
isActive: boolean;
createdAt?: string | null;
updatedAt?: string | null;
};
export type TariffSectionInput = Omit<TariffSection, "createdAt" | "updatedAt">;
type TariffSectionRow = {
id?: string | null;
name?: string | null;
scope?: string | null;
client_name?: string | null;
pricing_mode?: string | null;
allow_manual?: boolean | null;
description?: string | null;
sort_order?: number | string | null;
is_active?: boolean | null;
created_at?: string | null;
updated_at?: string | null;
};
type TariffSectionsState = {
sections: TariffSection[];
loading: boolean;
error: string | null;
source: "supabase" | "fallback";
};
const TABLE_NAME = "tablero_cdc_tariff_sections";
export const FALLBACK_TARIFF_SECTIONS: TariffSection[] = [
{
id: "grafico",
name: "Tarifario Gráfico CDC",
scope: "general",
clientName: "",
pricingMode: "guided",
allowManual: true,
description: "Diseño gráfico, artes finales, adaptaciones y materiales de punto de venta.",
sortOrder: 10,
isActive: true,
},
{
id: "estrategia",
name: "Estrategia y Creatividad",
scope: "general",
clientName: "",
pricingMode: "guided",
allowManual: true,
description: "Servicios de estrategia, conceptualización y creatividad.",
sortOrder: 20,
isActive: true,
},
{
id: "wmc",
name: "Tarifario Walmart Connect",
scope: "client",
clientName: "Walmart Connect WMC",
pricingMode: "fixed_multi",
allowManual: true,
description: "Piezas de precio fijo que aparecen únicamente para Walmart Connect WMC.",
sortOrder: 100,
isActive: true,
},
];
let cache: TariffSectionsState = {
sections: FALLBACK_TARIFF_SECTIONS,
loading: false,
error: null,
source: "fallback",
};
let initialized = false;
let listeners: Array<() => void> = [];
let requestSequence = 0;
function notify() {
listeners.forEach((listener) => listener());
}
export function slugifyTariffValue(value: string) {
return String(value || "")
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 70);
}
export function buildTariffSectionId(scope: TariffSectionScope, name: string, clientName = "") {
const base = slugifyTariffValue(scope === "client" ? clientName || name : name) || "seccion";
const suffix = Date.now().toString(36).slice(-5);
return `${scope === "client" ? "cliente" : "general"}-${base}-${suffix}`;
}
function normalizeRow(row: TariffSectionRow): TariffSection {
const scope: TariffSectionScope = row.scope === "client" ? "client" : "general";
const pricingMode: TariffPricingMode =
row.pricing_mode === "fixed_multi" ? "fixed_multi" : "guided";
return {
id: String(row.id || "").trim(),
name: String(row.name || "Sección sin nombre").trim(),
scope,
clientName: String(row.client_name || "").trim(),
pricingMode,
allowManual: row.allow_manual !== false,
description: String(row.description || "").trim(),
sortOrder: Number(row.sort_order || 0),
isActive: row.is_active !== false,
createdAt: row.created_at || null,
updatedAt: row.updated_at || null,
};
}
function missingTable(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return (
message.includes(TABLE_NAME) ||
message.includes("Could not find the table") ||
message.includes("does not exist") ||
message.includes("relation")
);
}
export async function loadTariffSections(force = false): Promise<TariffSectionsState> {
if (initialized && !force) return cache;
const requestId = (requestSequence += 1);
cache = { ...cache, loading: true, error: null };
notify();
try {
const { data, error } = await supabase
.from(TABLE_NAME)
.select(
"id, name, scope, client_name, pricing_mode, allow_manual, description, sort_order, is_active, created_at, updated_at",
)
.order("sort_order", { ascending: true })
.order("name", { ascending: true });
if (error) throw error;
const dbSections = ((data || []) as TariffSectionRow[]).map(normalizeRow);
const merged = new Map(FALLBACK_TARIFF_SECTIONS.map((section) => [section.id, section] as const));
dbSections.forEach((section) => merged.set(section.id, section));
const sections = Array.from(merged.values()).sort(
(a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name),
);
if (requestId === requestSequence) {
cache = {
sections,
loading: false,
error: dbSections.length
? null
: "La tabla de secciones está vacía. Se muestran las secciones base como respaldo.",
source: dbSections.length ? "supabase" : "fallback",
};
initialized = true;
notify();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || "");
if (requestId === requestSequence) {
cache = {
sections: FALLBACK_TARIFF_SECTIONS,
loading: false,
error: missingTable(error)
? "Falta ejecutar el SQL del módulo de tarifario. Se muestran las secciones base como respaldo."
: message || "No se pudieron cargar las secciones del tarifario.",
source: "fallback",
};
initialized = true;
notify();
}
}
return cache;
}
export async function saveTariffSection(input: TariffSectionInput) {
const cleanName = input.name.trim();
const cleanClient = input.scope === "client" ? input.clientName.trim() : "";
if (!cleanName) throw new Error("Escribe el nombre de la sección.");
if (input.scope === "client" && !cleanClient) {
throw new Error("Selecciona el cliente del tarifario especial.");
}
const { error } = await supabase.from(TABLE_NAME).upsert(
{
id: input.id,
name: cleanName,
scope: input.scope,
client_name: cleanClient,
pricing_mode: input.scope === "client" ? "fixed_multi" : "guided",
allow_manual: input.allowManual,
description: input.description.trim(),
sort_order: Number(input.sortOrder || 0),
is_active: input.isActive,
},
{ onConflict: "id" },
);
if (error) throw error;
initialized = false;
return loadTariffSections(true);
}
export async function setTariffSectionActive(section: TariffSection, isActive: boolean) {
const { error } = await supabase
.from(TABLE_NAME)
.update({ is_active: isActive })
.eq("id", section.id);
if (error) throw error;
initialized = false;
return loadTariffSections(true);
}
export function useTariffSections() {
const [, force] = useState(0);
useEffect(() => {
const listener = () => force((value) => value + 1);
listeners.push(listener);
if (!initialized && !cache.loading) {
void loadTariffSections();
}
return () => {
listeners = listeners.filter((current) => current !== listener);
};
}, []);
return {
...cache,
refresh: loadTariffSections,
};
}
+293 -41
View File
@@ -4,10 +4,14 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
AlertCircle, AlertCircle,
BadgeDollarSign,
DollarSign, DollarSign,
Eye, Eye,
EyeOff, EyeOff,
LayoutGrid, LayoutGrid,
ListChecks,
Inbox,
FileX2,
LogOut, LogOut,
Plus, Plus,
RefreshCw, RefreshCw,
@@ -27,17 +31,30 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ProjectCard } from "@/components/board/ProjectCard"; import { ProjectCard } from "@/components/board/ProjectCard";
import { ProjectDialog } from "@/components/board/ProjectDialog"; import { ProjectDialog } from "@/components/board/ProjectDialog";
import { ProjectTimeDialog } from "@/components/board/ProjectTimeDialog";
import { BriefReviewCard } from "@/components/board/BriefReviewCard";
import { SearchableSelect } from "@/components/board/SearchableSelect"; import { SearchableSelect } from "@/components/board/SearchableSelect";
import { useProjectPricingSummary, useProjects, type Project } from "@/lib/store"; import {
useProjectPricingSummary,
useProjects,
type Project,
type ProjectTab,
} from "@/lib/store";
import { useBriefInbox, useBriefUnreadCount, type BriefReviewStatus } from "@/lib/briefInbox";
import { usePricingSummaryVisibility } from "@/lib/pricingSummaryVisibility"; import { usePricingSummaryVisibility } from "@/lib/pricingSummaryVisibility";
import { useAppLists } from "@/lib/appLists"; import { useAppLists } from "@/lib/appLists";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { dedupeOptions } from "@/lib/optionUtils"; import { dedupeOptions } from "@/lib/optionUtils";
import { expandCountryOptions, expandMultiValueOptions } from "@/lib/filterNormalization";
import { GLMLogo } from "@/components/GLMLogo"; import { GLMLogo } from "@/components/GLMLogo";
import { TariffManagerDialog } from "@/components/tariff/TariffManagerDialog";
import { ListManagerDialog } from "@/components/lists/ListManagerDialog";
/** 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 = 24; const PROJECTS_PER_PAGE = 24;
type BoardView = "nuevos" | ProjectTab | "no_aprobados";
type AdvancedFilters = { type AdvancedFilters = {
country: string; country: string;
brand: string; brand: string;
@@ -66,37 +83,61 @@ export default function BoardPage() {
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<BoardView>("todos");
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(EMPTY_ADVANCED_FILTERS); const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(EMPTY_ADVANCED_FILTERS);
const [pageByFilter, setPageByFilter] = useState({ const [pageByFilter, setPageByFilter] = useState<Record<BoardView, number>>({
nuevos: 1,
todos: 1, todos: 1,
abiertos: 1, abiertos: 1,
cerrados: 1, cerrados: 1,
no_aprobados: 1,
}); });
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null); const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [tariffManagerOpen, setTariffManagerOpen] = useState(false);
const [listManagerOpen, setListManagerOpen] = useState(false);
const [timeProject, setTimeProject] = useState<Project | null>(null);
const [reviewingBriefId, setReviewingBriefId] = useState<string | null>(null);
const isBriefReviewView = filter === "nuevos" || filter === "no_aprobados";
const briefStatus: BriefReviewStatus = filter === "no_aprobados" ? "rejected" : "new";
const projectFilter: ProjectTab = isBriefReviewView ? "todos" : filter;
const currentPage = pageByFilter[filter]; const currentPage = pageByFilter[filter];
const { lists } = useAppLists(); const { lists } = useAppLists();
const { projects, totalProjects, filterOptions, loading, error, refresh, remove } = useProjects({ const { projects, totalProjects, filterOptions, loading, error, refresh, remove } = useProjects({
tab: filter, tab: projectFilter,
search: isBriefReviewView ? "" : query,
page: isBriefReviewView ? pageByFilter[projectFilter] : currentPage,
pageSize: PROJECTS_PER_PAGE,
country: isBriefReviewView ? "" : advancedFilters.country,
brand: isBriefReviewView ? "" : advancedFilters.brand,
client: isBriefReviewView ? "" : advancedFilters.client,
cm: isBriefReviewView ? "" : advancedFilters.cm,
});
const briefInbox = useBriefInbox({
status: briefStatus,
search: query, search: query,
page: currentPage, page: currentPage,
pageSize: PROJECTS_PER_PAGE, pageSize: PROJECTS_PER_PAGE,
country: advancedFilters.country, enabled: isBriefReviewView,
brand: advancedFilters.brand,
client: advancedFilters.client,
cm: advancedFilters.cm,
}); });
const { user, isGerardo, canControlPricingSummary, logout } = useAuth(); const {
user,
isGerardo,
canManageInternalPricing,
canControlPricingSummary,
canManageTariffCatalog,
logout,
} = useAuth();
const briefUnread = useBriefUnreadCount(Boolean(user));
const pricingSummaryParams = { const pricingSummaryParams = {
tab: filter, tab: projectFilter,
search: query, search: isBriefReviewView ? "" : query,
page: 1, page: 1,
pageSize: PROJECTS_PER_PAGE, pageSize: PROJECTS_PER_PAGE,
country: advancedFilters.country, country: isBriefReviewView ? "" : advancedFilters.country,
brand: advancedFilters.brand, brand: isBriefReviewView ? "" : advancedFilters.brand,
client: advancedFilters.client, client: isBriefReviewView ? "" : advancedFilters.client,
cm: advancedFilters.cm, cm: isBriefReviewView ? "" : advancedFilters.cm,
} as const; } as const;
const { const {
isPublic: isPricingSummaryPublic, isPublic: isPricingSummaryPublic,
@@ -105,7 +146,8 @@ export default function BoardPage() {
error: pricingSummaryVisibilityError, error: pricingSummaryVisibilityError,
setPublicVisibility: setPricingSummaryPublicVisibility, setPublicVisibility: setPricingSummaryPublicVisibility,
} = usePricingSummaryVisibility(Boolean(user)); } = usePricingSummaryVisibility(Boolean(user));
const canViewPricingSummary = canControlPricingSummary || isPricingSummaryPublic; const canViewPricingSummary =
!isBriefReviewView && (canControlPricingSummary || isPricingSummaryPublic);
const { const {
summary: pricingSummary, summary: pricingSummary,
loading: pricingSummaryLoading, loading: pricingSummaryLoading,
@@ -115,31 +157,32 @@ export default function BoardPage() {
const cmListOptions = Object.values(lists.buCm); const cmListOptions = Object.values(lists.buCm);
const countryFilterOptions = dedupeOptions([ const countryFilterOptions = dedupeOptions([
...lists.bus, ...lists.bus,
...filterOptions.countries, ...expandCountryOptions(filterOptions.countries, lists.bus),
advancedFilters.country, advancedFilters.country,
]); ]);
const brandFilterOptions = dedupeOptions([ const brandFilterOptions = dedupeOptions([
...lists.marcas, ...lists.marcas,
...filterOptions.brands, ...expandMultiValueOptions(filterOptions.brands, lists.marcas),
advancedFilters.brand, advancedFilters.brand,
]); ]);
const clientFilterOptions = dedupeOptions([ const clientFilterOptions = dedupeOptions([
...lists.clientes, ...lists.clientes,
...filterOptions.clients, ...expandMultiValueOptions(filterOptions.clients, lists.clientes),
advancedFilters.client, advancedFilters.client,
]); ]);
const cmFilterOptions = dedupeOptions([ const cmFilterOptions = dedupeOptions([
...cmListOptions, ...cmListOptions,
...filterOptions.cms, ...expandMultiValueOptions(filterOptions.cms, cmListOptions),
advancedFilters.cm, advancedFilters.cm,
]); ]);
const activeAdvancedFilterCount = Object.values(advancedFilters).filter(Boolean).length; const activeAdvancedFilterCount = Object.values(advancedFilters).filter(Boolean).length;
const totalPages = Math.max(1, Math.ceil(totalProjects / PROJECTS_PER_PAGE)); const currentTotalItems = isBriefReviewView ? briefInbox.totalItems : totalProjects;
const totalPages = Math.max(1, Math.ceil(currentTotalItems / PROJECTS_PER_PAGE));
const safePage = Math.min(currentPage, totalPages); const safePage = Math.min(currentPage, totalPages);
const pageStart = (safePage - 1) * PROJECTS_PER_PAGE; const pageStart = (safePage - 1) * PROJECTS_PER_PAGE;
const visibleStart = totalProjects === 0 ? 0 : pageStart + 1; const visibleStart = currentTotalItems === 0 ? 0 : pageStart + 1;
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, totalProjects); const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, currentTotalItems);
useEffect(() => { useEffect(() => {
setPageByFilter((prev) => ({ ...prev, [filter]: 1 })); setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
@@ -158,6 +201,18 @@ export default function BoardPage() {
} }
}, [currentPage, filter, totalPages]); }, [currentPage, filter, totalPages]);
// Entering "Nuevos" clears only this user's pending notification. If a new
// brief arrives while this view is open, Realtime increments unreadCount and
// this effect immediately marks that currently visible queue as seen again.
// Other teammates keep their own independent unread state.
useEffect(() => {
if (filter !== "nuevos" || !user || briefUnread.unreadCount <= 0) return;
void briefUnread.markAllSeen().catch((markError) => {
console.error("Error marcando briefs como vistos:", markError);
});
}, [filter, user?.id, briefUnread.unreadCount, briefUnread.markAllSeen]);
const goToPage = (page: number) => { const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), totalPages); const nextPage = Math.min(Math.max(page, 1), totalPages);
@@ -212,6 +267,61 @@ export default function BoardPage() {
} }
}; };
const approveBrief = async (briefId: string, title: string, fromRejected = false) => {
const confirmed = window.confirm(
fromRejected
? `¿Aprobar el brief rechazado "${title || "Sin título"}"?\n\nSe recuperará de Rechazados, se creará como proyecto Activo y aparecerá en Todos y Activos.`
: `¿Aprobar el brief "${title || "Sin título"}"?\n\nSe creará como proyecto Activo y aparecerá en Todos y Activos.`,
);
if (!confirmed || reviewingBriefId) return;
setReviewingBriefId(briefId);
try {
const result = await briefInbox.approve(briefId);
await refresh();
if (result.sheetSyncStatus === "synced") {
window.alert("Brief aprobado. El proyecto quedó Activo y sincronizado con Google Sheet.");
} else {
window.alert(result.sheetSyncMessage);
}
} catch (reviewError) {
console.error("Error aprobando brief:", reviewError);
window.alert(
reviewError instanceof Error
? reviewError.message
: "No se pudo aprobar el brief. Revisa Supabase e intenta de nuevo.",
);
} finally {
setReviewingBriefId(null);
}
};
const rejectBrief = async (briefId: string, title: string) => {
const confirmed = window.confirm(
`¿Mover el brief "${title || "Sin título"}" a Rechazados?\n\nNo se creará ningún proyecto en el tablero general.`,
);
if (!confirmed || reviewingBriefId) return;
setReviewingBriefId(briefId);
try {
await briefInbox.reject(briefId);
} catch (reviewError) {
console.error("Error marcando brief como rechazado:", reviewError);
window.alert(
reviewError instanceof Error
? reviewError.message
: "No se pudo mover el brief a Rechazados. Revisa Supabase e intenta de nuevo.",
);
} finally {
setReviewingBriefId(null);
}
};
const displayName = user?.displayName ?? user?.email ?? "Usuario"; const displayName = user?.displayName ?? user?.email ?? "Usuario";
const userRole = isGerardo ? "Jefe CDC" : "Usuario CDC"; const userRole = isGerardo ? "Jefe CDC" : "Usuario CDC";
const userInitials = initials(user?.displayName, user?.email); const userInitials = initials(user?.displayName, user?.email);
@@ -225,7 +335,7 @@ export default function BoardPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<GLMLogo size="sm" /> <GLMLogo size="sm" />
<div className="h-7 w-px bg-border" /> <div className="h-7 w-px bg-border" />
<h1 className="text-[15px] font-semibold tracking-tight text-[#4F758B]">Tablero CDC</h1> <h1 className="text-[15px] font-semibold tracking-tight text-[#4F758B]">CDC Project Management</h1>
</div> </div>
{/* Search */} {/* Search */}
@@ -234,7 +344,7 @@ export default function BoardPage() {
<Input <Input
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar proyecto, cliente, marca…" placeholder={isBriefReviewView ? "Buscar brief, cliente, marca…" : "Buscar proyecto, cliente, marca…"}
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border" className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
/> />
</div> </div>
@@ -286,6 +396,30 @@ export default function BoardPage() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Tariff administration — controlled by Supabase permission */}
{canManageTariffCatalog && (
<Button
type="button"
variant="outline"
onClick={() => setTariffManagerOpen(true)}
className="gap-1.5 h-9 rounded-full px-4 shadow-sm"
>
<BadgeDollarSign className="w-4 h-4" /> Tarifario
</Button>
)}
{/* App lists administration — same trusted admin permission as Tarifario */}
{canManageTariffCatalog && (
<Button
type="button"
variant="outline"
onClick={() => setListManagerOpen(true)}
className="gap-1.5 h-9 rounded-full px-4 shadow-sm"
>
<ListChecks className="w-4 h-4" /> Listas
</Button>
)}
{/* New project button */} {/* New project button */}
<Button onClick={openNew} className="gap-1.5 h-9 rounded-full px-4 shadow-sm"> <Button onClick={openNew} className="gap-1.5 h-9 rounded-full px-4 shadow-sm">
<Plus className="w-4 h-4" /> Nuevo <Plus className="w-4 h-4" /> Nuevo
@@ -297,28 +431,54 @@ export default function BoardPage() {
<div className="max-w-[1400px] mx-auto px-6 pt-6 pb-3 space-y-3"> <div 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 className="flex items-center justify-between gap-4 flex-wrap">
<div> <div>
<h2 className="pl-3 text-2xl font-semibold tracking-tight">Proyectos</h2> <h2 className="text-2xl font-semibold tracking-tight">
{filter === "nuevos"
? "Briefs por revisar"
: filter === "no_aprobados"
? "Briefs rechazados"
: "Proyectos"}
</h2>
{filter === "nuevos" && (
<p className="mt-1 text-xs text-muted-foreground">
Los briefs de CDC Brief llegan aquí antes de convertirse en proyectos Activos.
</p>
)}
{filter === "no_aprobados" && (
<p className="mt-1 text-xs text-muted-foreground">
Briefs revisados que no se incorporaron al tablero general.
</p>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="inline-flex bg-muted/60 rounded-full p-1 text-xs"> <div className="inline-flex flex-wrap bg-muted/60 rounded-full p-1 text-xs">
{( {(
[ [
["todos", "Todos"], ["todos", "Todos"],
["abiertos", "Activos"], ["abiertos", "Activos"],
["cerrados", "Cerrados"], ["cerrados", "Cerrados"],
["nuevos", "Nuevos"],
["no_aprobados", "Rechazados"],
] as const ] as const
).map(([k, l]) => ( ).map(([k, l]) => (
<button <button
key={k} key={k}
onClick={() => setFilter(k)} onClick={() => setFilter(k)}
className={`px-3.5 py-1.5 rounded-full transition-colors font-medium ${ className={`inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full transition-colors font-medium ${
filter === k filter === k
? "bg-card text-foreground shadow-sm" ? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
}`} }`}
> >
{l} <span>{l}</span>
{k === "nuevos" && briefUnread.unreadCount > 0 && (
<span
className="inline-flex min-w-[18px] h-[18px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white shadow-sm"
aria-label={`${briefUnread.unreadCount} briefs nuevos sin revisar`}
>
{briefUnread.unreadCount > 99 ? "99+" : briefUnread.unreadCount}
</span>
)}
</button> </button>
))} ))}
</div> </div>
@@ -330,11 +490,12 @@ export default function BoardPage() {
<Input <Input
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar proyecto, cliente, marca…" placeholder={isBriefReviewView ? "Buscar brief, cliente, marca…" : "Buscar proyecto, cliente, marca…"}
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border" className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
/> />
</div> </div>
{!isBriefReviewView && (
<AdvancedProjectFilters <AdvancedProjectFilters
filters={advancedFilters} filters={advancedFilters}
activeCount={activeAdvancedFilterCount} activeCount={activeAdvancedFilterCount}
@@ -345,6 +506,7 @@ export default function BoardPage() {
onFilterChange={setAdvancedFilter} onFilterChange={setAdvancedFilter}
onClear={clearAdvancedFilters} onClear={clearAdvancedFilters}
/> />
)}
{canViewPricingSummary && ( {canViewPricingSummary && (
<PricingSummaryCard <PricingSummaryCard
@@ -367,50 +529,86 @@ export default function BoardPage() {
{/* Grid */} {/* Grid */}
<main className="max-w-[1400px] mx-auto px-6 pb-16"> <main className="max-w-[1400px] mx-auto px-6 pb-16">
{error && ( {(isBriefReviewView ? briefInbox.error : error) && (
<div className="mb-4 flex flex-col gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive sm:flex-row sm:items-center sm:justify-between"> <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"> <span className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" /> <AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span> <span>
<span className="font-medium">No se pudieron cargar los proyectos.</span> {error} <span className="font-medium">
{isBriefReviewView
? "No se pudieron cargar los briefs."
: "No se pudieron cargar los proyectos."}
</span>{" "}
{isBriefReviewView ? briefInbox.error : error}
</span> </span>
</span> </span>
<Button variant="outline" size="sm" onClick={() => void refresh()} className="gap-1.5"> <Button
variant="outline"
size="sm"
onClick={() => void (isBriefReviewView ? briefInbox.refresh() : refresh())}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" /> Reintentar <RefreshCw className="h-3.5 w-3.5" /> Reintentar
</Button> </Button>
</div> </div>
)} )}
{loading && projects.length === 0 ? ( {isBriefReviewView ? (
briefInbox.loading && briefInbox.items.length === 0 ? (
<LoadingState />
) : briefInbox.items.length === 0 ? (
<BriefEmptyState mode={briefStatus} query={query} />
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{briefInbox.items.map((item) => (
<BriefReviewCard
key={item.id}
item={item}
mode={briefStatus}
busy={reviewingBriefId === item.id}
onApprove={() => void approveBrief(item.id, item.title, briefStatus === "rejected")}
onReject={() => void rejectBrief(item.id, item.title)}
/>
))}
</div>
)
) : loading && projects.length === 0 ? (
<LoadingState /> <LoadingState />
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<EmptyState <EmptyState
onNew={openNew} onNew={openNew}
hasProjects={ hasProjects={
totalProjects > 0 || totalProjects > 0 ||
filter !== "todos" || projectFilter !== "todos" ||
query.trim().length > 0 || query.trim().length > 0 ||
activeAdvancedFilterCount > 0 activeAdvancedFilterCount > 0
} }
filter={filter} filter={projectFilter}
query={query} query={query}
activeAdvancedFilterCount={activeAdvancedFilterCount} activeAdvancedFilterCount={activeAdvancedFilterCount}
/> />
) : ( ) : (
<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">
{projects.map((p) => ( {projects.map((p) => (
<ProjectCard key={p.id} project={p} onClick={() => openEdit(p)} /> <ProjectCard
key={p.id}
project={p}
onClick={() => openEdit(p)}
onTimeClick={() => setTimeProject(p)}
showInternalAmount={canManageInternalPricing}
/>
))} ))}
</div> </div>
)} )}
{totalProjects > PROJECTS_PER_PAGE && ( {currentTotalItems > PROJECTS_PER_PAGE && (
<PaginationControls <PaginationControls
currentPage={safePage} currentPage={safePage}
totalPages={totalPages} totalPages={totalPages}
visibleStart={visibleStart} visibleStart={visibleStart}
visibleEnd={visibleEnd} visibleEnd={visibleEnd}
totalItems={totalProjects} totalItems={currentTotalItems}
itemLabel={isBriefReviewView ? "briefs" : "proyectos"}
onPageChange={goToPage} onPageChange={goToPage}
/> />
)} )}
@@ -423,6 +621,27 @@ export default function BoardPage() {
onDelete={deleteProject} onDelete={deleteProject}
deleting={!!editing && deletingProjectId === editing.id} deleting={!!editing && deletingProjectId === editing.id}
/> />
<ProjectTimeDialog
open={!!timeProject}
onOpenChange={(nextOpen) => {
if (!nextOpen) setTimeProject(null);
}}
project={timeProject}
/>
{canManageTariffCatalog && (
<>
<TariffManagerDialog
open={tariffManagerOpen}
onOpenChange={setTariffManagerOpen}
/>
<ListManagerDialog
open={listManagerOpen}
onOpenChange={setListManagerOpen}
/>
</>
)}
</div> </div>
); );
} }
@@ -632,6 +851,7 @@ function PaginationControls({
visibleStart, visibleStart,
visibleEnd, visibleEnd,
totalItems, totalItems,
itemLabel = "proyectos",
onPageChange, onPageChange,
}: { }: {
currentPage: number; currentPage: number;
@@ -639,6 +859,7 @@ function PaginationControls({
visibleStart: number; visibleStart: number;
visibleEnd: number; visibleEnd: number;
totalItems: number; totalItems: number;
itemLabel?: string;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
}) { }) {
const pages = getVisiblePages(currentPage, totalPages); const pages = getVisiblePages(currentPage, totalPages);
@@ -649,7 +870,7 @@ function PaginationControls({
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{visibleStart}-{visibleEnd} {visibleStart}-{visibleEnd}
</span>{" "} </span>{" "}
de <span className="font-medium text-foreground">{totalItems}</span> proyectos de <span className="font-medium text-foreground">{totalItems}</span> {itemLabel}
</p> </p>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -757,6 +978,37 @@ function LoadingState() {
); );
} }
function BriefEmptyState({ mode, query }: { mode: BriefReviewStatus; query: string }) {
const hasSearch = query.trim().length > 0;
const isRejected = mode === "rejected";
return (
<div className="rounded-2xl border border-dashed border-border bg-card/40 py-24 text-center">
<div
className={`mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl ${
isRejected ? "bg-red-50 text-red-600" : "bg-amber-50 text-amber-700"
}`}
>
{isRejected ? <FileX2 className="h-6 w-6" /> : <Inbox className="h-6 w-6" />}
</div>
<h3 className="text-lg font-semibold">
{hasSearch
? "No hay briefs que coincidan con la búsqueda"
: isRejected
? "No hay briefs rechazados"
: "No hay briefs pendientes"}
</h3>
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
{hasSearch
? "Prueba con otro nombre, cliente, marca, país o solicitante."
: isRejected
? "Los briefs que el equipo decida no convertir en proyecto aparecerán aquí."
: "Cuando CDC Brief genere un nuevo brief, aparecerá aquí automáticamente para revisión."}
</p>
</div>
);
}
function EmptyState({ function EmptyState({
onNew, onNew,
hasProjects, hasProjects,
@@ -766,7 +1018,7 @@ function EmptyState({
}: { }: {
onNew: () => void; onNew: () => void;
hasProjects: boolean; hasProjects: boolean;
filter: "todos" | "abiertos" | "cerrados"; filter: ProjectTab;
query: string; query: string;
activeAdvancedFilterCount: number; activeAdvancedFilterCount: number;
}) { }) {
+191
View File
@@ -0,0 +1,191 @@
-- =============================================================
-- TABLERO CDC — APROBAR BRIEFS DESDE "RECHAZADOS"
-- Ejecutar una sola vez si ya instalaste la bandeja Nuevos/Rechazados.
--
-- Este parche NO crea tablas ni modifica proyectos existentes.
-- Solo reemplaza la RPC de aprobación para permitir:
-- new -> approved
-- rejected -> approved
-- Mantiene idempotencia para approved y crea el proyecto como Activo.
-- =============================================================
create or replace function public.tablero_cdc_approve_brief(p_inbox_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_item public.tablero_cdc_brief_inbox%rowtype;
v_project_id uuid;
v_email text;
v_name text;
v_country_manager text := '';
v_title text;
v_description text;
v_activity_description text;
begin
if auth.uid() is null or not public.tablero_cdc_is_active_allowed_user() then
raise exception 'No tienes permiso para revisar briefs del Tablero CDC.';
end if;
select *
into v_item
from public.tablero_cdc_brief_inbox
where id = p_inbox_id
for update;
if not found then
raise exception 'No se encontró el brief solicitado.';
end if;
-- Idempotencia: si el usuario repite el clic o se reintenta la petición
-- después de haber completado la transacción, devolvemos el mismo proyecto.
if v_item.review_status = 'approved' and v_item.approved_project_id is not null then
return jsonb_build_object(
'ok', true,
'already_approved', true,
'project_id', v_item.approved_project_id,
'brief_id', v_item.brief_id
);
end if;
if v_item.review_status not in ('new', 'rejected') then
raise exception 'Este brief ya fue revisado y no puede aprobarse desde esta bandeja.';
end if;
-- Seguridad adicional: un brief rechazado no debe conservar un proyecto asociado.
-- Si existiera una inconsistencia manual, detenemos la operación para evitar duplicados.
if v_item.review_status = 'rejected' and v_item.approved_project_id is not null then
raise exception 'Este brief rechazado ya tiene un proyecto asociado. Revisa los datos antes de aprobarlo.';
end if;
v_activity_description := case
when v_item.review_status = 'rejected'
then 'El brief fue recuperado de Rechazados y aprobado como proyecto Activo.'
else 'El brief fue revisado y aprobado desde la bandeja Nuevos.'
end;
v_email := lower(trim(coalesce(auth.jwt() ->> 'email', '')));
select coalesce(nullif(trim(u.full_name), ''), nullif(trim(u.email), ''), 'Usuario CDC')
into v_name
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = v_email
and u.is_active = true
limit 1;
v_name := coalesce(v_name, nullif(v_email, ''), 'Usuario CDC');
-- Si existe el mapeo BU -> CM en las listas dinámicas, lo reutilizamos.
-- Si no existe, se deja vacío y el proyecto continúa siendo válido/editable.
begin
select coalesce(nullif(trim(l.label), ''), '')
into v_country_manager
from public.tablero_cdc_app_lists l
where l.is_active = true
and lower(trim(coalesce(l.value, ''))) = lower(trim(coalesce(v_item.country, '')))
and lower(trim(coalesce(l.category, ''))) in (
'country_manager', 'country manager', 'cm', 'bu_cm', 'bu cm', 'manager', 'managers'
)
order by coalesce(l.sort_order, 999999), l.label
limit 1;
exception
when undefined_table then
v_country_manager := '';
end;
v_title := coalesce(
nullif(trim(v_item.title), ''),
nullif(trim(v_item.client), ''),
'Brief CDC ' || v_item.brief_id
);
v_description := coalesce(nullif(trim(v_item.summary), ''), '');
insert into public.tablero_cdc_projects (
title,
client,
brand,
country,
requested_by,
country_manager,
description,
status,
internal_amount,
brief_link,
extra_data,
created_by,
updated_by
)
values (
v_title,
coalesce(v_item.client, ''),
coalesce(v_item.brand, ''),
coalesce(v_item.country, ''),
coalesce(v_item.requested_by, ''),
coalesce(v_country_manager, ''),
v_description,
'Activo',
null,
coalesce(nullif(trim(v_item.brief_link), ''), nullif(trim(v_item.document_link), ''), ''),
jsonb_strip_nulls(
jsonb_build_object(
'source', 'cdc_brief',
'source_brief_id', v_item.brief_id,
'source_brief_inbox_id', v_item.id,
'source_brief_created_at', v_item.source_created_at,
'cdc_brief_document_link', nullif(trim(v_item.document_link), ''),
'delivery_date', nullif(trim(v_item.delivery_date), ''),
'request_type', nullif(trim(v_item.request_type), ''),
'deliverable_type', nullif(trim(v_item.deliverable_type), ''),
'cdc_brief_fields', v_item.raw_fields,
'color', 'blue'
)
),
auth.uid(),
auth.uid()
)
returning id into v_project_id;
update public.tablero_cdc_brief_inbox
set
review_status = 'approved',
approved_project_id = v_project_id,
reviewed_by = auth.uid(),
reviewed_by_name = v_name,
reviewed_by_email = v_email,
reviewed_at = now(),
rejection_reason = null
where id = v_item.id;
-- Mantiene el historial existente cuando el módulo de actividad ya está instalado,
-- sin convertirlo en una dependencia obligatoria de esta migración.
if to_regclass('public.tablero_cdc_project_activity') is not null then
execute $activity$
insert into public.tablero_cdc_project_activity (
project_id,
activity_type,
title,
description,
actor_id,
actor_name,
actor_email
)
values ($1, 'created', 'Proyecto creado desde CDC Brief',
$5, $2, $3, $4)
$activity$
using v_project_id, auth.uid(), v_name, v_email, v_activity_description;
end if;
return jsonb_build_object(
'ok', true,
'already_approved', false,
'project_id', v_project_id,
'brief_id', v_item.brief_id
);
end;
$$;
revoke all on function public.tablero_cdc_approve_brief(uuid) from public;
grant execute on function public.tablero_cdc_approve_brief(uuid) to authenticated;
+572
View File
@@ -0,0 +1,572 @@
-- =============================================================
-- TABLERO CDC — Bandeja de revisión de CDC Brief
-- Fecha: 2026-08-12
--
-- Objetivo:
-- 1) Recibir automáticamente briefs creados por CDC Brief.
-- 2) Mantenerlos fuera de tablero_cdc_projects mientras están en revisión.
-- 3) Aprobarlos de forma transaccional como proyectos Activos.
-- 4) Mover los descartados a "No aprobados" sin crear proyecto.
-- 5) Mantener un contador de briefs no vistos independiente por usuario.
--
-- Este script NO altera la lógica existente de proyectos, tarifarios,
-- tiempos, Google Sheet ni Banco Fulgencio.
-- =============================================================
begin;
create extension if not exists pgcrypto;
create table if not exists public.tablero_cdc_brief_inbox (
id uuid primary key default gen_random_uuid(),
brief_id text not null unique,
source_created_at timestamptz,
title text not null default '',
client text not null default '',
brand text not null default '',
country text not null default '',
requested_by text not null default '',
requested_by_email text not null default '',
delivery_date text not null default '',
brief_link text not null default '',
document_link text not null default '',
summary text not null default '',
request_type text not null default '',
deliverable_type text not null default '',
raw_fields jsonb not null default '{}'::jsonb,
review_status text not null default 'new',
rejection_reason text,
approved_project_id uuid references public.tablero_cdc_projects(id) on delete set null,
reviewed_by uuid references auth.users(id) on delete set null,
reviewed_by_name text,
reviewed_by_email text,
reviewed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Compatibilidad idempotente si el script se vuelve a ejecutar tras una instalación parcial.
alter table public.tablero_cdc_brief_inbox
add column if not exists source_created_at timestamptz,
add column if not exists title text not null default '',
add column if not exists client text not null default '',
add column if not exists brand text not null default '',
add column if not exists country text not null default '',
add column if not exists requested_by text not null default '',
add column if not exists requested_by_email text not null default '',
add column if not exists delivery_date text not null default '',
add column if not exists brief_link text not null default '',
add column if not exists document_link text not null default '',
add column if not exists summary text not null default '',
add column if not exists request_type text not null default '',
add column if not exists deliverable_type text not null default '',
add column if not exists raw_fields jsonb not null default '{}'::jsonb,
add column if not exists review_status text not null default 'new',
add column if not exists rejection_reason text,
add column if not exists approved_project_id uuid references public.tablero_cdc_projects(id) on delete set null,
add column if not exists reviewed_by uuid references auth.users(id) on delete set null,
add column if not exists reviewed_by_name text,
add column if not exists reviewed_by_email text,
add column if not exists reviewed_at timestamptz,
add column if not exists created_at timestamptz not null default now(),
add column if not exists updated_at timestamptz not null default now();
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'tablero_cdc_brief_inbox_review_status_check'
and conrelid = 'public.tablero_cdc_brief_inbox'::regclass
) then
alter table public.tablero_cdc_brief_inbox
add constraint tablero_cdc_brief_inbox_review_status_check
check (review_status in ('new', 'approved', 'rejected'));
end if;
end $$;
create index if not exists tablero_cdc_brief_inbox_status_created_idx
on public.tablero_cdc_brief_inbox (review_status, created_at desc);
create index if not exists tablero_cdc_brief_inbox_source_created_idx
on public.tablero_cdc_brief_inbox (source_created_at desc);
create index if not exists tablero_cdc_brief_inbox_approved_project_idx
on public.tablero_cdc_brief_inbox (approved_project_id)
where approved_project_id is not null;
create or replace function public.tablero_cdc_brief_inbox_touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists tablero_cdc_brief_inbox_touch_updated_at
on public.tablero_cdc_brief_inbox;
create trigger tablero_cdc_brief_inbox_touch_updated_at
before update on public.tablero_cdc_brief_inbox
for each row
execute function public.tablero_cdc_brief_inbox_touch_updated_at();
-- Verifica que la sesión corresponda a un usuario activo del Tablero CDC.
create or replace function public.tablero_cdc_is_active_allowed_user()
returns boolean
language sql
stable
security definer
set search_path = public, auth
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
);
$$;
revoke all on function public.tablero_cdc_is_active_allowed_user() from public;
grant execute on function public.tablero_cdc_is_active_allowed_user() to authenticated;
alter table public.tablero_cdc_brief_inbox enable row level security;
drop policy if exists "tablero_cdc_brief_inbox_select_allowed"
on public.tablero_cdc_brief_inbox;
create policy "tablero_cdc_brief_inbox_select_allowed"
on public.tablero_cdc_brief_inbox
for select
to authenticated
using (public.tablero_cdc_is_active_allowed_user());
-- El frontend solo necesita SELECT directo. Las decisiones se realizan por RPC
-- para que aprobar + crear proyecto sea una sola transacción.
revoke insert, update, delete on public.tablero_cdc_brief_inbox from authenticated;
grant select on public.tablero_cdc_brief_inbox to authenticated;
grant all on public.tablero_cdc_brief_inbox to service_role;
-- =============================================================
-- NOTIFICACIONES PERSONALES DE "NUEVOS"
-- =============================================================
-- Cada usuario conserva su propio estado de lectura. Se registra la pareja
-- (usuario, brief) en vez de un timestamp global para evitar carreras: un brief
-- que llegue después de que alguien abrió "Nuevos" seguirá siendo no leído.
create table if not exists public.tablero_cdc_brief_inbox_seen (
user_id uuid not null references auth.users(id) on delete cascade,
inbox_id uuid not null references public.tablero_cdc_brief_inbox(id) on delete cascade,
seen_at timestamptz not null default now(),
primary key (user_id, inbox_id)
);
create index if not exists tablero_cdc_brief_inbox_seen_inbox_idx
on public.tablero_cdc_brief_inbox_seen (inbox_id);
alter table public.tablero_cdc_brief_inbox_seen enable row level security;
drop policy if exists "tablero_cdc_brief_inbox_seen_select_own"
on public.tablero_cdc_brief_inbox_seen;
create policy "tablero_cdc_brief_inbox_seen_select_own"
on public.tablero_cdc_brief_inbox_seen
for select
to authenticated
using (
user_id = auth.uid()
and public.tablero_cdc_is_active_allowed_user()
);
-- La aplicación lee el contador mediante RPC y marca vistos mediante RPC.
-- No concedemos escritura directa a authenticated.
revoke insert, update, delete on public.tablero_cdc_brief_inbox_seen from authenticated;
grant select on public.tablero_cdc_brief_inbox_seen to authenticated;
grant all on public.tablero_cdc_brief_inbox_seen to service_role;
create or replace function public.tablero_cdc_get_unseen_brief_count()
returns integer
language plpgsql
stable
security definer
set search_path = public, auth
as $$
declare
v_count integer;
begin
if auth.uid() is null or not public.tablero_cdc_is_active_allowed_user() then
raise exception 'No tienes permiso para consultar notificaciones de briefs.';
end if;
select count(*)::integer
into v_count
from public.tablero_cdc_brief_inbox i
where i.review_status = 'new'
and not exists (
select 1
from public.tablero_cdc_brief_inbox_seen s
where s.user_id = auth.uid()
and s.inbox_id = i.id
);
return coalesce(v_count, 0);
end;
$$;
revoke all on function public.tablero_cdc_get_unseen_brief_count() from public;
grant execute on function public.tablero_cdc_get_unseen_brief_count() to authenticated;
create or replace function public.tablero_cdc_mark_briefs_seen()
returns jsonb
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_marked integer := 0;
v_remaining integer := 0;
begin
if auth.uid() is null or not public.tablero_cdc_is_active_allowed_user() then
raise exception 'No tienes permiso para marcar briefs como vistos.';
end if;
insert into public.tablero_cdc_brief_inbox_seen (user_id, inbox_id, seen_at)
select auth.uid(), i.id, now()
from public.tablero_cdc_brief_inbox i
where i.review_status = 'new'
on conflict (user_id, inbox_id) do nothing;
get diagnostics v_marked = row_count;
-- Si un brief nuevo entró concurrentemente después del snapshot anterior,
-- se conserva como no leído en vez de borrarle la notificación por accidente.
select count(*)::integer
into v_remaining
from public.tablero_cdc_brief_inbox i
where i.review_status = 'new'
and not exists (
select 1
from public.tablero_cdc_brief_inbox_seen s
where s.user_id = auth.uid()
and s.inbox_id = i.id
);
return jsonb_build_object(
'ok', true,
'marked_count', v_marked,
'unseen_count', coalesce(v_remaining, 0)
);
end;
$$;
revoke all on function public.tablero_cdc_mark_briefs_seen() from public;
grant execute on function public.tablero_cdc_mark_briefs_seen() to authenticated;
-- =============================================================
-- RPC: APROBAR BRIEF
-- =============================================================
create or replace function public.tablero_cdc_approve_brief(p_inbox_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_item public.tablero_cdc_brief_inbox%rowtype;
v_project_id uuid;
v_email text;
v_name text;
v_country_manager text := '';
v_title text;
v_description text;
v_activity_description text;
begin
if auth.uid() is null or not public.tablero_cdc_is_active_allowed_user() then
raise exception 'No tienes permiso para revisar briefs del Tablero CDC.';
end if;
select *
into v_item
from public.tablero_cdc_brief_inbox
where id = p_inbox_id
for update;
if not found then
raise exception 'No se encontró el brief solicitado.';
end if;
-- Idempotencia: si el usuario repite el clic o se reintenta la petición
-- después de haber completado la transacción, devolvemos el mismo proyecto.
if v_item.review_status = 'approved' and v_item.approved_project_id is not null then
return jsonb_build_object(
'ok', true,
'already_approved', true,
'project_id', v_item.approved_project_id,
'brief_id', v_item.brief_id
);
end if;
if v_item.review_status not in ('new', 'rejected') then
raise exception 'Este brief ya fue revisado y no puede aprobarse desde esta bandeja.';
end if;
-- Seguridad adicional: un brief rechazado no debe conservar un proyecto asociado.
-- Si existiera una inconsistencia manual, detenemos la operación para evitar duplicados.
if v_item.review_status = 'rejected' and v_item.approved_project_id is not null then
raise exception 'Este brief rechazado ya tiene un proyecto asociado. Revisa los datos antes de aprobarlo.';
end if;
v_activity_description := case
when v_item.review_status = 'rejected'
then 'El brief fue recuperado de Rechazados y aprobado como proyecto Activo.'
else 'El brief fue revisado y aprobado desde la bandeja Nuevos.'
end;
v_email := lower(trim(coalesce(auth.jwt() ->> 'email', '')));
select coalesce(nullif(trim(u.full_name), ''), nullif(trim(u.email), ''), 'Usuario CDC')
into v_name
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = v_email
and u.is_active = true
limit 1;
v_name := coalesce(v_name, nullif(v_email, ''), 'Usuario CDC');
-- Si existe el mapeo BU -> CM en las listas dinámicas, lo reutilizamos.
-- Si no existe, se deja vacío y el proyecto continúa siendo válido/editable.
begin
select coalesce(nullif(trim(l.label), ''), '')
into v_country_manager
from public.tablero_cdc_app_lists l
where l.is_active = true
and lower(trim(coalesce(l.value, ''))) = lower(trim(coalesce(v_item.country, '')))
and lower(trim(coalesce(l.category, ''))) in (
'country_manager', 'country manager', 'cm', 'bu_cm', 'bu cm', 'manager', 'managers'
)
order by coalesce(l.sort_order, 999999), l.label
limit 1;
exception
when undefined_table then
v_country_manager := '';
end;
v_title := coalesce(
nullif(trim(v_item.title), ''),
nullif(trim(v_item.client), ''),
'Brief CDC ' || v_item.brief_id
);
v_description := coalesce(nullif(trim(v_item.summary), ''), '');
insert into public.tablero_cdc_projects (
title,
client,
brand,
country,
requested_by,
country_manager,
description,
status,
internal_amount,
brief_link,
extra_data,
created_by,
updated_by
)
values (
v_title,
coalesce(v_item.client, ''),
coalesce(v_item.brand, ''),
coalesce(v_item.country, ''),
coalesce(v_item.requested_by, ''),
coalesce(v_country_manager, ''),
v_description,
'Activo',
null,
coalesce(nullif(trim(v_item.brief_link), ''), nullif(trim(v_item.document_link), ''), ''),
jsonb_strip_nulls(
jsonb_build_object(
'source', 'cdc_brief',
'source_brief_id', v_item.brief_id,
'source_brief_inbox_id', v_item.id,
'source_brief_created_at', v_item.source_created_at,
'cdc_brief_document_link', nullif(trim(v_item.document_link), ''),
'delivery_date', nullif(trim(v_item.delivery_date), ''),
'request_type', nullif(trim(v_item.request_type), ''),
'deliverable_type', nullif(trim(v_item.deliverable_type), ''),
'cdc_brief_fields', v_item.raw_fields,
'color', 'blue'
)
),
auth.uid(),
auth.uid()
)
returning id into v_project_id;
update public.tablero_cdc_brief_inbox
set
review_status = 'approved',
approved_project_id = v_project_id,
reviewed_by = auth.uid(),
reviewed_by_name = v_name,
reviewed_by_email = v_email,
reviewed_at = now(),
rejection_reason = null
where id = v_item.id;
-- Mantiene el historial existente cuando el módulo de actividad ya está instalado,
-- sin convertirlo en una dependencia obligatoria de esta migración.
if to_regclass('public.tablero_cdc_project_activity') is not null then
execute $activity$
insert into public.tablero_cdc_project_activity (
project_id,
activity_type,
title,
description,
actor_id,
actor_name,
actor_email
)
values ($1, 'created', 'Proyecto creado desde CDC Brief',
$5, $2, $3, $4)
$activity$
using v_project_id, auth.uid(), v_name, v_email, v_activity_description;
end if;
return jsonb_build_object(
'ok', true,
'already_approved', false,
'project_id', v_project_id,
'brief_id', v_item.brief_id
);
end;
$$;
revoke all on function public.tablero_cdc_approve_brief(uuid) from public;
grant execute on function public.tablero_cdc_approve_brief(uuid) to authenticated;
-- =============================================================
-- RPC: NO APROBAR BRIEF
-- =============================================================
create or replace function public.tablero_cdc_reject_brief(
p_inbox_id uuid,
p_reason text default null
)
returns jsonb
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_item public.tablero_cdc_brief_inbox%rowtype;
v_email text;
v_name text;
begin
if auth.uid() is null or not public.tablero_cdc_is_active_allowed_user() then
raise exception 'No tienes permiso para revisar briefs del Tablero CDC.';
end if;
select *
into v_item
from public.tablero_cdc_brief_inbox
where id = p_inbox_id
for update;
if not found then
raise exception 'No se encontró el brief solicitado.';
end if;
if v_item.review_status = 'rejected' then
return jsonb_build_object(
'ok', true,
'already_rejected', true,
'brief_id', v_item.brief_id
);
end if;
if v_item.review_status <> 'new' then
raise exception 'Este brief ya fue aprobado y no puede enviarse a No aprobados.';
end if;
v_email := lower(trim(coalesce(auth.jwt() ->> 'email', '')));
select coalesce(nullif(trim(u.full_name), ''), nullif(trim(u.email), ''), 'Usuario CDC')
into v_name
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = v_email
and u.is_active = true
limit 1;
v_name := coalesce(v_name, nullif(v_email, ''), 'Usuario CDC');
update public.tablero_cdc_brief_inbox
set
review_status = 'rejected',
rejection_reason = nullif(trim(coalesce(p_reason, '')), ''),
reviewed_by = auth.uid(),
reviewed_by_name = v_name,
reviewed_by_email = v_email,
reviewed_at = now(),
approved_project_id = null
where id = v_item.id;
return jsonb_build_object(
'ok', true,
'already_rejected', false,
'brief_id', v_item.brief_id
);
end;
$$;
revoke all on function public.tablero_cdc_reject_brief(uuid, text) from public;
grant execute on function public.tablero_cdc_reject_brief(uuid, text) to authenticated;
-- Realtime para que un brief nuevo o una decisión aparezcan/desaparezcan
-- sin que otro miembro del equipo tenga que recargar la página.
do $$
begin
if exists (select 1 from pg_publication where pubname = 'supabase_realtime')
and not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'tablero_cdc_brief_inbox'
) then
alter publication supabase_realtime add table public.tablero_cdc_brief_inbox;
end if;
if exists (select 1 from pg_publication where pubname = 'supabase_realtime')
and not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'tablero_cdc_brief_inbox_seen'
) then
alter publication supabase_realtime add table public.tablero_cdc_brief_inbox_seen;
end if;
end $$;
commit;
-- =============================================================
-- VERIFICACIÓN
-- =============================================================
select
review_status,
count(*) as cantidad
from public.tablero_cdc_brief_inbox
group by review_status
order by review_status;
select
proname as funcion,
pg_get_function_identity_arguments(p.oid) as argumentos
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and proname in ('tablero_cdc_approve_brief', 'tablero_cdc_reject_brief', 'tablero_cdc_get_unseen_brief_count', 'tablero_cdc_mark_briefs_seen')
order by proname;
+135
View File
@@ -0,0 +1,135 @@
-- =============================================================
-- TABLERO CDC — Notificación personal de briefs "Nuevos"
-- Fecha: 2026-08-12
--
-- Ejecutar SOLO si ya habías ejecutado una versión anterior de
-- supabase_cdc_brief_review_inbox.sql. Si aún no la habías ejecutado,
-- usa el archivo completo actualizado y no necesitas este delta.
-- =============================================================
begin;
create table if not exists public.tablero_cdc_brief_inbox_seen (
user_id uuid not null references auth.users(id) on delete cascade,
inbox_id uuid not null references public.tablero_cdc_brief_inbox(id) on delete cascade,
seen_at timestamptz not null default now(),
primary key (user_id, inbox_id)
);
create index if not exists tablero_cdc_brief_inbox_seen_inbox_idx
on public.tablero_cdc_brief_inbox_seen (inbox_id);
alter table public.tablero_cdc_brief_inbox_seen enable row level security;
drop policy if exists "tablero_cdc_brief_inbox_seen_select_own"
on public.tablero_cdc_brief_inbox_seen;
create policy "tablero_cdc_brief_inbox_seen_select_own"
on public.tablero_cdc_brief_inbox_seen
for select
to authenticated
using (
user_id = auth.uid()
and public.tablero_cdc_is_active_allowed_user()
);
revoke insert, update, delete on public.tablero_cdc_brief_inbox_seen from authenticated;
grant select on public.tablero_cdc_brief_inbox_seen to authenticated;
grant all on public.tablero_cdc_brief_inbox_seen to service_role;
create or replace function public.tablero_cdc_get_unseen_brief_count()
returns integer
language plpgsql
stable
security definer
set search_path = public, auth
as $$
declare
v_count integer;
begin
if auth.uid() is null or not public.tablero_cdc_is_active_allowed_user() then
raise exception 'No tienes permiso para consultar notificaciones de briefs.';
end if;
select count(*)::integer
into v_count
from public.tablero_cdc_brief_inbox i
where i.review_status = 'new'
and not exists (
select 1
from public.tablero_cdc_brief_inbox_seen s
where s.user_id = auth.uid()
and s.inbox_id = i.id
);
return coalesce(v_count, 0);
end;
$$;
revoke all on function public.tablero_cdc_get_unseen_brief_count() from public;
grant execute on function public.tablero_cdc_get_unseen_brief_count() to authenticated;
create or replace function public.tablero_cdc_mark_briefs_seen()
returns jsonb
language plpgsql
security definer
set search_path = public, auth
as $$
declare
v_marked integer := 0;
v_remaining integer := 0;
begin
if auth.uid() is null or not public.tablero_cdc_is_active_allowed_user() then
raise exception 'No tienes permiso para marcar briefs como vistos.';
end if;
insert into public.tablero_cdc_brief_inbox_seen (user_id, inbox_id, seen_at)
select auth.uid(), i.id, now()
from public.tablero_cdc_brief_inbox i
where i.review_status = 'new'
on conflict (user_id, inbox_id) do nothing;
get diagnostics v_marked = row_count;
select count(*)::integer
into v_remaining
from public.tablero_cdc_brief_inbox i
where i.review_status = 'new'
and not exists (
select 1
from public.tablero_cdc_brief_inbox_seen s
where s.user_id = auth.uid()
and s.inbox_id = i.id
);
return jsonb_build_object(
'ok', true,
'marked_count', v_marked,
'unseen_count', coalesce(v_remaining, 0)
);
end;
$$;
revoke all on function public.tablero_cdc_mark_briefs_seen() from public;
grant execute on function public.tablero_cdc_mark_briefs_seen() to authenticated;
do $$
begin
if exists (select 1 from pg_publication where pubname = 'supabase_realtime')
and not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'tablero_cdc_brief_inbox_seen'
) then
alter publication supabase_realtime add table public.tablero_cdc_brief_inbox_seen;
end if;
end $$;
commit;
select
to_regclass('public.tablero_cdc_brief_inbox_seen') as tabla_estado_visto,
to_regprocedure('public.tablero_cdc_get_unseen_brief_count()') as rpc_contador,
to_regprocedure('public.tablero_cdc_mark_briefs_seen()') as rpc_marcar_vistos;
+2 -2
View File
@@ -94,8 +94,8 @@ values
'Alicia Thaía Reyes', 'Alicia Thaía Reyes',
'user', 'user',
true, true,
false, true,
false, true,
false false
), ),
( (
+188
View File
@@ -0,0 +1,188 @@
-- =========================================================
-- TABLERO CDC - TIEMPO DEDICADO POR PROYECTO
-- Ejecutar una sola vez en Supabase SQL Editor antes de desplegar
-- la versión que incluye el botón "Tiempo".
--
-- Objetivo:
-- - Registrar tiempo dedicado a un proyecto.
-- - Desglosar ese tiempo por país y por tarea.
-- - Conservar fecha, notas y quién registró cada entrada.
-- - No modifica tablero_cdc_projects ni altera tarifarios, montos,
-- Google Sheets, n8n o datos históricos existentes.
-- =========================================================
begin;
create table if not exists public.tablero_cdc_project_time_entries (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.tablero_cdc_projects(id) on delete cascade,
country text not null,
task_name text not null,
duration_minutes integer not null check (duration_minutes > 0),
work_date date not null default current_date,
notes text not null default '',
created_by uuid references auth.users(id),
created_by_email text,
created_by_name text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint tablero_cdc_project_time_entries_country_not_blank
check (length(trim(country)) > 0),
constraint tablero_cdc_project_time_entries_task_not_blank
check (length(trim(task_name)) > 0)
);
comment on table public.tablero_cdc_project_time_entries is
'Registros de tiempo dedicado a proyectos CDC, desglosados por país y tarea.';
comment on column public.tablero_cdc_project_time_entries.duration_minutes is
'Duración exacta almacenada en minutos para evitar errores de redondeo.';
create index if not exists tablero_cdc_project_time_entries_project_date_idx
on public.tablero_cdc_project_time_entries(project_id, work_date desc, created_at desc);
create index if not exists tablero_cdc_project_time_entries_project_country_idx
on public.tablero_cdc_project_time_entries(project_id, country);
create index if not exists tablero_cdc_project_time_entries_project_task_idx
on public.tablero_cdc_project_time_entries(project_id, task_name);
create or replace function public.set_tablero_cdc_project_time_entries_audit()
returns trigger
language plpgsql
security invoker
set search_path = public
as $$
begin
new.country = trim(new.country);
new.task_name = trim(new.task_name);
new.notes = coalesce(trim(new.notes), '');
new.updated_at = now();
if tg_op = 'INSERT' then
-- La identidad se toma de la sesión autenticada, no de valores enviados por el cliente.
new.created_by = auth.uid();
new.created_by_email = coalesce(
nullif(lower(trim(coalesce(auth.jwt() ->> 'email', ''))), ''),
nullif(lower(trim(coalesce(new.created_by_email, ''))), '')
);
new.created_by_name = coalesce(
nullif(trim(coalesce(auth.jwt() -> 'user_metadata' ->> 'full_name', '')), ''),
nullif(trim(coalesce(auth.jwt() -> 'user_metadata' ->> 'name', '')), ''),
nullif(trim(coalesce(new.created_by_name, '')), ''),
new.created_by_email,
'Usuario CDC'
);
end if;
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_project_time_entries_audit
on public.tablero_cdc_project_time_entries;
create trigger trg_tablero_cdc_project_time_entries_audit
before insert or update on public.tablero_cdc_project_time_entries
for each row
execute function public.set_tablero_cdc_project_time_entries_audit();
alter table public.tablero_cdc_project_time_entries enable row level security;
drop policy if exists "tablero_cdc_project_time_entries_select_allowed" on public.tablero_cdc_project_time_entries;
drop policy if exists "tablero_cdc_project_time_entries_insert_allowed" on public.tablero_cdc_project_time_entries;
drop policy if exists "tablero_cdc_project_time_entries_update_allowed" on public.tablero_cdc_project_time_entries;
drop policy if exists "tablero_cdc_project_time_entries_delete_allowed" on public.tablero_cdc_project_time_entries;
-- Se valida contra la misma tabla central de acceso del Tablero CDC.
-- Así, una sesión Google autenticada pero NO autorizada para el Tablero
-- tampoco puede leer o escribir tiempos mediante la API de Supabase.
create policy "tablero_cdc_project_time_entries_select_allowed"
on public.tablero_cdc_project_time_entries
for select
to authenticated
using (
exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
)
);
create policy "tablero_cdc_project_time_entries_insert_allowed"
on public.tablero_cdc_project_time_entries
for insert
to authenticated
with check (
exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
)
);
create policy "tablero_cdc_project_time_entries_update_allowed"
on public.tablero_cdc_project_time_entries
for update
to authenticated
using (
exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
)
)
with check (
exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
)
);
create policy "tablero_cdc_project_time_entries_delete_allowed"
on public.tablero_cdc_project_time_entries
for delete
to authenticated
using (
exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
)
);
grant select, insert, update, delete
on public.tablero_cdc_project_time_entries
to authenticated;
-- Realtime no es obligatorio para el funcionamiento, pero se habilita
-- para que el módulo quede preparado para sincronización multiusuario.
do $$
begin
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'tablero_cdc_project_time_entries'
) then
alter publication supabase_realtime add table public.tablero_cdc_project_time_entries;
end if;
end $$;
commit;
-- =========================================================
-- VERIFICACIÓN
-- Debe devolver la tabla y 0 registros si todavía no se ha agregado tiempo.
-- =========================================================
select
count(*) as registros_de_tiempo,
coalesce(sum(duration_minutes), 0) as minutos_totales
from public.tablero_cdc_project_time_entries;
+2 -2
View File
@@ -94,8 +94,8 @@ values
'Alicia Thaía Reyes', 'Alicia Thaía Reyes',
'user', 'user',
true, true,
false, true,
false, true,
false false
), ),
( (
@@ -0,0 +1,23 @@
-- =========================================================
-- TABLERO CDC - Permisos Alicia Thaía Reyes
-- Ejecutar si Alicia debe tarifar/ver montos internos y eliminar duplicados.
-- No toca proyectos, links, tarifario ni n8n.
-- =========================================================
update public.tablero_cdc_allowed_users
set
can_manage_internal_pricing = true,
can_delete_projects = true,
updated_at = now()
where lower(email) = lower('areyes@gomezleemarketing.com');
select
email,
full_name,
is_active,
can_manage_internal_pricing,
can_delete_projects,
can_control_pricing_summary,
updated_at
from public.tablero_cdc_allowed_users
where lower(email) = lower('areyes@gomezleemarketing.com');
+829
View File
@@ -0,0 +1,829 @@
-- =========================================================
-- TABLERO CDC - TARIFARIO WALMART CONNECT + MÓDULO DE ADMINISTRACIÓN
-- Versión segura e idempotente para producción.
--
-- Qué hace:
-- 1) Habilita la sección "wmc" en el catálogo dinámico existente.
-- 2) Inserta/actualiza las 12 piezas y montos del Excel Tarifario_XCDC_WMC.
-- 3) Mantiene intactos proyectos, costos históricos, links, listas, n8n y tarifas CDC existentes.
-- 4) Añade secciones generales dinámicas y tarifarios especiales por cliente.
-- 5) Controla el módulo con can_manage_tariff_catalog desde Supabase.
-- 6) Protege las tarifas administradas en la app frente al sync del Sheet.
--
-- Puede ejecutarse más de una vez: usa CREATE IF NOT EXISTS y UPSERT.
-- =========================================================
begin;
-- 1) Asegurar la tabla del catálogo dinámico.
create table if not exists public.tablero_cdc_tariff_catalog (
id text primary key,
catalog_item_id text not null,
section text not null,
category text not null default '',
service text not null default '',
notes text not null default '',
work_type_id text not null default 'reference',
work_type_label text not null default 'Referencia',
work_type_short_label text not null default 'Ref.',
hour_reference text not null default '',
level_id text not null default 'project',
level_label text not null default 'Precio único',
reference text not null default '',
reference_min numeric,
reference_max numeric,
level_hint text not null default '',
sort_order integer not null default 9999,
is_active boolean not null default true,
created_by uuid references auth.users(id),
updated_by uuid references auth.users(id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Compatibilidad con instalaciones donde alguna columna todavía no exista.
alter table public.tablero_cdc_tariff_catalog
add column if not exists catalog_item_id text,
add column if not exists section text,
add column if not exists category text not null default '',
add column if not exists service text not null default '',
add column if not exists notes text not null default '',
add column if not exists work_type_id text not null default 'reference',
add column if not exists work_type_label text not null default 'Referencia',
add column if not exists work_type_short_label text not null default 'Ref.',
add column if not exists hour_reference text not null default '',
add column if not exists level_id text not null default 'project',
add column if not exists level_label text not null default 'Precio único',
add column if not exists reference text not null default '',
add column if not exists reference_min numeric,
add column if not exists reference_max numeric,
add column if not exists level_hint text not null default '',
add column if not exists sort_order integer not null default 9999,
add column if not exists is_active boolean not null default true,
add column if not exists created_by uuid references auth.users(id),
add column if not exists updated_by uuid references auth.users(id),
add column if not exists created_at timestamptz not null default now(),
add column if not exists updated_at timestamptz not null default now();
-- Completar únicamente valores vacíos si la tabla venía de una versión antigua.
-- No se normalizan IDs de sección porque el módulo admite secciones nuevas y dinámicas.
update public.tablero_cdc_tariff_catalog
set
catalog_item_id = coalesce(nullif(trim(catalog_item_id), ''), id),
section = coalesce(nullif(trim(section), ''), 'grafico')
where catalog_item_id is null
or nullif(trim(catalog_item_id), '') is null
or section is null
or nullif(trim(section), '') is null;
alter table public.tablero_cdc_tariff_catalog
alter column catalog_item_id set not null,
alter column section set not null;
-- Retirar cualquier restricción antigua que limite las secciones a IDs fijos.
alter table public.tablero_cdc_tariff_catalog
drop constraint if exists tablero_cdc_tariff_catalog_section_check;
create index if not exists tablero_cdc_tariff_catalog_section_idx
on public.tablero_cdc_tariff_catalog(section, is_active, sort_order);
create index if not exists tablero_cdc_tariff_catalog_item_idx
on public.tablero_cdc_tariff_catalog(catalog_item_id);
-- 2) RLS: lectura para usuarios autenticados y escritura para quienes pueden tarifar.
alter table public.tablero_cdc_tariff_catalog enable row level security;
create or replace function public.tablero_cdc_current_user_can_manage_internal_pricing()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
and u.can_manage_internal_pricing = true
);
$$;
grant execute on function public.tablero_cdc_current_user_can_manage_internal_pricing()
to authenticated;
drop policy if exists "tablero_cdc_tariff_catalog_select_authenticated"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_insert_pricing_admins"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_update_pricing_admins"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_delete_pricing_admins"
on public.tablero_cdc_tariff_catalog;
create policy "tablero_cdc_tariff_catalog_select_authenticated"
on public.tablero_cdc_tariff_catalog
for select
to authenticated
using (true);
create policy "tablero_cdc_tariff_catalog_insert_pricing_admins"
on public.tablero_cdc_tariff_catalog
for insert
to authenticated
with check (public.tablero_cdc_current_user_can_manage_internal_pricing());
create policy "tablero_cdc_tariff_catalog_update_pricing_admins"
on public.tablero_cdc_tariff_catalog
for update
to authenticated
using (public.tablero_cdc_current_user_can_manage_internal_pricing())
with check (public.tablero_cdc_current_user_can_manage_internal_pricing());
-- No se recomienda borrar tarifas usadas históricamente; se deben desactivar.
create policy "tablero_cdc_tariff_catalog_delete_pricing_admins"
on public.tablero_cdc_tariff_catalog
for delete
to authenticated
using (public.tablero_cdc_current_user_can_manage_internal_pricing());
grant select, insert, update, delete
on public.tablero_cdc_tariff_catalog
to authenticated;
create or replace function public.set_tablero_cdc_tariff_catalog_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
new.updated_by = auth.uid();
if tg_op = 'INSERT' then
new.created_by = coalesce(new.created_by, auth.uid());
end if;
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_tariff_catalog_updated_at
on public.tablero_cdc_tariff_catalog;
create trigger trg_tablero_cdc_tariff_catalog_updated_at
before insert or update on public.tablero_cdc_tariff_catalog
for each row
execute function public.set_tablero_cdc_tariff_catalog_updated_at();
-- 3) Cargar las 12 piezas del Excel de Walmart Connect.
insert into public.tablero_cdc_tariff_catalog (
id,
catalog_item_id,
section,
category,
service,
notes,
work_type_id,
work_type_label,
work_type_short_label,
hour_reference,
level_id,
level_label,
reference,
reference_min,
reference_max,
level_hint,
sort_order,
is_active
)
values
(
'wmc-uniformes__wmc-fixed__fixed',
'wmc-uniformes',
'wmc',
'Walmart Connect',
'Uniformes',
'Uniforme completo para personal de impulso en piso (playera/top, jogger, falda, chaleco), con 4 variantes de diseño exploradas. Equivalencia CDC: Materiales de PDV estándar y otros materiales básicos > Uniformes. Incluye diseño en alta resolución y 3 rondas de cambios.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '80 USD', 80, 80,
'Incluye diseño en alta resolución y 3 rondas de cambios.',
20001, true
),
(
'wmc-uniformes-supervisor__wmc-fixed__fixed',
'wmc-uniformes-supervisor',
'wmc',
'Walmart Connect',
'Uniformes Supervisor',
'Uniforme para supervisor de piso (polo, chumpa/track jacket, camisa formal), con 3 variantes de diseño exploradas. Equivalencia CDC: Materiales de PDV estándar y otros materiales básicos > Uniformes. Incluye diseño en alta resolución y 3 rondas de cambios.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '100 USD', 100, 100,
'Incluye diseño en alta resolución y 3 rondas de cambios.',
20002, true
),
(
'wmc-glorificador-mano__wmc-fixed__fixed',
'wmc-glorificador-mano',
'wmc',
'Walmart Connect',
'Glorificador de mano',
'Exhibidor portátil pequeño para presentar el producto: bandeja iluminada, aro LED, estuche o base acrílica. Equivalencia CDC: Stands, Muebles y Exhibidores > Estándar (Displays básicos). Incluye diseño 3D, troqueles y 3 rondas de cambios.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '150 USD', 150, 150,
'Incluye diseño 3D, troqueles y 3 rondas de cambios.',
20003, true
),
(
'wmc-carritos-intervenidos__wmc-fixed__fixed',
'wmc-carritos-intervenidos',
'wmc',
'Walmart Connect',
'Carritos intervenidos',
'Vinil o calcomanía de marca aplicada a la canasta y al mango del carrito de compras. Equivalencia CDC: Materiales de PDV estándar y otros materiales básicos (análogo a Backings/Rompetráficos). Pieza análoga a material gráfico estándar aplicado sobre una superficie existente.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'Pieza análoga a material gráfico estándar aplicado sobre una superficie existente.',
20004, true
),
(
'wmc-dummies-exhibicion__wmc-fixed__fixed',
'wmc-dummies-exhibicion',
'wmc',
'Walmart Connect',
'Dummies de exhibición',
'Réplica escultórica a gran escala de pasta y cepillo dental como punto focal decorativo o táctil. Equivalencia CDC: Proyectos especiales > Diseño de muebles / proyectos con planos y troqueles desde cero. Requiere diseño estructural y visualización 3D; no es solo arte de impresión.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'Requiere diseño estructural y visualización 3D; no es solo arte de impresión.',
20005, true
),
(
'wmc-photobooth__wmc-fixed__fixed',
'wmc-photobooth',
'wmc',
'Walmart Connect',
'Photobooth',
'Estructura tipo arco con iluminación LED y espacio fotográfico de marca para activaciones. Equivalencia CDC: Proyectos especiales > Diseño de Stands Creativos para ferias o eventos. Estructura autoportante con iluminación, ambientación y 4 rondas de cambios.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'Estructura autoportante con iluminación, ambientación y 4 rondas de cambios.',
20006, true
),
(
'wmc-play-station__wmc-fixed__fixed',
'wmc-play-station',
'wmc',
'Walmart Connect',
'Play Station',
'Kiosco interactivo con pantalla táctil y mecánica de juego. Equivalencia CDC: Proyectos especiales > Diseño de muebles / proyectos especiales. Incluye diseño de mueble e interfaz gráfica; el desarrollo funcional del juego se cotiza aparte.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'Incluye diseño de mueble e interfaz gráfica; el desarrollo funcional del juego se cotiza aparte.',
20007, true
),
(
'wmc-isla-wm__wmc-fixed__fixed',
'wmc-isla-wm',
'wmc',
'Walmart Connect',
'Isla WM',
'Mueble isla completo multinivel con iluminación, gráficos y espacio de exhibición de la línea completa de producto. Equivalencia CDC: Proyectos especiales > Diseño de Stands Creativos para ferias o eventos. Mayor escala y complejidad estructural dentro del set de piezas.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '150 USD', 150, 150,
'Mayor escala y complejidad estructural dentro del set de piezas.',
20008, true
),
(
'wmc-exhibicion-especial__wmc-fixed__fixed',
'wmc-exhibicion-especial',
'wmc',
'Walmart Connect',
'Exhibición Especial',
'Propuesta de muebles de exhibición. Equivalencia CDC: Proyectos especiales > Diseño de muebles con planos y troqueles desde cero. Costo de referencia por concepto; las variantes adicionales se cobran como adicionales.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '250 USD', 250, 250,
'Costo de referencia por concepto; las variantes adicionales se cobran como adicionales.',
20009, true
),
(
'wmc-punta-gondola__wmc-fixed__fixed',
'wmc-punta-gondola',
'wmc',
'Walmart Connect',
'Punta de góndola',
'Cabecera de góndola personalizada con iluminación, gráficos y espacio para producto. Equivalencia CDC: Proyectos especiales > Diseño de muebles con planos y troqueles desde cero. Costo de referencia por concepto; las variantes adicionales se cobran como adicionales.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '250 USD', 250, 250,
'Costo de referencia por concepto; las variantes adicionales se cobran como adicionales.',
20010, true
),
(
'wmc-estacion-prueba-producto__wmc-fixed__fixed',
'wmc-estacion-prueba-producto',
'wmc',
'Walmart Connect',
'Estación para prueba de producto',
'Carrito móvil con lavamanos funcional, grifo y desagüe para pruebas de producto en piso. Equivalencia CDC: Proyectos especiales > Diseño de Stands Creativos para ferias o eventos. La complejidad funcional puede requerir una cotización especial fuera del tarifario estándar.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'La complejidad funcional puede requerir una cotización especial fuera del tarifario estándar.',
20011, true
),
(
'wmc-arco-entrada__wmc-fixed__fixed',
'wmc-arco-entrada',
'wmc',
'Walmart Connect',
'Arco de entrada',
'Estructura de entrada de tienda a gran formato con iluminación, efecto de niebla y gráficos de marca. Equivalencia CDC: Proyectos especiales > Diseño de Stands Creativos para ferias o eventos. La instalación en sitio se cotiza aparte.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'La instalación en sitio se cotiza aparte.',
20012, true
)
on conflict (id) do update
set
catalog_item_id = excluded.catalog_item_id,
section = excluded.section,
category = excluded.category,
service = excluded.service,
notes = excluded.notes,
work_type_id = excluded.work_type_id,
work_type_label = excluded.work_type_label,
work_type_short_label = excluded.work_type_short_label,
hour_reference = excluded.hour_reference,
level_id = excluded.level_id,
level_label = excluded.level_label,
reference = excluded.reference,
reference_min = excluded.reference_min,
reference_max = excluded.reference_max,
level_hint = excluded.level_hint,
sort_order = excluded.sort_order,
is_active = true,
updated_at = now();
-- 4) Si el flujo estándar usa esta RPC para desactivar tarifas ausentes,
-- solo afectará grafico/estrategia y nunca apagará la sección WMC.
create or replace function public.tablero_cdc_deactivate_missing_tariffs(p_active_ids text[])
returns integer
language plpgsql
security definer
set search_path = public
as $$
declare
v_count integer;
begin
if p_active_ids is null or array_length(p_active_ids, 1) is null then
raise exception 'No se recibieron IDs activos. Se cancela la desactivación.';
end if;
if array_length(p_active_ids, 1) < 3 then
raise exception 'Se recibieron menos de 3 IDs activos. Se cancela la desactivación por seguridad.';
end if;
update public.tablero_cdc_tariff_catalog
set
is_active = false,
updated_at = now()
where section in ('grafico', 'estrategia')
and is_active = true
and not (id = any(p_active_ids));
get diagnostics v_count = row_count;
return v_count;
end;
$$;
grant execute on function public.tablero_cdc_deactivate_missing_tariffs(text[])
to authenticated, service_role;
commit;
-- Verificación final: deben aparecer 12 filas activas y un total de referencia de 2,180 USD.
select
count(*) as piezas_wmc_activas,
coalesce(sum(reference_min), 0)::numeric(12, 2) as suma_de_todas_las_piezas
from public.tablero_cdc_tariff_catalog
where section = 'wmc'
and is_active = true;
select
service as pieza,
reference_min::numeric(12, 2) as monto_fijo,
is_active
from public.tablero_cdc_tariff_catalog
where section = 'wmc'
order by sort_order, service;
-- =========================================================
-- MÓDULO DE ADMINISTRACIÓN DEL TARIFARIO
-- Añade permisos dinámicos, secciones generales y tarifarios por cliente.
-- Seguro para ejecutar después del bloque anterior y repetir posteriormente.
-- =========================================================
begin;
-- 1) Permiso específico del módulo, independiente del permiso para tarifar proyectos.
alter table public.tablero_cdc_allowed_users
add column if not exists can_manage_tariff_catalog boolean not null default false;
insert into public.tablero_cdc_allowed_users (
email,
full_name,
role,
is_active,
can_delete_projects,
can_manage_internal_pricing,
can_control_pricing_summary,
can_manage_tariff_catalog
)
values
(
'iaracena@gomezleemarketing.com',
'Isaac Daniel Aracena Toribio',
'admin_it',
true,
true,
true,
true,
true
),
(
'gmarrero@gomezleemarketing.com',
'Gerardo Marrero',
'director_creativo',
true,
true,
true,
true,
true
),
(
'areyes@gomezleemarketing.com',
'Alicia Thaía Reyes',
'user',
true,
true,
true,
false,
true
)
on conflict (email) do update
set
can_manage_tariff_catalog = true,
updated_at = now();
create or replace function public.tablero_cdc_current_user_can_manage_tariff_catalog()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
and u.can_manage_tariff_catalog = true
);
$$;
grant execute on function public.tablero_cdc_current_user_can_manage_tariff_catalog()
to authenticated;
-- 2) Tabla de secciones: generales o asociadas a un cliente.
create table if not exists public.tablero_cdc_tariff_sections (
id text primary key,
name text not null,
scope text not null default 'general'
check (scope in ('general', 'client')),
client_name text not null default '',
pricing_mode text not null default 'guided'
check (pricing_mode in ('guided', 'fixed_multi')),
allow_manual boolean not null default true,
description text not null default '',
sort_order integer not null default 9999,
is_active boolean not null default true,
created_by uuid references auth.users(id),
updated_by uuid references auth.users(id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint tablero_cdc_tariff_sections_client_check check (
(scope = 'general' and client_name = '' and pricing_mode = 'guided')
or
(scope = 'client' and nullif(trim(client_name), '') is not null and pricing_mode = 'fixed_multi')
)
);
create index if not exists tablero_cdc_tariff_sections_scope_idx
on public.tablero_cdc_tariff_sections(scope, is_active, sort_order);
create index if not exists tablero_cdc_tariff_sections_client_idx
on public.tablero_cdc_tariff_sections(lower(client_name), is_active);
insert into public.tablero_cdc_tariff_sections (
id, name, scope, client_name, pricing_mode, allow_manual,
description, sort_order, is_active
)
values
(
'grafico',
'Tarifario Gráfico CDC',
'general',
'',
'guided',
true,
'Diseño gráfico, artes finales, adaptaciones y materiales de punto de venta.',
10,
true
),
(
'estrategia',
'Estrategia y Creatividad',
'general',
'',
'guided',
true,
'Servicios de estrategia, conceptualización y creatividad.',
20,
true
),
(
'wmc',
'Tarifario Walmart Connect',
'client',
'Walmart Connect WMC',
'fixed_multi',
true,
'Piezas de precio fijo que aparecen únicamente al seleccionar Walmart Connect WMC.',
100,
true
)
on conflict (id) do update
set
name = excluded.name,
scope = excluded.scope,
client_name = excluded.client_name,
pricing_mode = excluded.pricing_mode,
allow_manual = excluded.allow_manual,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now();
alter table public.tablero_cdc_tariff_sections enable row level security;
drop policy if exists "tablero_cdc_tariff_sections_select_authenticated"
on public.tablero_cdc_tariff_sections;
drop policy if exists "tablero_cdc_tariff_sections_insert_admins"
on public.tablero_cdc_tariff_sections;
drop policy if exists "tablero_cdc_tariff_sections_update_admins"
on public.tablero_cdc_tariff_sections;
drop policy if exists "tablero_cdc_tariff_sections_delete_admins"
on public.tablero_cdc_tariff_sections;
create policy "tablero_cdc_tariff_sections_select_authenticated"
on public.tablero_cdc_tariff_sections
for select
to authenticated
using (true);
create policy "tablero_cdc_tariff_sections_insert_admins"
on public.tablero_cdc_tariff_sections
for insert
to authenticated
with check (public.tablero_cdc_current_user_can_manage_tariff_catalog());
create policy "tablero_cdc_tariff_sections_update_admins"
on public.tablero_cdc_tariff_sections
for update
to authenticated
using (public.tablero_cdc_current_user_can_manage_tariff_catalog())
with check (public.tablero_cdc_current_user_can_manage_tariff_catalog());
create policy "tablero_cdc_tariff_sections_delete_admins"
on public.tablero_cdc_tariff_sections
for delete
to authenticated
using (public.tablero_cdc_current_user_can_manage_tariff_catalog());
grant select, insert, update, delete
on public.tablero_cdc_tariff_sections
to authenticated;
create or replace function public.set_tablero_cdc_tariff_sections_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
new.updated_by = auth.uid();
if tg_op = 'INSERT' then
new.created_by = coalesce(new.created_by, auth.uid());
end if;
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_tariff_sections_updated_at
on public.tablero_cdc_tariff_sections;
create trigger trg_tablero_cdc_tariff_sections_updated_at
before insert or update on public.tablero_cdc_tariff_sections
for each row
execute function public.set_tablero_cdc_tariff_sections_updated_at();
-- 3) El catálogo acepta cualquier sección registrada, no solo tres IDs fijos.
alter table public.tablero_cdc_tariff_catalog
drop constraint if exists tablero_cdc_tariff_catalog_section_check;
alter table public.tablero_cdc_tariff_catalog
add column if not exists managed_by text not null default 'sheet';
alter table public.tablero_cdc_tariff_catalog
drop constraint if exists tablero_cdc_tariff_catalog_managed_by_check;
alter table public.tablero_cdc_tariff_catalog
add constraint tablero_cdc_tariff_catalog_managed_by_check
check (managed_by in ('sheet', 'app', 'system'));
update public.tablero_cdc_tariff_catalog
set managed_by = 'system'
where section = 'wmc'
and managed_by = 'sheet';
-- Las escrituras desde el módulo requieren el permiso específico.
drop policy if exists "tablero_cdc_tariff_catalog_select_authenticated"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_insert_pricing_admins"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_update_pricing_admins"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_delete_pricing_admins"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_insert_catalog_admins"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_update_catalog_admins"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_delete_catalog_admins"
on public.tablero_cdc_tariff_catalog;
create policy "tablero_cdc_tariff_catalog_select_authenticated"
on public.tablero_cdc_tariff_catalog
for select
to authenticated
using (true);
create policy "tablero_cdc_tariff_catalog_insert_catalog_admins"
on public.tablero_cdc_tariff_catalog
for insert
to authenticated
with check (public.tablero_cdc_current_user_can_manage_tariff_catalog());
create policy "tablero_cdc_tariff_catalog_update_catalog_admins"
on public.tablero_cdc_tariff_catalog
for update
to authenticated
using (public.tablero_cdc_current_user_can_manage_tariff_catalog())
with check (public.tablero_cdc_current_user_can_manage_tariff_catalog());
create policy "tablero_cdc_tariff_catalog_delete_catalog_admins"
on public.tablero_cdc_tariff_catalog
for delete
to authenticated
using (public.tablero_cdc_current_user_can_manage_tariff_catalog());
grant select, insert, update, delete
on public.tablero_cdc_tariff_catalog
to authenticated;
-- Protege los registros administrados desde la app para que el sync del Sheet no los sobrescriba.
create or replace function public.set_tablero_cdc_tariff_catalog_updated_at()
returns trigger
language plpgsql
as $$
declare
v_request_role text := coalesce(
nullif(auth.role(), ''),
nullif(current_setting('request.jwt.claim.role', true), ''),
''
);
begin
if tg_op = 'UPDATE'
and old.managed_by = 'app'
and v_request_role = 'service_role' then
-- El sync de n8n no puede sobrescribir una tarifa que ya fue administrada en la app.
return old;
end if;
new.updated_at = now();
new.updated_by = auth.uid();
if v_request_role = 'authenticated' then
new.managed_by = 'app';
end if;
if tg_op = 'INSERT' then
new.created_by = coalesce(new.created_by, auth.uid());
end if;
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_tariff_catalog_updated_at
on public.tablero_cdc_tariff_catalog;
create trigger trg_tablero_cdc_tariff_catalog_updated_at
before insert or update on public.tablero_cdc_tariff_catalog
for each row
execute function public.set_tablero_cdc_tariff_catalog_updated_at();
-- El sync estándar solo desactiva registros gestionados por el Sheet.
create or replace function public.tablero_cdc_deactivate_missing_tariffs(p_active_ids text[])
returns integer
language plpgsql
security definer
set search_path = public
as $$
declare
v_count integer;
begin
if p_active_ids is null or array_length(p_active_ids, 1) is null then
raise exception 'No se recibieron IDs activos. Se cancela la desactivación.';
end if;
if array_length(p_active_ids, 1) < 3 then
raise exception 'Se recibieron menos de 3 IDs activos. Se cancela la desactivación por seguridad.';
end if;
update public.tablero_cdc_tariff_catalog
set
is_active = false,
updated_at = now()
where section in ('grafico', 'estrategia')
and managed_by <> 'app'
and is_active = true
and not (id = any(p_active_ids));
get diagnostics v_count = row_count;
return v_count;
end;
$$;
grant execute on function public.tablero_cdc_deactivate_missing_tariffs(text[])
to authenticated, service_role;
-- 4) Trazabilidad opcional de la tarifa usada en cada proyecto.
alter table public.tablero_cdc_project_pricing_items
add column if not exists tariff_section_id text,
add column if not exists tariff_catalog_id text;
create index if not exists tablero_cdc_project_pricing_items_tariff_section_idx
on public.tablero_cdc_project_pricing_items(tariff_section_id);
create index if not exists tablero_cdc_project_pricing_items_tariff_catalog_idx
on public.tablero_cdc_project_pricing_items(tariff_catalog_id);
commit;
-- =========================================================
-- VERIFICACIÓN
-- =========================================================
select
email,
full_name,
is_active,
can_manage_tariff_catalog
from public.tablero_cdc_allowed_users
where lower(trim(email)) in (
'iaracena@gomezleemarketing.com',
'gmarrero@gomezleemarketing.com',
'areyes@gomezleemarketing.com'
)
order by email;
select
id,
name,
scope,
client_name,
pricing_mode,
allow_manual,
is_active,
sort_order
from public.tablero_cdc_tariff_sections
order by sort_order, name;
select
section,
managed_by,
count(*) filter (where is_active) as tarifas_activas,
count(*) filter (where not is_active) as tarifas_inactivas
from public.tablero_cdc_tariff_catalog
group by section, managed_by
order by section, managed_by;
+422
View File
@@ -0,0 +1,422 @@
-- =========================================================
-- TABLERO CDC - TARIFARIO FIJO WALMART CONNECT
-- Versión segura para producción.
--
-- Qué hace:
-- 1) Habilita la sección "wmc" en el catálogo dinámico existente.
-- 2) Inserta/actualiza las 12 piezas y montos del Excel Tarifario_XCDC_WMC.
-- 3) Mantiene intactos proyectos, costos históricos, links, listas, n8n y tarifas CDC existentes.
-- 4) Permite administrar el catálogo a usuarios activos con can_manage_internal_pricing = true.
-- 5) Protege las tarifas WMC de una futura desactivación masiva del sync del tarifario estándar.
--
-- Puede ejecutarse más de una vez: usa CREATE IF NOT EXISTS y UPSERT.
-- =========================================================
begin;
-- 1) Asegurar la tabla del catálogo dinámico.
create table if not exists public.tablero_cdc_tariff_catalog (
id text primary key,
catalog_item_id text not null,
section text not null,
category text not null default '',
service text not null default '',
notes text not null default '',
work_type_id text not null default 'reference',
work_type_label text not null default 'Referencia',
work_type_short_label text not null default 'Ref.',
hour_reference text not null default '',
level_id text not null default 'project',
level_label text not null default 'Precio único',
reference text not null default '',
reference_min numeric,
reference_max numeric,
level_hint text not null default '',
sort_order integer not null default 9999,
is_active boolean not null default true,
created_by uuid references auth.users(id),
updated_by uuid references auth.users(id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Compatibilidad con instalaciones donde alguna columna todavía no exista.
alter table public.tablero_cdc_tariff_catalog
add column if not exists catalog_item_id text,
add column if not exists section text,
add column if not exists category text not null default '',
add column if not exists service text not null default '',
add column if not exists notes text not null default '',
add column if not exists work_type_id text not null default 'reference',
add column if not exists work_type_label text not null default 'Referencia',
add column if not exists work_type_short_label text not null default 'Ref.',
add column if not exists hour_reference text not null default '',
add column if not exists level_id text not null default 'project',
add column if not exists level_label text not null default 'Precio único',
add column if not exists reference text not null default '',
add column if not exists reference_min numeric,
add column if not exists reference_max numeric,
add column if not exists level_hint text not null default '',
add column if not exists sort_order integer not null default 9999,
add column if not exists is_active boolean not null default true,
add column if not exists created_by uuid references auth.users(id),
add column if not exists updated_by uuid references auth.users(id),
add column if not exists created_at timestamptz not null default now(),
add column if not exists updated_at timestamptz not null default now();
-- Completar valores obligatorios si la tabla venía de una versión antigua.
update public.tablero_cdc_tariff_catalog
set
catalog_item_id = coalesce(nullif(trim(catalog_item_id), ''), id),
section = case
when lower(trim(coalesce(section, ''))) in ('grafico', 'estrategia', 'wmc')
then lower(trim(section))
else 'grafico'
end
where catalog_item_id is null
or nullif(trim(catalog_item_id), '') is null
or section is null
or lower(trim(section)) not in ('grafico', 'estrategia', 'wmc');
alter table public.tablero_cdc_tariff_catalog
alter column catalog_item_id set not null,
alter column section set not null;
-- Reemplazar el check antiguo que solo permitía grafico/estrategia.
alter table public.tablero_cdc_tariff_catalog
drop constraint if exists tablero_cdc_tariff_catalog_section_check;
alter table public.tablero_cdc_tariff_catalog
add constraint tablero_cdc_tariff_catalog_section_check
check (section in ('grafico', 'estrategia', 'wmc'));
create index if not exists tablero_cdc_tariff_catalog_section_idx
on public.tablero_cdc_tariff_catalog(section, is_active, sort_order);
create index if not exists tablero_cdc_tariff_catalog_item_idx
on public.tablero_cdc_tariff_catalog(catalog_item_id);
-- 2) RLS: lectura para usuarios autenticados y escritura para quienes pueden tarifar.
alter table public.tablero_cdc_tariff_catalog enable row level security;
create or replace function public.tablero_cdc_current_user_can_manage_internal_pricing()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where lower(trim(u.email)) = lower(trim(coalesce(auth.jwt() ->> 'email', '')))
and u.is_active = true
and u.can_manage_internal_pricing = true
);
$$;
grant execute on function public.tablero_cdc_current_user_can_manage_internal_pricing()
to authenticated;
drop policy if exists "tablero_cdc_tariff_catalog_select_authenticated"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_insert_pricing_admins"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_update_pricing_admins"
on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_delete_pricing_admins"
on public.tablero_cdc_tariff_catalog;
create policy "tablero_cdc_tariff_catalog_select_authenticated"
on public.tablero_cdc_tariff_catalog
for select
to authenticated
using (true);
create policy "tablero_cdc_tariff_catalog_insert_pricing_admins"
on public.tablero_cdc_tariff_catalog
for insert
to authenticated
with check (public.tablero_cdc_current_user_can_manage_internal_pricing());
create policy "tablero_cdc_tariff_catalog_update_pricing_admins"
on public.tablero_cdc_tariff_catalog
for update
to authenticated
using (public.tablero_cdc_current_user_can_manage_internal_pricing())
with check (public.tablero_cdc_current_user_can_manage_internal_pricing());
-- No se recomienda borrar tarifas usadas históricamente; se deben desactivar.
create policy "tablero_cdc_tariff_catalog_delete_pricing_admins"
on public.tablero_cdc_tariff_catalog
for delete
to authenticated
using (public.tablero_cdc_current_user_can_manage_internal_pricing());
grant select, insert, update, delete
on public.tablero_cdc_tariff_catalog
to authenticated;
create or replace function public.set_tablero_cdc_tariff_catalog_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
new.updated_by = auth.uid();
if tg_op = 'INSERT' then
new.created_by = coalesce(new.created_by, auth.uid());
end if;
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_tariff_catalog_updated_at
on public.tablero_cdc_tariff_catalog;
create trigger trg_tablero_cdc_tariff_catalog_updated_at
before insert or update on public.tablero_cdc_tariff_catalog
for each row
execute function public.set_tablero_cdc_tariff_catalog_updated_at();
-- 3) Cargar las 12 piezas del Excel de Walmart Connect.
insert into public.tablero_cdc_tariff_catalog (
id,
catalog_item_id,
section,
category,
service,
notes,
work_type_id,
work_type_label,
work_type_short_label,
hour_reference,
level_id,
level_label,
reference,
reference_min,
reference_max,
level_hint,
sort_order,
is_active
)
values
(
'wmc-uniformes__wmc-fixed__fixed',
'wmc-uniformes',
'wmc',
'Walmart Connect',
'Uniformes',
'Uniforme completo para personal de impulso en piso (playera/top, jogger, falda, chaleco), con 4 variantes de diseño exploradas. Equivalencia CDC: Materiales de PDV estándar y otros materiales básicos > Uniformes. Incluye diseño en alta resolución y 3 rondas de cambios.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '80 USD', 80, 80,
'Incluye diseño en alta resolución y 3 rondas de cambios.',
20001, true
),
(
'wmc-uniformes-supervisor__wmc-fixed__fixed',
'wmc-uniformes-supervisor',
'wmc',
'Walmart Connect',
'Uniformes Supervisor',
'Uniforme para supervisor de piso (polo, chumpa/track jacket, camisa formal), con 3 variantes de diseño exploradas. Equivalencia CDC: Materiales de PDV estándar y otros materiales básicos > Uniformes. Incluye diseño en alta resolución y 3 rondas de cambios.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '100 USD', 100, 100,
'Incluye diseño en alta resolución y 3 rondas de cambios.',
20002, true
),
(
'wmc-glorificador-mano__wmc-fixed__fixed',
'wmc-glorificador-mano',
'wmc',
'Walmart Connect',
'Glorificador de mano',
'Exhibidor portátil pequeño para presentar el producto: bandeja iluminada, aro LED, estuche o base acrílica. Equivalencia CDC: Stands, Muebles y Exhibidores > Estándar (Displays básicos). Incluye diseño 3D, troqueles y 3 rondas de cambios.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '150 USD', 150, 150,
'Incluye diseño 3D, troqueles y 3 rondas de cambios.',
20003, true
),
(
'wmc-carritos-intervenidos__wmc-fixed__fixed',
'wmc-carritos-intervenidos',
'wmc',
'Walmart Connect',
'Carritos intervenidos',
'Vinil o calcomanía de marca aplicada a la canasta y al mango del carrito de compras. Equivalencia CDC: Materiales de PDV estándar y otros materiales básicos (análogo a Backings/Rompetráficos). Pieza análoga a material gráfico estándar aplicado sobre una superficie existente.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'Pieza análoga a material gráfico estándar aplicado sobre una superficie existente.',
20004, true
),
(
'wmc-dummies-exhibicion__wmc-fixed__fixed',
'wmc-dummies-exhibicion',
'wmc',
'Walmart Connect',
'Dummies de exhibición',
'Réplica escultórica a gran escala de pasta y cepillo dental como punto focal decorativo o táctil. Equivalencia CDC: Proyectos especiales > Diseño de muebles / proyectos con planos y troqueles desde cero. Requiere diseño estructural y visualización 3D; no es solo arte de impresión.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'Requiere diseño estructural y visualización 3D; no es solo arte de impresión.',
20005, true
),
(
'wmc-photobooth__wmc-fixed__fixed',
'wmc-photobooth',
'wmc',
'Walmart Connect',
'Photobooth',
'Estructura tipo arco con iluminación LED y espacio fotográfico de marca para activaciones. Equivalencia CDC: Proyectos especiales > Diseño de Stands Creativos para ferias o eventos. Estructura autoportante con iluminación, ambientación y 4 rondas de cambios.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'Estructura autoportante con iluminación, ambientación y 4 rondas de cambios.',
20006, true
),
(
'wmc-play-station__wmc-fixed__fixed',
'wmc-play-station',
'wmc',
'Walmart Connect',
'Play Station',
'Kiosco interactivo con pantalla táctil y mecánica de juego. Equivalencia CDC: Proyectos especiales > Diseño de muebles / proyectos especiales. Incluye diseño de mueble e interfaz gráfica; el desarrollo funcional del juego se cotiza aparte.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'Incluye diseño de mueble e interfaz gráfica; el desarrollo funcional del juego se cotiza aparte.',
20007, true
),
(
'wmc-isla-wm__wmc-fixed__fixed',
'wmc-isla-wm',
'wmc',
'Walmart Connect',
'Isla WM',
'Mueble isla completo multinivel con iluminación, gráficos y espacio de exhibición de la línea completa de producto. Equivalencia CDC: Proyectos especiales > Diseño de Stands Creativos para ferias o eventos. Mayor escala y complejidad estructural dentro del set de piezas.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '150 USD', 150, 150,
'Mayor escala y complejidad estructural dentro del set de piezas.',
20008, true
),
(
'wmc-exhibicion-especial__wmc-fixed__fixed',
'wmc-exhibicion-especial',
'wmc',
'Walmart Connect',
'Exhibición Especial',
'Propuesta de muebles de exhibición. Equivalencia CDC: Proyectos especiales > Diseño de muebles con planos y troqueles desde cero. Costo de referencia por concepto; las variantes adicionales se cobran como adicionales.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '250 USD', 250, 250,
'Costo de referencia por concepto; las variantes adicionales se cobran como adicionales.',
20009, true
),
(
'wmc-punta-gondola__wmc-fixed__fixed',
'wmc-punta-gondola',
'wmc',
'Walmart Connect',
'Punta de góndola',
'Cabecera de góndola personalizada con iluminación, gráficos y espacio para producto. Equivalencia CDC: Proyectos especiales > Diseño de muebles con planos y troqueles desde cero. Costo de referencia por concepto; las variantes adicionales se cobran como adicionales.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '250 USD', 250, 250,
'Costo de referencia por concepto; las variantes adicionales se cobran como adicionales.',
20010, true
),
(
'wmc-estacion-prueba-producto__wmc-fixed__fixed',
'wmc-estacion-prueba-producto',
'wmc',
'Walmart Connect',
'Estación para prueba de producto',
'Carrito móvil con lavamanos funcional, grifo y desagüe para pruebas de producto en piso. Equivalencia CDC: Proyectos especiales > Diseño de Stands Creativos para ferias o eventos. La complejidad funcional puede requerir una cotización especial fuera del tarifario estándar.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'La complejidad funcional puede requerir una cotización especial fuera del tarifario estándar.',
20011, true
),
(
'wmc-arco-entrada__wmc-fixed__fixed',
'wmc-arco-entrada',
'wmc',
'Walmart Connect',
'Arco de entrada',
'Estructura de entrada de tienda a gran formato con iluminación, efecto de niebla y gráficos de marca. Equivalencia CDC: Proyectos especiales > Diseño de Stands Creativos para ferias o eventos. La instalación en sitio se cotiza aparte.',
'wmc_fixed', 'Tarifa fija WMC', 'WMC', '35 - 80',
'fixed', 'Precio fijo', '200 USD', 200, 200,
'La instalación en sitio se cotiza aparte.',
20012, true
)
on conflict (id) do update
set
catalog_item_id = excluded.catalog_item_id,
section = excluded.section,
category = excluded.category,
service = excluded.service,
notes = excluded.notes,
work_type_id = excluded.work_type_id,
work_type_label = excluded.work_type_label,
work_type_short_label = excluded.work_type_short_label,
hour_reference = excluded.hour_reference,
level_id = excluded.level_id,
level_label = excluded.level_label,
reference = excluded.reference,
reference_min = excluded.reference_min,
reference_max = excluded.reference_max,
level_hint = excluded.level_hint,
sort_order = excluded.sort_order,
is_active = true,
updated_at = now();
-- 4) Si el flujo estándar usa esta RPC para desactivar tarifas ausentes,
-- solo afectará grafico/estrategia y nunca apagará la sección WMC.
create or replace function public.tablero_cdc_deactivate_missing_tariffs(p_active_ids text[])
returns integer
language plpgsql
security definer
set search_path = public
as $$
declare
v_count integer;
begin
if p_active_ids is null or array_length(p_active_ids, 1) is null then
raise exception 'No se recibieron IDs activos. Se cancela la desactivación.';
end if;
if array_length(p_active_ids, 1) < 3 then
raise exception 'Se recibieron menos de 3 IDs activos. Se cancela la desactivación por seguridad.';
end if;
update public.tablero_cdc_tariff_catalog
set
is_active = false,
updated_at = now()
where section in ('grafico', 'estrategia')
and is_active = true
and not (id = any(p_active_ids));
get diagnostics v_count = row_count;
return v_count;
end;
$$;
grant execute on function public.tablero_cdc_deactivate_missing_tariffs(text[])
to authenticated, service_role;
commit;
-- Verificación final: deben aparecer 12 filas activas y un total de referencia de 2,180 USD.
select
count(*) as piezas_wmc_activas,
coalesce(sum(reference_min), 0)::numeric(12, 2) as suma_de_todas_las_piezas
from public.tablero_cdc_tariff_catalog
where section = 'wmc'
and is_active = true;
select
service as pieza,
reference_min::numeric(12, 2) as monto_fijo,
is_active
from public.tablero_cdc_tariff_catalog
where section = 'wmc'
order by sort_order, service;