feat: actualizar Tablero CDC con administracion de listas
@@ -1,3 +1,4 @@
|
||||
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"
|
||||
VITE_LISTS_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-listas-admin"
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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/`.
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
| **Proyecto** | Tablero CDC / CDC Project Management |
|
||||
| **Área** | Creatividad y Diseño — CDC |
|
||||
| **Developer Principal** | Isaac Aracena |
|
||||
| **IT Manager** | Luis Matos |
|
||||
Esta versión incluye:
|
||||
|
||||
- Proyecto React + Vite limpio.
|
||||
- Limpieza del ecosistema Lovable.
|
||||
- Corrección de `County Manager` a `Country Manager`.
|
||||
- Configuración de `base: "/tablero-cdc/"` en `vite.config.ts`.
|
||||
- Favicon personalizado para la pestaña del navegador.
|
||||
- Login corporativo con 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
|
||||
|
||||
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.
|
||||
|
||||
### Solución implementada
|
||||
|
||||
El Tablero CDC ofrece una aplicación web donde los usuarios autorizados pueden:
|
||||
|
||||
- crear, consultar, editar y cerrar proyectos;
|
||||
- filtrar y buscar proyectos;
|
||||
- manejar links de brief, propuestas y artes finales;
|
||||
- visualizar actividad e información clave de cada proyecto;
|
||||
- calcular y guardar el costo interno mediante tarifarios;
|
||||
- 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.
|
||||
- Tablero visual de proyectos.
|
||||
- Tarjetas por proyecto.
|
||||
- Filtros por estado: Todos, Activos y Cerrados.
|
||||
- Buscador por datos del proyecto.
|
||||
- Modal de creación y edición de proyecto.
|
||||
- Campos para cliente, marca, país, solicitante y `Country Manager`.
|
||||
- Link de brief.
|
||||
- Links múltiples de propuestas.
|
||||
- Links múltiples de artes finales.
|
||||
- Etiquetas de color por proyecto.
|
||||
- Estatus visible para todos los usuarios.
|
||||
- Monto interno visible solo para Gerardo Marrero.
|
||||
- Paginación local para manejar tableros con muchos proyectos.
|
||||
|
||||
---
|
||||
|
||||
## ARQUITECTURA
|
||||
## Funcionalidades pendientes
|
||||
|
||||
### Diagrama de flujo principal
|
||||
Las siguientes funcionalidades se implementarán en próximas fases:
|
||||
|
||||
```text
|
||||
Usuario autorizado
|
||||
|
|
||||
v
|
||||
React + TypeScript + Vite
|
||||
|
|
||||
+--> Supabase Auth (Google OAuth)
|
||||
|
|
||||
+--> tablero_cdc_allowed_users
|
||||
| |
|
||||
| +--> 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
|
||||
```
|
||||
|
||||
### Flujo del tarifario
|
||||
|
||||
```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 |
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## REGLAS DE NEGOCIO
|
||||
## Arquitectura prevista
|
||||
|
||||
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`.
|
||||
```text
|
||||
Frontend React/Vite
|
||||
↓
|
||||
Supabase Auth
|
||||
↓
|
||||
Supabase Database/Postgres + Realtime + RLS
|
||||
↓
|
||||
n8n como capa de integración
|
||||
↓
|
||||
Google Sheets original
|
||||
↓
|
||||
Power BI
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## CONFIGURACIÓN Y SETUP
|
||||
## Instalación local
|
||||
|
||||
### 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
|
||||
Instalar dependencias:
|
||||
|
||||
```bash
|
||||
git clone https://git.digitalcompass.agency/Isaac_Aracena/cdc-project-management.git
|
||||
cd cdc-project-management
|
||||
npm install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Editar `.env` con los valores correctos.
|
||||
|
||||
Ejecutar en desarrollo:
|
||||
Ejecutar en modo desarrollo:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
La configuración actual de Vite usa:
|
||||
|
||||
```text
|
||||
http://localhost:3000/tablero-cdc/
|
||||
```
|
||||
|
||||
### Build
|
||||
Generar build de producción:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
El script ejecuta:
|
||||
|
||||
```text
|
||||
tsc && vite build
|
||||
```
|
||||
|
||||
Por lo tanto, un error de TypeScript detiene el build y debe corregirse antes de publicar.
|
||||
|
||||
### Preview
|
||||
Vista previa del build:
|
||||
|
||||
```bash
|
||||
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
|
||||
/tablero-cdc/
|
||||
Estructura esperada:
|
||||
|
||||
```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
|
||||
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
|
||||
dist/
|
||||
├── assets/
|
||||
├── index.html
|
||||
├── favicon.ico
|
||||
└── demás archivos públicos generados
|
||||
supabase_realtime_projects.sql
|
||||
```
|
||||
|
||||
> `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`.
|
||||
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.
|
||||
La app intenta cargar estos desplegables desde `public.tablero_cdc_app_lists`:
|
||||
|
||||
---
|
||||
- `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.
|
||||
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`.
|
||||
Esta versión mantiene la app como consumidora del tarifario, pero elimina la administración manual dentro de la app:
|
||||
|
||||
### 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**
|
||||
- **Estrategia y Creatividad**
|
||||
|
||||
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
|
||||
```sql
|
||||
-- Archivo incluido en este ZIP:
|
||||
-- supabase_tariff_catalog_and_safe_links.sql
|
||||
```
|
||||
|
||||
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;
|
||||
- crear tarifarios por cliente;
|
||||
- crear tarifas / piezas;
|
||||
- editar secciones y tarifas;
|
||||
- desactivar y reactivar;
|
||||
- buscar;
|
||||
- mostrar inactivas;
|
||||
- paginar listas extensas.
|
||||
- Al seleccionar `Walmart Connect WMC` como cliente, la estimación interna muestra únicamente:
|
||||
- selección múltiple de piezas WMC con montos fijos;
|
||||
- costo/propuesta manual WMC para casos cotizados por proyecto.
|
||||
- Para los demás clientes se conservan sin cambios el Tarifario Gráfico CDC, Estrategia y Creatividad y el costo manual.
|
||||
- Las piezas seleccionadas se totalizan automáticamente y se guardan en `tablero_cdc_project_pricing_items` como líneas normales del tarifario.
|
||||
- Antes de desplegar, ejecutar `supabase_walmart_connect_tariff.sql` en Supabase SQL Editor.
|
||||
|
||||
La administración no debe borrar costos ya guardados en proyectos históricos.
|
||||
|
||||
### 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` |
|
||||
## Módulo de administración del tarifario
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -11,8 +11,8 @@
|
||||
<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="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>
|
||||
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-D0h6tUNa.css">
|
||||
<script type="module" crossorigin src="/tablero-cdc/assets/index-Deer85MW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-Npcv_R3U.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
<title>Tablero CDC | GLM</title>
|
||||
<meta name="description" content="Herramienta interna de GomezLee Marketing para gestión de proyectos CDC." />
|
||||
<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="shortcut icon" type="image/png" href="%BASE_URL%glm-favicon.png?v=glm-logo-final-20260626" />
|
||||
<link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.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="icon" type="image/png" sizes="16x16" href="%BASE_URL%favicon-16.png?v=glm-tab-v12" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 519 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 436 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 302 B After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 24 KiB |
@@ -59,7 +59,7 @@ class ErrorBoundary extends Component<EBProps, EBState> {
|
||||
function AuthGate() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
if (loading && !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
|
||||
@@ -9,9 +9,9 @@ export function LoginScreen() {
|
||||
<div className="min-h-screen bg-background flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* 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" />
|
||||
<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>
|
||||
|
||||
{/* Card */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 { cn } from "@/lib/utils";
|
||||
import { colorHex } from "@/lib/colors";
|
||||
import type { Project } from "@/lib/store";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: Project;
|
||||
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 { isGerardo } = useAuth();
|
||||
|
||||
const internalAmount = Number(project.monto);
|
||||
const hasInternalAmount =
|
||||
showInternalAmount &&
|
||||
project.monto !== null &&
|
||||
Number.isFinite(internalAmount) &&
|
||||
internalAmount > 0;
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
@@ -23,6 +33,9 @@ export function ProjectCard({ project, onClick }: ProjectCardProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-cdc-project-id={project.id}
|
||||
data-cdc-project-country={project.bu}
|
||||
data-cdc-project-name={project.nombre || "Sin título"}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
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} />
|
||||
|
||||
<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 && (
|
||||
<span className="flex items-center gap-1" title="Propuestas">
|
||||
<Link2 className="w-3 h-3" /> {project.propuestaLinks.length}
|
||||
@@ -78,12 +106,16 @@ export function ProjectCard({ project, onClick }: ProjectCardProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isGerardo && project.monto != null && (
|
||||
<span className="text-[11px] font-medium text-foreground/70">
|
||||
${project.monto.toLocaleString()}
|
||||
<div className="flex items-center gap-2">
|
||||
{hasInternalAmount && (
|
||||
<span
|
||||
className="text-[11px] font-semibold tabular-nums text-foreground"
|
||||
title="Monto interno del proyecto"
|
||||
>
|
||||
{formatInternalAmount(internalAmount)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{project.status && (
|
||||
<Badge
|
||||
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 }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 text-xs">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
import { useCallback, useRef, useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Lock,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Trash2,
|
||||
Search,
|
||||
@@ -69,6 +68,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const WALMART_CONNECT_CLIENT = "Walmart Connect WMC";
|
||||
const WMC_CREATE_EXCLUDED_BU = new Set(["wmc"]);
|
||||
const ACTIVITY_PAGE_SIZE = 15;
|
||||
|
||||
function formatActivityDate(timestamp: number) {
|
||||
@@ -107,6 +107,8 @@ function toPricingInputItems(
|
||||
referenceMax: item.referenceMax,
|
||||
amount: item.amount,
|
||||
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 [pricingError, setPricingError] = useState<string | null>(null);
|
||||
const [internalAmountManuallyEdited, setInternalAmountManuallyEdited] = useState(false);
|
||||
const saveInFlightRef = useRef(false);
|
||||
|
||||
const selectedBrands = splitBrandValues(form.marca, lists.marcas);
|
||||
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 buOptions = dedupeOptions([...lists.bus, ...selectedCountries]);
|
||||
const statusOptions = dedupeOptions(ensureOption(lists.status, form.status)) as Status[];
|
||||
const filteredWmcBuOptions = buOptions.filter((b) =>
|
||||
normalizeOptionKey(b).includes(normalizeOptionKey(wmcCountrySearch)),
|
||||
);
|
||||
const filteredWmcBuOptions = buOptions.filter((b) => {
|
||||
const key = normalizeOptionKey(b);
|
||||
return !WMC_CREATE_EXCLUDED_BU.has(key) && key.includes(normalizeOptionKey(wmcCountrySearch));
|
||||
});
|
||||
const pricingTotal = getPricingTotal(pricingItems);
|
||||
|
||||
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]) =>
|
||||
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(() => {
|
||||
if (!canManageInternalPricing || !pricingItems.length || internalAmountManuallyEdited) return;
|
||||
|
||||
@@ -540,7 +557,9 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!requiredOk || saving) return;
|
||||
if (!requiredOk || saving || saveInFlightRef.current) return;
|
||||
|
||||
saveInFlightRef.current = true;
|
||||
|
||||
const safeForm = isGerardo
|
||||
? form
|
||||
@@ -587,7 +606,7 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
|
||||
if (failedSyncs.length === 0) {
|
||||
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 {
|
||||
toast.warning("Proyectos multipaís creados con aviso", {
|
||||
@@ -624,6 +643,7 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
description: message,
|
||||
});
|
||||
} finally {
|
||||
saveInFlightRef.current = false;
|
||||
setSaving(false);
|
||||
setSavingStep(null);
|
||||
}
|
||||
@@ -632,7 +652,13 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
const closed = !!form.status;
|
||||
|
||||
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">
|
||||
{/* Pestaña de color superior */}
|
||||
<div
|
||||
@@ -864,7 +890,7 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
|
||||
{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">
|
||||
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.
|
||||
</div>
|
||||
)}
|
||||
@@ -940,8 +966,9 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
|
||||
{canManageInternalPricing && (
|
||||
<ProjectPricingPanel
|
||||
client={form.cliente}
|
||||
items={pricingItems}
|
||||
onChange={setPricingItems}
|
||||
onChange={onPricingItemsChange}
|
||||
loading={pricingLoading}
|
||||
error={pricingError}
|
||||
/>
|
||||
@@ -986,32 +1013,6 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
)}
|
||||
</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>
|
||||
|
||||
{(formError || savingStep) && (
|
||||
@@ -1057,7 +1058,10 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={() => {
|
||||
if (saving || saveInFlightRef.current) return;
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={saving || deleting}
|
||||
>
|
||||
Cancelar
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Calculator, DollarSign, Plus, Trash2, Info } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SearchableSelect } from "@/components/board/SearchableSelect";
|
||||
import {
|
||||
TARIFF_SECTIONS,
|
||||
defaultTariffAmount,
|
||||
formatTariffRange,
|
||||
type TariffCatalogItem,
|
||||
type TariffLevel,
|
||||
type TariffWorkType,
|
||||
} from "@/data/tariff";
|
||||
import { useTariffCatalog } from "@/lib/tariffCatalog";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
client: string;
|
||||
items: ProjectPricingItemInput[];
|
||||
onChange: (items: ProjectPricingItemInput[]) => void;
|
||||
loading?: boolean;
|
||||
@@ -52,39 +54,130 @@ function clean(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function ProjectPricingPanel({ items, onChange, loading, error }: Props) {
|
||||
const [section, setSection] = useState<string>("grafico");
|
||||
const [serviceId, setServiceId] = useState<string>("");
|
||||
function getFixedLevel(item: TariffCatalogItem) {
|
||||
const workType = item.workTypes[0] || null;
|
||||
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 [levelId, setLevelId] = useState<string>("");
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
const [description, setDescription] = useState<string>("");
|
||||
|
||||
const [manualName, setManualName] = useState<string>("");
|
||||
const [manualDescription, setManualDescription] = useState<string>("");
|
||||
const [manualAmount, setManualAmount] = useState<string>("");
|
||||
const { catalog: tariffCatalog, loading: tariffLoading, error: tariffError } = useTariffCatalog();
|
||||
|
||||
const sectionOptions = TARIFF_SECTIONS.map((option) => option.label);
|
||||
const selectedSectionLabel = TARIFF_SECTIONS.find((option) => option.id === section)?.label || "";
|
||||
const {
|
||||
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(
|
||||
() =>
|
||||
tariffCatalog
|
||||
.filter((item) => item.section === section)
|
||||
.filter((item) => item.section === activeGeneralSection?.id)
|
||||
.map((item) => `${item.category} · ${item.service}`),
|
||||
[section, tariffCatalog],
|
||||
[activeGeneralSection?.id, tariffCatalog],
|
||||
);
|
||||
|
||||
const selectedItem = useMemo(() => {
|
||||
const selectedLabel = serviceId;
|
||||
return (
|
||||
const selectedItem = useMemo(
|
||||
() =>
|
||||
tariffCatalog.find(
|
||||
(item) =>
|
||||
item.section === section && `${item.category} · ${item.service}` === selectedLabel,
|
||||
) || null
|
||||
item.section === activeGeneralSection?.id &&
|
||||
`${item.category} · ${item.service}` === serviceLabel,
|
||||
) || null,
|
||||
[activeGeneralSection?.id, serviceLabel, tariffCatalog],
|
||||
);
|
||||
}, [section, serviceId, tariffCatalog]);
|
||||
|
||||
const selectedWorkType = useMemo(() => {
|
||||
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 resetTariffSelection = () => {
|
||||
setServiceId("");
|
||||
setServiceLabel("");
|
||||
setWorkTypeId("");
|
||||
setLevelId("");
|
||||
setAmount("");
|
||||
setDescription("");
|
||||
};
|
||||
|
||||
const onSectionChange = (label: string) => {
|
||||
const option = TARIFF_SECTIONS.find((item) => item.label === label);
|
||||
setSection(option?.id || "grafico");
|
||||
const onSectionChange = (name: string) => {
|
||||
const section = generalSections.find((item) => item.name === name);
|
||||
setSectionId(section?.id || generalSections[0]?.id || "");
|
||||
resetTariffSelection();
|
||||
};
|
||||
|
||||
const onServiceChange = (label: string) => {
|
||||
setServiceId(label);
|
||||
setServiceLabel(label);
|
||||
const item = tariffCatalog.find(
|
||||
(catalogItem) =>
|
||||
catalogItem.section === section &&
|
||||
catalogItem.section === activeGeneralSection?.id &&
|
||||
`${catalogItem.category} · ${catalogItem.service}` === label,
|
||||
);
|
||||
const firstWorkType = findDefaultWorkType(item);
|
||||
@@ -146,7 +239,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
};
|
||||
|
||||
const addTariffItem = () => {
|
||||
if (!selectedItem || !selectedWorkType || !selectedLevel) return;
|
||||
if (!selectedItem || !selectedWorkType || !selectedLevel || !activeGeneralSection) return;
|
||||
|
||||
const finalAmount = parseAmount(amount);
|
||||
if (finalAmount <= 0) return;
|
||||
@@ -164,6 +257,8 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
referenceMax: selectedLevel.max ?? null,
|
||||
amount: finalAmount,
|
||||
description: clean(description),
|
||||
tariffSectionId: activeGeneralSection.id,
|
||||
tariffCatalogId: selectedItem.id,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -171,9 +266,43 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
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 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;
|
||||
|
||||
@@ -181,7 +310,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
...items,
|
||||
{
|
||||
source: "manual",
|
||||
category: "Otros",
|
||||
category: contextName,
|
||||
serviceName: name,
|
||||
workType: "Manual",
|
||||
complexityLevel: "",
|
||||
@@ -190,6 +319,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
referenceMax: null,
|
||||
amount: finalAmount,
|
||||
description: clean(manualDescription),
|
||||
tariffSectionId: manualContext?.id,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -204,6 +334,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
|
||||
const selectedWorkTypeLabel = selectedWorkType?.label || "";
|
||||
const selectedLevelLabel = selectedLevel?.label || "";
|
||||
const combinedError = error || tariffError || sectionsError;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<h3 className="text-sm font-semibold">Tarifario / Estimación interna</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Agrega costos del tarifario o montos manuales. El total se guarda como Interno
|
||||
Cargado.
|
||||
{hasSpecialTariff
|
||||
? `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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-sm font-semibold text-primary">
|
||||
Total: {formatCurrency(total)}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{(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">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>{error || tariffError}</span>
|
||||
<span>{combinedError}</span>
|
||||
</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">
|
||||
{loading ? "Cargando costos guardados…" : "Cargando tarifario…"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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="flex items-center justify-between gap-2">
|
||||
<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">
|
||||
Referencia flexible. El Director Creativo decide el monto final.
|
||||
Referencia flexible. El usuario autorizado decide el monto final.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">Tarifario</Badge>
|
||||
@@ -255,23 +488,25 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
<div className="space-y-1.5">
|
||||
<Label>Sección</Label>
|
||||
<SearchableSelect
|
||||
value={selectedSectionLabel}
|
||||
options={sectionOptions}
|
||||
value={activeGeneralSection?.name || ""}
|
||||
options={generalSections.map((section) => section.name)}
|
||||
onValueChange={onSectionChange}
|
||||
placeholder="Seleccionar sección…"
|
||||
searchPlaceholder="Buscar sección…"
|
||||
emptyText="No hay secciones generales activas."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Ítem a cobrar</Label>
|
||||
<SearchableSelect
|
||||
value={serviceId}
|
||||
value={serviceLabel}
|
||||
options={serviceOptions}
|
||||
onValueChange={onServiceChange}
|
||||
placeholder="Seleccionar ítem…"
|
||||
searchPlaceholder="Buscar ítem…"
|
||||
emptyText="No se encontró ese ítem."
|
||||
disabled={!activeGeneralSection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -354,12 +589,20 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allowManual && (
|
||||
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -369,7 +612,11 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
<Input
|
||||
value={manualName}
|
||||
onChange={(event) => setManualName(event.target.value)}
|
||||
placeholder="Ej: Piezas adicionales"
|
||||
placeholder={
|
||||
manualContext
|
||||
? `Ej: Propuesta general ${manualContext.clientName}`
|
||||
: "Ej: Piezas adicionales"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -391,7 +638,11 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
<Textarea
|
||||
value={manualDescription}
|
||||
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}
|
||||
className="min-h-[110px] resize-y"
|
||||
/>
|
||||
@@ -410,6 +661,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
@@ -422,11 +674,20 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
|
||||
{items.length === 0 ? (
|
||||
<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 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
|
||||
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"
|
||||
@@ -434,7 +695,11 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={item.source === "manual" ? "outline" : "secondary"}>
|
||||
{item.source === "manual" ? "Manual" : "Tarifario"}
|
||||
{item.source === "manual"
|
||||
? "Manual"
|
||||
: itemSpecialSection
|
||||
? itemSpecialSection.clientName
|
||||
: "Tarifario"}
|
||||
</Badge>
|
||||
<p className="text-sm font-medium">{item.serviceName}</p>
|
||||
</div>
|
||||
@@ -469,7 +734,8 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { canonicalOptionLabel, dedupeOptions, normalizeOptionKey } from "@/lib/optionUtils";
|
||||
|
||||
function handleDropdownWheel(event: WheelEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
@@ -43,10 +44,23 @@ export function SearchableSelect({
|
||||
}: SearchableSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedLabel = useMemo(
|
||||
() => options.find((option) => option === value) ?? value,
|
||||
[options, value],
|
||||
);
|
||||
const normalizedOptions = useMemo(() => {
|
||||
const unique = new Map<string, string>();
|
||||
|
||||
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 (
|
||||
<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")} />
|
||||
<span className="whitespace-normal break-words font-medium">{clearLabel}</span>
|
||||
</CommandItem>
|
||||
{options.length > 0 && <CommandSeparator className="my-1" />}
|
||||
{normalizedOptions.length > 0 && <CommandSeparator className="my-1" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{options.map((option) => (
|
||||
{normalizedOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
key={normalizeOptionKey(option)}
|
||||
value={`${normalizeOptionKey(option)} ${option}`}
|
||||
onSelect={() => {
|
||||
onValueChange(option);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</CommandItem>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ interface AuthContextValue {
|
||||
canDeleteProjects: boolean;
|
||||
canManageInternalPricing: boolean;
|
||||
canControlPricingSummary: boolean;
|
||||
canManageTariffCatalog: boolean;
|
||||
loginWithGoogle: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
@@ -190,6 +191,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
() => access?.canControlPricingSummary === true,
|
||||
[access?.canControlPricingSummary],
|
||||
);
|
||||
const canManageTariffCatalog = useMemo(
|
||||
() => access?.canManageTariffCatalog === true,
|
||||
[access?.canManageTariffCatalog],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
@@ -201,6 +206,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
canDeleteProjects,
|
||||
canManageInternalPricing,
|
||||
canControlPricingSummary,
|
||||
canManageTariffCatalog,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
}}
|
||||
|
||||
@@ -88,6 +88,7 @@ export const BU_CM: Record<string, string> = {
|
||||
Vozez: "Nathalia",
|
||||
"GLM Digital": "Lil",
|
||||
IQM: "Lil",
|
||||
WMC: "Willy",
|
||||
};
|
||||
|
||||
export const BUS = Object.keys(BU_CM).sort();
|
||||
|
||||
@@ -17,7 +17,7 @@ export type TariffWorkType = {
|
||||
|
||||
export type TariffCatalogItem = {
|
||||
id: string;
|
||||
section: "grafico" | "estrategia";
|
||||
section: string;
|
||||
category: string;
|
||||
service: string;
|
||||
notes?: string;
|
||||
@@ -32,6 +32,9 @@ export const TARIFF_SECTIONS = [
|
||||
export const WORK_TYPE_DESIGN = "design_final_art";
|
||||
export const WORK_TYPE_ADAPTATION = "adaptation_final_art";
|
||||
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(
|
||||
id: string,
|
||||
@@ -78,6 +81,56 @@ function graphicWorkTypes({
|
||||
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[] = [
|
||||
{
|
||||
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) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type TableroCdcUserAccess = {
|
||||
canDeleteProjects: boolean;
|
||||
canManageInternalPricing: boolean;
|
||||
canControlPricingSummary: boolean;
|
||||
canManageTariffCatalog: boolean;
|
||||
};
|
||||
|
||||
type AccessRow = {
|
||||
@@ -18,6 +19,7 @@ type AccessRow = {
|
||||
can_delete_projects?: boolean | null;
|
||||
can_manage_internal_pricing?: boolean | null;
|
||||
can_control_pricing_summary?: boolean | null;
|
||||
can_manage_tariff_catalog?: boolean | null;
|
||||
};
|
||||
|
||||
function normalizeEmail(email: string | null | undefined): string {
|
||||
@@ -37,6 +39,7 @@ function normalizeAccessRow(row: AccessRow | null | undefined): TableroCdcUserAc
|
||||
canDeleteProjects: row.can_delete_projects === true,
|
||||
canManageInternalPricing: row.can_manage_internal_pricing === 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;
|
||||
|
||||
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")
|
||||
.select(
|
||||
"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)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
if (fallbackQuery.error) throw fallbackQuery.error;
|
||||
|
||||
return normalizeAccessRow(data as AccessRow | null);
|
||||
return normalizeAccessRow(fallbackQuery.data as AccessRow | null);
|
||||
}
|
||||
|
||||
export async function getCurrentTableroCdcAccess(): Promise<TableroCdcUserAccess> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { canonicalOptionLabel, dedupeOptions } from "@/lib/optionUtils";
|
||||
import { canonicalOptionLabel, dedupeOptions, normalizeOptionKey } from "@/lib/optionUtils";
|
||||
import {
|
||||
BUS as FALLBACK_BUS,
|
||||
BU_CM as FALLBACK_BU_CM,
|
||||
@@ -51,6 +51,16 @@ function uniqOptions(values: string[]) {
|
||||
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) {
|
||||
return String(value || "")
|
||||
.normalize("NFD")
|
||||
@@ -92,7 +102,7 @@ function buildLists(rows: AppListRow[]): AppLists {
|
||||
const marcas: string[] = [];
|
||||
const bus: string[] = [];
|
||||
const status: string[] = [];
|
||||
const buCm: Record<string, string> = { ...FALLBACK_BU_CM };
|
||||
const dynamicBuCm: Record<string, string> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.is_active === false) continue;
|
||||
@@ -111,7 +121,7 @@ function buildLists(rows: AppListRow[]): AppLists {
|
||||
if (category === "country_manager" && value) {
|
||||
const manager = display || value;
|
||||
if (manager && manager !== value) {
|
||||
buCm[value] = manager;
|
||||
dynamicBuCm[value] = manager;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,8 +129,8 @@ function buildLists(rows: AppListRow[]): AppLists {
|
||||
return {
|
||||
clientes: clientes.length ? uniqOptions(clientes) : uniqOptions(FALLBACK_LISTS.clientes),
|
||||
marcas: marcas.length ? uniqOptions(marcas) : uniqOptions(FALLBACK_LISTS.marcas),
|
||||
bus: bus.length ? uniqOptions(bus) : uniqOptions(FALLBACK_LISTS.bus),
|
||||
buCm,
|
||||
bus: bus.length ? cleanCountryOptions(bus) : uniqOptions(FALLBACK_LISTS.bus),
|
||||
buCm: bus.length ? dynamicBuCm : { ...FALLBACK_LISTS.buCm },
|
||||
status: (status.length ? uniqOptions(status) : uniqOptions(FALLBACK_LISTS.status)) as Status[],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
/* eslint-disable no-control-regex */
|
||||
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",
|
||||
);
|
||||
|
||||
export function cleanOptionLabel(value: string | null | undefined) {
|
||||
return String(value || "")
|
||||
.replace(INVISIBLE_CONTROL_CHARS, "")
|
||||
.replace(/\u00A0/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
@@ -24,7 +25,12 @@ export function canonicalOptionLabel(value: string | null | undefined) {
|
||||
const cleanValue = cleanOptionLabel(value);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getCurrentTableroCdcAccess } from "@/lib/accessControl";
|
||||
import {
|
||||
buildSmartFilterOptionsFromProjects,
|
||||
hasSmartFilter,
|
||||
projectMatchesSmartFilters,
|
||||
} from "@/lib/filterNormalization";
|
||||
import type { ColorId, Status } from "@/data/lists";
|
||||
|
||||
export interface Project {
|
||||
@@ -38,6 +43,23 @@ export interface ProjectActivity {
|
||||
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 interface ProjectPricingItemInput {
|
||||
@@ -52,6 +74,8 @@ export interface ProjectPricingItemInput {
|
||||
referenceMax?: number | null;
|
||||
amount: number;
|
||||
description?: string;
|
||||
tariffSectionId?: string;
|
||||
tariffCatalogId?: string;
|
||||
}
|
||||
|
||||
export interface ProjectPricingItem extends ProjectPricingItemInput {
|
||||
@@ -135,6 +159,20 @@ type DbProjectActivity = {
|
||||
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 = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
@@ -148,6 +186,8 @@ type DbProjectPricingItem = {
|
||||
reference_max: number | string | null;
|
||||
amount: number | string | null;
|
||||
description: string | null;
|
||||
tariff_section_id?: string | null;
|
||||
tariff_catalog_id?: 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 {
|
||||
const createdAt = row.created_at ? new Date(row.created_at).getTime() : Date.now();
|
||||
const amount = Number(row.amount ?? 0);
|
||||
@@ -360,6 +420,8 @@ function mapDbProjectPricingItem(row: DbProjectPricingItem): ProjectPricingItem
|
||||
referenceMax: Number.isFinite(referenceMax) ? referenceMax : null,
|
||||
amount: Number.isFinite(amount) ? amount : 0,
|
||||
description: row.description || "",
|
||||
tariffSectionId: row.tariff_section_id || "",
|
||||
tariffCatalogId: row.tariff_catalog_id || "",
|
||||
createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -754,6 +816,15 @@ async function replaceProjectLinks(
|
||||
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[]) {
|
||||
if (!Array.isArray(items)) return;
|
||||
|
||||
@@ -779,20 +850,47 @@ async function replaceProjectPricingItems(projectId: string, items?: ProjectPric
|
||||
reference_max: item.referenceMax ?? null,
|
||||
amount: Number(item.amount),
|
||||
description: item.description || "",
|
||||
tariff_section_id: item.tariffSectionId || null,
|
||||
tariff_catalog_id: item.tariffCatalogId || null,
|
||||
sort_order: index,
|
||||
}));
|
||||
|
||||
if (cleanItems.length === 0) return;
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
const insertResult = await supabase
|
||||
.from("tablero_cdc_project_pricing_items")
|
||||
.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[]> {
|
||||
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")
|
||||
.select(
|
||||
"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("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(
|
||||
@@ -828,6 +926,100 @@ export async function loadProjectActivities(
|
||||
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(
|
||||
description: string,
|
||||
actorName: string,
|
||||
@@ -1023,7 +1215,7 @@ function escapePostgrestFilterValue(value: string) {
|
||||
return value.replace(/[%_]/g, "\\$&").replace(/,/g, " ");
|
||||
}
|
||||
|
||||
function applyDirectFilters(query: DirectProjectQuery, params: ProjectQueryParams) {
|
||||
function applyDirectBaseFilters(query: DirectProjectQuery, params: ProjectQueryParams) {
|
||||
let nextQuery = query;
|
||||
|
||||
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) {
|
||||
const pattern = `%${escapePostgrestFilterValue(params.country)}%`;
|
||||
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> {
|
||||
const { data, error } = await supabase.rpc("tablero_cdc_get_projects_paginated", {
|
||||
p_tab: params.tab,
|
||||
@@ -1136,6 +1380,9 @@ async function loadProjects(params?: Partial<ProjectQueryParams>) {
|
||||
const canManageInternalPricing = await currentUserCanManageInternalPricing();
|
||||
let result: ProjectLoadResult;
|
||||
|
||||
if (hasSmartFilter(nextParams) || canManageInternalPricing) {
|
||||
result = await loadProjectsSmartDirect(nextParams, canManageInternalPricing);
|
||||
} else {
|
||||
try {
|
||||
result = await loadProjectsFromRpc(nextParams);
|
||||
} catch (rpcError) {
|
||||
@@ -1147,6 +1394,7 @@ async function loadProjects(params?: Partial<ProjectQueryParams>) {
|
||||
);
|
||||
result = await loadProjectsDirect(nextParams, canManageInternalPricing);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
params?: Partial<ProjectQueryParams>,
|
||||
): Promise<ProjectPricingSummary> {
|
||||
@@ -1434,6 +1733,10 @@ async function loadProjectPricingSummary(
|
||||
pageSize: 1000,
|
||||
});
|
||||
|
||||
if (hasSmartFilter(nextParams)) {
|
||||
return loadProjectPricingSummarySmartDirect(nextParams);
|
||||
}
|
||||
|
||||
try {
|
||||
return await loadProjectPricingSummaryFromRpc(nextParams);
|
||||
} catch (rpcError) {
|
||||
|
||||
@@ -13,7 +13,7 @@ export type TariffCatalogSource = "supabase" | "fallback";
|
||||
export type TariffCatalogRow = {
|
||||
id: string;
|
||||
catalog_item_id: string;
|
||||
section: "grafico" | "estrategia";
|
||||
section: string;
|
||||
category: string;
|
||||
service: string;
|
||||
notes: string;
|
||||
@@ -29,6 +29,7 @@ export type TariffCatalogRow = {
|
||||
level_hint: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
managed_by?: "sheet" | "app" | "system" | string;
|
||||
created_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 {
|
||||
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 service = String(row.service || "").trim();
|
||||
const catalogItemId = String(
|
||||
@@ -120,6 +121,7 @@ function normalizeDbRow(row: Record<string, unknown>): TariffCatalogRow {
|
||||
level_hint: String(row.level_hint || "").trim(),
|
||||
sort_order: Number(row.sort_order || 0),
|
||||
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,
|
||||
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 || "",
|
||||
sort_order: itemIndex * 1000 + workTypeIndex * 100 + levelIndex,
|
||||
is_active: true,
|
||||
managed_by: "system",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -240,7 +243,19 @@ export async function loadTariffCatalog(force = false): Promise<TariffCatalogSta
|
||||
notify();
|
||||
|
||||
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)
|
||||
.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",
|
||||
@@ -248,6 +263,10 @@ export async function loadTariffCatalog(force = false): Promise<TariffCatalogSta
|
||||
.order("sort_order", { ascending: true })
|
||||
.order("category", { ascending: true });
|
||||
|
||||
data = legacyQuery.data as Array<Record<string, unknown>> | null;
|
||||
error = legacyQuery.error;
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
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,
|
||||
sort_order: row.sort_order,
|
||||
is_active: row.is_active,
|
||||
managed_by: "app",
|
||||
},
|
||||
{ onConflict: "id" },
|
||||
);
|
||||
@@ -331,10 +351,70 @@ export async function upsertTariffCatalogRow(input: TariffCatalogUpsertInput) {
|
||||
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) {
|
||||
const { error } = await supabase
|
||||
.from(TABLE_NAME)
|
||||
.update({ is_active: isActive })
|
||||
.update({ is_active: isActive, managed_by: "app" })
|
||||
.eq("id", row.id);
|
||||
|
||||
if (error) throw error;
|
||||
@@ -361,6 +441,6 @@ export function useTariffCatalog() {
|
||||
|
||||
return {
|
||||
...cache,
|
||||
refresh: () => loadTariffCatalog(true),
|
||||
refresh: loadTariffCatalog,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -4,10 +4,14 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
BadgeDollarSign,
|
||||
DollarSign,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LayoutGrid,
|
||||
ListChecks,
|
||||
Inbox,
|
||||
FileX2,
|
||||
LogOut,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -27,17 +31,30 @@ import {
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { ProjectCard } from "@/components/board/ProjectCard";
|
||||
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 { 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 { useAppLists } from "@/lib/appLists";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { dedupeOptions } from "@/lib/optionUtils";
|
||||
import { expandCountryOptions, expandMultiValueOptions } from "@/lib/filterNormalization";
|
||||
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 */
|
||||
const PROJECTS_PER_PAGE = 24;
|
||||
|
||||
type BoardView = "nuevos" | ProjectTab | "no_aprobados";
|
||||
|
||||
type AdvancedFilters = {
|
||||
country: string;
|
||||
brand: string;
|
||||
@@ -66,37 +83,61 @@ export default function BoardPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Project | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState<"todos" | "abiertos" | "cerrados">("todos");
|
||||
const [filter, setFilter] = useState<BoardView>("todos");
|
||||
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(EMPTY_ADVANCED_FILTERS);
|
||||
const [pageByFilter, setPageByFilter] = useState({
|
||||
const [pageByFilter, setPageByFilter] = useState<Record<BoardView, number>>({
|
||||
nuevos: 1,
|
||||
todos: 1,
|
||||
abiertos: 1,
|
||||
cerrados: 1,
|
||||
no_aprobados: 1,
|
||||
});
|
||||
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 { lists } = useAppLists();
|
||||
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,
|
||||
page: currentPage,
|
||||
pageSize: PROJECTS_PER_PAGE,
|
||||
country: advancedFilters.country,
|
||||
brand: advancedFilters.brand,
|
||||
client: advancedFilters.client,
|
||||
cm: advancedFilters.cm,
|
||||
enabled: isBriefReviewView,
|
||||
});
|
||||
const { user, isGerardo, canControlPricingSummary, logout } = useAuth();
|
||||
const {
|
||||
user,
|
||||
isGerardo,
|
||||
canManageInternalPricing,
|
||||
canControlPricingSummary,
|
||||
canManageTariffCatalog,
|
||||
logout,
|
||||
} = useAuth();
|
||||
const briefUnread = useBriefUnreadCount(Boolean(user));
|
||||
const pricingSummaryParams = {
|
||||
tab: filter,
|
||||
search: query,
|
||||
tab: projectFilter,
|
||||
search: isBriefReviewView ? "" : query,
|
||||
page: 1,
|
||||
pageSize: PROJECTS_PER_PAGE,
|
||||
country: advancedFilters.country,
|
||||
brand: advancedFilters.brand,
|
||||
client: advancedFilters.client,
|
||||
cm: advancedFilters.cm,
|
||||
country: isBriefReviewView ? "" : advancedFilters.country,
|
||||
brand: isBriefReviewView ? "" : advancedFilters.brand,
|
||||
client: isBriefReviewView ? "" : advancedFilters.client,
|
||||
cm: isBriefReviewView ? "" : advancedFilters.cm,
|
||||
} as const;
|
||||
const {
|
||||
isPublic: isPricingSummaryPublic,
|
||||
@@ -105,7 +146,8 @@ export default function BoardPage() {
|
||||
error: pricingSummaryVisibilityError,
|
||||
setPublicVisibility: setPricingSummaryPublicVisibility,
|
||||
} = usePricingSummaryVisibility(Boolean(user));
|
||||
const canViewPricingSummary = canControlPricingSummary || isPricingSummaryPublic;
|
||||
const canViewPricingSummary =
|
||||
!isBriefReviewView && (canControlPricingSummary || isPricingSummaryPublic);
|
||||
const {
|
||||
summary: pricingSummary,
|
||||
loading: pricingSummaryLoading,
|
||||
@@ -115,31 +157,32 @@ export default function BoardPage() {
|
||||
const cmListOptions = Object.values(lists.buCm);
|
||||
const countryFilterOptions = dedupeOptions([
|
||||
...lists.bus,
|
||||
...filterOptions.countries,
|
||||
...expandCountryOptions(filterOptions.countries, lists.bus),
|
||||
advancedFilters.country,
|
||||
]);
|
||||
const brandFilterOptions = dedupeOptions([
|
||||
...lists.marcas,
|
||||
...filterOptions.brands,
|
||||
...expandMultiValueOptions(filterOptions.brands, lists.marcas),
|
||||
advancedFilters.brand,
|
||||
]);
|
||||
const clientFilterOptions = dedupeOptions([
|
||||
...lists.clientes,
|
||||
...filterOptions.clients,
|
||||
...expandMultiValueOptions(filterOptions.clients, lists.clientes),
|
||||
advancedFilters.client,
|
||||
]);
|
||||
const cmFilterOptions = dedupeOptions([
|
||||
...cmListOptions,
|
||||
...filterOptions.cms,
|
||||
...expandMultiValueOptions(filterOptions.cms, cmListOptions),
|
||||
advancedFilters.cm,
|
||||
]);
|
||||
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 pageStart = (safePage - 1) * PROJECTS_PER_PAGE;
|
||||
const visibleStart = totalProjects === 0 ? 0 : pageStart + 1;
|
||||
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, totalProjects);
|
||||
const visibleStart = currentTotalItems === 0 ? 0 : pageStart + 1;
|
||||
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, currentTotalItems);
|
||||
|
||||
useEffect(() => {
|
||||
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
|
||||
@@ -158,6 +201,18 @@ export default function BoardPage() {
|
||||
}
|
||||
}, [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 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 userRole = isGerardo ? "Jefe CDC" : "Usuario CDC";
|
||||
const userInitials = initials(user?.displayName, user?.email);
|
||||
@@ -225,7 +335,7 @@ export default function BoardPage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<GLMLogo size="sm" />
|
||||
<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>
|
||||
|
||||
{/* Search */}
|
||||
@@ -234,7 +344,7 @@ export default function BoardPage() {
|
||||
<Input
|
||||
value={query}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -286,6 +396,30 @@ export default function BoardPage() {
|
||||
</DropdownMenuContent>
|
||||
</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 */}
|
||||
<Button onClick={openNew} className="gap-1.5 h-9 rounded-full px-4 shadow-sm">
|
||||
<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="flex items-center justify-between gap-4 flex-wrap">
|
||||
<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 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"],
|
||||
["abiertos", "Activos"],
|
||||
["cerrados", "Cerrados"],
|
||||
["nuevos", "Nuevos"],
|
||||
["no_aprobados", "Rechazados"],
|
||||
] as const
|
||||
).map(([k, l]) => (
|
||||
<button
|
||||
key={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
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "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>
|
||||
))}
|
||||
</div>
|
||||
@@ -330,11 +490,12 @@ export default function BoardPage() {
|
||||
<Input
|
||||
value={query}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isBriefReviewView && (
|
||||
<AdvancedProjectFilters
|
||||
filters={advancedFilters}
|
||||
activeCount={activeAdvancedFilterCount}
|
||||
@@ -345,6 +506,7 @@ export default function BoardPage() {
|
||||
onFilterChange={setAdvancedFilter}
|
||||
onClear={clearAdvancedFilters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canViewPricingSummary && (
|
||||
<PricingSummaryCard
|
||||
@@ -367,50 +529,86 @@ export default function BoardPage() {
|
||||
|
||||
{/* Grid */}
|
||||
<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">
|
||||
<span className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
<span className="font-medium">No se pudieron cargar los proyectos.</span> {error}
|
||||
<span className="font-medium">
|
||||
{isBriefReviewView
|
||||
? "No se pudieron cargar los briefs."
|
||||
: "No se pudieron cargar los proyectos."}
|
||||
</span>{" "}
|
||||
{isBriefReviewView ? briefInbox.error : error}
|
||||
</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
|
||||
</Button>
|
||||
</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 />
|
||||
) : projects.length === 0 ? (
|
||||
<EmptyState
|
||||
onNew={openNew}
|
||||
hasProjects={
|
||||
totalProjects > 0 ||
|
||||
filter !== "todos" ||
|
||||
projectFilter !== "todos" ||
|
||||
query.trim().length > 0 ||
|
||||
activeAdvancedFilterCount > 0
|
||||
}
|
||||
filter={filter}
|
||||
filter={projectFilter}
|
||||
query={query}
|
||||
activeAdvancedFilterCount={activeAdvancedFilterCount}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{totalProjects > PROJECTS_PER_PAGE && (
|
||||
{currentTotalItems > PROJECTS_PER_PAGE && (
|
||||
<PaginationControls
|
||||
currentPage={safePage}
|
||||
totalPages={totalPages}
|
||||
visibleStart={visibleStart}
|
||||
visibleEnd={visibleEnd}
|
||||
totalItems={totalProjects}
|
||||
totalItems={currentTotalItems}
|
||||
itemLabel={isBriefReviewView ? "briefs" : "proyectos"}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
)}
|
||||
@@ -423,6 +621,27 @@ export default function BoardPage() {
|
||||
onDelete={deleteProject}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -632,6 +851,7 @@ function PaginationControls({
|
||||
visibleStart,
|
||||
visibleEnd,
|
||||
totalItems,
|
||||
itemLabel = "proyectos",
|
||||
onPageChange,
|
||||
}: {
|
||||
currentPage: number;
|
||||
@@ -639,6 +859,7 @@ function PaginationControls({
|
||||
visibleStart: number;
|
||||
visibleEnd: number;
|
||||
totalItems: number;
|
||||
itemLabel?: string;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
const pages = getVisiblePages(currentPage, totalPages);
|
||||
@@ -649,7 +870,7 @@ function PaginationControls({
|
||||
<span className="font-medium text-foreground">
|
||||
{visibleStart}-{visibleEnd}
|
||||
</span>{" "}
|
||||
de <span className="font-medium text-foreground">{totalItems}</span> proyectos
|
||||
de <span className="font-medium text-foreground">{totalItems}</span> {itemLabel}
|
||||
</p>
|
||||
|
||||
<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({
|
||||
onNew,
|
||||
hasProjects,
|
||||
@@ -766,7 +1018,7 @@ function EmptyState({
|
||||
}: {
|
||||
onNew: () => void;
|
||||
hasProjects: boolean;
|
||||
filter: "todos" | "abiertos" | "cerrados";
|
||||
filter: ProjectTab;
|
||||
query: string;
|
||||
activeAdvancedFilterCount: number;
|
||||
}) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -94,8 +94,8 @@ values
|
||||
'Alicia Thaía Reyes',
|
||||
'user',
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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;
|
||||
@@ -94,8 +94,8 @@ values
|
||||
'Alicia Thaía Reyes',
|
||||
'user',
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
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');
|
||||
@@ -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;
|
||||
@@ -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;
|
||||