feat: actualizar dist final con CDC Brief y reaprobacion de rechazados

This commit is contained in:
2026-08-13 10:22:18 -04:00
parent 2e02753ea9
commit 7e41b2f26a
112 changed files with 482 additions and 25603 deletions
-3
View File
@@ -1,3 +0,0 @@
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
@@ -1,38 +0,0 @@
# 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
@@ -1,8 +0,0 @@
node_modules
dist
.output
.vinxi
pnpm-lock.yaml
package-lock.json
bun.lock
routeTree.gen.ts
-6
View File
@@ -1,6 +0,0 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
@@ -1,525 +0,0 @@
{
"name": "Tablero CDC - Migrar Legacy Sheet a Supabase",
"nodes": [
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q",
"mode": "list",
"cachedResultName": "APROBACIONES PROYECTOS",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": 1563472127,
"mode": "list",
"cachedResultName": "PROYECTOS 2026",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit#gid=1563472127"
},
"options": {
"dataLocationOnSheet": {
"values": {
"rangeDefinition": "specifyRangeA1",
"range": "A3:N183"
}
}
}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
160,
-16
],
"id": "c2e04d79-3014-49ec-b213-931cf57fa5f1",
"name": "Sheets - Leer legacy rows",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const rows = $input.all();\n\nconst FIRST_DATA_ROW = 4;\n\n// PRUEBA SEGURA:\n// Solo migramos la fila 4 por ahora.\n// Cuando validemos, cambiamos esto a 183.\nconst LAST_LEGACY_ROW = 183;\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction createUuid() {\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (char) {\n const random = Math.random() * 16 | 0;\n const value = char === 'x' ? random : (random & 0x3 | 0x8);\n return value.toString(16);\n });\n}\n\nfunction parseAmount(value) {\n const text = clean(value);\n if (!text) return null;\n\n const normalized = text\n .replace(/RD\\$/gi, '')\n .replace(/DOP/gi, '')\n .replace(/,/g, '')\n .trim();\n\n const number = Number(normalized);\n return Number.isFinite(number) ? number : null;\n}\n\nfunction extractRequestedBy(comments) {\n const text = clean(comments);\n const match = text.match(/^solicitado\\s+por\\s+(.+)$/i);\n return match ? match[1].trim() : '';\n}\n\nconst output = [];\n\nrows.forEach((item, index) => {\n const row = item.json;\n\n const sheetRowNumber = FIRST_DATA_ROW + index;\n\n if (sheetRowNumber < FIRST_DATA_ROW || sheetRowNumber > LAST_LEGACY_ROW) {\n return;\n }\n\n const existingProjectId = clean(row['Project ID']);\n if (existingProjectId) {\n return;\n }\n\n const title = clean(row['Tipo o nombre de proyecto']);\n const client = clean(row['Cliente']);\n const country = clean(row['BU Solicita']);\n const brand = clean(row['Marca']);\n const countryManager = clean(row['CM']);\n const status = clean(row['Status']) || 'Activo';\n const comments = clean(row['Comentarios']);\n const proposalLink = clean(row['Link de propuesta']);\n const internalAmount = parseAmount(row['$ Interno Cargado']);\n const briefLink = clean(row['Link del Brief']);\n const finalArtLink = clean(row['Link de artes finales']);\n const year = clean(row['Año']);\n const month = clean(row['Mes']);\n\n if (!title || !client || !country || !brand) {\n throw new Error(\n `Fila ${sheetRowNumber}: faltan campos mínimos. title=\"${title}\", client=\"${client}\", country=\"${country}\", brand=\"${brand}\"`\n );\n }\n\n const projectId = createUuid();\n\n const projectPayload = {\n id: projectId,\n title,\n client,\n brand,\n country,\n requested_by: extractRequestedBy(comments) || null,\n country_manager: countryManager || null,\n description: comments || null,\n status,\n internal_amount: internalAmount,\n currency: 'DOP',\n brief_link: briefLink || null,\n extra_data: {\n legacy_migration: true,\n legacy_source: 'PROYECTOS 2026',\n legacy_sheet_row_number: sheetRowNumber,\n legacy_year: year,\n legacy_month: month,\n migrated_at: new Date().toISOString(),\n },\n };\n\n const links = [];\n\n if (proposalLink) {\n links.push({\n project_id: projectId,\n link_type: 'proposal',\n url: proposalLink,\n label: 'Propuesta legacy',\n extra_data: {\n legacy_migration: true,\n legacy_sheet_row_number: sheetRowNumber,\n },\n });\n }\n\n if (finalArtLink) {\n links.push({\n project_id: projectId,\n link_type: 'final_art',\n url: finalArtLink,\n label: 'Artes finales legacy',\n extra_data: {\n legacy_migration: true,\n legacy_sheet_row_number: sheetRowNumber,\n },\n });\n }\n\n output.push({\n json: {\n sheet_row_number: sheetRowNumber,\n project_id: projectId,\n project_id_cell: `N${sheetRowNumber}`,\n title,\n project_payload: projectPayload,\n links,\n },\n });\n});\n\nreturn output;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
416,
-16
],
"id": "0303074a-5370-4bab-9fb8-e06e265d17aa",
"name": "Code - Preparar migracion legacy"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_projects",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "TU_SERVICE_ROLE_KEY_REAL"
},
{
"name": "Authorization",
"value": "Bearer TU_SERVICE_ROLE_KEY_REAL"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Prefer",
"value": "return=minimal"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.project_payload }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
624,
-16
],
"id": "6be4d8e1-13d5-4273-b5c5-d83b9ae28188",
"name": "Supabase - Insertar proyecto legacy"
},
{
"parameters": {
"jsCode": "const preparedItems = $('Code - Preparar migracion legacy').all();\n\nconst output = [];\n\nfor (const item of preparedItems) {\n const links = item.json.links || [];\n\n for (const link of links) {\n output.push({\n json: link,\n });\n }\n}\n\nreturn output;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
832,
-16
],
"id": "5c149992-85e2-4ade-a32f-e280131b0d5a",
"name": "Code - Preparar links legacy"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_project_links",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "TU_SERVICE_ROLE_KEY_REAL"
},
{
"name": "Authorization",
"value": "Bearer TU_SERVICE_ROLE_KEY_REAL"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Prefer",
"value": "return=minimal"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1040,
-16
],
"id": "5968b76f-2846-4c2f-a643-a52116e544a3",
"name": "Supabase - Insertar links legacy"
},
{
"parameters": {
"jsCode": "const preparedItems = $('Code - Preparar migracion legacy').all();\n\nreturn preparedItems.map(item => {\n return {\n json: {\n row_number: item.json.sheet_row_number,\n project_id: item.json.project_id,\n title: item.json.title,\n project_id_cell: item.json.project_id_cell,\n },\n };\n});"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1248,
-16
],
"id": "056908f7-3f2c-48d6-8613-90cd8eb3a3d3",
"name": "Code - Preparar Project IDs Sheet"
},
{
"parameters": {
"operation": "update",
"documentId": {
"__rl": true,
"value": "1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q",
"mode": "list",
"cachedResultName": "APROBACIONES PROYECTOS",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": 1563472127,
"mode": "list",
"cachedResultName": "PROYECTOS 2026",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit#gid=1563472127"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"row_number": "={{ $json.row_number }}",
"Project ID": "={{ $json.project_id }}"
},
"matchingColumns": [
"row_number"
],
"schema": [
{
"id": "Año",
"displayName": "Año",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "Mes",
"displayName": "Mes",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "Tipo o nombre de proyecto",
"displayName": "Tipo o nombre de proyecto",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "Cliente",
"displayName": "Cliente",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "BU Solicita",
"displayName": "BU Solicita",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "Marca",
"displayName": "Marca",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "CM",
"displayName": "CM",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "Status",
"displayName": "Status",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "Comentarios",
"displayName": "Comentarios",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "Link de propuesta",
"displayName": "Link de propuesta",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "$ Interno Cargado",
"displayName": "$ Interno Cargado",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "Link del Brief",
"displayName": "Link del Brief",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "Link de artes finales",
"displayName": "Link de artes finales",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": true
},
{
"id": "Project ID",
"displayName": "Project ID",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "row_number",
"displayName": "row_number",
"required": false,
"defaultMatch": false,
"display": true,
"type": "number",
"canBeUsedToMatch": true,
"readOnly": true,
"removed": false
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {
"locationDefine": {
"values": {
"headerRow": 3,
"firstDataRow": 4
}
}
}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
1456,
-16
],
"id": "666a950b-cd80-4f0a-885b-80745e74bb0b",
"name": "Sheets - Escribir Project ID legacy",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"path": "tablero-cdc-migracion-legacy-archivada",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-304,
16
],
"id": "9ba81599-f14c-45c7-b273-2ee1f6d2e8da",
"name": "Webhook",
"webhookId": "ba3e5457-3806-4e55-ac0b-9e4d90d3735c"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "{\n \"ok\": true,\n \"status\": \"archived\",\n \"workflow\": \"Tablero CDC - Migrar Legacy Sheet a Supabase\",\n \"message\": \"Workflow archivado solo para respaldo en Gitea. No ejecuta migración.\"\n}",
"options": {
"responseCode": 200
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
-48,
16
],
"id": "aef53048-0059-46ce-9eb6-36be924d678b",
"name": "Respond - Workflow archivado"
},
{
"parameters": {
"content": "## Migrar los proyectos históricos del Sheet PROYECTOS 2026 a Supabase.\n\nEl Webhook publicado es solo para que el flujo de respaldo a Gitea pueda guardar este workflow.\n\n",
"height": 336,
"width": 2048,
"color": "#33801E"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-352,
-128
],
"id": "f45a8a78-a258-4934-b755-9f82792a6d69",
"name": "Sticky Note"
}
],
"pinData": {},
"connections": {
"Sheets - Leer legacy rows": {
"main": [
[
{
"node": "Code - Preparar migracion legacy",
"type": "main",
"index": 0
}
]
]
},
"Code - Preparar migracion legacy": {
"main": [
[
{
"node": "Supabase - Insertar proyecto legacy",
"type": "main",
"index": 0
}
]
]
},
"Supabase - Insertar proyecto legacy": {
"main": [
[
{
"node": "Code - Preparar links legacy",
"type": "main",
"index": 0
}
]
]
},
"Code - Preparar links legacy": {
"main": [
[
{
"node": "Supabase - Insertar links legacy",
"type": "main",
"index": 0
}
]
]
},
"Supabase - Insertar links legacy": {
"main": [
[
{
"node": "Code - Preparar Project IDs Sheet",
"type": "main",
"index": 0
}
]
]
},
"Code - Preparar Project IDs Sheet": {
"main": [
[
{
"node": "Sheets - Escribir Project ID legacy",
"type": "main",
"index": 0
}
]
]
},
"Webhook": {
"main": [
[
{
"node": "Respond - Workflow archivado",
"type": "main",
"index": 0
}
]
]
},
"Respond - Workflow archivado": {
"main": [
[
{
"node": "Sheets - Leer legacy rows",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": true,
"timeSavedMode": "fixed",
"errorWorkflow": "puF4LUczoSz3hcek",
"timezone": "America/Santo_Domingo",
"callerPolicy": "workflowsFromSameOwner"
},
"versionId": "e3cc75d8-5d2d-421f-9714-45efb1d62aa1",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
},
"id": "IZLgEpGSfWhjj6Qy",
"tags": []
}
File diff suppressed because one or more lines are too long
@@ -1,175 +0,0 @@
{
"name": "Tablero CDC - Sync Listas",
"nodes": [
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q",
"mode": "list",
"cachedResultName": "APROBACIONES PROYECTOS",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": 1332738713,
"mode": "list",
"cachedResultName": "listas",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit#gid=1332738713"
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
208,
0
],
"id": "cd67f274-a366-4efb-841a-3a8bceda956f",
"name": "Sheets - Leer listas",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "function normalizeText(value) {\n return String(value || '')\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeKey(value) {\n return normalizeText(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction pick(row, possibleHeaders) {\n const normalizedRow = {};\n\n for (const [key, value] of Object.entries(row || {})) {\n normalizedRow[normalizeKey(key)] = value;\n }\n\n for (const header of possibleHeaders) {\n const value = normalizedRow[normalizeKey(header)];\n\n if (value !== undefined && value !== null && String(value).trim() !== '') {\n return normalizeText(value);\n }\n }\n\n return '';\n}\n\nfunction splitValues(value) {\n const text = normalizeText(value);\n if (!text) return [];\n\n return text\n .split(/\\n|;/)\n .map(item => normalizeText(item))\n .filter(Boolean);\n}\n\nfunction isInvalidValue(value) {\n const invalidValues = new Set([\n 'no especificado',\n 'no especificada',\n 'n/a',\n 'na',\n '-',\n '--',\n '',\n ]);\n\n return invalidValues.has(normalizeKey(value));\n}\n\nfunction addRecord(records, category, value, label, sortOrder) {\n const cleanValue = normalizeText(value);\n const cleanLabel = normalizeText(label || value);\n\n if (!cleanValue || !cleanLabel) return;\n if (isInvalidValue(cleanValue)) return;\n\n records.push({\n category,\n value: cleanValue,\n label: cleanLabel,\n sort_order: sortOrder,\n is_active: true,\n });\n}\n\nconst rows = $input.all().map(item => item.json || {});\n\nconst records = [];\nlet sortOrder = 1;\n\nfor (const row of rows) {\n const client = pick(row, [\n 'Cliente',\n 'Clientes',\n 'CLIENTE',\n 'client',\n ]);\n\n const brand = pick(row, [\n 'Marca',\n 'Marcas',\n 'MARCA',\n 'brand',\n ]);\n\n const country = pick(row, [\n 'Pais / BU',\n 'País / BU',\n 'Pais',\n 'País',\n 'Business Unit',\n 'BU',\n 'Country',\n ]);\n\n const countryManager = pick(row, [\n 'CM',\n 'Country Manager',\n 'County Manager',\n 'Manager',\n ]);\n\n const workedBy = pick(row, [\n 'Trabajado',\n 'Trabajado por',\n 'Responsable',\n 'Diseñador',\n 'Creativo',\n ]);\n\n const workType = pick(row, [\n 'Tipo de trabajo',\n 'Tipo Trabajo',\n 'Trabajo',\n 'Tipo',\n ]);\n\n const executedWon = pick(row, [\n 'Ejecutado / Ganado',\n 'Ejecutado/Ganado',\n 'Ejecutado',\n 'Ganado',\n 'Resultado',\n ]);\n\n const timeStatus = pick(row, [\n 'Time',\n 'Tiempo',\n 'Estado tiempo',\n 'Estado de tiempo',\n ]);\n\n const status = pick(row, [\n 'estatus',\n 'Estatus',\n 'Status',\n 'Estado',\n 'ESTATUS',\n ]);\n\n const month = pick(row, [\n 'mes',\n 'Mes',\n 'MES',\n 'month',\n ]);\n\n for (const value of splitValues(client)) {\n addRecord(records, 'client', value, value, sortOrder++);\n }\n\n for (const value of splitValues(brand)) {\n addRecord(records, 'brand', value, value, sortOrder++);\n }\n\n for (const value of splitValues(country)) {\n addRecord(records, 'country', value, value, sortOrder++);\n }\n\n if (country && countryManager) {\n addRecord(records, 'country_manager', country, countryManager, sortOrder++);\n }\n\n for (const value of splitValues(workedBy)) {\n addRecord(records, 'worked_by', value, value, sortOrder++);\n }\n\n for (const value of splitValues(workType)) {\n addRecord(records, 'work_type', value, value, sortOrder++);\n }\n\n for (const value of splitValues(executedWon)) {\n addRecord(records, 'execution_result', value, value, sortOrder++);\n }\n\n for (const value of splitValues(timeStatus)) {\n addRecord(records, 'time_status', value, value, sortOrder++);\n }\n\n for (const value of splitValues(status)) {\n addRecord(records, 'status', value, value, sortOrder++);\n }\n\n for (const value of splitValues(month)) {\n addRecord(records, 'month', value, value, sortOrder++);\n }\n}\n\n// Deduplicar por category + value\nconst uniqueMap = new Map();\n\nfor (const record of records) {\n const key = `${normalizeKey(record.category)}::${normalizeKey(record.value)}`;\n\n if (!uniqueMap.has(key)) {\n uniqueMap.set(key, record);\n }\n}\n\nconst uniqueRecords = Array.from(uniqueMap.values())\n .map((record, index) => ({\n ...record,\n sort_order: index + 1,\n }));\n\nreturn [\n {\n json: {\n total_rows_read: rows.length,\n total_records_to_sync: uniqueRecords.length,\n categories_found: [...new Set(uniqueRecords.map(item => item.category))],\n records: uniqueRecords,\n preview: uniqueRecords.slice(0, 50),\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
416,
0
],
"id": "6c6131fe-f6df-434c-9596-c9d29a90ed0a",
"name": "Code - Normalizar listas para Supabase"
},
{
"parameters": {
"content": "## Flujo que se ejecuta cada vez que se agrega una fila nueva, lee las listas del Sheet luego normaliza las listas y por último hace el Upsert en Supabase",
"height": 352,
"width": 912,
"color": "#632313"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-64,
-144
],
"id": "2ec4d347-68e4-4358-9a82-3748c01af9a6",
"name": "Sticky Note"
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 3
}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
16,
0
],
"id": "4639502f-e8b1-4373-8f7f-d122598d2051",
"name": "Schedule Trigger"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/tablero_cdc_sync_app_lists",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({\n p_records: $json.records\n}) }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
624,
0
],
"id": "df494429-1c90-4e18-9bb1-d9b0d9b46cad",
"name": "Supabase - Sincronizar listas completas"
}
],
"pinData": {},
"connections": {
"Sheets - Leer listas": {
"main": [
[
{
"node": "Code - Normalizar listas para Supabase",
"type": "main",
"index": 0
}
]
]
},
"Code - Normalizar listas para Supabase": {
"main": [
[
{
"node": "Supabase - Sincronizar listas completas",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger": {
"main": [
[
{
"node": "Sheets - Leer listas",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"timeSavedMode": "fixed",
"errorWorkflow": "puF4LUczoSz3hcek",
"callerPolicy": "workflowsFromSameOwner",
"availableInMCP": true,
"timezone": "America/Santo_Domingo"
},
"versionId": "f4df1ba5-3c93-4f84-bcd4-ea272d847da0",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
},
"id": "MU5GdxLtFzcRcfyM",
"tags": []
}
File diff suppressed because one or more lines are too long
@@ -1,610 +0,0 @@
{
"name": "Tablero CDC - Sync Proyecto a Banco Fulgencio",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "tablero-cdc-sync-fulgencio",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-272,
96
],
"id": "5fbd25cf-6c2d-4364-a10e-91954b61256f",
"name": "Webhook",
"webhookId": "b9c9551b-86ee-4f3d-bbe2-12e93eef4b35"
},
{
"parameters": {
"jsCode": "const input = $json || {};\nconst body = input.body || input;\n\nconst projectId =\n body.project_id ||\n body.projectId ||\n body.id ||\n '';\n\nconst action =\n String(body.action || 'sync').trim().toLowerCase();\n\nif (!projectId) {\n throw new Error('Falta project_id en el payload del webhook');\n}\n\nreturn [\n {\n json: {\n action,\n project_id: projectId,\n received_at: new Date().toISOString(),\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-64,
96
],
"id": "61c5d83f-33d1-45d8-bb6a-4f657461f3c1",
"name": "Code - Preparar project_id"
},
{
"parameters": {
"url": "=https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_projects?id=eq.{{ $('Code - Preparar project_id').first().json.project_id }}&select=*",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Accept",
"value": " application/json"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
144,
96
],
"id": "007cdb0b-ab8e-4558-8ce0-b4cf238b2ef9",
"name": "Supabase - Leer proyecto"
},
{
"parameters": {
"url": "=https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_project_links?project_id=eq.{{ $('Code - Preparar project_id').first().json.project_id }}&select=*&order=created_at.asc",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Accept",
"value": " application/json"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
320,
96
],
"id": "ab47b077-7d17-4145-9a19-c0b9134a7931",
"name": "Supabase - Leer links del proyecto",
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "function getNodeJson(nodeName) {\n try {\n return $(nodeName).first().json;\n } catch (error) {\n return {};\n }\n}\n\nfunction getNodeItems(nodeName) {\n try {\n return $(nodeName).all();\n } catch (error) {\n return [];\n }\n}\n\nfunction normalizeProjectResponse(value) {\n if (Array.isArray(value)) return value[0] || null;\n return value || null;\n}\n\nfunction normalizeLinksResponse(items) {\n const links = [];\n\n for (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n links.push(...json);\n } else if (json && typeof json === 'object') {\n links.push(json);\n }\n }\n\n return links.filter(link => link && link.url);\n}\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction getYear(value) {\n const date = value ? new Date(value) : new Date();\n\n if (Number.isNaN(date.getTime())) {\n return String(new Date().getFullYear());\n }\n\n return String(date.getFullYear());\n}\n\nfunction extractGoogleFileId(url) {\n const text = clean(url);\n\n if (!text) return '';\n\n const patterns = [\n /\\/d\\/([a-zA-Z0-9_-]+)/,\n /id=([a-zA-Z0-9_-]+)/,\n /\\/file\\/d\\/([a-zA-Z0-9_-]+)/,\n /\\/presentation\\/d\\/([a-zA-Z0-9_-]+)/,\n ];\n\n for (const pattern of patterns) {\n const match = text.match(pattern);\n if (match && match[1]) return match[1];\n }\n\n return '';\n}\n\nconst rawProject = getNodeJson('Supabase - Leer proyecto');\nconst project = normalizeProjectResponse(rawProject);\n\nif (!project || !project.id) {\n throw new Error('No se encontró el proyecto en Supabase. Revisa el project_id.');\n}\n\nconst linkItems = getNodeItems('Supabase - Leer links del proyecto');\nconst links = normalizeLinksResponse(linkItems);\n\nconst proposalLinks = links.filter(link =>\n String(link.link_type || '').toLowerCase() === 'proposal'\n);\n\nif (!proposalLinks.length) {\n return [\n {\n json: {\n ok: true,\n skipped: true,\n reason: 'El proyecto no tiene links de propuesta. No se escribe en Banco Fulgencio.',\n project_id: project.id,\n },\n },\n ];\n}\n\nreturn proposalLinks.map((link, index) => {\n const proposalUrl = clean(link.url);\n const fileId = extractGoogleFileId(proposalUrl);\n const syncKey = `${project.id}::${fileId || proposalUrl}`;\n\n return {\n json: {\n skipped: false,\n\n 'NOMBRE': project.title || '',\n 'TIPO DE ACCION': 'PENDIENTE CLASIFICAR',\n 'CLIENTE': project.client || '',\n 'MARCA': project.brand || '',\n 'PAIS': project.country || '',\n 'CANAL': '',\n 'AMBIENTE DE COMPRA (RE)': '',\n 'TÁCTICA PROMOCIONAL': '',\n 'APROBADA': '',\n 'ETIQUETAS': '',\n 'AÑO': getYear(project.created_at),\n 'Enlace a la propuesta': proposalUrl,\n 'Descripcion': project.description || project.title || '',\n 'file_id': fileId,\n 'nombre_archivo': '',\n 'mime_type': '',\n 'fuente_pais': 'Tablero CDC',\n 'confianza_pais': '',\n 'requiere_revision': '',\n 'procesado_ia': 'NO',\n 'ultima_actualizacion': new Date().toISOString(),\n 'motivos_revision': '',\n 'Enlaces a propuestas ejecutadas': '',\n\n 'tablero_sync_key': syncKey,\n 'tablero_project_id': project.id,\n 'tablero_origen': 'Tablero CDC',\n },\n };\n});"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
528,
96
],
"id": "4a37a2a6-3138-455b-8806-288d852873a6",
"name": "Code - Preparar filas Fulgencio"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "8b0c7244-a4a1-4fd9-b2eb-dc62b98fb7a3",
"leftValue": "={{ $json.skipped }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "false",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
736,
96
],
"id": "5b14f1ea-8203-4daf-8f14-b39da2a7dc97",
"name": "IF - Tiene filas para escribir?"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "{\n \"ok\": true,\n \"message\": \"Proyecto sincronizado con Banco Fulgencio\",\n \"source\": \"Tablero CDC\"\n}",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
1248,
0
],
"id": "c221409e-9777-4453-afa1-ad8bc7107910",
"name": "Respond to Webhook"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "{\n \"ok\": true,\n \"skipped\": true,\n \"message\": \"Proyecto sin links de propuesta. No se escribió en Banco Fulgencio.\"\n}",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
944,
192
],
"id": "363e5ac4-43be-4a9e-afd0-923bec81a017",
"name": "Respond to Webhook1"
},
{
"parameters": {
"operation": "appendOrUpdate",
"documentId": {
"__rl": true,
"value": "1iy3lC3DS3ZfShb7d26iQOrPjx9s_3B-PC07XfYew7ng",
"mode": "list",
"cachedResultName": "BANCO DE PROPUESTAS DE CDC PARA FULGENCIO FUMADO",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1iy3lC3DS3ZfShb7d26iQOrPjx9s_3B-PC07XfYew7ng/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": "gid=0",
"mode": "list",
"cachedResultName": "propuestas",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1iy3lC3DS3ZfShb7d26iQOrPjx9s_3B-PC07XfYew7ng/edit#gid=0"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"NOMBRE": "={{ $json[\"NOMBRE\"] }}",
"TIPO DE ACCION": "={{ $json[\"TIPO DE ACCION\"] }}",
"CLIENTE": "={{ $json[\"CLIENTE\"] }}",
"MARCA": "={{ $json[\"MARCA\"] }}",
"PAIS": "={{ $json[\"PAIS\"] }}",
"CANAL": "={{ $json[\"CANAL\"] }}",
"AMBIENTE DE COMPRA (RE)": "={{ $json[\"AMBIENTE DE COMPRA (RE)\"] }}",
"TÁCTICA PROMOCIONAL": "={{ $json[\"TÁCTICA PROMOCIONAL\"] }}",
"APROBADA": "={{ $json[\"APROBADA\"] }}",
"ETIQUETAS": "={{ $json[\"ETIQUETAS\"] }}",
"AÑO": "={{ $json[\"AÑO\"] }}",
"Enlace a la propuesta": "={{ $json[\"Enlace a la propuesta\"] }}",
"Descripcion": "={{ $json[\"Descripcion\"] }}",
"file_id": "={{ $json[\"file_id\"] }}",
"nombre_archivo": "={{ $json[\"nombre_archivo\"] }}",
"mime_type": "={{ $json[\"mime_type\"] }}",
"fuente_pais": "={{ $json[\"fuente_pais\"] }}",
"confianza_pais": "={{ $json[\"confianza_pais\"] }}",
"requiere_revision": "={{ $json[\"requiere_revision\"] }}",
"procesado_ia": "={{ $json[\"procesado_ia\"] }}",
"ultima_actualizacion": "={{ $json[\"ultima_actualizacion\"] }}",
"motivos_revision": "={{ $json[\"motivos_revision\"] }}",
"Enlaces a propuestas ejecutadas": "={{ $json[\"Enlaces a propuestas ejecutadas\"] }}",
"tablero_project_id": "={{ $json[\"tablero_project_id\"] }}",
"tablero_origen": "={{ $json[\"tablero_origen\"] }}",
"tablero_sync_key": "={{ $json[\"tablero_sync_key\"] }}"
},
"matchingColumns": [
"tablero_sync_key"
],
"schema": [
{
"id": "NOMBRE",
"displayName": "NOMBRE",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "TIPO DE ACCION",
"displayName": "TIPO DE ACCION",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "CLIENTE",
"displayName": "CLIENTE",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "MARCA",
"displayName": "MARCA",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "PAIS",
"displayName": "PAIS",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "CANAL",
"displayName": "CANAL",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "AMBIENTE DE COMPRA (RE)",
"displayName": "AMBIENTE DE COMPRA (RE)",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "TÁCTICA PROMOCIONAL",
"displayName": "TÁCTICA PROMOCIONAL",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "APROBADA",
"displayName": "APROBADA",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "ETIQUETAS",
"displayName": "ETIQUETAS",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "AÑO",
"displayName": "AÑO",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "Enlace a la propuesta",
"displayName": "Enlace a la propuesta",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "Descripcion",
"displayName": "Descripcion",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "file_id",
"displayName": "file_id",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "nombre_archivo",
"displayName": "nombre_archivo",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "mime_type",
"displayName": "mime_type",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "fuente_pais",
"displayName": "fuente_pais",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "confianza_pais",
"displayName": "confianza_pais",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "requiere_revision",
"displayName": "requiere_revision",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "procesado_ia",
"displayName": "procesado_ia",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "ultima_actualizacion",
"displayName": "ultima_actualizacion",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "motivos_revision",
"displayName": "motivos_revision",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "Enlaces a propuestas ejecutadas",
"displayName": "Enlaces a propuestas ejecutadas",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "tablero_sync_key",
"displayName": "tablero_sync_key",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "tablero_project_id",
"displayName": "tablero_project_id",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
},
{
"id": "tablero_origen",
"displayName": "tablero_origen",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
1040,
0
],
"id": "f3bee0f1-50da-4908-923f-51b17a2dbbdc",
"name": "Google Sheets - Upsert Banco Fulgencio",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"content": "# 🔄 SYNC TABLERO CDC → BANCO FULGENCIO\n\nSincroniza automáticamente un proyecto del Tablero CDC con el Banco de Propuestas de Fulgencio.\n\nFuncionamiento:\n\n1. Recibe mediante Webhook el identificador del proyecto.\n2. Normaliza y valida el project_id recibido.\n3. Consulta en Supabase los datos principales del proyecto.\n4. Recupera los enlaces asociados, como propuestas, presentaciones y artes finales.\n5. Convierte la información al formato requerido por el Banco Fulgencio.\n6. Valida si existen filas aptas para escribir.\n7. Inserta o actualiza el proyecto en Google Sheets.\n8. Devuelve al sistema solicitante el resultado de la sincronización.\n\nDatos procesados:\n\n- Identificador del proyecto.\n- Nombre o descripción de la propuesta.\n- Cliente y marca.\n- País o unidad de negocio.\n- Responsable del proyecto.\n- Estado y fechas disponibles.\n- Enlaces relacionados con la propuesta.\n- Información necesaria para las consultas de Fulgencio.\n\nReglas:\n\n- Supabase es la fuente oficial de los datos del proyecto.\n- Los enlaces se consultan por separado y se relacionan mediante el project_id.\n- El Upsert actualiza una fila existente o crea una nueva, evitando duplicados.\n- Solo se escribe cuando existe información válida para el Banco Fulgencio.\n- Si no hay filas procesables, el flujo termina correctamente sin modificar el Sheet.\n- Ambas ramas responden al Webhook para evitar solicitudes abiertas o tiempos de espera.",
"height": 960,
"width": 1792,
"color": "#24555C"
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-336,
-624
],
"id": "f561433a-a85c-497c-b3ef-fd36eef3b0b6",
"name": "Sticky Note"
}
],
"pinData": {},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Code - Preparar project_id",
"type": "main",
"index": 0
}
]
]
},
"Code - Preparar project_id": {
"main": [
[
{
"node": "Supabase - Leer proyecto",
"type": "main",
"index": 0
}
]
]
},
"Supabase - Leer proyecto": {
"main": [
[
{
"node": "Supabase - Leer links del proyecto",
"type": "main",
"index": 0
}
]
]
},
"Supabase - Leer links del proyecto": {
"main": [
[
{
"node": "Code - Preparar filas Fulgencio",
"type": "main",
"index": 0
}
]
]
},
"Code - Preparar filas Fulgencio": {
"main": [
[
{
"node": "IF - Tiene filas para escribir?",
"type": "main",
"index": 0
}
]
]
},
"IF - Tiene filas para escribir?": {
"main": [
[
{
"node": "Google Sheets - Upsert Banco Fulgencio",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond to Webhook1",
"type": "main",
"index": 0
}
]
]
},
"Google Sheets - Upsert Banco Fulgencio": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": true,
"timeSavedMode": "fixed",
"errorWorkflow": "puF4LUczoSz3hcek",
"timezone": "America/Santo_Domingo",
"callerPolicy": "workflowsFromSameOwner"
},
"versionId": "5b0c04ba-7275-4352-b6af-323cd6ead268",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
},
"id": "k2NNakqj2TaP0HKy",
"tags": []
}
@@ -1,801 +0,0 @@
{
"name": "Tablero CDC - Sync Proyecto a Sheet WEBHOOK",
"nodes": [
{
"parameters": {
"url": "=https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_projects?id=eq.{{ $('Code - Preparar project_id').first().json.project_id }}&select=*",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
1824,
288
],
"id": "5fdded5f-5a19-4f40-bc75-21ae92ac3df2",
"name": "Supabase - Leer proyecto"
},
{
"parameters": {
"url": "=https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_project_links?project_id=eq.{{ $('Code - Preparar project_id').first().json.project_id }}&select=*&order=created_at.asc",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2064,
288
],
"id": "d2839f2d-af1f-49ab-bfd6-4c3b76f656ad",
"name": "Supabase - Leer links del proyecto",
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "function getNodeJson(nodeName) {\n try {\n return $(nodeName).first().json;\n } catch (error) {\n return {};\n }\n}\n\nfunction getNodeItems(nodeName) {\n try {\n return $(nodeName).all();\n } catch (error) {\n return [];\n }\n}\n\nfunction normalizeProjectResponse(value) {\n if (Array.isArray(value)) return value[0] || null;\n return value || null;\n}\n\nfunction normalizeLinksResponse(items) {\n const links = [];\n\n for (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n links.push(...json);\n } else if (json && typeof json === 'object') {\n links.push(json);\n }\n }\n\n return links.filter(link => link && link.url);\n}\n\nfunction formatMonth(dateValue) {\n const date = dateValue ? new Date(dateValue) : new Date();\n\n if (Number.isNaN(date.getTime())) {\n return '';\n }\n\n const month = date.toLocaleDateString('es-DO', {\n month: 'long',\n });\n\n return month.charAt(0).toUpperCase() + month.slice(1);\n}\n\nfunction formatYear(dateValue) {\n const date = dateValue ? new Date(dateValue) : new Date();\n\n if (Number.isNaN(date.getTime())) {\n return String(new Date().getFullYear());\n }\n\n return String(date.getFullYear());\n}\n\nfunction formatMoney(value) {\n if (value === null || value === undefined || value === '') return '';\n return value;\n}\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction formatLinks(links, type) {\n const filtered = links.filter(link => String(link.link_type || '').toLowerCase() === type);\n\n if (!filtered.length) return '';\n\n return filtered\n .map(link => clean(link.url))\n .filter(Boolean)\n .join('\\n');\n}\n\nfunction findExistingSheetRow(projectId) {\n const sheetItems = getNodeItems('Sheets - Leer proyectos existentes');\n\n for (const item of sheetItems) {\n const row = item.json || {};\n const rowProjectId = clean(row['Project ID']);\n\n if (rowProjectId && rowProjectId === projectId) {\n return row;\n }\n }\n\n return null;\n}\n\nconst rawProject = getNodeJson('Supabase - Leer proyecto');\nconst project = normalizeProjectResponse(rawProject);\n\nif (!project || !project.id) {\n throw new Error('No se encontró el proyecto en Supabase. Revisa el project_id.');\n}\n\nconst linkItems = getNodeItems('Supabase - Leer links del proyecto');\nconst links = normalizeLinksResponse(linkItems);\n\nconst proposalLinks = formatLinks(links, 'proposal');\nconst finalArtLinks = formatLinks(links, 'final_art');\n\nconst createdAt = project.created_at || new Date().toISOString();\n\nconst existingSheetRow = findExistingSheetRow(project.id);\n\nconst sheetYear = existingSheetRow && clean(existingSheetRow['Año'])\n ? clean(existingSheetRow['Año'])\n : formatYear(createdAt);\n\nconst sheetMonth = existingSheetRow && clean(existingSheetRow['Mes'])\n ? clean(existingSheetRow['Mes'])\n : formatMonth(createdAt);\n\nreturn [\n {\n json: {\n 'Año': sheetYear,\n 'Mes': sheetMonth,\n 'Tipo o nombre de proyecto': project.title || '',\n 'Cliente': project.client || '',\n 'BU Solicita': project.country || '',\n 'Marca': project.brand || '',\n 'CM': project.country_manager || '',\n 'Status': project.status || '',\n 'Comentarios': project.requested_by ? `Solicitado por ${project.requested_by}` : '',\n 'Link de propuesta': proposalLinks,\n '$ Interno Cargado': formatMoney(project.internal_amount),\n 'Link del Brief': project.brief_link || '',\n 'Link de artes finales': finalArtLinks,\n 'Project ID': project.id,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2496,
288
],
"id": "1a9e76bd-20f8-481b-82c1-95d631e261ae",
"name": "Code - Preparar fila Sheet"
},
{
"parameters": {
"operation": "appendOrUpdate",
"documentId": {
"__rl": true,
"value": "1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q",
"mode": "list",
"cachedResultName": "APROBACIONES PROYECTOS",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": 1563472127,
"mode": "list",
"cachedResultName": "PROYECTOS 2026",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit#gid=1563472127"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"Año": "={{ $json[\"Año\"] }}",
"Mes": "={{ $json[\"Mes\"] }}",
"Tipo o nombre de proyecto": "={{ $json[\"Tipo o nombre de proyecto\"] }}",
"Cliente": "={{ $json[\"Cliente\"] }}",
"BU Solicita": "={{ $json[\"BU Solicita\"] }}",
"Marca": "={{ $json[\"Marca\"] }}",
"CM": "={{ $json[\"CM\"] }}",
"Status": "={{ $json[\"Status\"] }}",
"Comentarios": "={{ $json[\"Comentarios\"] }}",
"Link de propuesta": "={{ $json[\"Link de propuesta\"] }}",
"$ Interno Cargado": "={{ $json[\"$ Interno Cargado\"] }}",
"Link del Brief": "={{ $json[\"Link del Brief\"] }}",
"Link de artes finales": "={{ $json[\"Link de artes finales\"] }}",
"Project ID": "={{ $json[\"Project ID\"] }}"
},
"matchingColumns": [
"Project ID"
],
"schema": [
{
"id": "Año",
"displayName": "Año",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "Mes",
"displayName": "Mes",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "Tipo o nombre de proyecto",
"displayName": "Tipo o nombre de proyecto",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "Cliente",
"displayName": "Cliente",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "BU Solicita",
"displayName": "BU Solicita",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "Marca",
"displayName": "Marca",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "CM",
"displayName": "CM",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "Status",
"displayName": "Status",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "Comentarios",
"displayName": "Comentarios",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "Link de propuesta",
"displayName": "Link de propuesta",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "$ Interno Cargado",
"displayName": "$ Interno Cargado",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "Link del Brief",
"displayName": "Link del Brief",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "Link de artes finales",
"displayName": "Link de artes finales",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
},
{
"id": "Project ID",
"displayName": "Project ID",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"canBeUsedToMatch": true,
"removed": false
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {
"locationDefine": {
"values": {
"headerRow": 3,
"firstDataRow": 4
}
}
}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
2736,
288
],
"id": "d66bf9b4-9694-4f43-8dd6-4e9efe4eece8",
"name": "Sheets - Upsert proyecto",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"httpMethod": "POST",
"path": "tablero-cdc-sync-proyecto",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
208,
272
],
"id": "b5d0c07b-8b2d-437c-9475-530f64e84247",
"name": "Webhook",
"webhookId": "4f237f0a-7607-4a8b-9020-11bc75a6ae32"
},
{
"parameters": {
"jsCode": "const input = $json || {};\nconst body = input.body || input;\n\nconst projectId =\n body.project_id ||\n body.projectId ||\n body.id ||\n '';\n\nconst action =\n String(body.action || 'sync').trim().toLowerCase();\n\nif (!projectId) {\n throw new Error('Falta project_id en el payload del webhook');\n}\n\nreturn [\n {\n json: {\n action,\n project_id: projectId,\n received_at: new Date().toISOString(),\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
416,
272
],
"id": "017aafcb-5f7a-407d-8224-caf365186b78",
"name": "Code - Preparar project_id"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{\n {\n ok: true,\n message: 'Proyecto sincronizado con Google Sheet',\n project_id: $('Code - Preparar project_id').first().json.project_id\n }\n}}",
"options": {
"responseCode": 200
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
3216,
224
],
"id": "425a3374-03dc-4424-b089-3d86efbd565e",
"name": "Respond to Webhook"
},
{
"parameters": {
"content": "## Sync Proyecto a Sheet WEBHOOK\n\nFlujo de producción que recibe project_id desde la app Tablero CDC y sincroniza el proyecto con Google Sheet PROYECTOS 2026.\n\nFlujo:\nWebhook -> preparar project_id -> leer projects -> leer project_links -> preparar fila -> upsert en Sheet -> responder webhook.\n\nNotas:\n- La app llama este flujo usando VITE_WEBHOOK_URL.\n- El upsert usa Project ID.\n- Encabezados del Sheet: fila 3.\n- Datos desde fila 4.\n- Filas viejas sin Project ID quedan como histórico.\n- Leer links del proyecto debe tener Always Output Data activo para que proyectos sin links también se peguen.\n\nSi cambia Supabase empresarial/self-hosted, actualizar URL y keys en los HTTP Request.",
"height": 704,
"width": 3380,
"color": 5
},
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
160,
-208
],
"id": "16a4a31a-a288-45f5-86fd-be4d92a7a2c1",
"name": "Sticky Note"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "36231932-b806-4b8a-8d59-e9a74ef4c74b",
"leftValue": "={{ $json.action }}",
"rightValue": "delete",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
688,
272
],
"id": "e891ba4b-23d2-4432-bb33-deffbee8097a",
"name": "IF - Acción eliminar?"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q",
"mode": "list",
"cachedResultName": "APROBACIONES PROYECTOS",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": 1563472127,
"mode": "list",
"cachedResultName": "PROYECTOS 2026",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit#gid=1563472127"
},
"options": {
"dataLocationOnSheet": {
"values": {
"rangeDefinition": "specifyRange",
"headerRow": 3,
"firstDataRow": 4
}
}
}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
1200,
-96
],
"id": "df240622-6ec0-44cb-b945-28c09bd50496",
"name": "Sheets - Leer proyectos para eliminar",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"jsCode": "const projectId = $('Code - Preparar project_id').first().json.project_id;\n\nconst rows = $input.all();\n\n// En este Sheet los encabezados están en fila 3 y los datos empiezan en fila 4.\n// Si Google Sheets no devuelve row_number, lo calculamos con index + 4.\nconst FIRST_DATA_ROW = 4;\n\nconst matches = [];\n\nrows.forEach((item, index) => {\n const row = item.json;\n\n const rowProjectId =\n row['Project ID'] ||\n row['project_id'] ||\n row['PROJECT ID'] ||\n '';\n\n if (String(rowProjectId).trim() === String(projectId).trim()) {\n const rowNumber =\n row.row_number ||\n row.rowNumber ||\n row.__rowNumber ||\n row.rowIndex ||\n index + FIRST_DATA_ROW;\n\n matches.push({\n row,\n row_number: Number(rowNumber),\n });\n }\n});\n\nif (matches.length === 0) {\n return [\n {\n json: {\n ok: true,\n action: 'delete',\n project_id: projectId,\n found: false,\n message: 'No se encontró fila en Google Sheet para ese Project ID. Se considera eliminado.',\n },\n },\n ];\n}\n\nreturn matches.map((match) => ({\n json: {\n ok: true,\n action: 'delete',\n project_id: projectId,\n found: true,\n row_number: match.row_number,\n row: match.row,\n },\n}));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1408,
-96
],
"id": "ec6056cf-99b9-4b23-9ee4-62b71100aaa2",
"name": "Code - Buscar fila a eliminar"
},
{
"parameters": {
"operation": "delete",
"documentId": {
"__rl": true,
"value": "1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q",
"mode": "list",
"cachedResultName": "APROBACIONES PROYECTOS",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": 1563472127,
"mode": "list",
"cachedResultName": "PROYECTOS 2026",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit#gid=1563472127"
},
"startIndex": "={{ $json.row_number }}"
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
1840,
-112
],
"id": "fc0117c5-9b01-44cc-b8d4-659ed9784fc1",
"name": "Sheets - Eliminar fila proyecto",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"respondWith": "json",
"responseBody": "{\n \"ok\": true,\n \"action\": \"delete\",\n \"message\": \"Proyecto eliminado del Google Sheet\"\n}",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
2048,
-112
],
"id": "02857828-6a02-40a6-ade7-203361f0698b",
"name": "Respond - Proyecto eliminado del Sheet"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "b513a96e-250f-4730-a926-bcc438daa0c6",
"leftValue": "={{ $json.found }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
1616,
-96
],
"id": "c281d09b-16da-450b-bcf8-0412f6658dcb",
"name": "IF - Fila encontrada?"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "{\n \"ok\": true,\n \"action\": \"delete\",\n \"found\": false,\n \"message\": \"No se encontró fila en Google Sheet para ese Project ID. Se considera eliminado.\"\n}",
"options": {
"responseCode": 200
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
1856,
112
],
"id": "182432ed-700f-4fff-84b9-8aa45fd7c0b9",
"name": "Respond - Proyecto no encontrado en Sheet"
},
{
"parameters": {
"documentId": {
"__rl": true,
"value": "1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q",
"mode": "list",
"cachedResultName": "APROBACIONES PROYECTOS",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit?usp=drivesdk"
},
"sheetName": {
"__rl": true,
"value": 1563472127,
"mode": "list",
"cachedResultName": "PROYECTOS 2026",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1MfoIs_y7-4ad-g5dxzOo8oAFIdraA_ZT4k-OCGMtT4Q/edit#gid=1563472127"
},
"options": {
"dataLocationOnSheet": {
"values": {
"rangeDefinition": "specifyRangeA1",
"range": "A3:N"
}
}
}
},
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.7,
"position": [
2272,
288
],
"id": "b4f4366e-3381-4692-8740-8f7287eeb9a4",
"name": "Sheets - Leer proyectos existentes",
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "https://agenteit.digitalcompass.agency/webhook/tablero-cdc-sync-fulgencio-ia",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "action",
"value": "sync"
},
{
"name": "project_id",
"value": "={{ $('Code - Preparar project_id').first().json.project_id }}"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2944,
288
],
"id": "f2b3649c-0311-497e-bb48-cc793a55e51d",
"name": "HTTP - Sync Banco Fulgencio",
"retryOnFail": true,
"waitBetweenTries": 1000,
"onError": "continueRegularOutput"
}
],
"pinData": {},
"connections": {
"Supabase - Leer proyecto": {
"main": [
[
{
"node": "Supabase - Leer links del proyecto",
"type": "main",
"index": 0
}
]
]
},
"Supabase - Leer links del proyecto": {
"main": [
[
{
"node": "Sheets - Leer proyectos existentes",
"type": "main",
"index": 0
}
]
]
},
"Code - Preparar fila Sheet": {
"main": [
[
{
"node": "Sheets - Upsert proyecto",
"type": "main",
"index": 0
}
]
]
},
"Webhook": {
"main": [
[
{
"node": "Code - Preparar project_id",
"type": "main",
"index": 0
}
]
]
},
"Code - Preparar project_id": {
"main": [
[
{
"node": "IF - Acción eliminar?",
"type": "main",
"index": 0
}
]
]
},
"Sheets - Upsert proyecto": {
"main": [
[
{
"node": "HTTP - Sync Banco Fulgencio",
"type": "main",
"index": 0
}
]
]
},
"IF - Acción eliminar?": {
"main": [
[
{
"node": "Sheets - Leer proyectos para eliminar",
"type": "main",
"index": 0
}
],
[
{
"node": "Supabase - Leer proyecto",
"type": "main",
"index": 0
}
]
]
},
"Sheets - Leer proyectos para eliminar": {
"main": [
[
{
"node": "Code - Buscar fila a eliminar",
"type": "main",
"index": 0
}
]
]
},
"Code - Buscar fila a eliminar": {
"main": [
[
{
"node": "IF - Fila encontrada?",
"type": "main",
"index": 0
}
]
]
},
"Sheets - Eliminar fila proyecto": {
"main": [
[
{
"node": "Respond - Proyecto eliminado del Sheet",
"type": "main",
"index": 0
}
]
]
},
"IF - Fila encontrada?": {
"main": [
[
{
"node": "Sheets - Eliminar fila proyecto",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond - Proyecto no encontrado en Sheet",
"type": "main",
"index": 0
}
]
]
},
"Sheets - Leer proyectos existentes": {
"main": [
[
{
"node": "Code - Preparar fila Sheet",
"type": "main",
"index": 0
}
]
]
},
"HTTP - Sync Banco Fulgencio": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": true,
"timeSavedMode": "fixed",
"errorWorkflow": "puF4LUczoSz3hcek",
"callerPolicy": "workflowsFromSameOwner",
"timezone": "America/Santo_Domingo"
},
"versionId": "cc3433da-498f-47e0-902a-bf494199dcf5",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
},
"id": "d39DtIUTQzduqX0v",
"tags": []
}
File diff suppressed because one or more lines are too long
-635
View File
@@ -1,635 +0,0 @@
# 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
@@ -1,22 +0,0 @@
{
"$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
+479
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<link rel="icon" type="image/png" sizes="192x192" href="/tablero-cdc/icon-192.png?v=glm-tab-v12" /> <link rel="icon" type="image/png" sizes="192x192" href="/tablero-cdc/icon-192.png?v=glm-tab-v12" />
<link rel="shortcut icon" type="image/x-icon" href="/tablero-cdc/favicon.ico?v=glm-tab-v12" /> <link rel="shortcut icon" type="image/x-icon" href="/tablero-cdc/favicon.ico?v=glm-tab-v12" />
<link rel="apple-touch-icon" sizes="180x180" href="/tablero-cdc/apple-touch-icon.png?v=glm-tab-v12" /> <link rel="apple-touch-icon" sizes="180x180" href="/tablero-cdc/apple-touch-icon.png?v=glm-tab-v12" />
<script type="module" crossorigin src="/tablero-cdc/assets/index-COXhoke_.js"></script> <script type="module" crossorigin src="/tablero-cdc/assets/index-DCvZdXJT.js"></script>
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-YGpmp8kO.css"> <link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-D0h6tUNa.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
-40
View File
@@ -1,40 +0,0 @@
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
@@ -1,17 +0,0 @@
<!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
@@ -1,81 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

-4
View File
@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

-99
View File
@@ -1,99 +0,0 @@
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
@@ -1,24 +0,0 @@
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
@@ -1,47 +0,0 @@
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
@@ -1,31 +0,0 @@
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
@@ -1,98 +0,0 @@
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
@@ -1,116 +0,0 @@
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
@@ -1,485 +0,0 @@
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>
);
}
@@ -1,150 +0,0 @@
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
@@ -1,116 +0,0 @@
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
@@ -1,51 +0,0 @@
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
@@ -1,115 +0,0 @@
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
@@ -1,49 +0,0 @@
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
@@ -1,5 +0,0 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };
-47
View File
@@ -1,47 +0,0 @@
"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
@@ -1,32 +0,0 @@
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
@@ -1,101 +0,0 @@
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
@@ -1,49 +0,0 @@
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
@@ -1,177 +0,0 @@
"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
@@ -1,55 +0,0 @@
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
@@ -1,240 +0,0 @@
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
@@ -1,331 +0,0 @@
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
@@ -1,26 +0,0 @@
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
@@ -1,11 +0,0 @@
"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
@@ -1,143 +0,0 @@
"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
@@ -1,187 +0,0 @@
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
@@ -1,104 +0,0 @@
"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
@@ -1,98 +0,0 @@
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
@@ -1,188 +0,0 @@
"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
@@ -1,171 +0,0 @@
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
@@ -1,27 +0,0 @@
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
@@ -1,69 +0,0 @@
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
@@ -1,22 +0,0 @@
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
@@ -1,21 +0,0 @@
"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
@@ -1,229 +0,0 @@
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
@@ -1,120 +0,0 @@
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
@@ -1,98 +0,0 @@
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
@@ -1,31 +0,0 @@
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
@@ -1,25 +0,0 @@
"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
@@ -1,36 +0,0 @@
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
@@ -1,37 +0,0 @@
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
@@ -1,44 +0,0 @@
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
@@ -1,152 +0,0 @@
"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
@@ -1,24 +0,0 @@
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
@@ -1,122 +0,0 @@
"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
@@ -1,744 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,23 +0,0 @@
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
@@ -1,23 +0,0 @@
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
@@ -1,27 +0,0 @@
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
@@ -1,94 +0,0 @@
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
@@ -1,53 +0,0 @@
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
@@ -1,21 +0,0 @@
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
@@ -1,57 +0,0 @@
"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
@@ -1,42 +0,0 @@
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
@@ -1,32 +0,0 @@
"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
@@ -1,219 +0,0 @@
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
@@ -1,206 +0,0 @@
// 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
@@ -1,526 +0,0 @@
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
@@ -1,19 +0,0 @@
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
@@ -1,80 +0,0 @@
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
@@ -1,235 +0,0 @@
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
@@ -1,9 +0,0 @@
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
@@ -1,60 +0,0 @@
/* 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
@@ -1,122 +0,0 @@
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
@@ -1,23 +0,0 @@
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
@@ -1,366 +0,0 @@
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
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

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