fix: restaurar proyecto completo y actualizar dist final

This commit is contained in:
2026-08-13 11:15:00 -04:00
parent 7e41b2f26a
commit f4f6ee23df
102 changed files with 20834 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
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"
+38
View File
@@ -0,0 +1,38 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
.output
.vinxi
.tanstack/**
.nitro
*.local
# Environment variables — never commit secrets
.env
.env.local
.env.development
.env.production
# Wrangler / Cloudflare
.wrangler/
.dev.vars
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.output
.vinxi
pnpm-lock.yaml
package-lock.json
bun.lock
routeTree.gen.ts
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
+635
View File
@@ -0,0 +1,635 @@
# Tablero CDC — Project Management
> 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.
---
## INFORMACIÓN GENERAL
| Campo | Detalle |
|---|---|
| **Proyecto** | Tablero CDC / CDC Project Management |
| **Área** | Creatividad y Diseño — CDC |
| **Developer Principal** | Isaac Aracena |
| **IT Manager** | Luis Matos |
---
## OBJETIVO
### 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.
---
## ARQUITECTURA
### Diagrama de flujo principal
```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 |
---
## REGLAS DE NEGOCIO
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`.
2. **Los permisos especiales se controlan desde Supabase**, principalmente mediante:
- `can_delete_projects`
- `can_manage_internal_pricing`
- `can_control_pricing_summary`
- `can_manage_tariff_catalog`
3. **No se deben hardcodear administradores nuevos en el frontend.** Los accesos y permisos se administran en Supabase.
4. **Los tarifarios generales** están disponibles para los clientes que no dependen de un tarifario especial.
5. **Los tarifarios por cliente** solo aparecen al seleccionar el cliente asociado a esa sección.
6. **Walmart Connect WMC** utiliza un tarifario especial con 12 piezas de precio fijo y mantiene la posibilidad de agregar un costo manual para propuestas o conceptos no contemplados.
7. **Las tarifas administradas desde la app** quedan marcadas con `managed_by = 'app'`. El sincronizador del Sheet no debe sobrescribirlas.
8. **Las tarifas históricas no deben eliminarse como operación normal.** Se deben desactivar para conservar integridad histórica.
9. **Los costos seleccionados en un proyecto** se guardan en `tablero_cdc_project_pricing_items` y alimentan el total interno del proyecto.
10. **El Total Tarifado Global** se calcula mediante la RPC `tablero_cdc_get_pricing_summary` y respeta los filtros activos. Los proyectos creados con tarifarios actuales o futuros siguen formando parte del total al guardar su costo interno.
11. **La visibilidad del Total Tarifado** se controla mediante configuración y permisos de Supabase.
12. **La estructura del Google Sheet original debe mantenerse estable**, especialmente cuando es consumida por Power BI. Cualquier cambio estructural debe validarse previamente.
13. **Paginación actual:**
- proyectos: `24` por página;
- secciones del módulo de tarifario: `6` por página;
- tarifas / piezas del módulo: `10` por página.
14. Al cambiar de sección, buscar, cambiar entre **Generales / Por cliente** o activar **Mostrar inactivas**, el módulo de tarifario reajusta la página automáticamente.
---
## CONFIGURACIÓN Y SETUP
### Prerrequisitos
- Git.
- Node.js + npm.
- Acceso al repositorio interno en Gitea.
- Proyecto Supabase configurado.
- Google OAuth configurado en Supabase Auth.
- Acceso a SQL Editor de Supabase para instalaciones o migraciones.
- Acceso al workflow n8n correspondiente si se requiere sincronización con Google Sheets.
### Variables de entorno
Crear `.env` a partir de `.env.example`.
| Variable | Descripción | Dónde se obtiene |
|---|---|---|
| `VITE_SUPABASE_URL` | URL pública del proyecto Supabase | Supabase / infraestructura GLM |
| `VITE_SUPABASE_ANON_KEY` | Anon/Public Key usada por el frontend | Supabase |
| `VITE_WEBHOOK_URL` | Webhook de sincronización de proyectos | n8n |
Ejemplo:
```env
VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
VITE_SUPABASE_ANON_KEY="TU_ANON_PUBLIC_KEY"
VITE_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-proyecto"
```
> **Nunca commitear `.env`, `service_role`, secretos OAuth, contraseñas, tokens privados ni credenciales administrativas.**
La `anon key` del frontend no debe sustituirse por una `service_role`.
### Esquema de base de datos / SQL incluidos
El repositorio mantiene scripts SQL separados para las distintas capacidades:
```text
supabase_tablero_cdc_access_control.sql
supabase_tablero_cdc_alicia_permissions.sql
supabase_project_activity.sql
supabase_project_pricing_items.sql
supabase_realtime_projects.sql
supabase_rpc_projects_paginated.sql
supabase_pricing_summary_visibility.sql
supabase_tariff_catalog_and_safe_links.sql
supabase_walmart_connect_tariff.sql
supabase_tariff_admin_module.sql
```
#### Script principal del módulo de tarifario
Para una instalación que ya tenga la base anterior del Tablero CDC, el archivo:
```text
supabase_tariff_admin_module.sql
```
incorpora de forma idempotente el tarifario Walmart Connect y el módulo de administración del catálogo, incluyendo `can_manage_tariff_catalog`, las secciones dinámicas y sus políticas RLS.
Después de ejecutarlo, verificar que los usuarios administradores esperados tengan:
```text
can_manage_tariff_catalog = true
```
### Instalación local
```bash
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:
```bash
npm run dev
```
La configuración actual de Vite usa:
```text
http://localhost:3000/tablero-cdc/
```
### Build
```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
```bash
npm run preview
```
---
## INSTALACIÓN / DEPLOY
La aplicación está compilada para funcionar debajo de:
```text
/tablero-cdc/
```
Configuración en `vite.config.ts`:
```ts
base: "/tablero-cdc/";
```
### Deploy manual mediante `dist`
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:
```text
dist/
├── assets/
├── index.html
├── favicon.ico
└── demás archivos públicos generados
```
> `dist/assets/` es obligatorio. Sin esa carpeta, `index.html` no podrá cargar correctamente el JavaScript y CSS compilados.
Flujo recomendado:
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.
---
## CÓMO FUNCIONA
### Flujo paso a paso
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`.
### Tarifario general
El estimador puede utilizar secciones generales como:
- **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
```
El usuario puede seleccionar varias piezas y el subtotal se calcula automáticamente.
### Administración del tarifario
Los usuarios autorizados pueden:
- crear secciones generales;
- crear tarifarios por cliente;
- crear tarifas / piezas;
- editar secciones y tarifas;
- desactivar y reactivar;
- buscar;
- mostrar inactivas;
- paginar listas extensas.
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` |
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"css": "src/styles.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+433
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
import js from "@eslint/js";
import eslintPluginPrettier from "eslint-plugin-prettier/recommended";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", ".output", ".vinxi"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"no-restricted-imports": [
"error",
{
paths: [
{
name: "server-only",
message:
"TanStack Start does not use the Next.js `server-only` package. Rename the module to `*.server.ts` or mark it with `@tanstack/react-start/server-only`.",
},
],
},
],
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
eslintPluginPrettier,
);
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6523
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
{
"name": "project-flow-hub",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/supabase-js": "^2.87.1",
"@tailwindcss/vite": "^4.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.2",
"react-resizable-panels": "^4.6.5",
"react-router-dom": "^7.3.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.3.4",
"vaul": "^1.1.2",
"vite-tsconfig-paths": "^6.0.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"prettier": "^3.7.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.56.1",
"vite": "^6.2.1"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="transparent"/>
<text x="256" y="310" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="142" font-weight="700" fill="#6B8294">G<tspan fill="#61B72A">L</tspan>M</text>
</svg>

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

+99
View File
@@ -0,0 +1,99 @@
import React, { Component, ReactNode } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider, useAuth } from "@/context/AuthContext";
import { LoginScreen } from "@/components/LoginScreen";
import { GLMLogo } from "@/components/GLMLogo";
import BoardPage from "./pages/BoardPage";
import { Toaster } from "@/components/ui/sonner";
// ─── Error Boundary ──────────────────────────────────────────────────────────
interface EBProps {
children: ReactNode;
}
interface EBState {
hasError: boolean;
}
class ErrorBoundary extends Component<EBProps, EBState> {
constructor(props: EBProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Uncaught error:", error, info);
}
render() {
if (this.state.hasError) {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md text-center">
<h1 className="text-xl font-semibold tracking-tight text-foreground">
Ocurrió un error al cargar el tablero.
</h1>
<p className="mt-2 text-sm text-muted-foreground">Intenta recargar la página.</p>
<div className="mt-6">
<button
onClick={() => window.location.reload()}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Recargar
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
// ─── Auth Gate ───────────────────────────────────────────────────────────────
function AuthGate() {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<div className="animate-pulse">
<GLMLogo showWordmark={false} />
</div>
<p className="text-sm text-muted-foreground">Cargando tablero</p>
</div>
</div>
);
}
if (!user) {
return <LoginScreen />;
}
return (
<Routes>
<Route path="/*" element={<BoardPage />} />
</Routes>
);
}
// ─── App ─────────────────────────────────────────────────────────────────────
export function App() {
return (
<ErrorBoundary>
<AuthProvider>
<BrowserRouter basename="/tablero-cdc/">
<AuthGate />
<Toaster richColors position="top-right" />
</BrowserRouter>
</AuthProvider>
</ErrorBoundary>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { cn } from "@/lib/utils";
type GLMLogoProps = {
size?: "sm" | "lg";
showWordmark?: boolean;
className?: string;
};
export function GLMLogo({ size = "sm", className }: GLMLogoProps) {
const imageSize = size === "lg" ? "h-auto w-[168px]" : "h-auto w-[104px]";
const logoNudge = size === "lg" ? "-translate-x-1" : "";
return (
<div className={cn("inline-flex items-center", className)} aria-label="GLM">
<img
src={`${import.meta.env.BASE_URL}glm-logo.png`}
alt="GLM"
className={cn(imageSize, logoNudge)}
loading="eager"
decoding="async"
/>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { Button } from "@/components/ui/button";
import { useAuth } from "@/context/AuthContext";
import { GLMLogo } from "@/components/GLMLogo";
export function LoginScreen() {
const { loginWithGoogle, error, loading } = useAuth();
return (
<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">
<GLMLogo size="lg" />
<h1 className="text-2xl font-bold tracking-tight text-[#4F758B]">Tablero CDC</h1>
</div>
{/* Card */}
<div className="bg-card border border-border rounded-2xl p-8 shadow-sm">
<Button
onClick={loginWithGoogle}
disabled={loading}
className="w-full gap-2.5 h-10 rounded-full font-medium"
>
{/* Google G icon */}
<svg aria-hidden="true" className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Entrar con Google
</Button>
{error && (
<div className="mt-4 rounded-lg bg-destructive/10 border border-destructive/20 px-4 py-3">
<p className="text-sm text-destructive text-center leading-snug">{error}</p>
</div>
)}
</div>
<p className="text-center text-xs text-muted-foreground mt-6">
Acceso restringido a usuarios autorizados
</p>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { COLORS, type ColorId } from "@/data/lists";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export function ColorPicker({
value,
onChange,
}: {
value: ColorId;
onChange: (c: ColorId) => void;
}) {
return (
<div className="flex flex-wrap gap-2">
{COLORS.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onChange(c.id)}
title={c.name}
className={cn(
"w-9 h-9 rounded-full flex items-center justify-center transition-all ring-offset-2 ring-offset-background hover:scale-110",
value === c.id && "ring-2 ring-foreground/40 scale-110",
)}
style={{ backgroundColor: c.hex }}
>
{value === c.id && <Check className="w-4 h-4 text-white drop-shadow" />}
</button>
))}
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useState } from "react";
import { Link as LinkIcon, ExternalLink, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface Props {
label: string;
prefix?: string; // ej "AF"
links: string[];
onChange: (next: string[]) => void;
readOnly?: boolean;
emptyText?: string;
}
export function LinkList({ label, prefix, links, onChange, readOnly, emptyText }: Props) {
const [draft, setDraft] = useState("");
const add = () => {
const v = draft.trim();
if (!v) return;
onChange([...links, v]);
setDraft("");
};
const update = (i: number, v: string) => onChange(links.map((x, idx) => (idx === i ? v : x)));
const remove = (i: number) => onChange(links.filter((_, idx) => idx !== i));
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{label}{" "}
{prefix && <span className="text-xs text-muted-foreground font-normal">({prefix})</span>}
</span>
<span className="text-xs text-muted-foreground">{links.length}</span>
</div>
{links.length === 0 && (
<p className="text-xs text-muted-foreground italic px-1">{emptyText ?? "Sin links aún"}</p>
)}
<div className="space-y-1.5">
{links.map((l, i) => (
<div key={i} className="group flex items-center gap-1.5">
<div className="flex-shrink-0 w-7 h-7 rounded-md bg-accent flex items-center justify-center text-[10px] font-semibold text-accent-foreground">
{prefix ?? "·"}
{i + 1}
</div>
{readOnly ? (
<a
href={l}
target="_blank"
rel="noreferrer"
className="flex-1 text-sm text-primary hover:underline truncate flex items-center gap-1.5 py-1.5 px-2 rounded-md hover:bg-accent/60 transition-colors"
>
<LinkIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{l}</span>
<ExternalLink className="w-3 h-3 flex-shrink-0 opacity-60" />
</a>
) : (
<>
<Input
value={l}
onChange={(e) => update(i, e.target.value)}
placeholder="https://…"
className="h-9 text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => remove(i)}
>
<X className="w-4 h-4" />
</Button>
</>
)}
</div>
))}
</div>
{!readOnly && (
<div className="flex gap-1.5 pt-1">
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), add())}
placeholder="Pegar link y Enter"
className="h-9 text-sm"
/>
<Button type="button" variant="outline" size="sm" onClick={add} className="h-9">
<Plus className="w-4 h-4 mr-1" /> Añadir
</Button>
</div>
)}
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { MapPin, User, Tag, Link2, FileCheck2, CheckCircle2 } 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;
}
export function ProjectCard({ project, onClick }: ProjectCardProps) {
const closed = !!project.status;
const { isGerardo } = useAuth();
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
};
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
className={cn(
"group block text-left w-full rounded-2xl bg-card overflow-hidden p-0 ring-1 ring-border cursor-pointer",
"transition-all duration-200 hover:-translate-y-0.5",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
closed && "opacity-65 hover:opacity-90",
)}
style={{
boxShadow: closed ? "0 1px 2px oklch(0.5 0.02 280 / 0.04)" : "var(--shadow-soft)",
}}
onMouseEnter={(e) => {
if (!closed) e.currentTarget.style.boxShadow = "var(--shadow-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = closed
? "0 1px 2px oklch(0.5 0.02 280 / 0.04)"
: "var(--shadow-soft)";
}}
>
{/* Pestaña de color con título */}
<div
className="min-h-14 rounded-t-2xl px-4 py-3"
style={{
backgroundColor: closed ? "#9AA0A6" : colorHex(project.color),
}}
>
<h3 className="text-white font-semibold text-sm leading-snug line-clamp-2 drop-shadow-sm">
{project.nombre || "Sin título"}
</h3>
</div>
{/* Cuerpo blanco */}
<div className="p-4 space-y-2.5">
<Row icon={<Tag className="w-3.5 h-3.5" />} label="Cliente" value={project.cliente} />
<Row icon={<Tag className="w-3.5 h-3.5" />} label="Marca" value={project.marca} />
<Row icon={<MapPin className="w-3.5 h-3.5" />} label="País" value={project.bu} />
<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">
{project.propuestaLinks.length > 0 && (
<span className="flex items-center gap-1" title="Propuestas">
<Link2 className="w-3 h-3" /> {project.propuestaLinks.length}
</span>
)}
{project.afLinks.length > 0 && (
<span className="flex items-center gap-1" title="Artes finales">
<FileCheck2 className="w-3 h-3" /> AF·{project.afLinks.length}
</span>
)}
</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()}
</span>
)}
{project.status && (
<Badge
variant="secondary"
className={cn(
"text-[10px] px-1.5 py-0 h-5 font-medium",
project.status === "Aprobado" &&
"bg-emerald-100 text-emerald-800 hover:bg-emerald-100",
project.status === "No aprobado" && "bg-red-100 text-red-800 hover:bg-red-100",
)}
>
{project.status === "Aprobado" && <CheckCircle2 className="w-2.5 h-2.5 mr-0.5" />}
{project.status}
</Badge>
)}
</div>
</div>
</div>
</div>
);
}
function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="flex items-start gap-2 text-xs">
<span className="text-muted-foreground mt-0.5 flex-shrink-0">{icon}</span>
<span className="text-muted-foreground w-14 flex-shrink-0">{label}</span>
<span className="text-foreground font-medium truncate">{value || "—"}</span>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,485 @@
import { useMemo, useState } from "react";
import { Calculator, DollarSign, Plus, Trash2, Info } from "lucide-react";
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 { cn } from "@/lib/utils";
interface Props {
items: ProjectPricingItemInput[];
onChange: (items: ProjectPricingItemInput[]) => void;
loading?: boolean;
error?: string | null;
}
function parseAmount(value: string) {
const parsed = Number(value.replace(/,/g, "").trim());
return Number.isFinite(parsed) ? parsed : 0;
}
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(value || 0);
}
function findDefaultWorkType(item?: TariffCatalogItem | null) {
return item?.workTypes[0] || null;
}
function findDefaultLevel(workType?: TariffWorkType | null) {
return workType?.levels[0] || null;
}
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>("");
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 serviceOptions = useMemo(
() =>
tariffCatalog
.filter((item) => item.section === section)
.map((item) => `${item.category} · ${item.service}`),
[section, tariffCatalog],
);
const selectedItem = useMemo(() => {
const selectedLabel = serviceId;
return (
tariffCatalog.find(
(item) =>
item.section === section && `${item.category} · ${item.service}` === selectedLabel,
) || null
);
}, [section, serviceId, tariffCatalog]);
const selectedWorkType = useMemo(() => {
if (!selectedItem) return null;
return selectedItem.workTypes.find((workType) => workType.id === workTypeId) || null;
}, [selectedItem, workTypeId]);
const selectedLevel = useMemo(() => {
if (!selectedWorkType) return null;
return selectedWorkType.levels.find((level) => level.id === levelId) || null;
}, [selectedWorkType, levelId]);
const total = items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const resetTariffSelection = () => {
setServiceId("");
setWorkTypeId("");
setLevelId("");
setAmount("");
setDescription("");
};
const onSectionChange = (label: string) => {
const option = TARIFF_SECTIONS.find((item) => item.label === label);
setSection(option?.id || "grafico");
resetTariffSelection();
};
const onServiceChange = (label: string) => {
setServiceId(label);
const item = tariffCatalog.find(
(catalogItem) =>
catalogItem.section === section &&
`${catalogItem.category} · ${catalogItem.service}` === label,
);
const firstWorkType = findDefaultWorkType(item);
const firstLevel = findDefaultLevel(firstWorkType);
setWorkTypeId(firstWorkType?.id || "");
setLevelId(firstLevel?.id || "");
setAmount(defaultTariffAmount(firstLevel));
setDescription("");
};
const onWorkTypeChange = (label: string) => {
if (!selectedItem) return;
const workType = selectedItem.workTypes.find((item) => item.label === label);
const firstLevel = findDefaultLevel(workType);
setWorkTypeId(workType?.id || "");
setLevelId(firstLevel?.id || "");
setAmount(defaultTariffAmount(firstLevel));
};
const onLevelChange = (label: string) => {
if (!selectedWorkType) return;
const level = selectedWorkType.levels.find((item) => item.label === label);
setLevelId(level?.id || "");
setAmount(defaultTariffAmount(level));
};
const addTariffItem = () => {
if (!selectedItem || !selectedWorkType || !selectedLevel) return;
const finalAmount = parseAmount(amount);
if (finalAmount <= 0) return;
onChange([
...items,
{
source: "tariff",
category: selectedItem.category,
serviceName: selectedItem.service,
workType: selectedWorkType.label,
complexityLevel: selectedLevel.label,
referenceLabel: formatTariffRange(selectedLevel),
referenceMin: selectedLevel.min ?? null,
referenceMax: selectedLevel.max ?? null,
amount: finalAmount,
description: clean(description),
},
]);
setDescription("");
setAmount(defaultTariffAmount(selectedLevel));
};
const addManualItem = () => {
const finalAmount = parseAmount(manualAmount);
const name = clean(manualName) || "Otros / costo manual";
if (finalAmount <= 0) return;
onChange([
...items,
{
source: "manual",
category: "Otros",
serviceName: name,
workType: "Manual",
complexityLevel: "",
referenceLabel: "Monto manual",
referenceMin: null,
referenceMax: null,
amount: finalAmount,
description: clean(manualDescription),
},
]);
setManualName("");
setManualDescription("");
setManualAmount("");
};
const removeItem = (index: number) => {
onChange(items.filter((_, itemIndex) => itemIndex !== index));
};
const selectedWorkTypeLabel = selectedWorkType?.label || "";
const selectedLevelLabel = selectedLevel?.label || "";
return (
<div className="md:col-span-2 rounded-2xl border border-border bg-muted/20 p-4">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-2.5">
<span className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<Calculator className="h-4 w-4" />
</span>
<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.
</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>
</div>
{(error || tariffError) && (
<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>
</div>
)}
{(loading || tariffLoading) && (
<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">
<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-xs text-muted-foreground">
Referencia flexible. El Director Creativo decide el monto final.
</p>
</div>
<Badge variant="secondary">Tarifario</Badge>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label>Sección</Label>
<SearchableSelect
value={selectedSectionLabel}
options={sectionOptions}
onValueChange={onSectionChange}
placeholder="Seleccionar sección…"
searchPlaceholder="Buscar sección…"
/>
</div>
<div className="space-y-1.5">
<Label>Ítem a cobrar</Label>
<SearchableSelect
value={serviceId}
options={serviceOptions}
onValueChange={onServiceChange}
placeholder="Seleccionar ítem…"
searchPlaceholder="Buscar ítem…"
emptyText="No se encontró ese ítem."
/>
</div>
<div className="space-y-1.5">
<Label>Tipo de trabajo</Label>
<SearchableSelect
value={selectedWorkTypeLabel}
options={selectedItem?.workTypes.map((workType) => workType.label) || []}
onValueChange={onWorkTypeChange}
placeholder="Seleccionar tipo…"
searchPlaceholder="Buscar tipo…"
disabled={!selectedItem}
/>
</div>
<div className="space-y-1.5">
<Label>Nivel / referencia</Label>
<SearchableSelect
value={selectedLevelLabel}
options={selectedWorkType?.levels.map((item) => item.label) || []}
onValueChange={onLevelChange}
placeholder="Seleccionar nivel…"
searchPlaceholder="Buscar nivel…"
disabled={!selectedWorkType}
/>
</div>
</div>
{selectedItem && selectedWorkType && selectedLevel && (
<div className="rounded-xl border border-dashed border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<p>
<span className="font-medium text-foreground">Referencia:</span>{" "}
{formatTariffRange(selectedLevel)} USD
{selectedWorkType.hourReference
? ` · Hora hombre ref.: ${selectedWorkType.hourReference}`
: ""}
</p>
{selectedItem.notes && <p className="mt-1">{selectedItem.notes}</p>}
{selectedLevel.hint && <p className="mt-1">{selectedLevel.hint}</p>}
</div>
)}
<div className="grid grid-cols-1 gap-3 md:grid-cols-[220px_minmax(0,1fr)]">
<div className="space-y-1.5">
<Label>Monto final</Label>
<Input
type="number"
min="0"
step="0.01"
value={amount}
onChange={(event) => setAmount(event.target.value)}
placeholder="0.00"
disabled={!selectedLevel}
/>
</div>
<div className="space-y-1.5">
<Label>Observación</Label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Ej: más rondas de cambios, piezas adicionales, complejidad especial…"
disabled={!selectedLevel}
rows={5}
className="min-h-[132px] resize-y"
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={addTariffItem}
disabled={
!selectedItem || !selectedWorkType || !selectedLevel || parseAmount(amount) <= 0
}
className="gap-1.5 rounded-full"
>
<Plus className="h-4 w-4" />
Agregar tarifa
</Button>
</div>
</div>
<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-xs text-muted-foreground">
Para otros, adicionales o casos fuera del tarifario.
</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-1.5">
<Label>Nombre</Label>
<Input
value={manualName}
onChange={(event) => setManualName(event.target.value)}
placeholder="Ej: Piezas adicionales"
/>
</div>
<div className="space-y-1.5">
<Label>Monto</Label>
<Input
type="number"
min="0"
step="0.01"
value={manualAmount}
onChange={(event) => setManualAmount(event.target.value)}
placeholder="0.00"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Descripción</Label>
<Textarea
value={manualDescription}
onChange={(event) => setManualDescription(event.target.value)}
placeholder="Ej: Cliente pidió 8 piezas adicionales fuera del paquete."
rows={4}
className="min-h-[110px] resize-y"
/>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={addManualItem}
disabled={parseAmount(manualAmount) <= 0}
className="w-full gap-1.5 rounded-full sm:w-auto sm:min-w-[240px]"
>
<Plus className="h-4 w-4" />
Agregar costo manual
</Button>
</div>
</div>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">Costos agregados</p>
<p className="text-xs text-muted-foreground">{items.length} línea(s)</p>
</div>
{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.
</div>
) : (
<div className="space-y-2">
{items.map((item, index) => (
<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"
>
<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"}
</Badge>
<p className="text-sm font-medium">{item.serviceName}</p>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{item.category && <span>{item.category}</span>}
{item.workType && <span>{item.workType}</span>}
{item.complexityLevel && <span>{item.complexityLevel}</span>}
{item.referenceLabel && <span>Ref.: {item.referenceLabel}</span>}
</div>
{item.description && (
<p className="mt-2 text-sm text-muted-foreground">{item.description}</p>
)}
</div>
<div className="flex items-center justify-between gap-3 sm:flex-col sm:items-end">
<p className="text-sm font-semibold text-foreground">
{formatCurrency(Number(item.amount || 0))}
</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeItem(index)}
className={cn(
"h-8 rounded-full px-2 text-muted-foreground hover:text-destructive",
)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Quitar
</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" />
<span className="text-sm font-semibold">
Total interno estimado: {formatCurrency(total)}
</span>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,150 @@
import { useMemo, useState, type WheelEvent } from "react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { canonicalOptionLabel, dedupeOptions, normalizeOptionKey } from "@/lib/optionUtils";
import { cn } from "@/lib/utils";
function handleDropdownWheel(event: WheelEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
event.currentTarget.scrollTop += event.deltaY;
}
interface SearchableMultiSelectProps {
values: string[];
options: string[];
onValuesChange: (values: string[]) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
summaryLabel?: string;
}
function cleanValues(values: string[]) {
return dedupeOptions(values.map((value) => canonicalOptionLabel(value)).filter(Boolean));
}
export function SearchableMultiSelect({
values,
options,
onValuesChange,
placeholder = "Seleccionar…",
searchPlaceholder = "Buscar…",
emptyText = "No hay resultados.",
disabled = false,
summaryLabel = "opciones seleccionadas",
}: SearchableMultiSelectProps) {
const [open, setOpen] = useState(false);
const selectedValues = useMemo(() => cleanValues(values), [values]);
const normalizedSelected = useMemo(
() => new Set(selectedValues.map((value) => normalizeOptionKey(value))),
[selectedValues],
);
const cleanOptions = useMemo(
() =>
dedupeOptions([...options, ...selectedValues].map((option) => canonicalOptionLabel(option))),
[options, selectedValues],
);
const selectedSummary = useMemo(() => {
if (selectedValues.length === 0) return "";
if (selectedValues.length <= 2) return selectedValues.join(", ");
return `${selectedValues.length} ${summaryLabel}`;
}, [selectedValues, summaryLabel]);
const updateValues = (nextValues: string[]) => {
onValuesChange(cleanValues(nextValues));
};
const toggleOption = (option: string) => {
const cleanOption = canonicalOptionLabel(option);
const optionKey = normalizeOptionKey(cleanOption);
if (normalizedSelected.has(optionKey)) {
updateValues(selectedValues.filter((value) => normalizeOptionKey(value) !== optionKey));
return;
}
updateValues([...selectedValues, cleanOption]);
};
const removeOption = (option: string) => {
const optionKey = normalizeOptionKey(option);
updateValues(selectedValues.filter((value) => normalizeOptionKey(value) !== optionKey));
};
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"min-h-10 w-full justify-between rounded-md border-border bg-background px-3 font-normal hover:bg-background",
!selectedSummary && "text-muted-foreground",
)}
>
<span className="truncate text-left">{selectedSummary || placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList className="overscroll-contain" onWheel={handleDropdownWheel}>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{cleanOptions.map((option) => {
const selected = normalizedSelected.has(normalizeOptionKey(option));
return (
<CommandItem key={option} value={option} onSelect={() => toggleOption(option)}>
<Check
className={cn("mr-2 h-4 w-4", selected ? "opacity-100" : "opacity-0")}
/>
<span className="truncate">{option}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedValues.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedValues.map((value) => (
<Badge key={value} variant="secondary" className="gap-1 rounded-full px-2 py-0.5">
<span className="max-w-[190px] truncate">{value}</span>
<button
type="button"
onClick={() => removeOption(value)}
className="rounded-full text-muted-foreground transition-colors hover:text-foreground"
aria-label={`Quitar ${value}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useMemo, useState, type WheelEvent } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
function handleDropdownWheel(event: WheelEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
event.currentTarget.scrollTop += event.deltaY;
}
interface SearchableSelectProps {
value: string;
options: string[];
onValueChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
clearLabel?: string;
}
export function SearchableSelect({
value,
options,
onValueChange,
placeholder = "Seleccionar…",
searchPlaceholder = "Buscar…",
emptyText = "No hay resultados.",
disabled = false,
clearLabel,
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const selectedLabel = useMemo(
() => options.find((option) => option === value) ?? value,
[options, value],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"h-10 w-full justify-between rounded-md border-border bg-background px-3 font-normal hover:bg-background",
!selectedLabel && "text-muted-foreground",
)}
>
<span className="truncate">{selectedLabel || placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[min(92vw,560px)] min-w-[var(--radix-popover-trigger-width)] p-0"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList className="overscroll-contain" onWheel={handleDropdownWheel}>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{clearLabel && (
<>
<CommandItem
key="__clear__"
value={`__clear_${clearLabel}`}
onSelect={() => {
onValueChange("");
setOpen(false);
}}
>
<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" />}
</>
)}
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => {
onValueChange(option);
setOpen(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")}
/>
<span className="whitespace-normal break-words leading-snug">{option}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+51
View File
@@ -0,0 +1,51 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium cursor-pointer transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+115
View File
@@ -0,0 +1,115 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
+5
View File
@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };
+47
View File
@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+32
View File
@@ -0,0 +1,32 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+101
View File
@@ -0,0 +1,101 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+177
View File
@@ -0,0 +1,177 @@
"use client";
import * as React from "react";
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next,
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root,
),
dropdown: cn("bg-popover absolute inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday,
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn("w-(--cell-size) select-none", defaultClassNames.week_number_header),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number,
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day,
),
range_start: cn("bg-accent rounded-l-md", defaultClassNames.range_start),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
}
if (orientation === "right") {
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
}
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+240
View File
@@ -0,0 +1,240 @@
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
});
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
+331
View File
@@ -0,0 +1,331 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("grid place-content-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+11
View File
@@ -0,0 +1,11 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+143
View File
@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+187
View File
@@ -0,0 +1,187 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin)",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin)",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+104
View File
@@ -0,0 +1,104 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background cursor-pointer transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+98
View File
@@ -0,0 +1,98 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
+188
View File
@@ -0,0 +1,188 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin)",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin)",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+171
View File
@@ -0,0 +1,171 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
if (!itemContext) {
throw new Error("useFormField should be used within <FormItem>");
}
const fieldState = getFieldState(fieldContext.name, formState);
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue | null>(null);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-hover-card-content-transform-origin)",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
+69
View File
@@ -0,0 +1,69 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Minus } from "lucide-react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
));
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
));
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+21
View File
@@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+229
View File
@@ -0,0 +1,229 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
function MenubarMenu({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu {...props} />;
}
function MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group {...props} />;
}
function MenubarPortal({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal {...props} />;
}
function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return <MenubarPrimitive.RadioGroup {...props} />;
}
function MenubarSub({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
className,
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className,
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-menubar-content-transform-origin)",
className,
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-menubar-content-transform-origin)",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
));
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};
+98
View File
@@ -0,0 +1,98 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { ButtonProps, buttonVariants } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
),
);
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
({ className, ...props }, ref) => <li ref={ref} className={cn("", className)} {...props} />,
);
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+25
View File
@@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow cursor-pointer focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
+37
View File
@@ -0,0 +1,37 @@
import { GripVertical } from "lucide-react";
import { Group, Panel, Separator } from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof Group>) => (
<Group
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
{...props}
/>
);
const ResizablePanel = Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof Separator> & {
withHandle?: boolean;
}) => (
<Separator
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</Separator>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
+44
View File
@@ -0,0 +1,44 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+152
View File
@@ -0,0 +1,152 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background cursor-pointer data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-select-content-transform-origin)",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+122
View File
@@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background cursor-pointer transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+744
View File
@@ -0,0 +1,744 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref,
) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
},
);
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref,
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
},
);
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(
({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
},
);
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
});
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
},
);
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
},
);
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
},
);
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
},
);
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring cursor-pointer transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
),
);
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
),
);
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
),
);
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring cursor-pointer transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring cursor-pointer transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
({ ...props }, ref) => <li ref={ref} {...props} />,
);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring cursor-pointer hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
+7
View File
@@ -0,0 +1,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-primary/10", className)} {...props} />;
}
export { Skeleton };
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };
+23
View File
@@ -0,0 +1,23 @@
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+94
View File
@@ -0,0 +1,94 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
));
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background cursor-pointer transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
+57
View File
@@ -0,0 +1,57 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium cursor-pointer transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };
+32
View File
@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin)",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+219
View File
@@ -0,0 +1,219 @@
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
useCallback,
type ReactNode,
} from "react";
import type { User as SupabaseUser } from "@supabase/supabase-js";
import { supabase } from "@/lib/supabase";
import { getActiveTableroCdcAccess, type TableroCdcUserAccess } from "@/lib/accessControl";
function getRedirectTo(): string {
if (typeof window === "undefined") return "/tablero-cdc/";
return new URL(import.meta.env.BASE_URL || "/", window.location.origin).toString();
}
export interface AuthUser {
id: string;
uid: string;
email: string | null;
displayName: string | null;
photoURL: string | null;
raw: SupabaseUser;
}
function normalizeSupabaseUser(user: SupabaseUser): AuthUser {
const metadata = user.user_metadata || {};
const displayName =
metadata.full_name ||
metadata.name ||
metadata.display_name ||
user.email?.split("@")[0] ||
null;
const photoURL = metadata.avatar_url || metadata.picture || null;
return {
id: user.id,
uid: user.id,
email: user.email ?? null,
displayName,
photoURL,
raw: user,
};
}
interface AuthContextValue {
user: AuthUser | null;
loading: boolean;
error: string | null;
isGerardo: boolean;
canDeleteProjects: boolean;
canManageInternalPricing: boolean;
canControlPricingSummary: boolean;
loginWithGoogle: () => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [access, setAccess] = useState<TableroCdcUserAccess | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const applyUser = useCallback(async (supabaseUser: SupabaseUser | null) => {
if (!supabaseUser) {
setUser(null);
setAccess(null);
return;
}
let nextAccess: TableroCdcUserAccess | null = null;
try {
nextAccess = await getActiveTableroCdcAccess(supabaseUser.email);
} catch (accessError) {
console.error("Error al validar acceso al Tablero CDC:", accessError);
await supabase.auth.signOut();
setUser(null);
setAccess(null);
setError(
"No se pudo validar tu acceso al Tablero CDC. Verifica que el SQL de accesos esté ejecutado o contacta a IT.",
);
return;
}
if (!nextAccess) {
await supabase.auth.signOut();
setUser(null);
setAccess(null);
setError(
"Tu correo no está autorizado para acceder al Tablero CDC. Solicita acceso a IT o al equipo CDC.",
);
return;
}
setUser(normalizeSupabaseUser(supabaseUser));
setAccess(nextAccess);
setError(null);
}, []);
useEffect(() => {
let mounted = true;
supabase.auth
.getSession()
.then(async ({ data, error: sessionError }) => {
if (!mounted) return;
if (sessionError) {
console.error("Error al recuperar sesión de Supabase:", sessionError);
setUser(null);
setError("No se pudo recuperar la sesión. Intenta iniciar sesión de nuevo.");
return;
}
await applyUser(data.session?.user ?? null);
})
.catch((err: unknown) => {
if (!mounted) return;
console.error("Error inesperado al recuperar sesión:", err);
setUser(null);
setError("No se pudo recuperar la sesión. Intenta iniciar sesión de nuevo.");
})
.finally(() => {
if (mounted) setLoading(false);
});
const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
setLoading(true);
void applyUser(session?.user ?? null).finally(() => {
if (mounted) setLoading(false);
});
});
return () => {
mounted = false;
listener.subscription.unsubscribe();
};
}, [applyUser]);
const loginWithGoogle = useCallback(async () => {
setError(null);
const { error: loginError } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: getRedirectTo(),
queryParams: {
prompt: "select_account",
},
},
});
if (loginError) {
console.error("Error al iniciar sesión con Google:", loginError);
setError("No se pudo iniciar sesión. Intenta de nuevo.");
}
}, []);
const logout = useCallback(async () => {
const { error: logoutError } = await supabase.auth.signOut();
if (logoutError) {
console.error("Error al cerrar sesión:", logoutError);
setError("No se pudo cerrar sesión. Intenta de nuevo.");
return;
}
setUser(null);
setAccess(null);
setError(null);
}, []);
const isGerardo = useMemo(() => access?.role === "director_creativo", [access?.role]);
const canManageInternalPricing = useMemo(
() => access?.canManageInternalPricing === true,
[access?.canManageInternalPricing],
);
const canDeleteProjects = useMemo(
() => access?.canDeleteProjects === true,
[access?.canDeleteProjects],
);
const canControlPricingSummary = useMemo(
() => access?.canControlPricingSummary === true,
[access?.canControlPricingSummary],
);
return (
<AuthContext.Provider
value={{
user,
loading,
error,
isGerardo,
canDeleteProjects,
canManageInternalPricing,
canControlPricingSummary,
loginWithGoogle,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth debe usarse dentro de <AuthProvider>");
}
return ctx;
}
+206
View File
@@ -0,0 +1,206 @@
// Dropdowns extraídos directamente de la hoja "listas" del Excel
// Editar aquí = se actualiza en toda la app (en producción esto vendría del backend)
export const CLIENTES = [
"Ascenda",
"Bacardí",
"Bayer",
"Benedicto Wong",
"Big Cola",
"BMI",
"Casio",
"CDC",
"Colgate",
"Cordiflex",
"Diageo",
"Estelar",
"Estrella Azul",
"GLM",
"In Drive",
"Mars",
"Microsoft",
"Molino Criollo",
"Naicom",
"Nestlé",
"P&G",
"Pepsico",
"RRHH",
"Taco Bell",
"Us Meat",
"Vozez",
"Whirlpool",
"Little Caesars",
"Intcomex",
"Cemix",
"Súper Xtra",
"Grupo AJE",
"Varios",
"KitchenAid",
"Maytag",
"Across",
"Claro",
"Bruni",
"Volt",
"InDrive",
"TrueShore",
"Coca Cola",
"Más Móvil",
"US Rice",
"Café Indio",
"Café Maya",
"Jack Daniels",
"GLM Digital",
"Compass",
"Starbucks",
"Skechers",
"Ron Bermudez",
"Nature´s Heart",
"Larimart",
"Motorola",
"Bajaj - Pulsar",
"Xelet Media",
"Philip Morris",
"Malher",
"Carnation",
"La Lechera",
].sort();
// BU → Country Manager (Pais/BU + CM en la hoja listas)
export const BU_CM: Record<string, string> = {
CDC: "Marrero",
Colombia: "J. Fernando",
"Costa Rica": "Poul",
"El Salvador": "Christian",
Estelar: "Pablo",
Guatemala: "Gerardo",
Honduras: "Lucy",
Jamaica: "Liz",
Latam: "JL",
Mexico: "Poul",
Nicaragua: "Poul",
OCI: "Liz",
Panama: "Gabriel",
"Puerto Rico": "Liz",
"Republica Dominicana": "Liz",
RRHH: "Maximo",
Trinidad: "Liz",
Venezuela: "Jose Fer",
Vozez: "Nathalia",
"GLM Digital": "Lil",
IQM: "Lil",
};
export const BUS = Object.keys(BU_CM).sort();
export const MARCAS = [
"Carnation",
"GLM RRHH",
"La Lechera",
"Malher",
"US Meat",
"Coffee Mate y Nescafé",
"Nestum y Nido",
"Whirlpool",
"Motorola",
"Philip Morris",
"US Meat Guatemala",
"KitKat, Crunch, Choco Trío",
"Colgate",
"Kit Kat",
"UMA",
"Purina",
"Maggi e Ideal",
"Nestlé",
"Jimador",
"Multimarcas",
"Maggi",
"Covo",
"El Machetazo",
"Grupo Bocel",
"Supply Chain",
"Carnation y La Lechera",
"GLM Administración",
"Nestlé Bice",
"GLM",
"Nescafé",
"Idea, Qué Rico y Maggi",
"EY",
"Nesquik",
"Maggi, Ketchup, Qué Rico",
"Ideal",
"Claro Empresas",
"Nido",
"Presto",
"Nestlé RD",
"GLM Colombia",
"Motorola RD",
"GLM Caribe",
"Mandino",
"Coffee Mate",
"GLM Latam",
"RRHH RD",
"Philip Morris TT",
"GLM RD",
"Smirnoff",
"Kaspersky",
"US Pork",
"Qué Rico y Maggi",
"Claro y Motorola",
"Sedal",
"Maytag",
"Qué Rico, Maggi y Ketchup",
"Multimarca",
"Aviva y Princesa",
"KitchenAid",
"Enredé",
"Ascenda",
"Lipton Té",
"Heiniken",
"Oral B",
"Aafresh",
"IQOS",
"Natures Heart",
];
export const STATUS = [
"Aprobado",
"No aprobado",
"Stand by",
"Aprobado /no ejecutado",
"Pre aprobado",
"On going",
"Sin respuesta",
] as const;
export type Status = string;
// 10 colores primarios suaves estilo Google Material
export const COLORS = [
{ id: "blue", name: "Azul", hex: "#4285F4" },
{ id: "red", name: "Rojo", hex: "#EA4335" },
{ id: "yellow", name: "Amarillo", hex: "#FBBC04" },
{ id: "green", name: "Verde", hex: "#34A853" },
{ id: "purple", name: "Morado", hex: "#A142F4" },
{ id: "teal", name: "Turquesa", hex: "#00ACC1" },
{ id: "orange", name: "Naranja", hex: "#FB8C00" },
{ id: "pink", name: "Rosa", hex: "#E91E63" },
{ id: "indigo", name: "Índigo", hex: "#3F51B5" },
{ id: "gray", name: "Gris", hex: "#5F6368" },
] as const;
export type ColorId = (typeof COLORS)[number]["id"];
export const MESES = [
"Enero",
"Febrero",
"Marzo",
"Abril",
"Mayo",
"Junio",
"Julio",
"Agosto",
"Septiembre",
"Octubre",
"Noviembre",
"Diciembre",
];
+526
View File
@@ -0,0 +1,526 @@
export type TariffLevel = {
id: string;
label: string;
reference: string;
min?: number;
max?: number;
hint?: string;
};
export type TariffWorkType = {
id: string;
label: string;
shortLabel: string;
hourReference?: string;
levels: TariffLevel[];
};
export type TariffCatalogItem = {
id: string;
section: "grafico" | "estrategia";
category: string;
service: string;
notes?: string;
workTypes: TariffWorkType[];
};
export const TARIFF_SECTIONS = [
{ id: "grafico", label: "Tarifario Gráfico CDC" },
{ id: "estrategia", label: "Estrategia y Creatividad" },
] as const;
export const WORK_TYPE_DESIGN = "design_final_art";
export const WORK_TYPE_ADAPTATION = "adaptation_final_art";
export const WORK_TYPE_REFERENCE = "reference";
function level(
id: string,
label: string,
reference: string,
min?: number,
max?: number,
hint?: string,
): TariffLevel {
return { id, label, reference, min, max, hint };
}
function graphicWorkTypes({
designHour = "35 - 50",
adaptationHour = "30",
design,
adaptation,
}: {
designHour?: string;
adaptationHour?: string;
design: TariffLevel[];
adaptation?: TariffLevel[];
}): TariffWorkType[] {
const workTypes: TariffWorkType[] = [
{
id: WORK_TYPE_DESIGN,
label: "Diseño + Entregable Arte Final",
shortLabel: "Diseño + AF",
hourReference: designHour,
levels: design,
},
];
if (adaptation?.length) {
workTypes.push({
id: WORK_TYPE_ADAPTATION,
label: "Adaptación Arte Final",
shortLabel: "Adaptación AF",
hourReference: adaptationHour,
levels: adaptation,
});
}
return workTypes;
}
export const TARIFF_CATALOG: TariffCatalogItem[] = [
{
id: "pdv-materiales-basicos",
section: "grafico",
category: "Materiales de PDV estándar",
service:
"Cenefas, habladores, danglers, rompetráficos, afiches, backings, rollups, uniformes, piezas WhatsApp/web",
notes:
"Incluye diseño en alta resolución, troqueles y 3 rondas de cambios. Cambios adicionales pueden agregarse como Otros.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "50 - 80", 50, 80),
level("intermediate", "Intermedio", "100 - 150", 100, 150),
level("advanced", "Avanzado", "150 - 300", 150, 300),
],
adaptation: [
level("basic", "Básico", "30 - 50", 30, 50),
level("intermediate", "Intermedio", "60 - 80", 60, 80),
level("advanced", "Avanzado", "100 - 150", 100, 150),
],
}),
},
{
id: "pdv-promocion-pack",
section: "grafico",
category: "Promoción en PDV",
service: "Quick counter + roll up + mecánica de canje + uniforme (pack 3 a 5 piezas o más)",
notes:
"Montos por paquete de 3 a 5 piezas. Piezas o solicitudes extra deben agregarse como Otros.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "320 - 500", 320, 500),
level("intermediate", "Intermedio", "500 - 700", 500, 700),
level("advanced", "Avanzado", "800 - 1000", 800, 1000),
],
adaptation: [
level("basic", "Básico", "200 - 350", 200, 350),
level("intermediate", "Intermedio", "350 - 550", 350, 550),
level(
"advanced",
"Avanzado",
"600 - 800+",
600,
800,
"Puede superar el rango si el caso lo requiere.",
),
],
}),
},
{
id: "ooh-gigantografias",
section: "grafico",
category: "OOH y gigantografías",
service: "Vallas, mupies y gigantografías",
notes: "Los niveles están asociados a tamaño aproximado: 6m², más de 10m² y más de 20m².",
workTypes: graphicWorkTypes({
design: [
level("basic", "6m² aprox", "250 - 350", 250, 350),
level("intermediate", "Más 10m²", "400 - 650", 400, 650),
level("advanced", "Más de 20m²", "700 - 1100", 700, 1100),
],
adaptation: [
level("basic", "6m² aprox", "100 - 150", 100, 150),
level("intermediate", "Más 10m²", "200 - 300", 200, 300),
level("advanced", "Más de 20m²", "400 - 600", 400, 600),
],
}),
},
{
id: "stands-muebles-exhibidores",
section: "grafico",
category: "Stands, muebles y exhibidores",
service: "One Way, cabeceras de góndola, revestimiento de islas y displays básicos",
notes: "Incluye diseño en alta resolución, troqueles y 3 rondas de cambios.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "300 - 400", 300, 400),
level("intermediate", "Intermedio", "450 - 650", 450, 650),
level("advanced", "Avanzado", "700 - 1000", 700, 1000),
],
adaptation: [
level("basic", "Básico", "200 - 300", 200, 300),
level("intermediate", "Intermedio", "300 - 400", 300, 400),
level("advanced", "Avanzado", "450 - 600", 450, 600),
],
}),
},
{
id: "proyectos-especiales-muebles",
section: "grafico",
category: "Proyectos especiales",
service:
"Diseño de muebles u otros proyectos especiales con planos estructurales y troqueles desde cero",
notes:
"Precio por proyecto. Incluye diseño gráfico/estructural, planos, troqueles, conceptualización y 4 rondas de cambios.",
workTypes: graphicWorkTypes({
design: [level("project", "Precio único", "1000 - 1500", 1000, 1500)],
adaptation: [level("project", "Precio único", "600 - 850", 600, 850)],
}),
},
{
id: "stands-creativos-eventos",
section: "grafico",
category: "Proyectos especiales",
service:
"Stands creativos para ferias/eventos, montaje de eventos especiales y diseños de espacios especiales",
notes:
"Precio por proyecto. Incluye stand creativo, planos estructurales, visualizaciones 3D y 4 rondas de cambios.",
workTypes: graphicWorkTypes({
design: [level("project", "Precio único", "1500 - 3000", 1500, 3000)],
adaptation: [level("project", "Precio único", "800 - 1200", 800, 1200)],
}),
},
{
id: "empaque-simple",
section: "grafico",
category: "Diseño de empaques",
service: "Empaque simple",
notes: "Diseño gráfico original para empaques simples sin troqueles complejos.",
workTypes: graphicWorkTypes({
design: [level("project", "Precio único", "700 - 1000", 700, 1000)],
adaptation: [level("project", "Adaptación / SKU", "100 - 300", 100, 300)],
}),
},
{
id: "empaque-complejo",
section: "grafico",
category: "Diseño de empaques",
service: "Empaque complejo",
notes:
"Diseño gráfico y estructural para empaques con troqueles personalizados o formas no convencionales.",
workTypes: graphicWorkTypes({
design: [level("project", "Precio único", "1000 - 1500", 1000, 1500)],
adaptation: [level("project", "Adaptación / SKU", "100 - 300", 100, 300)],
}),
},
{
id: "brochures",
section: "grafico",
category: "Catálogos, brochures y folletos",
service: "Brochures",
notes: "Incluye diseño y 3 rondas de cambios.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "200 - 400", 200, 400),
level("intermediate", "Intermedio", "400 - 600", 400, 600),
level("advanced", "Avanzado", "600 - 800", 600, 800),
],
adaptation: [
level("basic", "Básico", "50 - 100", 50, 100),
level("intermediate", "Intermedio", "100 - 150", 100, 150),
level("advanced", "Avanzado", "150 - 350", 150, 350),
],
}),
},
{
id: "catalogos-folletos-pagina",
section: "grafico",
category: "Catálogos, brochures y folletos",
service: "Catálogos y folletos (costo por página)",
notes: "Costo por página. Incluye diseño y 3 rondas de cambios.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "30 - 60", 30, 60),
level("intermediate", "Intermedio", "60 - 90", 60, 90),
level("advanced", "Avanzado", "90 - 120", 90, 120),
],
adaptation: [
level("basic", "Básico", "15 - 30", 15, 30),
level("intermediate", "Intermedio", "30 - 60", 30, 60),
level("advanced", "Avanzado", "60 - 100", 60, 100),
],
}),
},
{
id: "web-landing",
section: "grafico",
category: "Páginas web",
service: "Landing page o sitio simple hasta 5 páginas",
notes:
"Sitio web básico con diseño estático, contenido del cliente y hasta 2 rondas de cambios.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Diseño web",
shortLabel: "Web",
hourReference: "35 - 50",
levels: [level("project", "Precio único", "800 - 1500", 800, 1500)],
},
],
},
{
id: "web-intermedio",
section: "grafico",
category: "Páginas web",
service: "Sitio corporativo intermedio de 6 a 15 páginas",
notes:
"Sitio mediano con páginas adicionales y diseño más personalizado. Hasta 3 rondas de cambios.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Diseño web",
shortLabel: "Web",
hourReference: "35 - 50",
levels: [level("project", "Precio único", "2000 - 5000", 2000, 5000)],
},
],
},
{
id: "web-avanzado",
section: "grafico",
category: "Páginas web",
service: "Sitio avanzado: más de 15 páginas, interactividad o integraciones complejas",
notes: "Incluye desarrollo a medida y hasta 5 rondas de cambios.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Diseño web",
shortLabel: "Web",
hourReference: "36 - 50",
levels: [level("project", "Precio único", "4000 - 8000", 4000, 8000)],
},
],
},
{
id: "retoques-fotograficos",
section: "grafico",
category: "Otros servicios",
service: "Retoques fotográficos",
notes: "Incluye 2 rondas de ajustes.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "20 - 40", 20, 40),
level("intermediate", "Intermedio", "50 - 100", 50, 100),
level("advanced", "Avanzado", "150 - 300", 150, 300),
],
adaptation: undefined,
}),
},
{
id: "videos",
section: "grafico",
category: "Otros servicios",
service: "Videos",
notes: "Referencia por complejidad del video.",
workTypes: graphicWorkTypes({
designHour: "35 - 50",
design: [
level("basic", "Básico", "100 - 250", 100, 250),
level("intermediate", "Intermedio", "250 - 500", 250, 500),
level("advanced", "Avanzado", "500 - 1200", 500, 1200),
],
}),
},
{
id: "copy-puntual",
section: "grafico",
category: "Otros servicios",
service: "Copy puntual para ADS, tarjetas o comunicaciones sueltas",
notes: "Solo copy puntual. Si requiere concepto, presupuestar según proyecto/hora hombre.",
workTypes: graphicWorkTypes({
designHour: "1 - 25",
design: [
level("basic", "Básico", "100", 100, 100),
level("intermediate", "Intermedio", "100 - 200", 100, 200),
level("advanced", "Avanzado", "200 - 500", 200, 500),
],
}),
},
{
id: "render-sencillo",
section: "grafico",
category: "Otros servicios",
service: "Render sencillo de 1 solo plano por unidad",
notes: "No aplica para eventos donde hay que conceptualizar el ambiente total.",
workTypes: graphicWorkTypes({
designHour: "1 - 50",
design: [
level("basic", "Básico", "100", 100, 100),
level("intermediate", "Intermedio", "100 - 150", 100, 150),
level("advanced", "Avanzado", "150 - 300", 150, 300),
],
}),
},
{
id: "carnets",
section: "grafico",
category: "Otros servicios",
service: "Carnets",
notes:
"Referencia especial: diseño base + réplicas. El Director Creativo calcula y escribe el monto final manualmente.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Referencia manual",
shortLabel: "Manual",
levels: [
level("base", "Diseño base", "20 base", 20, 20),
level("replica", "Réplicas", "5 por réplica", 5, 5),
],
},
],
},
{
id: "identidad-basica",
section: "estrategia",
category: "Desarrollo de Identidad Gráfica para Marca",
service: "Desarrollo básico para pequeñas marcas o emprendimientos",
notes:
"Incluye logo, paleta, tipografía, identidad visual básica, hasta 2 conceptos y 3 rondas de cambios.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Básico", "2000 - 4000", 2000, 4000)],
},
],
},
{
id: "identidad-intermedia",
section: "estrategia",
category: "Desarrollo de Identidad Gráfica para Marca",
service: "Desarrollo intermedio para marcas medianas",
notes:
"Incluye nombre si aplica, logotipo, paleta, tipografía, key visuals y manual de marca detallado.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Intermedio", "4000 - 8000", 4000, 8000)],
},
],
},
{
id: "identidad-avanzada",
section: "estrategia",
category: "Desarrollo de Identidad Gráfica para Marca",
service: "Desarrollo avanzado para grandes marcas o redes",
notes:
"Identidad visual robusta con aplicaciones de marca, investigación, key visuals y manual exhaustivo.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Avanzado", "8000 - 15000 o más", 8000, 15000)],
},
],
},
{
id: "campana-key-visual-basica",
section: "estrategia",
category: "Desarrollo de Campaña Creativa",
service: "Concepto y Key Visual básico para campañas pequeñas",
notes:
"Concepto creativo básico, key visual, línea gráfica y eslogan con aplicaciones limitadas.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Básico", "2000 - 3000", 2000, 3000)],
},
],
},
{
id: "campana-creativa-intermedia",
section: "estrategia",
category: "Desarrollo de Campaña Creativa",
service: "Campaña creativa intermedia para lanzamientos medianos",
notes: "Concepto creativo completo, key visuals, eslogan y piezas para distintos medios.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Intermedio", "3000 - 5000", 3000, 5000)],
},
],
},
{
id: "campana-creativa-avanzada",
section: "estrategia",
category: "Desarrollo de Campaña Creativa",
service: "Campaña creativa avanzada para lanzamientos grandes",
notes:
"Desarrollo estratégico y creativo de campaña grande con insights, key visuals y múltiples piezas.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Avanzado", "5000 - 8000 o más", 5000, 8000)],
},
],
},
{
id: "consultoria-estrategica",
section: "estrategia",
category: "Desarrollo Estratégico y Conceptual",
service: "Consultoría estratégica y desarrollo de concepto",
notes:
"Investigación de mercado, análisis de competencia, concepto estratégico y alineación con objetivos del cliente.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija o por hora",
shortLabel: "Referencia",
levels: [
level("project", "Por proyecto", "1500 - 3000", 1500, 3000),
level("hour", "Por hora", "100 - 200 por hora", 100, 200),
],
},
],
},
{
id: "rondas-adicionales-estrategia",
section: "estrategia",
category: "Adicionales estratégicos",
service: "Rondas adicionales de cambios",
notes: "Referencia para trabajo adicional en estrategia o creatividad.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Por hora",
shortLabel: "Hora",
levels: [level("hour", "Por hora", "50 - 150 por hora", 50, 150)],
},
],
},
];
export function formatTariffRange(level?: TariffLevel | null) {
if (!level) return "";
return level.reference;
}
export function defaultTariffAmount(level?: TariffLevel | null) {
if (!level) return "";
if (typeof level.min === "number" && typeof level.max === "number") {
return String(level.min === level.max ? level.min : level.min);
}
return "";
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
+80
View File
@@ -0,0 +1,80 @@
import { supabase } from "@/lib/supabase";
export type TableroCdcUserAccess = {
email: string;
fullName: string | null;
role: string;
isActive: boolean;
canDeleteProjects: boolean;
canManageInternalPricing: boolean;
canControlPricingSummary: boolean;
};
type AccessRow = {
email?: string | null;
full_name?: string | null;
role?: string | null;
is_active?: boolean | null;
can_delete_projects?: boolean | null;
can_manage_internal_pricing?: boolean | null;
can_control_pricing_summary?: boolean | null;
};
function normalizeEmail(email: string | null | undefined): string {
return String(email ?? "")
.trim()
.toLowerCase();
}
function normalizeAccessRow(row: AccessRow | null | undefined): TableroCdcUserAccess | null {
if (!row?.email || row.is_active !== true) return null;
return {
email: normalizeEmail(row.email),
fullName: row.full_name ?? null,
role: String(row.role || "user").trim() || "user",
isActive: true,
canDeleteProjects: row.can_delete_projects === true,
canManageInternalPricing: row.can_manage_internal_pricing === true,
canControlPricingSummary: row.can_control_pricing_summary === true,
};
}
export async function getActiveTableroCdcAccess(
email: string | null | undefined,
): Promise<TableroCdcUserAccess | null> {
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail) return null;
const { data, error } = 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",
)
.eq("email", normalizedEmail)
.eq("is_active", true)
.maybeSingle();
if (error) {
throw error;
}
return normalizeAccessRow(data as AccessRow | null);
}
export async function getCurrentTableroCdcAccess(): Promise<TableroCdcUserAccess> {
const { data, error } = await supabase.auth.getUser();
if (error || !data.user) {
throw new Error("No hay una sesión activa de Supabase.");
}
const access = await getActiveTableroCdcAccess(data.user.email);
if (!access) {
throw new Error("Tu correo no está autorizado para acceder al Tablero CDC.");
}
return access;
}
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { canonicalOptionLabel, dedupeOptions } from "@/lib/optionUtils";
import {
BUS as FALLBACK_BUS,
BU_CM as FALLBACK_BU_CM,
CLIENTES as FALLBACK_CLIENTES,
MARCAS as FALLBACK_MARCAS,
STATUS as FALLBACK_STATUS,
type Status,
} from "@/data/lists";
type AppListRow = {
category: string | null;
value: string | null;
label: string | null;
sort_order: number | null;
is_active: boolean | null;
};
export type AppLists = {
clientes: string[];
marcas: string[];
bus: string[];
buCm: Record<string, string>;
status: Status[];
};
const FALLBACK_LISTS: AppLists = {
clientes: FALLBACK_CLIENTES,
marcas: FALLBACK_MARCAS,
bus: FALLBACK_BUS,
buCm: FALLBACK_BU_CM,
status: [...FALLBACK_STATUS],
};
let listeners: Array<() => void> = [];
let cache: AppLists = FALLBACK_LISTS;
let loading = false;
let initialized = false;
let errorMessage: string | null = null;
let appListsRealtimeChannel: ReturnType<typeof supabase.channel> | null = null;
let realtimeRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
let activeHookInstances = 0;
function notify() {
listeners.forEach((listener) => listener());
}
function uniqOptions(values: string[]) {
return dedupeOptions(values);
}
function normalizeCategory(value: string | null | undefined) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
}
function canonicalCategory(value: string | null | undefined) {
const category = normalizeCategory(value);
if (["client", "clients", "cliente", "clientes"].includes(category)) return "client";
if (["brand", "brands", "marca", "marcas"].includes(category)) return "brand";
if (
["country", "countries", "pais", "paises", "bu", "business_unit", "business unit"].includes(
category,
)
) {
return "country";
}
if (["status", "estatus", "estado", "estados"].includes(category)) return "status";
if (
["country_manager", "country manager", "cm", "bu_cm", "bu cm", "manager", "managers"].includes(
category,
)
) {
return "country_manager";
}
return category;
}
function rowDisplay(row: AppListRow) {
return canonicalOptionLabel(String(row.label || row.value || ""));
}
function buildLists(rows: AppListRow[]): AppLists {
const clientes: string[] = [];
const marcas: string[] = [];
const bus: string[] = [];
const status: string[] = [];
const buCm: Record<string, string> = { ...FALLBACK_BU_CM };
for (const row of rows) {
if (row.is_active === false) continue;
const category = canonicalCategory(row.category);
const display = rowDisplay(row);
const value = canonicalOptionLabel(String(row.value || ""));
if (!display && !value) continue;
if (category === "client") clientes.push(display || value);
if (category === "brand") marcas.push(display || value);
if (category === "country") bus.push(display || value);
if (category === "status") status.push(display || value);
if (category === "country_manager" && value) {
const manager = display || value;
if (manager && manager !== value) {
buCm[value] = manager;
}
}
}
return {
clientes: clientes.length ? uniqOptions(clientes) : uniqOptions(FALLBACK_LISTS.clientes),
marcas: marcas.length ? uniqOptions(marcas) : uniqOptions(FALLBACK_LISTS.marcas),
bus: bus.length ? uniqOptions(bus) : uniqOptions(FALLBACK_LISTS.bus),
buCm,
status: (status.length ? uniqOptions(status) : uniqOptions(FALLBACK_LISTS.status)) as Status[],
};
}
function setError(message: string | null) {
errorMessage = message;
notify();
}
export async function loadAppLists() {
loading = true;
setError(null);
notify();
try {
const { data, error } = await supabase
.from("tablero_cdc_app_lists")
.select("category, value, label, sort_order, is_active")
.eq("is_active", true)
.order("category", { ascending: true })
.order("sort_order", { ascending: true })
.order("label", { ascending: true });
if (error) throw error;
cache = buildLists((data || []) as AppListRow[]);
initialized = true;
} catch (error) {
console.error("Error cargando listas desde Supabase:", error);
errorMessage =
error instanceof Error
? error.message
: "No se pudieron cargar los desplegables desde Supabase.";
cache = FALLBACK_LISTS;
} finally {
loading = false;
notify();
}
}
function scheduleRealtimeRefresh() {
if (realtimeRefreshTimeout) {
clearTimeout(realtimeRefreshTimeout);
}
realtimeRefreshTimeout = setTimeout(() => {
realtimeRefreshTimeout = null;
if (!loading) {
void loadAppLists();
}
}, 350);
}
function startAppListsRealtime() {
if (appListsRealtimeChannel) return;
appListsRealtimeChannel = supabase
.channel("tablero-cdc-app-lists")
.on("postgres_changes", { event: "*", schema: "public", table: "tablero_cdc_app_lists" }, () =>
scheduleRealtimeRefresh(),
)
.subscribe((status) => {
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
console.warn("Supabase Realtime no pudo suscribirse a tablero_cdc_app_lists:", status);
}
});
}
function stopAppListsRealtime() {
if (realtimeRefreshTimeout) {
clearTimeout(realtimeRefreshTimeout);
realtimeRefreshTimeout = null;
}
if (!appListsRealtimeChannel) return;
void supabase.removeChannel(appListsRealtimeChannel);
appListsRealtimeChannel = null;
}
export function useAppLists() {
const [, force] = useState(0);
useEffect(() => {
const listener = () => force((n) => n + 1);
listeners.push(listener);
activeHookInstances += 1;
startAppListsRealtime();
if (!initialized && !loading) {
void loadAppLists();
}
return () => {
listeners = listeners.filter((x) => x !== listener);
activeHookInstances = Math.max(0, activeHookInstances - 1);
if (activeHookInstances === 0) {
stopAppListsRealtime();
}
};
}, []);
return {
lists: cache,
loading,
error: errorMessage,
refresh: loadAppLists,
};
}
+9
View File
@@ -0,0 +1,9 @@
import { COLORS, type ColorId } from "@/data/lists";
export function colorHex(id: ColorId): string {
return COLORS.find((c) => c.id === id)?.hex ?? COLORS[0].hex;
}
export function colorName(id: ColorId): string {
return COLORS.find((c) => c.id === id)?.name ?? "";
}
+60
View File
@@ -0,0 +1,60 @@
/* eslint-disable no-control-regex */
const INVISIBLE_CONTROL_CHARS = new RegExp(
"[\\u0000-\\u001F\\u007F-\\u009F\\u200B-\\u200D\\uFEFF]",
"g",
);
export function cleanOptionLabel(value: string | null | undefined) {
return String(value || "")
.replace(INVISIBLE_CONTROL_CHARS, "")
.replace(/\s+/g, " ")
.trim();
}
export function normalizeOptionKey(value: string | null | undefined) {
return cleanOptionLabel(value)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
}
export function canonicalOptionLabel(value: string | null | undefined) {
const cleanValue = cleanOptionLabel(value);
const key = normalizeOptionKey(cleanValue);
if (key === "republica dominicana") return "Republica Dominicana";
return cleanValue;
}
export function dedupeOptions(values: Array<string | null | undefined>) {
const unique = new Map<string, string>();
for (const value of values) {
const cleanValue = canonicalOptionLabel(value);
if (!cleanValue) continue;
const key = normalizeOptionKey(cleanValue);
if (!key) continue;
if (!unique.has(key)) {
unique.set(key, cleanValue);
}
}
return Array.from(unique.values());
}
export function ensureOption(options: string[], value: string | null | undefined) {
const uniqueOptions = dedupeOptions(options);
const current = canonicalOptionLabel(value);
if (!current) return uniqueOptions;
const currentKey = normalizeOptionKey(current);
const exists = uniqueOptions.some((option) => normalizeOptionKey(option) === currentKey);
return exists ? uniqueOptions : [current, ...uniqueOptions];
}
+122
View File
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
const PRICING_SUMMARY_SETTING_KEY = "pricing_summary_public";
type SettingRow = {
key: string;
value: {
enabled?: boolean;
} | null;
};
function parseVisibility(row: SettingRow | null | undefined) {
return Boolean(row?.value?.enabled);
}
async function fetchPricingSummaryPublicVisibility() {
const { data, error } = await supabase
.from("tablero_cdc_app_settings")
.select("key, value")
.eq("key", PRICING_SUMMARY_SETTING_KEY)
.maybeSingle();
if (error) throw error;
return parseVisibility(data as SettingRow | null);
}
async function savePricingSummaryPublicVisibility(enabled: boolean) {
const { error } = await supabase.rpc("tablero_cdc_set_pricing_summary_public", {
p_enabled: enabled,
});
if (error) throw error;
}
export function usePricingSummaryVisibility(enabled = true) {
const [isPublic, setIsPublic] = useState(false);
const [loading, setLoading] = useState(enabled);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!enabled) return false;
setLoading(true);
setError(null);
try {
const nextIsPublic = await fetchPricingSummaryPublicVisibility();
setIsPublic(nextIsPublic);
return nextIsPublic;
} catch (err) {
console.error("No se pudo leer la visibilidad del total tarifado:", err);
setError(
err instanceof Error ? err.message : "No se pudo leer la visibilidad del total tarifado.",
);
return false;
} finally {
setLoading(false);
}
}, [enabled]);
const setPublicVisibility = useCallback(async (nextIsPublic: boolean) => {
setSaving(true);
setError(null);
try {
await savePricingSummaryPublicVisibility(nextIsPublic);
setIsPublic(nextIsPublic);
return nextIsPublic;
} catch (err) {
console.error("No se pudo actualizar la visibilidad del total tarifado:", err);
setError(
err instanceof Error
? err.message
: "No se pudo actualizar la visibilidad del total tarifado.",
);
throw err;
} finally {
setSaving(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!enabled) return;
const channel = supabase
.channel("tablero-cdc-pricing-summary-visibility")
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "tablero_cdc_app_settings",
filter: `key=eq.${PRICING_SUMMARY_SETTING_KEY}`,
},
(payload) => {
const nextRow = (payload.new || payload.old) as SettingRow | null;
setIsPublic(parseVisibility(nextRow));
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [enabled]);
return {
isPublic,
loading,
saving,
error,
refresh,
setPublicVisibility,
};
}
+1592
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
import { createClient } from "@supabase/supabase-js";
const requiredVars = ["VITE_SUPABASE_URL", "VITE_SUPABASE_ANON_KEY"] as const;
const missing = requiredVars.filter((key) => !import.meta.env[key]);
if (missing.length > 0) {
throw new Error(
`Faltan variables de entorno de Supabase. Revisa tu archivo .env.\nVariables faltantes: ${missing.join(", ")}`,
);
}
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY,
{
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
},
);
+366
View File
@@ -0,0 +1,366 @@
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import {
TARIFF_CATALOG,
WORK_TYPE_REFERENCE,
type TariffCatalogItem,
type TariffLevel,
type TariffWorkType,
} from "@/data/tariff";
export type TariffCatalogSource = "supabase" | "fallback";
export type TariffCatalogRow = {
id: string;
catalog_item_id: string;
section: "grafico" | "estrategia";
category: string;
service: string;
notes: string;
work_type_id: string;
work_type_label: string;
work_type_short_label: string;
hour_reference: string;
level_id: string;
level_label: string;
reference: string;
reference_min: number | null;
reference_max: number | null;
level_hint: string;
sort_order: number;
is_active: boolean;
created_at?: string | null;
updated_at?: string | null;
};
export type TariffCatalogUpsertInput = Omit<TariffCatalogRow, "created_at" | "updated_at">;
type TariffCatalogState = {
rows: TariffCatalogRow[];
catalog: TariffCatalogItem[];
source: TariffCatalogSource;
loading: boolean;
error: string | null;
};
const TABLE_NAME = "tablero_cdc_tariff_catalog";
const DEFAULT_WORK_TYPE_LABEL = "Referencia";
const DEFAULT_LEVEL_LABEL = "Precio único";
let cache: TariffCatalogState = {
rows: flattenTariffCatalog(TARIFF_CATALOG),
catalog: TARIFF_CATALOG,
source: "fallback",
loading: false,
error: null,
};
let initialized = false;
let listeners: Array<() => void> = [];
let loadRequestSeq = 0;
function notify() {
listeners.forEach((listener) => listener());
}
function asNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value.trim());
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function slugify(value: string) {
return String(value || "")
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function rowId(itemId: string, workTypeId: string, levelId: string) {
return [itemId, workTypeId, levelId].map(slugify).filter(Boolean).join("__");
}
function normalizeDbRow(row: Record<string, unknown>): TariffCatalogRow {
const section = String(row.section || "grafico") === "estrategia" ? "estrategia" : "grafico";
const category = String(row.category || "").trim();
const service = String(row.service || "").trim();
const catalogItemId = String(
row.catalog_item_id || slugify(`${section}-${category}-${service}`),
).trim();
const workTypeLabel = String(row.work_type_label || DEFAULT_WORK_TYPE_LABEL).trim();
const levelLabel = String(row.level_label || DEFAULT_LEVEL_LABEL).trim();
const workTypeId = String(
row.work_type_id || slugify(workTypeLabel) || WORK_TYPE_REFERENCE,
).trim();
const levelId = String(row.level_id || slugify(levelLabel) || "project").trim();
return {
id: String(row.id || rowId(catalogItemId, workTypeId, levelId)).trim(),
catalog_item_id: catalogItemId,
section,
category,
service,
notes: String(row.notes || "").trim(),
work_type_id: workTypeId,
work_type_label: workTypeLabel,
work_type_short_label: String(row.work_type_short_label || workTypeLabel).trim(),
hour_reference: String(row.hour_reference || "").trim(),
level_id: levelId,
level_label: levelLabel,
reference: String(row.reference || "").trim(),
reference_min: asNumber(row.reference_min),
reference_max: asNumber(row.reference_max),
level_hint: String(row.level_hint || "").trim(),
sort_order: Number(row.sort_order || 0),
is_active: row.is_active !== false,
created_at: typeof row.created_at === "string" ? row.created_at : null,
updated_at: typeof row.updated_at === "string" ? row.updated_at : null,
};
}
export function buildTariffCatalogFromRows(rows: TariffCatalogRow[]): TariffCatalogItem[] {
const itemMap = new Map<string, TariffCatalogItem>();
const workTypeMaps = new Map<string, Map<string, TariffWorkType>>();
const activeRows = rows
.filter((row) => row.is_active)
.sort((a, b) => a.sort_order - b.sort_order || a.category.localeCompare(b.category));
for (const row of activeRows) {
const itemId = row.catalog_item_id || slugify(`${row.section}-${row.category}-${row.service}`);
let item = itemMap.get(itemId);
if (!item) {
item = {
id: itemId,
section: row.section,
category: row.category,
service: row.service,
notes: row.notes,
workTypes: [],
};
itemMap.set(itemId, item);
workTypeMaps.set(itemId, new Map());
}
const workTypeKey = row.work_type_id || WORK_TYPE_REFERENCE;
const workTypeMap = workTypeMaps.get(itemId)!;
let workType = workTypeMap.get(workTypeKey);
if (!workType) {
workType = {
id: workTypeKey,
label: row.work_type_label || DEFAULT_WORK_TYPE_LABEL,
shortLabel: row.work_type_short_label || row.work_type_label || DEFAULT_WORK_TYPE_LABEL,
hourReference: row.hour_reference || undefined,
levels: [],
};
workTypeMap.set(workTypeKey, workType);
item.workTypes.push(workType);
}
const level: TariffLevel = {
id: row.level_id || "project",
label: row.level_label || DEFAULT_LEVEL_LABEL,
reference: row.reference || "",
min: row.reference_min ?? undefined,
max: row.reference_max ?? undefined,
hint: row.level_hint || undefined,
};
if (!workType.levels.some((existing) => existing.id === level.id)) {
workType.levels.push(level);
}
}
return Array.from(itemMap.values());
}
export function flattenTariffCatalog(catalog: TariffCatalogItem[]): TariffCatalogRow[] {
const rows: TariffCatalogRow[] = [];
catalog.forEach((item, itemIndex) => {
item.workTypes.forEach((workType, workTypeIndex) => {
workType.levels.forEach((level, levelIndex) => {
rows.push({
id: rowId(item.id, workType.id, level.id),
catalog_item_id: item.id,
section: item.section,
category: item.category,
service: item.service,
notes: item.notes || "",
work_type_id: workType.id,
work_type_label: workType.label,
work_type_short_label: workType.shortLabel,
hour_reference: workType.hourReference || "",
level_id: level.id,
level_label: level.label,
reference: level.reference,
reference_min: level.min ?? null,
reference_max: level.max ?? null,
level_hint: level.hint || "",
sort_order: itemIndex * 1000 + workTypeIndex * 100 + levelIndex,
is_active: true,
});
});
});
});
return rows;
}
export function buildTariffRowId(
input: Pick<TariffCatalogRow, "catalog_item_id" | "work_type_id" | "level_id">,
) {
return rowId(input.catalog_item_id, input.work_type_id, input.level_id);
}
function missingTariffTable(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("relation") ||
message.includes("does not exist")
);
}
export async function loadTariffCatalog(force = false): Promise<TariffCatalogState> {
if (initialized && !force) return cache;
const requestId = (loadRequestSeq += 1);
cache = { ...cache, loading: true, error: null };
notify();
try {
const { data, error } = 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",
)
.order("sort_order", { ascending: true })
.order("category", { ascending: true });
if (error) throw error;
const dbRows = (data || []).map((row) => normalizeDbRow(row as Record<string, unknown>));
const fallbackRows = flattenTariffCatalog(TARIFF_CATALOG);
const rowMap = new Map<string, TariffCatalogRow>();
fallbackRows.forEach((row) => rowMap.set(row.id, row));
dbRows.forEach((row) => rowMap.set(row.id, row));
const mergedRows = Array.from(rowMap.values()).sort(
(a, b) => a.sort_order - b.sort_order || a.category.localeCompare(b.category),
);
const catalog = buildTariffCatalogFromRows(mergedRows);
if (requestId === loadRequestSeq) {
cache = {
rows: mergedRows,
catalog,
source: dbRows.length ? "supabase" : "fallback",
loading: false,
error: dbRows.length
? null
: "La tabla del tarifario está vacía. Se está usando el tarifario base de la app como respaldo.",
};
initialized = true;
notify();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || "");
const friendlyMessage = missingTariffTable(error)
? "Falta crear la tabla tablero_cdc_tariff_catalog en Supabase. Se está usando el tarifario base de la app como respaldo."
: message || "No se pudo cargar el tarifario dinámico.";
if (requestId === loadRequestSeq) {
cache = {
rows: flattenTariffCatalog(TARIFF_CATALOG),
catalog: TARIFF_CATALOG,
source: "fallback",
loading: false,
error: friendlyMessage,
};
initialized = true;
notify();
}
}
return cache;
}
export async function upsertTariffCatalogRow(input: TariffCatalogUpsertInput) {
const row = normalizeDbRow(input as unknown as Record<string, unknown>);
const id = row.id || buildTariffRowId(row);
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,
},
{ onConflict: "id" },
);
if (error) throw error;
initialized = false;
await loadTariffCatalog(true);
}
export async function setTariffCatalogRowActive(row: TariffCatalogRow, isActive: boolean) {
const { error } = await supabase
.from(TABLE_NAME)
.update({ is_active: isActive })
.eq("id", row.id);
if (error) throw error;
initialized = false;
await loadTariffCatalog(true);
}
export function useTariffCatalog() {
const [, force] = useState(0);
useEffect(() => {
const listener = () => force((value) => value + 1);
listeners.push(listener);
if (!initialized && !cache.loading) {
void loadTariffCatalog();
}
return () => {
listeners = listeners.filter((current) => current !== listener);
};
}, []);
return {
...cache,
refresh: () => loadTariffCatalog(true),
};
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+813
View File
@@ -0,0 +1,813 @@
import React, { useEffect, useState } from "react";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
AlertCircle,
DollarSign,
Eye,
EyeOff,
LayoutGrid,
LogOut,
Plus,
RefreshCw,
Search,
SlidersHorizontal,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ProjectCard } from "@/components/board/ProjectCard";
import { ProjectDialog } from "@/components/board/ProjectDialog";
import { SearchableSelect } from "@/components/board/SearchableSelect";
import { useProjectPricingSummary, useProjects, type Project } from "@/lib/store";
import { usePricingSummaryVisibility } from "@/lib/pricingSummaryVisibility";
import { useAppLists } from "@/lib/appLists";
import { useAuth } from "@/context/AuthContext";
import { dedupeOptions } from "@/lib/optionUtils";
import { GLMLogo } from "@/components/GLMLogo";
/** Extract up-to-2-letter initials from a display name or email */
const PROJECTS_PER_PAGE = 24;
type AdvancedFilters = {
country: string;
brand: string;
client: string;
cm: string;
};
const EMPTY_ADVANCED_FILTERS: AdvancedFilters = {
country: "",
brand: "",
client: "",
cm: "",
};
function initials(name: string | null | undefined, email: string | null | undefined): string {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
if (email) return email.slice(0, 2).toUpperCase();
return "?";
}
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 [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(EMPTY_ADVANCED_FILTERS);
const [pageByFilter, setPageByFilter] = useState({
todos: 1,
abiertos: 1,
cerrados: 1,
});
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const currentPage = pageByFilter[filter];
const { lists } = useAppLists();
const { projects, totalProjects, filterOptions, loading, error, refresh, remove } = useProjects({
tab: filter,
search: query,
page: currentPage,
pageSize: PROJECTS_PER_PAGE,
country: advancedFilters.country,
brand: advancedFilters.brand,
client: advancedFilters.client,
cm: advancedFilters.cm,
});
const { user, isGerardo, canControlPricingSummary, logout } = useAuth();
const pricingSummaryParams = {
tab: filter,
search: query,
page: 1,
pageSize: PROJECTS_PER_PAGE,
country: advancedFilters.country,
brand: advancedFilters.brand,
client: advancedFilters.client,
cm: advancedFilters.cm,
} as const;
const {
isPublic: isPricingSummaryPublic,
loading: pricingSummaryVisibilityLoading,
saving: pricingSummaryVisibilitySaving,
error: pricingSummaryVisibilityError,
setPublicVisibility: setPricingSummaryPublicVisibility,
} = usePricingSummaryVisibility(Boolean(user));
const canViewPricingSummary = canControlPricingSummary || isPricingSummaryPublic;
const {
summary: pricingSummary,
loading: pricingSummaryLoading,
error: pricingSummaryError,
} = useProjectPricingSummary(pricingSummaryParams, canViewPricingSummary);
const cmListOptions = Object.values(lists.buCm);
const countryFilterOptions = dedupeOptions([
...lists.bus,
...filterOptions.countries,
advancedFilters.country,
]);
const brandFilterOptions = dedupeOptions([
...lists.marcas,
...filterOptions.brands,
advancedFilters.brand,
]);
const clientFilterOptions = dedupeOptions([
...lists.clientes,
...filterOptions.clients,
advancedFilters.client,
]);
const cmFilterOptions = dedupeOptions([
...cmListOptions,
...filterOptions.cms,
advancedFilters.cm,
]);
const activeAdvancedFilterCount = Object.values(advancedFilters).filter(Boolean).length;
const totalPages = Math.max(1, Math.ceil(totalProjects / 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);
useEffect(() => {
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
}, [
query,
filter,
advancedFilters.country,
advancedFilters.brand,
advancedFilters.client,
advancedFilters.cm,
]);
useEffect(() => {
if (currentPage > totalPages) {
setPageByFilter((prev) => ({ ...prev, [filter]: totalPages }));
}
}, [currentPage, filter, totalPages]);
const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), totalPages);
if (nextPage === currentPage) return;
setPageByFilter((prev) => ({ ...prev, [filter]: nextPage }));
window.scrollTo({ top: 0, behavior: "smooth" });
};
const setAdvancedFilter = (key: keyof AdvancedFilters, value: string) => {
setAdvancedFilters((current) => ({ ...current, [key]: value }));
};
const clearAdvancedFilters = () => {
setAdvancedFilters(EMPTY_ADVANCED_FILTERS);
};
const openNew = () => {
setEditing(null);
setOpen(true);
};
const openEdit = (p: Project) => {
setEditing(p);
setOpen(true);
};
const deleteProject = async (project: Project) => {
const confirmed = window.confirm(
`¿Seguro que deseas eliminar el proyecto "${project.nombre || "Sin título"}"?\n\nEsta acción eliminará el proyecto del tablero y del Google Sheet.`,
);
if (!confirmed) return;
setDeletingProjectId(project.id);
try {
await remove(project.id);
if (editing?.id === project.id) {
setOpen(false);
setEditing(null);
}
} catch (err) {
console.error("Error eliminando proyecto:", err);
const message =
err instanceof Error
? err.message
: "No se pudo eliminar el proyecto. Revisa n8n, Supabase o permisos.";
window.alert(message);
} finally {
setDeletingProjectId(null);
}
};
const displayName = user?.displayName ?? user?.email ?? "Usuario";
const userRole = isGerardo ? "Jefe CDC" : "Usuario CDC";
const userInitials = initials(user?.displayName, user?.email);
return (
<div className="min-h-screen bg-background">
{/* Top bar */}
<header className="sticky top-0 z-30 bg-background/85 backdrop-blur-md border-b border-border">
<div className="max-w-[1400px] mx-auto px-6 py-3.5 flex items-center gap-4">
{/* Brand */}
<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>
</div>
{/* Search */}
<div className="flex-1 max-w-md ml-4 relative hidden md:block">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar proyecto, cliente, marca…"
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
/>
</div>
<div className="flex-1 md:flex-none" />
{/* User menu — real authenticated user */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 h-9 px-2.5">
<Avatar className="w-7 h-7">
{user?.photoURL && (
<AvatarImage
src={user.photoURL}
alt={displayName}
referrerPolicy="no-referrer"
/>
)}
<AvatarFallback
className={
isGerardo
? "bg-primary text-primary-foreground text-xs"
: "bg-accent text-accent-foreground text-xs"
}
>
{userInitials}
</AvatarFallback>
</Avatar>
<div className="text-left leading-tight hidden sm:block">
<div className="text-xs font-medium">{displayName}</div>
<div className="text-[10px] text-muted-foreground">{userRole}</div>
</div>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<div className="px-3 py-2">
<p className="text-xs font-medium truncate">{displayName}</p>
<p className="text-[10px] text-muted-foreground truncate">{user?.email}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={logout}
className="gap-2 text-destructive focus:text-destructive focus:bg-destructive/10 cursor-pointer"
>
<LogOut className="w-3.5 h-3.5" />
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 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
</Button>
</div>
</header>
{/* Filters */}
<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>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex bg-muted/60 rounded-full p-1 text-xs">
{(
[
["todos", "Todos"],
["abiertos", "Activos"],
["cerrados", "Cerrados"],
] as const
).map(([k, l]) => (
<button
key={k}
onClick={() => setFilter(k)}
className={`px-3.5 py-1.5 rounded-full transition-colors font-medium ${
filter === k
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{l}
</button>
))}
</div>
</div>
</div>
<div className="relative md:hidden">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar proyecto, cliente, marca…"
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
/>
</div>
<AdvancedProjectFilters
filters={advancedFilters}
activeCount={activeAdvancedFilterCount}
countryOptions={countryFilterOptions}
brandOptions={brandFilterOptions}
clientOptions={clientFilterOptions}
cmOptions={cmFilterOptions}
onFilterChange={setAdvancedFilter}
onClear={clearAdvancedFilters}
/>
{canViewPricingSummary && (
<PricingSummaryCard
totalAmount={pricingSummary.totalAmount}
projectCount={pricingSummary.projectCount}
loading={pricingSummaryLoading || pricingSummaryVisibilityLoading}
error={pricingSummaryError || pricingSummaryVisibilityError}
hasFilters={
filter !== "todos" || query.trim().length > 0 || activeAdvancedFilterCount > 0
}
canControlVisibility={canControlPricingSummary}
isPublic={isPricingSummaryPublic}
visibilitySaving={pricingSummaryVisibilitySaving}
onToggleVisibility={() =>
setPricingSummaryPublicVisibility(!isPricingSummaryPublic).catch(() => undefined)
}
/>
)}
</div>
{/* Grid */}
<main className="max-w-[1400px] mx-auto px-6 pb-16">
{error && (
<div className="mb-4 flex flex-col gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive sm:flex-row sm:items-center sm:justify-between">
<span className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<span className="font-medium">No se pudieron cargar los proyectos.</span> {error}
</span>
</span>
<Button variant="outline" size="sm" onClick={() => void refresh()} className="gap-1.5">
<RefreshCw className="h-3.5 w-3.5" /> Reintentar
</Button>
</div>
)}
{loading && projects.length === 0 ? (
<LoadingState />
) : projects.length === 0 ? (
<EmptyState
onNew={openNew}
hasProjects={
totalProjects > 0 ||
filter !== "todos" ||
query.trim().length > 0 ||
activeAdvancedFilterCount > 0
}
filter={filter}
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)} />
))}
</div>
)}
{totalProjects > PROJECTS_PER_PAGE && (
<PaginationControls
currentPage={safePage}
totalPages={totalPages}
visibleStart={visibleStart}
visibleEnd={visibleEnd}
totalItems={totalProjects}
onPageChange={goToPage}
/>
)}
</main>
<ProjectDialog
open={open}
onOpenChange={setOpen}
project={editing}
onDelete={deleteProject}
deleting={!!editing && deletingProjectId === editing.id}
/>
</div>
);
}
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(value || 0);
}
function PricingSummaryCard({
totalAmount,
projectCount,
loading,
error,
hasFilters,
canControlVisibility,
isPublic,
visibilitySaving,
onToggleVisibility,
}: {
totalAmount: number;
projectCount: number;
loading: boolean;
error: string | null;
hasFilters: boolean;
canControlVisibility: boolean;
isPublic: boolean;
visibilitySaving: boolean;
onToggleVisibility: () => void;
}) {
return (
<div className="rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<DollarSign className="h-4 w-4" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{hasFilters ? "Total tarifado filtrado" : "Total tarifado global"}
</p>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${
isPublic
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-primary/20 bg-card text-primary"
}`}
>
{isPublic ? "Visible para todos" : "Privado"}
</span>
</div>
<p className="mt-0.5 text-2xl font-semibold tracking-tight text-foreground">
{loading ? "Calculando…" : formatCurrency(totalAmount)}
</p>
<p className="text-xs text-muted-foreground">
{error
? `No se pudo calcular el total: ${error}`
: `${projectCount} proyecto(s) incluidos según los filtros actuales.`}
</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
<div className="rounded-full border border-primary/20 bg-card px-3 py-1 text-xs font-medium text-primary">
{hasFilters ? "Según filtros activos" : "Sin filtros aplicados"}
</div>
{canControlVisibility && (
<Button
type="button"
variant={isPublic ? "secondary" : "outline"}
size="sm"
className="gap-1.5 rounded-full"
disabled={visibilitySaving}
onClick={onToggleVisibility}
title={
isPublic
? "Ocultar este total para los demás usuarios"
: "Mostrar este total temporalmente a todos los usuarios"
}
>
{isPublic ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{visibilitySaving
? "Actualizando…"
: isPublic
? "Ocultar a todos"
: "Mostrar a todos"}
</Button>
)}
</div>
</div>
</div>
);
}
function AdvancedProjectFilters({
filters,
activeCount,
countryOptions,
brandOptions,
clientOptions,
cmOptions,
onFilterChange,
onClear,
}: {
filters: AdvancedFilters;
activeCount: number;
countryOptions: string[];
brandOptions: string[];
clientOptions: string[];
cmOptions: string[];
onFilterChange: (key: keyof AdvancedFilters, value: string) => void;
onClear: () => void;
}) {
return (
<div className="rounded-2xl border border-border bg-card/70 px-3.5 py-3 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex items-center justify-between gap-3 lg:w-auto lg:min-w-[120px] lg:justify-start">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<SlidersHorizontal className="h-4 w-4" />
</span>
<span>Filtros</span>
{activeCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
{activeCount}
</span>
)}
</div>
{activeCount > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClear}
className="h-8 rounded-full px-2.5 text-xs text-muted-foreground hover:text-foreground lg:hidden"
>
<X className="mr-1 h-3.5 w-3.5" />
Limpiar
</Button>
)}
</div>
<div className="grid flex-1 grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
<SearchableSelect
value={filters.country}
options={countryOptions}
onValueChange={(value) => onFilterChange("country", value)}
placeholder="País: Todos"
searchPlaceholder="Buscar país…"
emptyText="No se encontró ese país."
clearLabel="Todos"
/>
<SearchableSelect
value={filters.brand}
options={brandOptions}
onValueChange={(value) => onFilterChange("brand", value)}
placeholder="Marca: Todas"
searchPlaceholder="Buscar marca…"
emptyText="No se encontró esa marca."
clearLabel="Todas"
/>
<SearchableSelect
value={filters.client}
options={clientOptions}
onValueChange={(value) => onFilterChange("client", value)}
placeholder="Cliente: Todos"
searchPlaceholder="Buscar cliente…"
emptyText="No se encontró ese cliente."
clearLabel="Todos"
/>
<SearchableSelect
value={filters.cm}
options={cmOptions}
onValueChange={(value) => onFilterChange("cm", value)}
placeholder="CM: Todos"
searchPlaceholder="Buscar CM…"
emptyText="No se encontró ese CM."
clearLabel="Todos"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClear}
disabled={activeCount === 0}
className="hidden h-9 rounded-full px-3 text-xs text-muted-foreground hover:text-foreground lg:inline-flex"
>
<X className="mr-1 h-3.5 w-3.5" />
Limpiar
</Button>
</div>
</div>
);
}
function PaginationControls({
currentPage,
totalPages,
visibleStart,
visibleEnd,
totalItems,
onPageChange,
}: {
currentPage: number;
totalPages: number;
visibleStart: number;
visibleEnd: number;
totalItems: number;
onPageChange: (page: number) => void;
}) {
const pages = getVisiblePages(currentPage, totalPages);
return (
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-3 rounded-2xl border border-border bg-card/70 px-4 py-3">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{visibleStart}-{visibleEnd}
</span>{" "}
de <span className="font-medium text-foreground">{totalItems}</span> proyectos
</p>
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 rounded-full px-2.5"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">Anterior</span>
</Button>
<div className="flex items-center gap-1">
{pages.map((page, index) =>
page === "ellipsis" ? (
<span
key={`ellipsis-${index}`}
className="flex h-8 w-8 items-center justify-center text-xs text-muted-foreground"
>
</span>
) : (
<Button
key={page}
type="button"
variant={page === currentPage ? "default" : "ghost"}
size="sm"
className="h-8 w-8 rounded-full p-0 text-xs"
onClick={() => onPageChange(page)}
aria-current={page === currentPage ? "page" : undefined}
>
{page}
</Button>
),
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 rounded-full px-2.5"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<span className="hidden sm:inline">Siguiente</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
function getVisiblePages(currentPage: number, totalPages: number): Array<number | "ellipsis"> {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, index) => index + 1);
const pages = new Set<number>([1, totalPages, currentPage]);
if (currentPage > 1) pages.add(currentPage - 1);
if (currentPage < totalPages) pages.add(currentPage + 1);
if (currentPage <= 3) {
pages.add(2);
pages.add(3);
pages.add(4);
}
if (currentPage >= totalPages - 2) {
pages.add(totalPages - 3);
pages.add(totalPages - 2);
pages.add(totalPages - 1);
}
const sortedPages = Array.from(pages)
.filter((page) => page >= 1 && page <= totalPages)
.sort((a, b) => a - b);
return sortedPages.reduce<Array<number | "ellipsis">>((acc, page) => {
const previous = acc[acc.length - 1];
if (typeof previous === "number" && page - previous > 1) acc.push("ellipsis");
acc.push(page);
return acc;
}, []);
}
function LoadingState() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="min-h-[230px] overflow-hidden rounded-2xl border border-border bg-card"
>
<div className="h-14 bg-muted animate-pulse" />
<div className="space-y-3 p-4">
<div className="h-3 w-3/4 rounded-full bg-muted animate-pulse" />
<div className="h-3 w-2/3 rounded-full bg-muted animate-pulse" />
<div className="h-3 w-1/2 rounded-full bg-muted animate-pulse" />
<div className="mt-6 h-8 rounded-full bg-muted animate-pulse" />
</div>
</div>
))}
</div>
);
}
function EmptyState({
onNew,
hasProjects,
filter,
query,
activeAdvancedFilterCount,
}: {
onNew: () => void;
hasProjects: boolean;
filter: "todos" | "abiertos" | "cerrados";
query: string;
activeAdvancedFilterCount: number;
}) {
const hasSearch = query.trim().length > 0;
const hasAdvancedFilters = activeAdvancedFilterCount > 0;
let title = "Tu tablero está vacío";
let message = "Crea tu primer proyecto para que el equipo pueda darle seguimiento.";
if (hasProjects && hasSearch && hasAdvancedFilters) {
title = "Nada coincide con la búsqueda y filtros";
message = "Intenta cambiar la búsqueda, limpiar filtros o seleccionar otras opciones.";
} else if (hasProjects && hasSearch) {
title = "Nada coincide con la búsqueda";
message = "Intenta cambiar la búsqueda o borrar el texto escrito.";
} else if (hasProjects && hasAdvancedFilters) {
title = "Nada coincide con los filtros";
message = "Intenta limpiar filtros o seleccionar otras opciones.";
} else if (hasProjects && filter === "abiertos") {
title = "No hay proyectos activos";
message = "Cuando se cree un proyecto nuevo, aparecerá en esta pestaña.";
} else if (hasProjects && filter === "cerrados") {
title = "No hay proyectos cerrados";
message = "Cuando el Director Creativo asigne un estatus a un proyecto, aparecerá aquí.";
} else if (hasProjects) {
title = "Nada coincide con el filtro";
message = "Intenta quitar el filtro o cambiar la búsqueda.";
}
return (
<div className="text-center py-24 rounded-2xl border border-dashed border-border bg-card/40">
<div className="w-14 h-14 rounded-2xl bg-primary/10 mx-auto flex items-center justify-center mb-4">
<LayoutGrid className="w-6 h-6 text-primary" />
</div>
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">{message}</p>
{!hasProjects && (
<Button onClick={onNew} className="mt-5 gap-1.5 rounded-full">
<Plus className="w-4 h-4" /> Crear proyecto
</Button>
)}
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
@import "tailwindcss" source(none);
@source "../src";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/*
* Design system definition.
*
* The @theme inline block maps CSS custom properties to Tailwind utility
* classes (e.g. --color-primary -> bg-primary, text-primary).
*
* The :root and .dark blocks define the actual color values using oklch.
* All colors MUST use oklch format.
*
* To add a new semantic color:
* 1. Add the variable to :root (light value) and .dark (dark value)
* 2. Register it in @theme inline as --color-<name>: var(--<name>)
*/
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-ring-offset-background: var(--background);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.875rem;
/* GLM palette — soft, clean and low-noise for the existing CDC UI */
--glm-blue: #4f758b;
--glm-blue-medium: #5b7f95;
--glm-steel: #6b8fa3;
--glm-teal: #2c6e6f;
--glm-green: #6cc24a;
--glm-green-soft: #eef6e8;
--background: oklch(0.99 0.004 131);
--foreground: oklch(0.4091 0 89.9);
--card: oklch(1 0 0);
--card-foreground: oklch(0.4091 0 89.9);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.4091 0 89.9);
--primary: oklch(0.5427 0.055 234.6);
--primary-foreground: oklch(1 0 89.9);
--secondary: oklch(0.9702 0 89.9);
--secondary-foreground: oklch(0.4091 0 89.9);
--muted: oklch(0.9702 0 89.9);
--muted-foreground: oklch(0.6304 0.0501 232.9);
--accent: oklch(0.9633 0.0203 131.5);
--accent-foreground: oklch(0.4968 0.0663 196.6);
--destructive: oklch(0.6 0.22 27);
--destructive-foreground: oklch(0.99 0 0);
--border: oklch(0.9 0.01 235);
--input: oklch(0.91 0.01 235);
--ring: oklch(0.7344 0.1772 137.9);
--chart-1: oklch(0.5427 0.055 234.6);
--chart-2: oklch(0.7344 0.1772 137.9);
--chart-3: oklch(0.5778 0.0529 235.7);
--chart-4: oklch(0.7013 0.1996 43.7);
--chart-5: oklch(0.787 0.1232 161.2);
--sidebar: oklch(0.985 0.004 131);
--sidebar-foreground: oklch(0.4091 0 89.9);
--sidebar-primary: oklch(0.5427 0.055 234.6);
--sidebar-primary-foreground: oklch(1 0 89.9);
--sidebar-accent: oklch(0.9633 0.0203 131.5);
--sidebar-accent-foreground: oklch(0.4968 0.0663 196.6);
--sidebar-border: oklch(0.9 0.01 235);
--sidebar-ring: oklch(0.7344 0.1772 137.9);
--shadow-soft:
0 1px 2px oklch(0.5427 0.055 234.6 / 0.06), 0 4px 14px oklch(0.5427 0.055 234.6 / 0.06);
--shadow-hover:
0 4px 10px oklch(0.5427 0.055 234.6 / 0.1), 0 14px 30px oklch(0.5427 0.055 234.6 / 0.12);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.984 0.003 247.858);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
border-color: var(--color-border);
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
}
}
+468
View File
@@ -0,0 +1,468 @@
-- =========================================================
-- TABLERO CDC - Acceso autorizado por Supabase
-- Ejecutar en Supabase SQL Editor ANTES de desplegar el ZIP.
--
-- Objetivo:
-- - El acceso a la app ya no depende de una lista fija en código.
-- - Los usuarios permitidos viven en public.tablero_cdc_allowed_users.
-- - Para agregar/quitar acceso luego, se actualiza esta tabla en Supabase.
-- - También centraliza permisos especiales usados por la app:
-- * can_manage_internal_pricing
-- * can_delete_projects
-- * can_control_pricing_summary
-- =========================================================
begin;
create table if not exists public.tablero_cdc_allowed_users (
email text primary key,
full_name text,
role text not null default 'user',
is_active boolean not null default true,
can_delete_projects boolean not null default false,
can_manage_internal_pricing boolean not null default false,
can_control_pricing_summary boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.tablero_cdc_allowed_users is
'Lista central de usuarios autorizados para entrar al Tablero CDC.';
comment on column public.tablero_cdc_allowed_users.role is
'Rol lógico de la app. director_creativo habilita comportamiento especial de Director Creativo.';
create or replace function public.set_tablero_cdc_allowed_users_updated_at()
returns trigger
language plpgsql
as $$
begin
new.email = lower(trim(new.email));
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_allowed_users_updated_at on public.tablero_cdc_allowed_users;
create trigger trg_tablero_cdc_allowed_users_updated_at
before insert or update on public.tablero_cdc_allowed_users
for each row
execute function public.set_tablero_cdc_allowed_users_updated_at();
-- Usuarios autorizados iniciales.
-- Nota: Re-ejecutar este SQL mantiene estos usuarios activos y actualiza sus permisos base.
insert into public.tablero_cdc_allowed_users (
email,
full_name,
role,
is_active,
can_delete_projects,
can_manage_internal_pricing,
can_control_pricing_summary
)
values
(
'iaracena@gomezleemarketing.com',
'Isaac Daniel Aracena Toribio',
'admin_it',
true,
true,
true,
true
),
(
'jgomez@gomezleemarketing.com',
'José Leopoldo Gomez',
'leadership',
true,
false,
false,
true
),
(
'gmarrero@gomezleemarketing.com',
'Gerardo Marrero',
'director_creativo',
true,
true,
true,
true
),
(
'areyes@gomezleemarketing.com',
'Alicia Thaía Reyes',
'user',
true,
false,
false,
false
),
(
'boliva@gomezleemarketing.com',
'Bonnie Oliva',
'user',
true,
false,
false,
false
),
(
'obrisceno@gomezleemarketing.com',
'Oscar Brisceño',
'user',
true,
false,
false,
false
),
(
'lavila01@gomezlee.marketing',
'Lady Avila Faura',
'user',
true,
false,
false,
false
),
(
'rmarcano@gomezleemarketing.com',
'Ramon Marcano',
'user',
true,
false,
false,
false
),
(
'lmatos@gomezleemarketing.com',
'Luis Matos',
'it',
true,
false,
false,
false
),
(
'mgomez@gomezleemarketing.com',
'Máximo Gomez',
'leadership',
true,
false,
false,
false
)
on conflict (email)
do update set
full_name = excluded.full_name,
role = excluded.role,
is_active = excluded.is_active,
can_delete_projects = excluded.can_delete_projects,
can_manage_internal_pricing = excluded.can_manage_internal_pricing,
can_control_pricing_summary = excluded.can_control_pricing_summary,
updated_at = now();
alter table public.tablero_cdc_allowed_users enable row level security;
drop policy if exists "tablero_cdc_allowed_users_select_own_active" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_insert_from_app" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_update_from_app" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_delete_from_app" on public.tablero_cdc_allowed_users;
-- La app solo puede leer la fila activa del usuario autenticado.
-- La administración real se hace desde Supabase SQL Editor.
create policy "tablero_cdc_allowed_users_select_own_active"
on public.tablero_cdc_allowed_users
for select
to authenticated
using (
is_active = true
and lower(email) = lower(coalesce(auth.jwt() ->> 'email', ''))
);
create policy "tablero_cdc_allowed_users_no_insert_from_app"
on public.tablero_cdc_allowed_users
for insert
to authenticated
with check (false);
create policy "tablero_cdc_allowed_users_no_update_from_app"
on public.tablero_cdc_allowed_users
for update
to authenticated
using (false)
with check (false);
create policy "tablero_cdc_allowed_users_no_delete_from_app"
on public.tablero_cdc_allowed_users
for delete
to authenticated
using (false);
grant select on public.tablero_cdc_allowed_users to authenticated;
-- Funciones auxiliares para políticas/RPC.
create or replace function public.tablero_cdc_current_user_is_allowed()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where u.email = lower(coalesce(auth.jwt() ->> 'email', ''))
and u.is_active = true
);
$$;
create or replace function public.tablero_cdc_current_user_can_control_pricing_summary()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where u.email = lower(coalesce(auth.jwt() ->> 'email', ''))
and u.is_active = true
and u.can_control_pricing_summary = true
);
$$;
grant execute on function public.tablero_cdc_current_user_is_allowed() to authenticated;
grant execute on function public.tablero_cdc_current_user_can_control_pricing_summary() to authenticated;
-- =========================================================
-- Total Tarifado: configuración pública/privada usando tabla de accesos
-- =========================================================
create table if not exists public.tablero_cdc_app_settings (
key text primary key,
value jsonb not null default '{}'::jsonb,
updated_by uuid references auth.users(id),
updated_at timestamptz not null default now()
);
insert into public.tablero_cdc_app_settings (key, value)
values ('pricing_summary_public', jsonb_build_object('enabled', false))
on conflict (key) do nothing;
alter table public.tablero_cdc_app_settings enable row level security;
drop policy if exists "tablero_cdc_app_settings_select_authenticated" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_select_allowed_users" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_insert_summary_controllers" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_update_summary_controllers" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_delete_none" on public.tablero_cdc_app_settings;
create policy "tablero_cdc_app_settings_select_allowed_users"
on public.tablero_cdc_app_settings
for select
to authenticated
using (public.tablero_cdc_current_user_is_allowed());
create policy "tablero_cdc_app_settings_insert_summary_controllers"
on public.tablero_cdc_app_settings
for insert
to authenticated
with check (
key = 'pricing_summary_public'
and public.tablero_cdc_current_user_can_control_pricing_summary()
);
create policy "tablero_cdc_app_settings_update_summary_controllers"
on public.tablero_cdc_app_settings
for update
to authenticated
using (
key = 'pricing_summary_public'
and public.tablero_cdc_current_user_can_control_pricing_summary()
)
with check (
key = 'pricing_summary_public'
and public.tablero_cdc_current_user_can_control_pricing_summary()
);
create policy "tablero_cdc_app_settings_delete_none"
on public.tablero_cdc_app_settings
for delete
to authenticated
using (false);
create or replace function public.set_tablero_cdc_app_settings_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
new.updated_by = auth.uid();
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_app_settings_updated_at on public.tablero_cdc_app_settings;
create trigger trg_tablero_cdc_app_settings_updated_at
before insert or update on public.tablero_cdc_app_settings
for each row
execute function public.set_tablero_cdc_app_settings_updated_at();
grant select on public.tablero_cdc_app_settings to authenticated;
create or replace function public.tablero_cdc_set_pricing_summary_public(p_enabled boolean)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
v_enabled boolean := coalesce(p_enabled, false);
begin
if not public.tablero_cdc_current_user_can_control_pricing_summary() then
raise exception 'No tienes permiso para cambiar la visibilidad del total tarifado.';
end if;
insert into public.tablero_cdc_app_settings (key, value, updated_by, updated_at)
values (
'pricing_summary_public',
jsonb_build_object('enabled', v_enabled),
auth.uid(),
now()
)
on conflict (key)
do update set
value = excluded.value,
updated_by = auth.uid(),
updated_at = now();
return jsonb_build_object('enabled', v_enabled);
end;
$$;
grant execute on function public.tablero_cdc_set_pricing_summary_public(boolean) to authenticated;
-- Reemplaza la RPC de resumen para respetar:
-- 1) que el usuario esté autorizado para la app;
-- 2) que sea controlador del Total Tarifado o que el total esté público.
create or replace function public.tablero_cdc_get_pricing_summary(
p_tab text default 'todos',
p_search text default '',
p_country text default '',
p_brand text default '',
p_client text default '',
p_cm text default ''
)
returns jsonb
language sql
stable
security invoker
set search_path = public
as $$
with visibility as (
select coalesce((value ->> 'enabled')::boolean, false) as is_public
from public.tablero_cdc_app_settings
where key = 'pricing_summary_public'
),
allowed as (
select
public.tablero_cdc_current_user_is_allowed()
and (
public.tablero_cdc_current_user_can_control_pricing_summary()
or coalesce((select is_public from visibility), false)
) as can_view_pricing
),
params as (
select
lower(coalesce(nullif(trim(p_tab), ''), 'todos')) as tab,
trim(coalesce(p_search, '')) as search_text,
trim(coalesce(p_country, '')) as country_filter,
trim(coalesce(p_brand, '')) as brand_filter,
trim(coalesce(p_client, '')) as client_filter,
trim(coalesce(p_cm, '')) as cm_filter
),
base_filtered as (
select p.*
from public.tablero_cdc_projects p
cross join params prm
where
case
when prm.tab in ('abiertos', 'activos', 'active', 'open') then
coalesce(nullif(trim(p.status), ''), 'Activo') = 'Activo'
when prm.tab in ('cerrados', 'closed') then
coalesce(nullif(trim(p.status), ''), 'Activo') <> 'Activo'
else
true
end
and (
prm.search_text = ''
or coalesce(p.title, '') ilike '%' || prm.search_text || '%'
or coalesce(p.client, '') ilike '%' || prm.search_text || '%'
or coalesce(p.brand, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country, '') ilike '%' || prm.search_text || '%'
or coalesce(p.requested_by, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country_manager, '') ilike '%' || prm.search_text || '%'
or coalesce(p.description, '') ilike '%' || prm.search_text || '%'
)
),
filtered as (
select bf.*
from base_filtered bf
cross join params prm
where
(prm.country_filter = '' or coalesce(bf.country, '') ilike '%' || prm.country_filter || '%')
and (prm.brand_filter = '' or coalesce(bf.brand, '') ilike '%' || prm.brand_filter || '%')
and (prm.client_filter = '' or coalesce(bf.client, '') ilike '%' || prm.client_filter || '%')
and (prm.cm_filter = '' or coalesce(bf.country_manager, '') ilike '%' || prm.cm_filter || '%')
),
totals as (
select
count(*)::integer as project_count,
coalesce(
sum(
greatest(
coalesce(nullif(trim(internal_amount::text), '')::numeric, 0),
0
)
),
0
)::numeric as total_amount
from filtered
)
select case
when (select can_view_pricing from allowed) then
jsonb_build_object(
'total_amount', (select total_amount from totals),
'project_count', (select project_count from totals)
)
else
jsonb_build_object(
'total_amount', 0,
'project_count', 0
)
end;
$$;
grant execute on function public.tablero_cdc_get_pricing_summary(text, text, text, text, text, text) to authenticated;
-- Intenta habilitar realtime para que el cambio de visible/oculto se refleje sin recargar.
do $$
begin
alter publication supabase_realtime add table public.tablero_cdc_app_settings;
exception
when duplicate_object then null;
when undefined_object then null;
end $$;
commit;
select
'Acceso Tablero CDC listo' as status,
count(*) filter (where is_active) as usuarios_activos,
count(*) filter (where can_control_pricing_summary and is_active) as controlan_total_tarifado,
count(*) filter (where can_manage_internal_pricing and is_active) as ven_interno_cargado,
count(*) filter (where can_delete_projects and is_active) as pueden_eliminar
from public.tablero_cdc_allowed_users;
+59
View File
@@ -0,0 +1,59 @@
-- =========================================================
-- TABLERO CDC - HISTORIAL DE ACTIVIDAD POR PROYECTO
-- Ejecutar en Supabase SQL Editor antes de desplegar esta versión.
--
-- Crea una tabla pequeña para registrar comentarios y movimientos
-- dentro de cada proyecto, sin tocar el Google Sheet ni n8n.
-- =========================================================
create table if not exists public.tablero_cdc_project_activity (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.tablero_cdc_projects(id) on delete cascade,
actor_id uuid null,
actor_email text null,
actor_name text null,
activity_type text not null default 'note',
title text not null,
description text null,
extra_data jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists tablero_cdc_project_activity_project_created_idx
on public.tablero_cdc_project_activity(project_id, created_at desc);
alter table public.tablero_cdc_project_activity enable row level security;
drop policy if exists "tablero_cdc_project_activity_select_authenticated" on public.tablero_cdc_project_activity;
drop policy if exists "tablero_cdc_project_activity_insert_authenticated" on public.tablero_cdc_project_activity;
create policy "tablero_cdc_project_activity_select_authenticated"
on public.tablero_cdc_project_activity
for select
to authenticated
using (true);
create policy "tablero_cdc_project_activity_insert_authenticated"
on public.tablero_cdc_project_activity
for insert
to authenticated
with check (auth.uid() is not null);
grant select, insert on public.tablero_cdc_project_activity to authenticated;
-- Realtime opcional para futuras mejoras. No es obligatorio para que la app funcione.
do $$
begin
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'tablero_cdc_project_activity'
) then
alter publication supabase_realtime add table public.tablero_cdc_project_activity;
end if;
end $$;
-- Validación opcional después de crear la tabla:
-- select count(*) from public.tablero_cdc_project_activity;
+87
View File
@@ -0,0 +1,87 @@
-- Tablero CDC - Tarifario / Estimación interna
-- Crea la tabla que guarda las líneas de costo por proyecto.
-- No modifica proyectos existentes, no toca created_at, no toca Google Sheets ni n8n.
create table if not exists public.tablero_cdc_project_pricing_items (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.tablero_cdc_projects(id) on delete cascade,
source text not null default 'manual' check (source in ('tariff', 'manual')),
category text not null default '',
service_name text not null default '',
work_type text not null default '',
complexity_level text not null default '',
reference_label text not null default '',
reference_min numeric,
reference_max numeric,
amount numeric not null default 0 check (amount >= 0),
description text not null default '',
sort_order integer not null default 0,
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()
);
create index if not exists tablero_cdc_project_pricing_items_project_id_idx
on public.tablero_cdc_project_pricing_items(project_id);
create index if not exists tablero_cdc_project_pricing_items_created_at_idx
on public.tablero_cdc_project_pricing_items(created_at desc);
alter table public.tablero_cdc_project_pricing_items enable row level security;
drop policy if exists "tablero_cdc_project_pricing_items_select_authenticated" on public.tablero_cdc_project_pricing_items;
drop policy if exists "tablero_cdc_project_pricing_items_insert_authenticated" on public.tablero_cdc_project_pricing_items;
drop policy if exists "tablero_cdc_project_pricing_items_update_authenticated" on public.tablero_cdc_project_pricing_items;
drop policy if exists "tablero_cdc_project_pricing_items_delete_authenticated" on public.tablero_cdc_project_pricing_items;
create policy "tablero_cdc_project_pricing_items_select_authenticated"
on public.tablero_cdc_project_pricing_items
for select
to authenticated
using (true);
create policy "tablero_cdc_project_pricing_items_insert_authenticated"
on public.tablero_cdc_project_pricing_items
for insert
to authenticated
with check (true);
create policy "tablero_cdc_project_pricing_items_update_authenticated"
on public.tablero_cdc_project_pricing_items
for update
to authenticated
using (true)
with check (true);
create policy "tablero_cdc_project_pricing_items_delete_authenticated"
on public.tablero_cdc_project_pricing_items
for delete
to authenticated
using (true);
create or replace function public.set_tablero_cdc_project_pricing_items_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_project_pricing_items_updated_at on public.tablero_cdc_project_pricing_items;
create trigger trg_tablero_cdc_project_pricing_items_updated_at
before insert or update on public.tablero_cdc_project_pricing_items
for each row
execute function public.set_tablero_cdc_project_pricing_items_updated_at();
select
'tablero_cdc_project_pricing_items lista' as status,
count(*) as registros_actuales
from public.tablero_cdc_project_pricing_items;
+38
View File
@@ -0,0 +1,38 @@
-- =========================================================
-- TABLERO CDC - HABILITAR SUPABASE REALTIME
-- Ejecutar una sola vez en Supabase SQL Editor.
-- Incluye proyectos, links y listas dinámicas.
-- =========================================================
do $$
begin
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'tablero_cdc_projects'
) then
alter publication supabase_realtime add table public.tablero_cdc_projects;
end if;
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'tablero_cdc_project_links'
) then
alter publication supabase_realtime add table public.tablero_cdc_project_links;
end if;
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'tablero_cdc_app_lists'
) then
alter publication supabase_realtime add table public.tablero_cdc_app_lists;
end if;
end $$;
+207
View File
@@ -0,0 +1,207 @@
-- =========================================================
-- TABLERO CDC - RPC PAGINADO + FILTROS AVANZADOS
-- Ejecutar en Supabase SQL Editor antes de desplegar esta versión.
--
-- La app usa esta función para cargar solo la página actual de:
-- - Todos
-- - Activos
-- - Cerrados
--
-- También filtra desde Supabase por:
-- - País / BU
-- - Marca
-- - Cliente
-- - CM / Country Manager
--
-- No borra datos, no cambia tablas y no toca crear/editar/eliminar.
-- =========================================================
-- Evita conflictos con la versión anterior de 4 parámetros.
drop function if exists public.tablero_cdc_get_projects_paginated(text, text, integer, integer);
drop function if exists public.tablero_cdc_get_projects_paginated(text, text, integer, integer, text, text, text, text);
create or replace function public.tablero_cdc_get_projects_paginated(
p_tab text default 'todos',
p_search text default '',
p_page integer default 1,
p_page_size integer default 24,
p_country text default '',
p_brand text default '',
p_client text default '',
p_cm text default ''
)
returns jsonb
language sql
stable
security invoker
set search_path = public
as $$
with params as (
select
lower(coalesce(nullif(trim(p_tab), ''), 'todos')) as tab,
trim(coalesce(p_search, '')) as search_text,
greatest(coalesce(p_page, 1), 1) as page_number,
least(greatest(coalesce(p_page_size, 24), 1), 100) as page_size,
trim(coalesce(p_country, '')) as country_filter,
trim(coalesce(p_brand, '')) as brand_filter,
trim(coalesce(p_client, '')) as client_filter,
trim(coalesce(p_cm, '')) as cm_filter
),
base_filtered as (
select p.*
from public.tablero_cdc_projects p
cross join params prm
where
case
when prm.tab in ('abiertos', 'activos', 'active', 'open') then
coalesce(nullif(trim(p.status), ''), 'Activo') = 'Activo'
when prm.tab in ('cerrados', 'closed') then
coalesce(nullif(trim(p.status), ''), 'Activo') <> 'Activo'
else
true
end
and (
prm.search_text = ''
or coalesce(p.title, '') ilike '%' || prm.search_text || '%'
or coalesce(p.client, '') ilike '%' || prm.search_text || '%'
or coalesce(p.brand, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country, '') ilike '%' || prm.search_text || '%'
or coalesce(p.requested_by, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country_manager, '') ilike '%' || prm.search_text || '%'
or coalesce(p.description, '') ilike '%' || prm.search_text || '%'
)
),
filtered as (
select bf.*
from base_filtered bf
cross join params prm
where
(prm.country_filter = '' or coalesce(bf.country, '') ilike '%' || prm.country_filter || '%')
and (prm.brand_filter = '' or coalesce(bf.brand, '') ilike '%' || prm.brand_filter || '%')
and (prm.client_filter = '' or coalesce(bf.client, '') ilike '%' || prm.client_filter || '%')
and (prm.cm_filter = '' or coalesce(bf.country_manager, '') ilike '%' || prm.cm_filter || '%')
),
total as (
select count(*)::integer as total_count
from filtered
),
paged as (
select f.*
from filtered f
cross join params prm
order by f.created_at desc nulls last, f.id desc
limit (select page_size from params)
offset ((select page_number - 1 from params) * (select page_size from params))
),
option_values as (
select
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(country) as value
from base_filtered
where nullif(trim(coalesce(country, '')), '') is not null
) countries
),
'[]'::jsonb
) as countries,
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(brand) as value
from base_filtered
where nullif(trim(coalesce(brand, '')), '') is not null
) brands
),
'[]'::jsonb
) as brands,
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(client) as value
from base_filtered
where nullif(trim(coalesce(client, '')), '') is not null
) clients
),
'[]'::jsonb
) as clients,
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(country_manager) as value
from base_filtered
where nullif(trim(coalesce(country_manager, '')), '') is not null
) cms
),
'[]'::jsonb
) as cms
)
select jsonb_build_object(
'total_count', (select total_count from total),
'filter_options', jsonb_build_object(
'countries', (select countries from option_values),
'brands', (select brands from option_values),
'clients', (select clients from option_values),
'cms', (select cms from option_values)
),
'projects', coalesce(
(
select jsonb_agg(
jsonb_build_object(
'id', p.id,
'title', p.title,
'client', p.client,
'brand', p.brand,
'country', p.country,
'requested_by', p.requested_by,
'country_manager', p.country_manager,
'description', p.description,
'status', p.status,
'internal_amount', case
when lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
then to_jsonb(p.internal_amount)
else 'null'::jsonb
end,
'brief_link', p.brief_link,
'created_at', p.created_at,
'extra_data', p.extra_data,
'project_links', coalesce(
(
select jsonb_agg(
jsonb_build_object(
'id', l.id,
'link_type', l.link_type,
'url', l.url,
'label', l.label
)
order by l.label asc nulls last, l.id asc
)
from public.tablero_cdc_project_links l
where l.project_id = p.id
),
'[]'::jsonb
)
)
order by p.created_at desc nulls last, p.id desc
)
from paged p
),
'[]'::jsonb
)
);
$$;
grant execute on function public.tablero_cdc_get_projects_paginated(text, text, integer, integer, text, text, text, text) to authenticated;
-- Validación rápida opcional después de crear la función:
-- select public.tablero_cdc_get_projects_paginated('todos', '', 1, 24, '', '', '', '');
-- select public.tablero_cdc_get_projects_paginated('abiertos', '', 1, 24, '', '', '', '');
-- select public.tablero_cdc_get_projects_paginated('cerrados', '', 1, 24, '', '', '', '');
-- select public.tablero_cdc_get_projects_paginated('todos', '', 1, 24, 'Republica Dominicana', '', '', '');
+468
View File
@@ -0,0 +1,468 @@
-- =========================================================
-- TABLERO CDC - Acceso autorizado por Supabase
-- Ejecutar en Supabase SQL Editor ANTES de desplegar el ZIP.
--
-- Objetivo:
-- - El acceso a la app ya no depende de una lista fija en código.
-- - Los usuarios permitidos viven en public.tablero_cdc_allowed_users.
-- - Para agregar/quitar acceso luego, se actualiza esta tabla en Supabase.
-- - También centraliza permisos especiales usados por la app:
-- * can_manage_internal_pricing
-- * can_delete_projects
-- * can_control_pricing_summary
-- =========================================================
begin;
create table if not exists public.tablero_cdc_allowed_users (
email text primary key,
full_name text,
role text not null default 'user',
is_active boolean not null default true,
can_delete_projects boolean not null default false,
can_manage_internal_pricing boolean not null default false,
can_control_pricing_summary boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.tablero_cdc_allowed_users is
'Lista central de usuarios autorizados para entrar al Tablero CDC.';
comment on column public.tablero_cdc_allowed_users.role is
'Rol lógico de la app. director_creativo habilita comportamiento especial de Director Creativo.';
create or replace function public.set_tablero_cdc_allowed_users_updated_at()
returns trigger
language plpgsql
as $$
begin
new.email = lower(trim(new.email));
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_allowed_users_updated_at on public.tablero_cdc_allowed_users;
create trigger trg_tablero_cdc_allowed_users_updated_at
before insert or update on public.tablero_cdc_allowed_users
for each row
execute function public.set_tablero_cdc_allowed_users_updated_at();
-- Usuarios autorizados iniciales.
-- Nota: Re-ejecutar este SQL mantiene estos usuarios activos y actualiza sus permisos base.
insert into public.tablero_cdc_allowed_users (
email,
full_name,
role,
is_active,
can_delete_projects,
can_manage_internal_pricing,
can_control_pricing_summary
)
values
(
'iaracena@gomezleemarketing.com',
'Isaac Daniel Aracena Toribio',
'admin_it',
true,
true,
true,
true
),
(
'jgomez@gomezleemarketing.com',
'José Leopoldo Gomez',
'leadership',
true,
false,
false,
true
),
(
'gmarrero@gomezleemarketing.com',
'Gerardo Marrero',
'director_creativo',
true,
true,
true,
true
),
(
'areyes@gomezleemarketing.com',
'Alicia Thaía Reyes',
'user',
true,
false,
false,
false
),
(
'boliva@gomezleemarketing.com',
'Bonnie Oliva',
'user',
true,
false,
false,
false
),
(
'obrisceno@gomezleemarketing.com',
'Oscar Brisceño',
'user',
true,
false,
false,
false
),
(
'lavila01@gomezlee.marketing',
'Lady Avila Faura',
'user',
true,
false,
false,
false
),
(
'rmarcano@gomezleemarketing.com',
'Ramon Marcano',
'user',
true,
false,
false,
false
),
(
'lmatos@gomezleemarketing.com',
'Luis Matos',
'it',
true,
false,
false,
false
),
(
'mgomez@gomezleemarketing.com',
'Máximo Gomez',
'leadership',
true,
false,
false,
false
)
on conflict (email)
do update set
full_name = excluded.full_name,
role = excluded.role,
is_active = excluded.is_active,
can_delete_projects = excluded.can_delete_projects,
can_manage_internal_pricing = excluded.can_manage_internal_pricing,
can_control_pricing_summary = excluded.can_control_pricing_summary,
updated_at = now();
alter table public.tablero_cdc_allowed_users enable row level security;
drop policy if exists "tablero_cdc_allowed_users_select_own_active" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_insert_from_app" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_update_from_app" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_delete_from_app" on public.tablero_cdc_allowed_users;
-- La app solo puede leer la fila activa del usuario autenticado.
-- La administración real se hace desde Supabase SQL Editor.
create policy "tablero_cdc_allowed_users_select_own_active"
on public.tablero_cdc_allowed_users
for select
to authenticated
using (
is_active = true
and lower(email) = lower(coalesce(auth.jwt() ->> 'email', ''))
);
create policy "tablero_cdc_allowed_users_no_insert_from_app"
on public.tablero_cdc_allowed_users
for insert
to authenticated
with check (false);
create policy "tablero_cdc_allowed_users_no_update_from_app"
on public.tablero_cdc_allowed_users
for update
to authenticated
using (false)
with check (false);
create policy "tablero_cdc_allowed_users_no_delete_from_app"
on public.tablero_cdc_allowed_users
for delete
to authenticated
using (false);
grant select on public.tablero_cdc_allowed_users to authenticated;
-- Funciones auxiliares para políticas/RPC.
create or replace function public.tablero_cdc_current_user_is_allowed()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where u.email = lower(coalesce(auth.jwt() ->> 'email', ''))
and u.is_active = true
);
$$;
create or replace function public.tablero_cdc_current_user_can_control_pricing_summary()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where u.email = lower(coalesce(auth.jwt() ->> 'email', ''))
and u.is_active = true
and u.can_control_pricing_summary = true
);
$$;
grant execute on function public.tablero_cdc_current_user_is_allowed() to authenticated;
grant execute on function public.tablero_cdc_current_user_can_control_pricing_summary() to authenticated;
-- =========================================================
-- Total Tarifado: configuración pública/privada usando tabla de accesos
-- =========================================================
create table if not exists public.tablero_cdc_app_settings (
key text primary key,
value jsonb not null default '{}'::jsonb,
updated_by uuid references auth.users(id),
updated_at timestamptz not null default now()
);
insert into public.tablero_cdc_app_settings (key, value)
values ('pricing_summary_public', jsonb_build_object('enabled', false))
on conflict (key) do nothing;
alter table public.tablero_cdc_app_settings enable row level security;
drop policy if exists "tablero_cdc_app_settings_select_authenticated" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_select_allowed_users" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_insert_summary_controllers" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_update_summary_controllers" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_delete_none" on public.tablero_cdc_app_settings;
create policy "tablero_cdc_app_settings_select_allowed_users"
on public.tablero_cdc_app_settings
for select
to authenticated
using (public.tablero_cdc_current_user_is_allowed());
create policy "tablero_cdc_app_settings_insert_summary_controllers"
on public.tablero_cdc_app_settings
for insert
to authenticated
with check (
key = 'pricing_summary_public'
and public.tablero_cdc_current_user_can_control_pricing_summary()
);
create policy "tablero_cdc_app_settings_update_summary_controllers"
on public.tablero_cdc_app_settings
for update
to authenticated
using (
key = 'pricing_summary_public'
and public.tablero_cdc_current_user_can_control_pricing_summary()
)
with check (
key = 'pricing_summary_public'
and public.tablero_cdc_current_user_can_control_pricing_summary()
);
create policy "tablero_cdc_app_settings_delete_none"
on public.tablero_cdc_app_settings
for delete
to authenticated
using (false);
create or replace function public.set_tablero_cdc_app_settings_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
new.updated_by = auth.uid();
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_app_settings_updated_at on public.tablero_cdc_app_settings;
create trigger trg_tablero_cdc_app_settings_updated_at
before insert or update on public.tablero_cdc_app_settings
for each row
execute function public.set_tablero_cdc_app_settings_updated_at();
grant select on public.tablero_cdc_app_settings to authenticated;
create or replace function public.tablero_cdc_set_pricing_summary_public(p_enabled boolean)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
v_enabled boolean := coalesce(p_enabled, false);
begin
if not public.tablero_cdc_current_user_can_control_pricing_summary() then
raise exception 'No tienes permiso para cambiar la visibilidad del total tarifado.';
end if;
insert into public.tablero_cdc_app_settings (key, value, updated_by, updated_at)
values (
'pricing_summary_public',
jsonb_build_object('enabled', v_enabled),
auth.uid(),
now()
)
on conflict (key)
do update set
value = excluded.value,
updated_by = auth.uid(),
updated_at = now();
return jsonb_build_object('enabled', v_enabled);
end;
$$;
grant execute on function public.tablero_cdc_set_pricing_summary_public(boolean) to authenticated;
-- Reemplaza la RPC de resumen para respetar:
-- 1) que el usuario esté autorizado para la app;
-- 2) que sea controlador del Total Tarifado o que el total esté público.
create or replace function public.tablero_cdc_get_pricing_summary(
p_tab text default 'todos',
p_search text default '',
p_country text default '',
p_brand text default '',
p_client text default '',
p_cm text default ''
)
returns jsonb
language sql
stable
security invoker
set search_path = public
as $$
with visibility as (
select coalesce((value ->> 'enabled')::boolean, false) as is_public
from public.tablero_cdc_app_settings
where key = 'pricing_summary_public'
),
allowed as (
select
public.tablero_cdc_current_user_is_allowed()
and (
public.tablero_cdc_current_user_can_control_pricing_summary()
or coalesce((select is_public from visibility), false)
) as can_view_pricing
),
params as (
select
lower(coalesce(nullif(trim(p_tab), ''), 'todos')) as tab,
trim(coalesce(p_search, '')) as search_text,
trim(coalesce(p_country, '')) as country_filter,
trim(coalesce(p_brand, '')) as brand_filter,
trim(coalesce(p_client, '')) as client_filter,
trim(coalesce(p_cm, '')) as cm_filter
),
base_filtered as (
select p.*
from public.tablero_cdc_projects p
cross join params prm
where
case
when prm.tab in ('abiertos', 'activos', 'active', 'open') then
coalesce(nullif(trim(p.status), ''), 'Activo') = 'Activo'
when prm.tab in ('cerrados', 'closed') then
coalesce(nullif(trim(p.status), ''), 'Activo') <> 'Activo'
else
true
end
and (
prm.search_text = ''
or coalesce(p.title, '') ilike '%' || prm.search_text || '%'
or coalesce(p.client, '') ilike '%' || prm.search_text || '%'
or coalesce(p.brand, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country, '') ilike '%' || prm.search_text || '%'
or coalesce(p.requested_by, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country_manager, '') ilike '%' || prm.search_text || '%'
or coalesce(p.description, '') ilike '%' || prm.search_text || '%'
)
),
filtered as (
select bf.*
from base_filtered bf
cross join params prm
where
(prm.country_filter = '' or coalesce(bf.country, '') ilike '%' || prm.country_filter || '%')
and (prm.brand_filter = '' or coalesce(bf.brand, '') ilike '%' || prm.brand_filter || '%')
and (prm.client_filter = '' or coalesce(bf.client, '') ilike '%' || prm.client_filter || '%')
and (prm.cm_filter = '' or coalesce(bf.country_manager, '') ilike '%' || prm.cm_filter || '%')
),
totals as (
select
count(*)::integer as project_count,
coalesce(
sum(
greatest(
coalesce(nullif(trim(internal_amount::text), '')::numeric, 0),
0
)
),
0
)::numeric as total_amount
from filtered
)
select case
when (select can_view_pricing from allowed) then
jsonb_build_object(
'total_amount', (select total_amount from totals),
'project_count', (select project_count from totals)
)
else
jsonb_build_object(
'total_amount', 0,
'project_count', 0
)
end;
$$;
grant execute on function public.tablero_cdc_get_pricing_summary(text, text, text, text, text, text) to authenticated;
-- Intenta habilitar realtime para que el cambio de visible/oculto se refleje sin recargar.
do $$
begin
alter publication supabase_realtime add table public.tablero_cdc_app_settings;
exception
when duplicate_object then null;
when undefined_object then null;
end $$;
commit;
select
'Acceso Tablero CDC listo' as status,
count(*) filter (where is_active) as usuarios_activos,
count(*) filter (where can_control_pricing_summary and is_active) as controlan_total_tarifado,
count(*) filter (where can_manage_internal_pricing and is_active) as ven_interno_cargado,
count(*) filter (where can_delete_projects and is_active) as pueden_eliminar
from public.tablero_cdc_allowed_users;
+430
View File
@@ -0,0 +1,430 @@
-- =========================================================
-- TABLERO CDC - Catálogo dinámico de tarifario + guardado seguro de links + RPC de totales
-- Seguro para producción: no borra proyectos, no altera Google Sheets, no cambia n8n.
-- La fuente de mantenimiento del tarifario será Google Sheets; n8n sincroniza hacia esta tabla.
-- =========================================================
-- 1) Catálogo dinámico de tarifas estándar.
-- La app mantiene el tarifario base como respaldo. Esta tabla es el destino normalizado
-- que n8n llenará leyendo el Google Sheet del tarifario. La app solo consume esta tabla.
create table if not exists public.tablero_cdc_tariff_catalog (
id text primary key,
catalog_item_id text not null,
section text not null check (section in ('grafico', 'estrategia')),
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()
);
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);
alter table public.tablero_cdc_tariff_catalog enable row level security;
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 (
lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
);
create policy "tablero_cdc_tariff_catalog_update_pricing_admins"
on public.tablero_cdc_tariff_catalog
for update
to authenticated
using (
lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
)
with check (
lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
);
-- No recomendamos borrar tarifas usadas históricamente. La app usa activar/desactivar.
-- Aun así dejamos delete restringido por si en pruebas se necesita limpiar un registro creado por error.
create policy "tablero_cdc_tariff_catalog_delete_pricing_admins"
on public.tablero_cdc_tariff_catalog
for delete
to authenticated
using (
lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
);
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();
-- 2) Guardado seguro de links de Propuesta / Artes Finales.
-- La app llama esta función para mover links de un campo a otro en una sola operación transaccional.
-- Si cualquier insert falla, el delete también se revierte automáticamente.
create or replace function public.tablero_cdc_replace_project_links(
p_project_id uuid,
p_links jsonb default '[]'::jsonb
)
returns void
language plpgsql
security invoker
set search_path = public
as $$
begin
delete from public.tablero_cdc_project_links
where project_id = p_project_id;
insert into public.tablero_cdc_project_links (project_id, link_type, url, label)
select
p_project_id,
nullif(trim(link_type), ''),
trim(url),
nullif(trim(label), '')
from jsonb_to_recordset(coalesce(p_links, '[]'::jsonb)) as x(
link_type text,
url text,
label text
)
where nullif(trim(url), '') is not null;
end;
$$;
grant execute on function public.tablero_cdc_replace_project_links(uuid, jsonb) to authenticated;
-- 3) RPC paginado actualizado para que el monto interno solo viaje a Marrero e Isaac.
-- No cambia datos. Solo reemplaza la función de lectura si ya existe.
create or replace function public.tablero_cdc_get_projects_paginated(
p_tab text default 'todos',
p_search text default '',
p_page integer default 1,
p_page_size integer default 24,
p_country text default '',
p_brand text default '',
p_client text default '',
p_cm text default ''
)
returns jsonb
language sql
stable
security invoker
set search_path = public
as $$
with params as (
select
lower(coalesce(nullif(trim(p_tab), ''), 'todos')) as tab,
trim(coalesce(p_search, '')) as search_text,
greatest(coalesce(p_page, 1), 1) as page_number,
least(greatest(coalesce(p_page_size, 24), 1), 100) as page_size,
trim(coalesce(p_country, '')) as country_filter,
trim(coalesce(p_brand, '')) as brand_filter,
trim(coalesce(p_client, '')) as client_filter,
trim(coalesce(p_cm, '')) as cm_filter
),
base_filtered as (
select p.*
from public.tablero_cdc_projects p
cross join params prm
where
case
when prm.tab in ('abiertos', 'activos', 'active', 'open') then
coalesce(nullif(trim(p.status), ''), 'Activo') = 'Activo'
when prm.tab in ('cerrados', 'closed') then
coalesce(nullif(trim(p.status), ''), 'Activo') <> 'Activo'
else
true
end
and (
prm.search_text = ''
or coalesce(p.title, '') ilike '%' || prm.search_text || '%'
or coalesce(p.client, '') ilike '%' || prm.search_text || '%'
or coalesce(p.brand, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country, '') ilike '%' || prm.search_text || '%'
or coalesce(p.requested_by, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country_manager, '') ilike '%' || prm.search_text || '%'
or coalesce(p.description, '') ilike '%' || prm.search_text || '%'
)
),
filtered as (
select bf.*
from base_filtered bf
cross join params prm
where
(prm.country_filter = '' or coalesce(bf.country, '') ilike '%' || prm.country_filter || '%')
and (prm.brand_filter = '' or coalesce(bf.brand, '') ilike '%' || prm.brand_filter || '%')
and (prm.client_filter = '' or coalesce(bf.client, '') ilike '%' || prm.client_filter || '%')
and (prm.cm_filter = '' or coalesce(bf.country_manager, '') ilike '%' || prm.cm_filter || '%')
),
total as (
select count(*)::integer as total_count
from filtered
),
paged as (
select f.*
from filtered f
cross join params prm
order by f.created_at desc nulls last, f.id desc
limit (select page_size from params)
offset ((select page_number - 1 from params) * (select page_size from params))
),
option_values as (
select
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(country) as value
from base_filtered
where nullif(trim(coalesce(country, '')), '') is not null
) countries
),
'[]'::jsonb
) as countries,
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(brand) as value
from base_filtered
where nullif(trim(coalesce(brand, '')), '') is not null
) brands
),
'[]'::jsonb
) as brands,
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(client) as value
from base_filtered
where nullif(trim(coalesce(client, '')), '') is not null
) clients
),
'[]'::jsonb
) as clients,
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(country_manager) as value
from base_filtered
where nullif(trim(coalesce(country_manager, '')), '') is not null
) cms
),
'[]'::jsonb
) as cms
)
select jsonb_build_object(
'total_count', (select total_count from total),
'filter_options', jsonb_build_object(
'countries', (select countries from option_values),
'brands', (select brands from option_values),
'clients', (select clients from option_values),
'cms', (select cms from option_values)
),
'projects', coalesce(
(
select jsonb_agg(
jsonb_build_object(
'id', p.id,
'title', p.title,
'client', p.client,
'brand', p.brand,
'country', p.country,
'requested_by', p.requested_by,
'country_manager', p.country_manager,
'description', p.description,
'status', p.status,
'internal_amount', case
when lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
then to_jsonb(p.internal_amount)
else 'null'::jsonb
end,
'brief_link', p.brief_link,
'created_at', p.created_at,
'extra_data', p.extra_data,
'project_links', coalesce(
(
select jsonb_agg(
jsonb_build_object(
'id', l.id,
'link_type', l.link_type,
'url', l.url,
'label', l.label
)
order by l.label asc nulls last, l.id asc
)
from public.tablero_cdc_project_links l
where l.project_id = p.id
),
'[]'::jsonb
)
)
order by p.created_at desc nulls last, p.id desc
)
from paged p
),
'[]'::jsonb
)
);
$$;
grant execute on function public.tablero_cdc_get_projects_paginated(text, text, integer, integer, text, text, text, text) to authenticated;
-- 4) RPC de total tarifado global/filtrado.
-- Calcula en Supabase el total de Interno Cargado y la cantidad de proyectos según los mismos filtros de la pantalla.
-- Solo Marrero e Isaac reciben valores reales; otros usuarios reciben 0.
create or replace function public.tablero_cdc_get_pricing_summary(
p_tab text default 'todos',
p_search text default '',
p_country text default '',
p_brand text default '',
p_client text default '',
p_cm text default ''
)
returns jsonb
language sql
stable
security invoker
set search_path = public
as $$
with allowed as (
select lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
) as can_view_pricing
),
params as (
select
lower(coalesce(nullif(trim(p_tab), ''), 'todos')) as tab,
trim(coalesce(p_search, '')) as search_text,
trim(coalesce(p_country, '')) as country_filter,
trim(coalesce(p_brand, '')) as brand_filter,
trim(coalesce(p_client, '')) as client_filter,
trim(coalesce(p_cm, '')) as cm_filter
),
base_filtered as (
select p.*
from public.tablero_cdc_projects p
cross join params prm
where
case
when prm.tab in ('abiertos', 'activos', 'active', 'open') then
coalesce(nullif(trim(p.status), ''), 'Activo') = 'Activo'
when prm.tab in ('cerrados', 'closed') then
coalesce(nullif(trim(p.status), ''), 'Activo') <> 'Activo'
else
true
end
and (
prm.search_text = ''
or coalesce(p.title, '') ilike '%' || prm.search_text || '%'
or coalesce(p.client, '') ilike '%' || prm.search_text || '%'
or coalesce(p.brand, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country, '') ilike '%' || prm.search_text || '%'
or coalesce(p.requested_by, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country_manager, '') ilike '%' || prm.search_text || '%'
or coalesce(p.description, '') ilike '%' || prm.search_text || '%'
)
),
filtered as (
select bf.*
from base_filtered bf
cross join params prm
where
(prm.country_filter = '' or coalesce(bf.country, '') ilike '%' || prm.country_filter || '%')
and (prm.brand_filter = '' or coalesce(bf.brand, '') ilike '%' || prm.brand_filter || '%')
and (prm.client_filter = '' or coalesce(bf.client, '') ilike '%' || prm.client_filter || '%')
and (prm.cm_filter = '' or coalesce(bf.country_manager, '') ilike '%' || prm.cm_filter || '%')
),
totals as (
select
count(*)::integer as project_count,
coalesce(
sum(
greatest(
coalesce(nullif(trim(internal_amount::text), '')::numeric, 0),
0
)
),
0
)::numeric as total_amount
from filtered
)
select case
when (select can_view_pricing from allowed) then
jsonb_build_object(
'total_amount', (select total_amount from totals),
'project_count', (select project_count from totals)
)
else
jsonb_build_object(
'total_amount', 0,
'project_count', 0
)
end;
$$;
grant execute on function public.tablero_cdc_get_pricing_summary(text, text, text, text, text, text) to authenticated;
select
'tablero_cdc_tariff_catalog, safe links y RPC de totales listos' as status,
(select count(*) from public.tablero_cdc_tariff_catalog) as tarifas_dinamicas_actuales;

Some files were not shown because too many files have changed in this diff Show More