diff --git a/.env.example b/.env.example deleted file mode 100644 index bbfc411..0000000 --- a/.env.example +++ /dev/null @@ -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" diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 026d544..0000000 --- a/.gitignore +++ /dev/null @@ -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? diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index be49b26..0000000 --- a/.prettierignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules -dist -.output -.vinxi -pnpm-lock.yaml -package-lock.json -bun.lock -routeTree.gen.ts diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 90abee2..0000000 --- a/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "printWidth": 100, - "semi": true, - "singleQuote": false, - "trailingComma": "all" -} diff --git a/Flujo de n8n: Tablero CDC - Migrar Legacy Sheet a Supabase.json b/Flujo de n8n: Tablero CDC - Migrar Legacy Sheet a Supabase.json deleted file mode 100644 index aa7bb95..0000000 --- a/Flujo de n8n: Tablero CDC - Migrar Legacy Sheet a Supabase.json +++ /dev/null @@ -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": [] -} \ No newline at end of file diff --git a/Flujo de n8n: Tablero CDC - Refrescar Banco Fulgencio Diario.json b/Flujo de n8n: Tablero CDC - Refrescar Banco Fulgencio Diario.json deleted file mode 100644 index eada902..0000000 --- a/Flujo de n8n: Tablero CDC - Refrescar Banco Fulgencio Diario.json +++ /dev/null @@ -1,1432 +0,0 @@ -{ - "name": "Tablero CDC - Refrescar Banco Fulgencio Diario", - "nodes": [ - { - "parameters": { - "rule": { - "interval": [ - { - "triggerAtHour": 23 - } - ] - } - }, - "type": "n8n-nodes-base.scheduleTrigger", - "typeVersion": 1.3, - "position": [ - -816, - -64 - ], - "id": "37cc170e-2f53-4e2e-915a-f6020e701c2e", - "name": "Schedule Trigger" - }, - { - "parameters": { - "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" - }, - "options": { - "dataLocationOnSheet": { - "values": { - "rangeDefinition": "specifyRange", - "firstDataRow": 465 - } - } - } - }, - "type": "n8n-nodes-base.googleSheets", - "typeVersion": 4.7, - "position": [ - -608, - -64 - ], - "id": "da0ce8a1-0efe-4f5b-909d-7f64b67e8f3b", - "name": "Sheets - Leer Banco Fulgencio", - "credentials": { - "googleSheetsOAuth2Api": { - "id": "K0hDZh3a85MpOHCs", - "name": "Google Sheets account 2" - } - } - }, - { - "parameters": { - "jsCode": "function clean(value) {\n return String(value ?? '').trim();\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\n if (match?.[1]) {\n return match[1];\n }\n }\n\n return '';\n}\n\nconst rows = $input.all();\nconst result = [];\n\nfor (let index = 0; index < rows.length; index++) {\n const item = rows[index];\n const row = item.json || {};\n\n const origen = clean(row.tablero_origen).toLowerCase();\n const syncKey = clean(row.tablero_sync_key);\n const projectId = clean(row.tablero_project_id);\n const proposalUrl = clean(row['Enlace a la propuesta']);\n\n const existingFileId = clean(row.file_id);\n const extractedFileId = extractGoogleFileId(proposalUrl);\n const fileId = existingFileId || extractedFileId;\n\n // Solo procesar filas válidas provenientes de Tablero CDC.\n if (origen !== 'tablero cdc') continue;\n if (!syncKey) continue;\n if (!projectId) continue;\n if (!proposalUrl) continue;\n\n result.push({\n json: {\n ...row,\n\n // Dejamos ambos campos consistentes.\n file_id: fileId,\n file_id_refresco: fileId,\n\n proposal_url_refresco: proposalUrl,\n refresh_attempts_actual: Number(row.refresh_attempts || 0),\n },\n\n // Conserva la vinculación entre el elemento original y el resultado.\n pairedItem: {\n item: index,\n },\n });\n}\n\nif (result.length === 0) {\n throw new Error(\n 'No se encontraron filas válidas de Tablero CDC con tablero_sync_key, tablero_project_id y enlace.'\n );\n}\n\nreturn result;" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - -400, - -64 - ], - "id": "1422a9de-12a8-4b2d-bda9-9dc7193a5d67", - "name": "Code - Filtrar filas Tablero CDC" - }, - { - "parameters": { - "options": {} - }, - "type": "n8n-nodes-base.splitInBatches", - "typeVersion": 3, - "position": [ - -176, - -64 - ], - "id": "3fb77467-0f3c-419e-8a6e-ab44d118ceef", - "name": "Loop - Filas Banco Fulgencio" - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "strict", - "version": 3 - }, - "conditions": [ - { - "id": "8c79f0ce-b901-43c8-b863-ad490d267c37", - "leftValue": "={{ String($json.file_id_refresco || $json.file_id || '').trim() }}", - "rightValue": "", - "operator": { - "type": "string", - "operation": "notEmpty", - "singleValue": true - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "type": "n8n-nodes-base.if", - "typeVersion": 2.3, - "position": [ - -16, - -208 - ], - "id": "6896c55a-b17b-41bf-8f99-3fa949c29d67", - "name": "IF - Tiene file_id?" - }, - { - "parameters": { - "url": "=https://www.googleapis.com/drive/v3/files/{{$json.file_id_refresco}}?fields=id,name,mimeType,modifiedTime,webViewLink,size&supportsAllDrives=true", - "authentication": "predefinedCredentialType", - "nodeCredentialType": "googleDriveOAuth2Api", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 368, - -336 - ], - "id": "40ccb6f9-4e2d-4b21-ad1c-583151c84641", - "name": "HTTP - Obtener metadata Drive", - "retryOnFail": true, - "credentials": { - "googleDriveOAuth2Api": { - "id": "g23xdGLZRzBGqKgH", - "name": "Isaac - Google Drive" - } - }, - "onError": "continueErrorOutput" - }, - { - "parameters": { - "jsCode": "const original = $('IF - Tiene file_id?').item.json || {};\nconst metadata = $json || {};\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nfunction toDate(value) {\n const date = value ? new Date(value) : null;\n\n if (!date || Number.isNaN(date.getTime())) {\n return null;\n }\n\n return date;\n}\n\nconst driveModifiedTime = clean(\n metadata.modifiedTime || metadata.modified_time\n);\n\nconst lastRefresh = clean(original.ultimo_refresco_ia);\nconst existingDriveModified = clean(original.drive_modified_time);\nconst previousStatus = clean(original.refresh_status).toUpperCase();\n\nconst driveDate = toDate(driveModifiedTime);\nconst lastRefreshDate = toDate(lastRefresh);\nconst existingDriveDate = toDate(existingDriveModified);\n\nconst driveSizeBytes = Number(metadata.size || 0);\n\nconst driveSizeMb = driveSizeBytes\n ? Number((driveSizeBytes / 1024 / 1024).toFixed(2))\n : 0;\n\n// Estados que deben volver a intentarse aunque una ejecución anterior\n// haya escrito incorrectamente ultimo_refresco_ia.\nconst retryableStatuses = new Set([\n 'PENDIENTE',\n 'SIN_FILE_ID',\n 'ERROR_METADATA_DRIVE',\n 'ERROR_EXPORT_SLIDES',\n 'ERROR_CONVERT_PPTX',\n 'SIN_CONTENIDO',\n 'PPTX_DEMASIADO_PESADO',\n]);\n\nlet shouldRefresh = false;\nlet reason = '';\n\nif (retryableStatuses.has(previousStatus)) {\n shouldRefresh = true;\n reason = `reintento_estado_${previousStatus.toLowerCase()}`;\n} else if (!driveDate) {\n shouldRefresh = false;\n reason = 'sin_modified_time';\n} else if (!lastRefreshDate) {\n shouldRefresh = true;\n reason = 'nunca_refrescado';\n} else if (driveDate > lastRefreshDate) {\n shouldRefresh = true;\n reason = 'archivo_modificado_desde_ultimo_refresco';\n} else if (existingDriveDate && driveDate > existingDriveDate) {\n shouldRefresh = true;\n reason = 'drive_modified_time_cambio';\n} else {\n shouldRefresh = false;\n reason = 'sin_cambios';\n}\n\nreturn [\n {\n json: {\n ...original,\n\n drive_file_id:\n metadata.id ||\n original.file_id_refresco ||\n original.file_id ||\n '',\n\n drive_name: metadata.name || '',\n drive_mime_type: metadata.mimeType || '',\n drive_modified_time_actual: driveModifiedTime,\n drive_size_bytes: driveSizeBytes,\n drive_size_mb: driveSizeMb,\n\n should_refresh: shouldRefresh,\n refresh_reason: reason,\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 640, - -288 - ], - "id": "ebeb387e-0038-4166-b3a9-bdfdd863550a", - "name": "Code - Evaluar refresco" - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "strict", - "version": 3 - }, - "conditions": [ - { - "id": "a6356649-3547-40be-bdc6-55730f280fa9", - "leftValue": "={{ $json.should_refresh }}", - "rightValue": "", - "operator": { - "type": "boolean", - "operation": "true", - "singleValue": true - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "type": "n8n-nodes-base.if", - "typeVersion": 2.3, - "position": [ - 864, - -288 - ], - "id": "a0ac9c90-9029-41de-9338-1d076f045947", - "name": "IF - Debe refrescar?" - }, - { - "parameters": { - "url": "=https://www.googleapis.com/drive/v3/files/{{ $json.drive_file_id }}/export?mimeType=text/plain", - "authentication": "predefinedCredentialType", - "nodeCredentialType": "googleDriveOAuth2Api", - "options": { - "response": { - "response": { - "responseFormat": "text" - } - } - } - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 2640, - -368 - ], - "id": "2eabdc4e-1bb5-4f8a-bacb-844e87d379c0", - "name": "HTTP - Exportar Slides TXT", - "retryOnFail": true, - "credentials": { - "googleDriveOAuth2Api": { - "id": "g23xdGLZRzBGqKgH", - "name": "Isaac - Google Drive" - } - }, - "onError": "continueErrorOutput" - }, - { - "parameters": { - "jsCode": "function getNodeJson(name) {\n try {\n return $(name).item.json || {};\n } catch (error) {\n return {};\n }\n}\n\nconst evaluated = getNodeJson('Code - Evaluar refresco');\nconst converted = getNodeJson('Code - Preparar ID convertido');\n\n// Si venimos de la rama PPTX convertida, usamos el JSON convertido.\n// Si venimos de Google Slides nativo, usamos el JSON evaluado.\nconst original = Object.keys(converted).length ? converted : evaluated;\n\nconst incoming = $json || {};\n\nfunction clean(value) {\n return String(value ?? '').trim().replace(/\\s+/g, ' ');\n}\n\nlet exportedText = '';\n\nif (typeof incoming === 'string') {\n exportedText = incoming;\n} else {\n exportedText =\n incoming.body ||\n incoming.data ||\n incoming.text ||\n incoming.content ||\n incoming.response ||\n '';\n}\n\nexportedText = clean(exportedText);\n\nconst hasContent = exportedText.length >= 80;\n\nreturn [\n {\n json: {\n ...original,\n contenido_presentacion: exportedText.slice(0, 18000),\n contenido_chars: exportedText.length,\n contenido_valido: hasContent,\n refresh_attempts: Number(original.refresh_attempts_actual || original.refresh_attempts || 0) + 1,\n converted_google_slides_id: original.converted_google_slides_id || '',\n converted_google_slides_link: original.converted_google_slides_link || '',\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 3536, - -432 - ], - "id": "fadb71e0-541f-4cc0-8e03-be43fb3fd841", - "name": "Code - Preparar texto IA Refresco" - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "strict", - "version": 3 - }, - "conditions": [ - { - "id": "c0faa484-9c5d-4af3-b725-bfbc67f7f89d", - "leftValue": "={{ $json.contenido_valido }}", - "rightValue": "", - "operator": { - "type": "boolean", - "operation": "true", - "singleValue": true - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "type": "n8n-nodes-base.if", - "typeVersion": 2.3, - "position": [ - 3744, - -432 - ], - "id": "cade8a8b-995a-4951-9b76-7f695a10fe7d", - "name": "IF - Tiene contenido?" - }, - { - "parameters": { - "text": "=Nombre de la propuesta: {{$json.NOMBRE}}\nCliente: {{$json.CLIENTE}}\nMarca: {{$json.MARCA}}\nPaís: {{$json.PAIS}}\nLink: {{$json[\"Enlace a la propuesta\"]}}\n\nContenido exportado de la presentación:\n{{$json.contenido_presentacion}}", - "attributes": { - "attributes": [ - { - "name": "nombre_propuesta", - "description": "Nombre limpio o comercial de la propuesta." - }, - { - "name": "tipo_accion", - "description": "Tipo de acción o formato de propuesta. Ejemplos: campaña, evento, mueble, exhibidor, diseño gráfico, activación PDV, material POP, uniforme, promoción, stand. Si no se puede determinar, usar PENDIENTE." - }, - { - "name": "canal", - "description": "Canal o lugar donde aplica la propuesta. Ejemplos: moderno, tradicional, online, PDV, no aplica. Si no se puede determinar, usar PENDIENTE." - }, - { - "name": "tags", - "description": "Palabras clave separadas por comas para facilitar búsqueda. Ejemplos: campaña, corporativo, exhibidor, punto de venta, juegos, uniforme, material POP." - }, - { - "name": "descripcion", - "description": "Párrafo profesional de 35 a 70 palabras. Debe iniciar preferiblemente con “La presentación muestra...”, “La pieza muestra...” o “La propuesta corresponde a...”. Debe mencionar cliente/marca, tipo de acción, concepto o tema principal y objetivo. No inventar detalles." - }, - { - "name": "tactica_promocional", - "description": "Táctica promocional principal. Ejemplos: DEGUSTACIÓN, RULETA, SAMPLING - MUESTREO, PLINKO, WHATSAPP, LANDING PAGE, POP, PHOTOBOOTH, CANJE, SORTEO, PREMIOS INSTANTÁNEOS, JUEGO DIGITAL, TRIVIA, EXHIBICIÓN, IMPULSO o PENDIENTE." - } - ] - }, - "options": { - "systemPromptTemplate": "Analiza el contenido real exportado de una presentación de Google Slides para actualizar el banco de propuestas de Fulgencio Fumado.\n\nIMPORTANTE:\n- Usa el contenido de la presentación como fuente principal.\n- No cambies el país, cliente ni marca recibidos desde Tablero CDC.\n- No determines APROBADA. Ese campo debe quedar como PENDIENTE DE APROBACION.\n- No determines AMBIENTE DE COMPRA (RE). Ese campo debe quedar como PENDIENTE.\n- Si el contenido es insuficiente, usa PENDIENTE.\n- No inventes detalles.\n- Devuelve:\n nombre_propuesta\n tipo_accion\n canal\n tags\n descripcion\n tactica_promocional" - } - }, - "type": "@n8n/n8n-nodes-langchain.informationExtractor", - "typeVersion": 1.2, - "position": [ - 4352, - -496 - ], - "id": "08c0d48c-400f-454a-832d-9e08d4724fbd", - "name": "Gemini - Reanalizar contenido Slides" - }, - { - "parameters": { - "modelName": "models/gemini-2.5-pro", - "options": {} - }, - "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini", - "typeVersion": 1.1, - "position": [ - 4352, - -288 - ], - "id": "5875c1c9-5aa3-4f60-a3ee-f94e0293ec04", - "name": "Google Gemini Chat Model", - "credentials": { - "googlePalmApi": { - "id": "jvsXYwL6IOoY2DBU", - "name": "Isaac - Gemini Api Pago" - } - } - }, - { - "parameters": { - "jsCode": "const original = $('Code - Preparar texto IA Refresco').item.json || {};\nconst incoming = $input.first().json || {};\nconst ai = incoming.output || incoming;\n\nfunction clean(value) {\n if (Array.isArray(value)) return value.map(clean).filter(Boolean).join(', ');\n return String(value ?? '').trim().replace(/\\s+/g, ' ');\n}\n\nfunction removeAccents(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction upper(value) {\n return removeAccents(value).toUpperCase();\n}\n\nfunction splitMulti(value) {\n return clean(value)\n .split(',')\n .map(v => upper(v))\n .map(v => v.trim())\n .filter(Boolean);\n}\n\nfunction unique(values) {\n return [...new Set(values.filter(Boolean))];\n}\n\nfunction joinMulti(values) {\n return unique(values).join(', ');\n}\n\nfunction normalizeTipoAccion(value) {\n const v = upper(value);\n\n const synonyms = {\n 'ACTIVACION PDV': 'ACTIVACION EN PDV',\n 'ACTIVACION EN PUNTO DE VENTA': 'ACTIVACION EN PDV',\n 'ACTIVACION PUNTO DE VENTA': 'ACTIVACION EN PDV',\n 'PUNTO DE VENTA': 'ACTIVACION EN PDV',\n 'PDV': 'ACTIVACION EN PDV',\n 'DISEÑO GRAFICO': 'DISENO GRAFICO',\n 'DISEÑO GRÁFICO': 'DISENO GRAFICO',\n 'DISENO GRAFICO': 'DISENO GRAFICO',\n 'CAMPAÑA': 'CAMPAÑA',\n 'CAMPANA': 'CAMPAÑA',\n 'PROMOCION': 'PROMOCION',\n 'PROMOCIÓN': 'PROMOCION',\n 'MUEBLE': 'MUEBLE',\n 'EXHIBIDOR': 'MUEBLE',\n 'STAND': 'STAND',\n 'EVENTO': 'EVENTO',\n 'UNIFORME': 'UNIFORME',\n 'MATERIAL POP': 'DISENO GRAFICO',\n 'POP': 'DISENO GRAFICO',\n };\n\n return synonyms[v] || v;\n}\n\nfunction normalizeCanal(value) {\n const v = upper(value);\n\n const synonyms = {\n 'PUNTO DE VENTA': 'PDV',\n 'PDV': 'PDV',\n 'CANAL MODERNO': 'MODERNO',\n 'MODERNO': 'MODERNO',\n 'CANAL TRADICIONAL': 'TRADICIONAL',\n 'TRADICIONAL': 'TRADICIONAL',\n 'DIGITAL': 'ONLINE',\n 'ONLINE': 'ONLINE',\n 'NO APLICA': 'NO APLICA',\n 'N/A': 'NO APLICA',\n };\n\n return synonyms[v] || v;\n}\n\nfunction normalizeTactica(value) {\n const v = upper(value);\n\n const synonyms = {\n 'SAMPLING': 'SAMPLING - MUESTREO',\n 'MUESTREO': 'SAMPLING - MUESTREO',\n 'DEGUSTACION': 'DEGUSTACIÓN',\n 'DEGUSTACIÓN': 'DEGUSTACIÓN',\n 'RULETA DIGITAL': 'RULETA',\n 'RULETA': 'RULETA',\n 'PLINKO': 'PLINKO',\n 'WHATSAPP': 'WHATSAPP',\n 'LANDING': 'LANDING PAGE',\n 'LANDING PAGE': 'LANDING PAGE',\n 'MATERIAL POP': 'POP',\n 'POP': 'POP',\n 'JUEGO DIGITAL': 'JUEGO DIGITAL',\n 'JUEGOS DIGITALES': 'JUEGO DIGITAL',\n 'EXHIBICION': 'EXHIBICIÓN',\n 'EXHIBICIÓN': 'EXHIBICIÓN',\n 'IMPULSO': 'IMPULSO',\n };\n\n return synonyms[v] || v;\n}\n\nfunction normalizeEtiqueta(value) {\n const v = upper(value);\n\n const synonyms = {\n 'ACTIVACION': 'ACTIVACION',\n 'ACTIVACIÓN': 'ACTIVACION',\n 'EXHIBICION': 'EXHIBICION',\n 'EXHIBICIÓN': 'EXHIBICION',\n 'EXHIBIDOR': 'EXHIBIDOR',\n 'DEGUSTACION': 'DEGUSTACION',\n 'DEGUSTACIÓN': 'DEGUSTACION',\n 'PROMOCION': 'PROMOCIÓN',\n 'PROMOCIÓN': 'PROMOCIÓN',\n 'DISENO': 'DISEÑO',\n 'DISEÑO': 'DISEÑO',\n 'CAMPANA': 'CAMPAÑA',\n 'CAMPAÑA': 'CAMPAÑA',\n 'JUEGO DIGITAL': 'JUEGOS DIGITALES',\n 'JUEGOS DIGITALES': 'JUEGOS DIGITALES',\n 'PUNTO DE VENTA': 'PUNTO DE VENTA',\n 'PDV': 'PUNTO DE VENTA',\n 'F1': 'JUEGOS',\n 'SIMULADOR': 'JUEGOS DIGITALES',\n 'EXPERIENCIA': 'ACTIVIDADES',\n 'CORPORATIVO': 'CORPORATIVO',\n 'UNIFORME': 'UNIFORMES',\n 'UNIFORMES': 'UNIFORMES',\n 'POP': 'PUNTO DE VENTA',\n 'MATERIAL POP': 'PUNTO DE VENTA',\n };\n\n return synonyms[v] || v;\n}\n\nfunction normalizeOne(value, allowedList, fallback = 'PENDIENTE', normalizer = upper) {\n const raw = clean(value);\n if (!raw) return fallback;\n\n const mapped = normalizer(raw);\n return allowedList.includes(mapped) ? mapped : fallback;\n}\n\nfunction normalizeMulti(value, allowedList, fallback = 'PENDIENTE', unknownValue = 'OTRO', normalizer = upper) {\n const parts = splitMulti(value);\n if (!parts.length) return fallback;\n\n const result = [];\n\n for (const part of parts) {\n const mapped = normalizer(part);\n const expanded = String(mapped)\n .split(',')\n .map(v => v.trim())\n .filter(Boolean);\n\n for (const val of expanded) {\n if (allowedList.includes(val)) {\n result.push(val);\n } else {\n result.push(unknownValue);\n }\n }\n }\n\n const finalValues = unique(result);\n if (!finalValues.length) return fallback;\n\n return joinMulti(finalValues);\n}\n\nconst ALLOWED_TIPO_ACCION = [\n 'ACTIVACION EN PDV',\n 'IMPULSO DE VENTA',\n 'ACTIVACION FUERA DE PDV',\n 'EVENTO',\n 'KICK OFF',\n 'FERIA',\n 'STAND',\n 'MUEBLE',\n 'DISENO GRAFICO',\n 'DIGITAL',\n 'PUBLICIDAD',\n 'TUTORIAL',\n 'INFORMATIVA',\n 'LOGOTIPO',\n 'PROMOCION',\n 'UNIFORME',\n 'ACTIVACIÓN',\n 'INAUGURACIÓN',\n 'PARQUE',\n 'TROFEO',\n 'ANAQUEL',\n 'FOOD TRUCK',\n 'EMPAQUES',\n 'CAMPAÑA',\n 'QUICK COUNTER',\n 'DISEÑO',\n 'PPT',\n 'PENDIENTE',\n 'OTRO',\n];\n\nconst ALLOWED_CANAL = [\n 'MODERNO',\n 'TRADICIONAL',\n 'ONLINE',\n 'NO APLICA',\n 'PDV',\n 'PENDIENTE',\n];\n\nconst ALLOWED_TACTICA = [\n 'DEGUSTACIÓN',\n 'RULETA',\n 'SAMPLING - MUESTREO',\n 'PLINKO',\n 'WHATSAPP',\n 'LANDING PAGE',\n 'POP',\n 'PHOTOBOOTH',\n 'CANJE',\n 'SORTEO',\n 'PREMIOS INSTANTÁNEOS',\n 'JUEGO DIGITAL',\n 'TRIVIA',\n 'EXHIBICIÓN',\n 'IMPULSO',\n 'PENDIENTE',\n 'OTRO',\n];\n\nconst ALLOWED_ETIQUETAS = [\n 'ACTIVACION',\n 'ACTIVIDADES',\n 'BANDEJA',\n 'BTS',\n 'CORPORATIVO',\n 'DEGUSTACION',\n 'EXHIBICION',\n 'EXHIBIDOR',\n 'FUERZA DE VENTAS',\n 'JUEGOS',\n 'PREMIOS',\n 'PUNTO DE VENTA',\n 'REFERENCIAS',\n 'REUNIÓN',\n 'RULETA',\n 'SAMPLING',\n 'HALLOWEEN',\n 'LUCES NEON',\n 'INFORMATIVA',\n 'CARRITO DEGUSTACIÓN',\n 'RED SOCIAL/INFLUENCER',\n 'UNIFORMES',\n 'REGALIAS',\n 'STAND',\n 'CAPACITACIÓN',\n 'NAVIDAD',\n 'DÍA DE LAS MADRES',\n 'INDEPENDENCIA',\n 'LOGOTIPO',\n 'QUICK COUNTER',\n 'PPT',\n 'TEMPLATES',\n 'DISEÑO',\n 'PROMOCIÓN',\n 'MODERNO',\n 'MAYORISTA',\n 'PULPERÍAS',\n 'JUEGOS DIGITALES',\n 'WHATSAPP',\n 'CAMPAÑA',\n 'CONCURSO',\n 'REGALOS CORPORATIVOS',\n 'PENDIENTE',\n 'OTRO',\n];\n\nlet nombrePropuesta = clean(\n ai.nombre_propuesta ||\n original.NOMBRE ||\n original.nombre_limpio ||\n original.drive_name ||\n ''\n);\n\nif (!nombrePropuesta) {\n nombrePropuesta = 'PENDIENTE';\n}\n\nlet tipoAccion = normalizeMulti(\n ai.tipo_accion,\n ALLOWED_TIPO_ACCION,\n 'PENDIENTE',\n 'OTRO',\n normalizeTipoAccion\n);\n\nconst canal = normalizeOne(\n ai.canal,\n ALLOWED_CANAL,\n 'PENDIENTE',\n normalizeCanal\n);\n\nconst tacticaPromocional = normalizeMulti(\n ai.tactica_promocional,\n ALLOWED_TACTICA,\n 'PENDIENTE',\n 'OTRO',\n normalizeTactica\n);\n\nlet etiquetas = normalizeMulti(\n ai.tags,\n ALLOWED_ETIQUETAS,\n 'PENDIENTE',\n 'OTRO',\n normalizeEtiqueta\n);\n\nif (etiquetas.includes('OTRO') && etiquetas !== 'OTRO') {\n etiquetas = etiquetas\n .split(',')\n .map(v => v.trim())\n .filter(v => v !== 'OTRO')\n .join(', ');\n}\n\nconst ambienteCompra = 'PENDIENTE';\nconst aprobada = 'PENDIENTE DE APROBACION';\n\nlet descripcion = clean(ai.descripcion);\n\nif (!descripcion) {\n descripcion = clean(\n original.Descripcion ||\n original.NOMBRE ||\n original.drive_name ||\n ''\n );\n}\n\nlet requiereRevision = false;\nconst revisionReasons = [];\n\nif (tipoAccion.includes('OTRO') || tipoAccion === 'PENDIENTE') {\n requiereRevision = true;\n revisionReasons.push('tipo_accion_revisar');\n}\n\nif (canal === 'PENDIENTE') {\n requiereRevision = true;\n revisionReasons.push('canal_revisar');\n}\n\nif (tacticaPromocional.includes('OTRO') || tacticaPromocional === 'PENDIENTE') {\n requiereRevision = true;\n revisionReasons.push('tactica_revisar');\n}\n\nif (etiquetas.includes('OTRO') || etiquetas === 'PENDIENTE') {\n requiereRevision = true;\n revisionReasons.push('etiquetas_revisar');\n}\n\nreturn [\n {\n json: {\n NOMBRE: nombrePropuesta,\n 'TIPO DE ACCION': tipoAccion,\n CLIENTE: original.CLIENTE || '',\n MARCA: original.MARCA || '',\n PAIS: original.PAIS || '',\n CANAL: canal,\n 'AMBIENTE DE COMPRA (RE)': ambienteCompra,\n 'TÁCTICA PROMOCIONAL': tacticaPromocional,\n APROBADA: aprobada,\n ETIQUETAS: etiquetas,\n 'AÑO': original['AÑO'] || '',\n 'Enlace a la propuesta': original['Enlace a la propuesta'] || '',\n Descripcion: descripcion,\n\n file_id: original.file_id || original.file_id_refresco || original.drive_file_id_original || original.drive_file_id || '',\n nombre_archivo: original.nombre_archivo || original.NOMBRE || original.drive_name || '',\n mime_type: original.mime_type || original.drive_mime_type || '',\n fuente_pais: original.fuente_pais || 'Tablero CDC',\n confianza_pais: original.confianza_pais || '',\n requiere_revision: requiereRevision,\n motivos_revision: revisionReasons.join(', '),\n procesado_ia: true,\n ultima_actualizacion: new Date().toISOString(),\n 'Enlaces a propuestas ejecutadas': original['Enlaces a propuestas ejecutadas'] || '',\n\n tablero_sync_key: original.tablero_sync_key || '',\n tablero_project_id: original.tablero_project_id || '',\n tablero_origen: original.tablero_origen || 'Tablero CDC',\n\n drive_modified_time: original.drive_modified_time_actual || original.drive_modified_time || '',\n ultimo_refresco_ia: new Date().toISOString(),\n refresh_status: 'OK',\n refresh_attempts: original.refresh_attempts || 1,\n\n converted_google_slides_id: original.converted_google_slides_id || '',\n converted_google_slides_link: original.converted_google_slides_link || '',\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 4704, - -496 - ], - "id": "525e3816-a692-49e3-9885-20b8b4ddccbe", - "name": "Code - Normalizar respuesta refresco" - }, - { - "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": { - "tablero_sync_key": "={{ String($json.tablero_sync_key || '').trim() }}", - "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\"] ? \"TRUE\" : \"FALSE\"}}", - "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\"]}}", - "drive_modified_time": "={{ $json[\"drive_modified_time\"]}}", - "ultimo_refresco_ia": "={{ $json[\"ultimo_refresco_ia\"]}}", - "refresh_status": "={{ $json[\"refresh_status\"]}}", - "refresh_attempts": "={{ $json[\"refresh_attempts\"]}}", - "converted_google_slides_id": "={{ $json[\"converted_google_slides_id\"]}}", - "converted_google_slides_link": "={{ $json[\"converted_google_slides_link\"]}}" - }, - "matchingColumns": [ - "tablero_sync_key" - ], - "schema": [ - { - "id": "NOMBRE", - "displayName": "NOMBRE", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": false - }, - { - "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 - }, - { - "id": "drive_modified_time", - "displayName": "drive_modified_time", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "ultimo_refresco_ia", - "displayName": "ultimo_refresco_ia", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "refresh_status", - "displayName": "refresh_status", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "refresh_attempts", - "displayName": "refresh_attempts", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true - }, - { - "id": "converted_google_slides_id", - "displayName": "converted_google_slides_id", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": false - }, - { - "id": "converted_google_slides_link", - "displayName": "converted_google_slides_link", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": false - } - ], - "attemptToConvertTypes": false, - "convertFieldsToString": false - }, - "options": {} - }, - "type": "n8n-nodes-base.googleSheets", - "typeVersion": 4.7, - "position": [ - 5472, - 352 - ], - "id": "f6487012-88de-4be3-b62e-fa9bdfdda270", - "name": "Google Sheets - Upsert Banco Fulgencio", - "retryOnFail": true, - "maxTries": 5, - "credentials": { - "googleSheetsOAuth2Api": { - "id": "K0hDZh3a85MpOHCs", - "name": "Google Sheets account 2" - } - } - }, - { - "parameters": { - "jsCode": "const original = $('Code - Preparar texto IA Refresco').item.json || {};\n\nreturn [\n {\n json: {\n ...original,\n drive_modified_time: original.drive_modified_time_actual || original.drive_modified_time || '',\n ultimo_refresco_ia: original.ultimo_refresco_ia || '',\n ultima_actualizacion: new Date().toISOString(),\n refresh_status: 'SIN_CONTENIDO',\n refresh_attempts: original.refresh_attempts || 1,\n procesado_ia: false,\n\n converted_google_slides_id: original.converted_google_slides_id || '',\n converted_google_slides_link: original.converted_google_slides_link || '',\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 4128, - 0 - ], - "id": "42c459da-dd49-4ed8-aa1d-ff1f39688acf", - "name": "Code - Marcar sin contenido" - }, - { - "parameters": { - "jsCode": "function nowIso() {\n return new Date().toISOString();\n}\n\nfunction appendMotivo(existing, motivo) {\n const current = String(existing || \"\").trim();\n\n if (!current) return motivo;\n\n const parts = current\n .split(\",\")\n .map((p) => p.trim())\n .filter(Boolean);\n\n if (!parts.includes(motivo)) {\n parts.push(motivo);\n }\n\n return parts.join(\", \");\n}\n\nfunction buildTableroSyncKey(data) {\n const tableroProjectId = String(data.tablero_project_id || \"\").trim();\n const fileId = String(data.file_id || \"\").trim();\n const existingKey = String(data.tablero_sync_key || \"\").trim();\n\n if (existingKey) return existingKey;\n if (tableroProjectId && fileId) return `${tableroProjectId}::${fileId}`;\n if (fileId) return `drive::${fileId}`;\n if (tableroProjectId) return `${tableroProjectId}::sin_file_id`;\n\n return \"\";\n}\n\nreturn items.map((item) => {\n const data = item.json;\n\n const fileId = String(data.file_id || \"\").trim();\n\n /**\n * Seguridad:\n * Si este nodo recibe por error una fila que SÍ tiene file_id,\n * no la marca como SIN_FILE_ID.\n */\n if (fileId) {\n return {\n json: {\n ...data,\n tablero_sync_key: buildTableroSyncKey(data),\n refresh_status: data.refresh_status || \"PENDIENTE\",\n ultima_actualizacion: data.ultima_actualizacion || nowIso(),\n },\n };\n }\n\n return {\n json: {\n ...data,\n file_id: \"\",\n tablero_sync_key: buildTableroSyncKey(data),\n refresh_status: \"SIN_FILE_ID\",\n refresh_attempts: Number(data.refresh_attempts || 0) + 1,\n requiere_revision: true,\n procesado_ia: false,\n ultima_actualizacion: nowIso(),\n ultimo_refresco_ia: data.ultimo_refresco_ia || \"\",\n motivos_revision: appendMotivo(data.motivos_revision, \"sin_file_id\"),\n },\n };\n});" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 736, - 112 - ], - "id": "6287af34-de8e-49aa-b432-778c016c9f8d", - "name": "Code - Marcar sin file_id" - }, - { - "parameters": { - "jsCode": "const original = $('IF - Tiene file_id?').item.json || {};\nconst errorData = $json || {};\n\nfunction stringifyError(value) {\n try {\n if (typeof value === 'string') return value;\n return JSON.stringify(value).slice(0, 500);\n } catch (error) {\n return String(value).slice(0, 500);\n }\n}\n\nreturn [\n {\n json: {\n ...original,\n drive_modified_time: original.drive_modified_time || '',\n ultimo_refresco_ia: original.ultimo_refresco_ia || '',\n ultima_actualizacion: new Date().toISOString(),\n refresh_status: 'ERROR_METADATA_DRIVE',\n refresh_attempts: Number(original.refresh_attempts_actual || original.refresh_attempts || 0) + 1,\n procesado_ia: false,\n motivos_revision: `error_metadata_drive: ${stringifyError(errorData)}`,\n\n converted_google_slides_id: original.converted_google_slides_id || '',\n converted_google_slides_link: original.converted_google_slides_link || '',\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 688, - -96 - ], - "id": "389332ff-4d6b-4a5d-824d-28a1098030ef", - "name": "Code - Marcar error metadata Drive" - }, - { - "parameters": { - "jsCode": "function getNodeJson(name) {\n try {\n return $(name).item.json || {};\n } catch (error) {\n return {};\n }\n}\n\nconst evaluated = getNodeJson('Code - Evaluar refresco');\nconst converted = getNodeJson('Code - Preparar ID convertido');\n\nconst original = Object.keys(converted).length ? converted : evaluated;\nconst errorData = $json || {};\n\nfunction stringifyError(value) {\n try {\n if (typeof value === 'string') return value;\n return JSON.stringify(value).slice(0, 500);\n } catch (error) {\n return String(value).slice(0, 500);\n }\n}\n\nreturn [\n {\n json: {\n ...original,\n drive_modified_time: original.drive_modified_time_actual || original.drive_modified_time || '',\n ultimo_refresco_ia: original.ultimo_refresco_ia || '',\n ultima_actualizacion: new Date().toISOString(),\n refresh_status: 'ERROR_EXPORT_SLIDES',\n refresh_attempts: Number(original.refresh_attempts_actual || original.refresh_attempts || 0) + 1,\n procesado_ia: false,\n motivos_revision: `error_export_slides: ${stringifyError(errorData)}`,\n\n converted_google_slides_id: original.converted_google_slides_id || '',\n converted_google_slides_link: original.converted_google_slides_link || '',\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 3472, - -240 - ], - "id": "251b1833-27c9-4a63-9a97-6ad600ea5283", - "name": "Code - Marcar error export Slides" - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "strict", - "version": 3 - }, - "conditions": [ - { - "id": "1dd1c68d-37b9-4bde-8ff6-7556f7fe979b", - "leftValue": "={{ $json.drive_mime_type }}", - "rightValue": "application/vnd.google-apps.presentation", - "operator": { - "type": "string", - "operation": "equals", - "name": "filter.operator.equals" - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "type": "n8n-nodes-base.if", - "typeVersion": 2.3, - "position": [ - 1408, - -304 - ], - "id": "5de289da-1c11-422e-be69-73d6d8c67db8", - "name": "IF - Es Google Slides nativo?" - }, - { - "parameters": { - "method": "POST", - "url": "https://script.google.com/macros/s/AKfycbwGdQZT_tRCWmUsIw9LzwzWClHx12CKVI23dbE-gUwVZ-dorjJJuQLWHxUACnH2vlK-Jw/exec", - "sendBody": true, - "bodyParameters": { - "parameters": [ - { - "name": "token", - "value": "tablero-cdc-fulgencio-convert-2026-isaac" - }, - { - "name": "file_id", - "value": "={{ $json.file_id_refresco || $json.drive_file_id }}" - }, - { - "name": "previous_converted_id", - "value": "={{ $json.converted_google_slides_id }}" - }, - { - "name": "folder_id", - "value": "1wUTt7ttK3EafPi-L8bIj-J_ZdN9EPg4r" - }, - { - "name": "replace_existing", - "value": "true" - } - ] - }, - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 1936, - -192 - ], - "id": "150797a7-4db0-4aff-88ed-d18eb0afc202", - "name": "HTTP - Convertir PPTX con Apps Script" - }, - { - "parameters": { - "jsCode": "const original = $('IF - Es Google Slides nativo?').item.json || {};\nconst response = $json || {};\n\nfunction clean(value) {\n return String(value ?? '').trim();\n}\n\nif (!response.ok || !response.converted_google_slides_id) {\n return [\n {\n json: {\n ...original,\n drive_modified_time: original.drive_modified_time_actual || original.drive_modified_time || '',\n ultimo_refresco_ia: original.ultimo_refresco_ia || '',\n ultima_actualizacion: new Date().toISOString(),\n refresh_status: 'ERROR_CONVERT_PPTX',\n refresh_attempts: Number(original.refresh_attempts_actual || original.refresh_attempts || 0) + 1,\n procesado_ia: false,\n motivos_revision: `error_convert_pptx: ${clean(response.error || JSON.stringify(response)).slice(0, 300)}`,\n },\n },\n ];\n}\n\nreturn [\n {\n json: {\n ...original,\n\n drive_file_id_original: original.drive_file_id || original.file_id_refresco || '',\n drive_file_id: response.converted_google_slides_id,\n drive_mime_type: 'application/vnd.google-apps.presentation',\n\n converted_google_slides_id: response.converted_google_slides_id,\n converted_google_slides_link: response.converted_google_slides_link,\n\n refresh_status: 'CONVERTIDO_PPTX',\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 2112, - -192 - ], - "id": "fcc131cb-6181-41fc-9231-129dfc7d6095", - "name": "Code - Preparar ID convertido" - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "strict", - "version": 3 - }, - "conditions": [ - { - "id": "f05881a1-7e59-447e-830d-a7bb81d74c7e", - "leftValue": "={{ $json.refresh_status }}", - "rightValue": "ERROR_CONVERT_PPTX", - "operator": { - "type": "string", - "operation": "notEquals" - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "type": "n8n-nodes-base.if", - "typeVersion": 2.3, - "position": [ - 2320, - -192 - ], - "id": "dd855b50-ab6f-449d-8c5c-6c5b5a80f339", - "name": "IF - Conversion OK?" - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "strict", - "version": 3 - }, - "conditions": [ - { - "id": "d8425980-f331-4f23-9cfb-118077f4437e", - "leftValue": "={{ $json.drive_size_mb }}", - "rightValue": 100, - "operator": { - "type": "number", - "operation": "lte" - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "type": "n8n-nodes-base.if", - "typeVersion": 2.3, - "position": [ - 1664, - -144 - ], - "id": "ea25df58-fe23-4f28-83d0-2ce8851aa353", - "name": "IF - PPTX pesa <= 100MB?" - }, - { - "parameters": { - "jsCode": "const original = $json || {};\n\nreturn [\n {\n json: {\n ...original,\n drive_modified_time: original.drive_modified_time_actual || original.drive_modified_time || '',\n ultimo_refresco_ia: original.ultimo_refresco_ia || '',\n ultima_actualizacion: new Date().toISOString(),\n refresh_status: 'PPTX_DEMASIADO_PESADO',\n refresh_attempts: Number(original.refresh_attempts_actual || original.refresh_attempts || 0) + 1,\n procesado_ia: false,\n motivos_revision: `pptx_demasiado_pesado_para_conversion_google_slides: ${original.drive_size_mb || 0} MB. Google Slides solo permite convertir presentaciones hasta 100 MB.`,\n\n converted_google_slides_id: original.converted_google_slides_id || '',\n converted_google_slides_link: original.converted_google_slides_link || '',\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 2320, - 96 - ], - "id": "2390d0c3-bf35-4480-8987-6ee1509d211e", - "name": "Code - Marcar PPTX demasiado pesado" - }, - { - "parameters": { - "content": "## 📅 ENTRADA DIARIA — BANCO FULGENCIO\n\nEste flujo corre automáticamente todos los días a las 23:00.\n\nLee el Banco de Propuestas de Fulgencio y procesa solo filas provenientes de Tablero CDC.\n\nCriterios mínimos:\n- tablero_origen = Tablero CDC\n- tablero_sync_key presente\n- tablero_project_id presente\n- Enlace a la propuesta presente\n\nObjetivo:\nmantener actualizado el banco que consulta FulgencioChat.", - "height": 544, - "width": 976, - "color": 5 - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - -864, - -384 - ], - "id": "9adcd60e-20bc-4e8a-a822-daaadb405c8e", - "name": "Sticky Note" - }, - { - "parameters": { - "content": "## 🔎 EVALUACIÓN DE CAMBIOS EN DRIVE\n\nPor cada fila válida, el flujo obtiene metadata del archivo en Google Drive.\n\nCompara:\n- drive_modified_time actual\n- ultimo_refresco_ia guardado\n- drive_modified_time previo\n\nSolo refresca si:\n- nunca se refrescó\n- el archivo cambió después del último refresco\n- cambió la fecha de modificación en Drive\n\nSi no hay cambios, pasa a la siguiente fila.", - "height": 928, - "width": 704, - "color": 3 - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 320, - -672 - ], - "id": "fa12fd6a-ee6b-47ee-af68-5fb1f186bd9a", - "name": "Sticky Note1" - }, - { - "parameters": { - "content": "## 📄 EXTRACCIÓN DE CONTENIDO\n\nSi el archivo es Google Slides nativo:\n- se exporta directamente a texto.\n\nSi el archivo es PPTX:\n- solo se convierte si pesa 100 MB o menos.\n- se convierte con Apps Script a Google Slides.\n- luego se exporta a texto.\n\nErrores controlados:\n- SIN_FILE_ID\n- ERROR_METADATA_DRIVE\n- ERROR_EXPORT_SLIDES\n- ERROR_CONVERT_PPTX\n- PPTX_DEMASIADO_PESADO\n- SIN_CONTENIDO\n\nUna fila con error se marca en el banco y el flujo continúa.", - "height": 1152, - "width": 1456, - "color": 2 - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 1344, - -832 - ], - "id": "75a29688-9331-4fdb-928f-600f4bd21136", - "name": "Sticky Note2" - }, - { - "parameters": { - "content": "## 🤖 REANÁLISIS IA Y UPSERT AL BANCO\n\nGemini analiza el texto exportado de la presentación.\n\nPuede actualizar:\n- nombre_propuesta\n- tipo_accion\n- canal\n- tags\n- descripcion\n- tactica_promocional\n\nNo debe cambiar:\n- país\n- cliente\n- marca\n- enlace\n- identificadores de Tablero CDC\n\nReglas fijas:\n- APROBADA = PENDIENTE DE APROBACION\n- AMBIENTE DE COMPRA (RE) = PENDIENTE\n\nEl banco se actualiza usando:\ntablero_sync_key\n\nTambién guarda:\n- refresh_status\n- refresh_attempts\n- ultimo_refresco_ia\n- drive_modified_time\n- requiere_revision\n- motivos_revision", - "height": 1760, - "width": 2576, - "color": 4 - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 3152, - -1136 - ], - "id": "fc72547c-317e-4ac7-9428-825e8504f143", - "name": "Sticky Note3" - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": true, - "leftValue": "", - "typeValidation": "strict", - "version": 3 - }, - "conditions": [ - { - "id": "cb44c645-00ba-43c1-a8f0-2fe556ebc382", - "leftValue": "={{ String($json.tablero_sync_key || '').trim() }}", - "rightValue": "", - "operator": { - "type": "string", - "operation": "notEmpty", - "singleValue": true - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "type": "n8n-nodes-base.if", - "typeVersion": 2.3, - "position": [ - 5024, - 112 - ], - "id": "1b56b546-c4af-412d-bef8-8105eb830ae1", - "name": "IF - Tiene sync key antes de Upsert?" - }, - { - "parameters": { - "amount": 4 - }, - "type": "n8n-nodes-base.wait", - "typeVersion": 1.1, - "position": [ - 3424, - 416 - ], - "id": "be090d5c-6fb6-4087-b25b-83fb8e3a3392", - "name": "Wait - Respetar cuota Google Sheets", - "webhookId": "8b997d7c-097c-4c8a-8793-6bce3db5e142" - } - ], - "pinData": {}, - "connections": { - "Schedule Trigger": { - "main": [ - [ - { - "node": "Sheets - Leer Banco Fulgencio", - "type": "main", - "index": 0 - } - ] - ] - }, - "Sheets - Leer Banco Fulgencio": { - "main": [ - [ - { - "node": "Code - Filtrar filas Tablero CDC", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Filtrar filas Tablero CDC": { - "main": [ - [ - { - "node": "Loop - Filas Banco Fulgencio", - "type": "main", - "index": 0 - } - ] - ] - }, - "Loop - Filas Banco Fulgencio": { - "main": [ - [], - [ - { - "node": "IF - Tiene file_id?", - "type": "main", - "index": 0 - } - ] - ] - }, - "IF - Tiene file_id?": { - "main": [ - [ - { - "node": "HTTP - Obtener metadata Drive", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Code - Marcar sin file_id", - "type": "main", - "index": 0 - } - ] - ] - }, - "HTTP - Obtener metadata Drive": { - "main": [ - [ - { - "node": "Code - Evaluar refresco", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Code - Marcar error metadata Drive", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Evaluar refresco": { - "main": [ - [ - { - "node": "IF - Debe refrescar?", - "type": "main", - "index": 0 - } - ] - ] - }, - "IF - Debe refrescar?": { - "main": [ - [ - { - "node": "IF - Es Google Slides nativo?", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Loop - Filas Banco Fulgencio", - "type": "main", - "index": 0 - } - ] - ] - }, - "HTTP - Exportar Slides TXT": { - "main": [ - [ - { - "node": "Code - Preparar texto IA Refresco", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Code - Marcar error export Slides", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Preparar texto IA Refresco": { - "main": [ - [ - { - "node": "IF - Tiene contenido?", - "type": "main", - "index": 0 - } - ] - ] - }, - "IF - Tiene contenido?": { - "main": [ - [ - { - "node": "Gemini - Reanalizar contenido Slides", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Code - Marcar sin contenido", - "type": "main", - "index": 0 - } - ] - ] - }, - "Google Gemini Chat Model": { - "ai_languageModel": [ - [ - { - "node": "Gemini - Reanalizar contenido Slides", - "type": "ai_languageModel", - "index": 0 - } - ] - ] - }, - "Gemini - Reanalizar contenido Slides": { - "main": [ - [ - { - "node": "Code - Normalizar respuesta refresco", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Normalizar respuesta refresco": { - "main": [ - [ - { - "node": "IF - Tiene sync key antes de Upsert?", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Marcar sin contenido": { - "main": [ - [ - { - "node": "IF - Tiene sync key antes de Upsert?", - "type": "main", - "index": 0 - } - ] - ] - }, - "Google Sheets - Upsert Banco Fulgencio": { - "main": [ - [ - { - "node": "Wait - Respetar cuota Google Sheets", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Marcar sin file_id": { - "main": [ - [ - { - "node": "IF - Tiene sync key antes de Upsert?", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Marcar error metadata Drive": { - "main": [ - [ - { - "node": "IF - Tiene sync key antes de Upsert?", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Marcar error export Slides": { - "main": [ - [ - { - "node": "IF - Tiene sync key antes de Upsert?", - "type": "main", - "index": 0 - } - ] - ] - }, - "IF - Es Google Slides nativo?": { - "main": [ - [ - { - "node": "HTTP - Exportar Slides TXT", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "IF - PPTX pesa <= 100MB?", - "type": "main", - "index": 0 - } - ] - ] - }, - "HTTP - Convertir PPTX con Apps Script": { - "main": [ - [ - { - "node": "Code - Preparar ID convertido", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Preparar ID convertido": { - "main": [ - [ - { - "node": "IF - Conversion OK?", - "type": "main", - "index": 0 - } - ] - ] - }, - "IF - Conversion OK?": { - "main": [ - [ - { - "node": "HTTP - Exportar Slides TXT", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "IF - Tiene sync key antes de Upsert?", - "type": "main", - "index": 0 - } - ] - ] - }, - "IF - PPTX pesa <= 100MB?": { - "main": [ - [ - { - "node": "HTTP - Convertir PPTX con Apps Script", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Code - Marcar PPTX demasiado pesado", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Marcar PPTX demasiado pesado": { - "main": [ - [ - { - "node": "IF - Tiene sync key antes de Upsert?", - "type": "main", - "index": 0 - } - ] - ] - }, - "IF - Tiene sync key antes de Upsert?": { - "main": [ - [ - { - "node": "Google Sheets - Upsert Banco Fulgencio", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Loop - Filas Banco Fulgencio", - "type": "main", - "index": 0 - } - ] - ] - }, - "Wait - Respetar cuota Google Sheets": { - "main": [ - [ - { - "node": "Loop - Filas Banco Fulgencio", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "active": true, - "settings": { - "executionOrder": "v1", - "binaryMode": "separate", - "availableInMCP": true, - "timeSavedMode": "fixed", - "errorWorkflow": "puF4LUczoSz3hcek", - "timezone": "America/Santo_Domingo", - "callerPolicy": "workflowsFromSameOwner" - }, - "versionId": "bc6f76a7-a109-443d-9c0e-887d77f7f4ce", - "meta": { - "templateCredsSetupCompleted": true, - "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" - }, - "id": "g2JtKXNz5GDbkmQH", - "tags": [] -} \ No newline at end of file diff --git a/Flujo de n8n: Tablero CDC - Sync Listas.json b/Flujo de n8n: Tablero CDC - Sync Listas.json deleted file mode 100644 index fd6acb8..0000000 --- a/Flujo de n8n: Tablero CDC - Sync Listas.json +++ /dev/null @@ -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": [] -} \ No newline at end of file diff --git a/Flujo de n8n: Tablero CDC - Sync Proyecto a Banco Fulgencio IA.json b/Flujo de n8n: Tablero CDC - Sync Proyecto a Banco Fulgencio IA.json deleted file mode 100644 index 90e01b2..0000000 --- a/Flujo de n8n: Tablero CDC - Sync Proyecto a Banco Fulgencio IA.json +++ /dev/null @@ -1,775 +0,0 @@ -{ - "name": "Tablero CDC - Sync Proyecto a Banco Fulgencio IA", - "nodes": [ - { - "parameters": { - "httpMethod": "POST", - "path": "tablero-cdc-sync-fulgencio-ia", - "responseMode": "responseNode", - "options": {} - }, - "type": "n8n-nodes-base.webhook", - "typeVersion": 2.1, - "position": [ - 0, - 0 - ], - "id": "b57d9cd2-3f7f-4e24-98e3-ef5ec131afc4", - "name": "Webhook", - "webhookId": "3b5cf1f9-a3e8-4511-82ce-188afabd5fe1" - }, - { - "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": [ - 208, - 0 - ], - "id": "2764c02c-8d8e-4edc-9dc3-48c0cd880b30", - "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": [ - 416, - 0 - ], - "id": "8db634e3-f411-4701-9845-673b5db1121f", - "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": [ - 592, - 0 - ], - "id": "6d151961-e0ae-4049-b3a2-88bae2494f8c", - "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": [ - 1088, - 0 - ], - "id": "ddfd7064-ad22-4a7c-aa8f-e400b132da83", - "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": [ - 1296, - 0 - ], - "id": "d6a1baa5-c035-4645-885e-d31b89a6ff2f", - "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": [ - 2832, - -96 - ], - "id": "875c95aa-c12e-40a1-b64c-4da83d0a1b06", - "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": [ - 1504, - 96 - ], - "id": "3d315115-95b4-4b60-a174-2067bb5545d2", - "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": [ - 2624, - -96 - ], - "id": "f285672d-8c71-4ebd-87ec-a8b83c4e1ce6", - "name": "Google Sheets - Upsert Banco Fulgencio", - "credentials": { - "googleSheetsOAuth2Api": { - "id": "K0hDZh3a85MpOHCs", - "name": "Google Sheets account 2" - } - } - }, - { - "parameters": { - "jsCode": "function clean(value) {\n return String(value ?? '').trim().replace(/\\s+/g, ' ');\n}\n\nconst item = $input.first().json || {};\n\nconst nombre = clean(item.NOMBRE);\nconst cliente = clean(item.CLIENTE);\nconst marca = clean(item.MARCA);\nconst pais = clean(item.PAIS);\nconst enlace = clean(item['Enlace a la propuesta']);\n\nreturn [\n {\n json: {\n ...item,\n\n // Campos compatibles con el flujo viejo de Fulgencio\n nombre_archivo: nombre || item.nombre_archivo || '',\n nombre_limpio: nombre || item.nombre_archivo || '',\n pais: pais || '',\n cliente_tablero: cliente || '',\n marca_tablero: marca || '',\n link_drive: enlace || '',\n mime_type: item.mime_type || '',\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 1552, - -112 - ], - "id": "bc209351-3c89-49fe-b891-3fd8d1fc5c4d", - "name": "Code - Preparar entrada IA Tablero" - }, - { - "parameters": { - "text": "==Nombre del proyecto/propuesta: {{$json.nombre_limpio}}\nCliente indicado en Tablero CDC: {{$json.cliente_tablero}}\nMarca indicada en Tablero CDC: {{$json.marca_tablero}}\nPaís indicado en Tablero CDC: {{$json.pais}}\nTipo de archivo: {{$json.mime_type}}\nLink de propuesta: {{$json.link_drive}}", - "attributes": { - "attributes": [ - { - "name": "nombre_propuesta", - "description": "Nombre limpio o comercial de la propuesta." - }, - { - "name": "tipo_accion", - "description": "Tipo de acción o formato de propuesta. Ejemplos: campaña, evento, mueble, exhibidor, diseño gráfico, activación PDV, material POP, uniforme, promoción, stand. Si no se puede determinar, usar PENDIENTE." - }, - { - "name": "canal", - "description": "Canal o lugar donde aplica la propuesta. Ejemplos: moderno, tradicional, online, PDV, no aplica. Si no se puede determinar, usar PENDIENTE." - }, - { - "name": "tags", - "description": "Palabras clave separadas por comas para facilitar búsqueda. Ejemplos: campaña, corporativo, exhibidor, punto de venta, juegos, uniforme, material POP." - }, - { - "name": "descripcion", - "description": "Párrafo profesional de 35 a 70 palabras. Debe iniciar preferiblemente con “La presentación muestra...”, “La pieza muestra...” o “La propuesta corresponde a...”. Debe mencionar cliente/marca, tipo de acción, concepto o tema principal y objetivo. No inventar detalles." - }, - { - "name": "tactica_promocional", - "description": "Táctica promocional principal. Ejemplos: DEGUSTACIÓN, RULETA, SAMPLING - MUESTREO, PLINKO, WHATSAPP, LANDING PAGE, POP, PHOTOBOOTH, CANJE, SORTEO, PREMIOS INSTANTÁNEOS, JUEGO DIGITAL, TRIVIA, EXHIBICIÓN, IMPULSO o PENDIENTE." - } - ] - }, - "options": { - "systemPromptTemplate": "Analiza la siguiente propuesta usando la información disponible desde Tablero CDC.\n\nIMPORTANTE:\n- El país viene desde Tablero CDC. No lo cambies.\n- El cliente y la marca vienen desde Tablero CDC. Úsalos como referencia principal.\n- No determines APROBADA. Ese campo se guardará como PENDIENTE DE APROBACION.\n- No determines AMBIENTE DE COMPRA (RE). Ese campo se guardará como PENDIENTE.\n- Si no puedes determinar un campo con claridad, usa PENDIENTE.\n- No inventes detalles que no estén claros a partir del nombre, cliente, marca, país o link.\n- Extrae metadata para alimentar el banco de propuestas de Fulgencio Fumado.\n\nDevuelve estos campos:\n- nombre_propuesta\n- tipo_accion\n- canal\n- tags\n- descripcion\n- tactica_promocional\n\nReglas:\n1. tipo_accion puede inferirse del nombre y contexto. Ejemplos: campaña, evento, mueble, exhibidor, activación PDV, diseño gráfico, promoción, uniforme, stand, material POP.\n2. canal debe ser MODERNO, TRADICIONAL, ONLINE, PDV, NO APLICA o PENDIENTE cuando sea posible.\n3. tags debe ser una lista corta de palabras clave separadas por comas.\n4. descripcion debe ser un párrafo profesional de 35 a 70 palabras, similar al estilo usado en el banco actual.\n5. tactica_promocional debe ser una opción clara como DEGUSTACIÓN, RULETA, SAMPLING - MUESTREO, PLINKO, WHATSAPP, LANDING PAGE, POP, PHOTOBOOTH, CANJE, SORTEO, PREMIOS INSTANTÁNEOS, JUEGO DIGITAL, TRIVIA, EXHIBICIÓN, IMPULSO, o PENDIENTE." - } - }, - "type": "@n8n/n8n-nodes-langchain.informationExtractor", - "typeVersion": 1.2, - "position": [ - 2064, - -96 - ], - "id": "28887e16-b735-4e11-8fe2-4e3270876c85", - "name": "Gemini - Extraer metadata Tablero" - }, - { - "parameters": { - "modelName": "models/gemini-2.5-pro", - "options": {} - }, - "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini", - "typeVersion": 1, - "position": [ - 2080, - 144 - ], - "id": "4b4e6b14-50fc-4700-ad33-dcfc8a489770", - "name": "Gemini - Extraer metadata1", - "credentials": { - "googlePalmApi": { - "id": "jvsXYwL6IOoY2DBU", - "name": "Isaac - Gemini Api Pago" - } - } - }, - { - "parameters": { - "jsCode": "const original = $('Code - Preparar entrada IA Tablero').item.json || {};\nconst incoming = $input.first().json || {};\nconst ai = incoming.output || incoming;\n\nfunction clean(value) {\n if (Array.isArray(value)) return value.map(clean).filter(Boolean).join(', ');\n return String(value ?? '').trim().replace(/\\s+/g, ' ');\n}\n\nfunction removeAccents(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction upper(value) {\n return removeAccents(value).toUpperCase();\n}\n\nfunction splitMulti(value) {\n return clean(value)\n .split(',')\n .map(v => upper(v))\n .map(v => v.trim())\n .filter(Boolean);\n}\n\nfunction unique(values) {\n return [...new Set(values.filter(Boolean))];\n}\n\nfunction joinMulti(values) {\n return unique(values).join(', ');\n}\n\nfunction normalizeTipoAccion(value) {\n const v = upper(value);\n\n const synonyms = {\n 'ACTIVACION PDV': 'ACTIVACION EN PDV',\n 'ACTIVACION EN PUNTO DE VENTA': 'ACTIVACION EN PDV',\n 'ACTIVACION PUNTO DE VENTA': 'ACTIVACION EN PDV',\n 'PUNTO DE VENTA': 'ACTIVACION EN PDV',\n 'PDV': 'ACTIVACION EN PDV',\n 'DISEÑO GRAFICO': 'DISENO GRAFICO',\n 'DISEÑO GRÁFICO': 'DISENO GRAFICO',\n 'DISENO GRAFICO': 'DISENO GRAFICO',\n 'CAMPAÑA': 'CAMPAÑA',\n 'CAMPANA': 'CAMPAÑA',\n 'PROMOCION': 'PROMOCION',\n 'PROMOCIÓN': 'PROMOCION',\n 'MUEBLE': 'MUEBLE',\n 'EXHIBIDOR': 'MUEBLE',\n 'STAND': 'STAND',\n 'EVENTO': 'EVENTO',\n 'UNIFORME': 'UNIFORME',\n 'MATERIAL POP': 'DISENO GRAFICO',\n 'POP': 'DISENO GRAFICO'\n };\n\n return synonyms[v] || v;\n}\n\nfunction normalizeCanal(value) {\n const v = upper(value);\n\n const synonyms = {\n 'PUNTO DE VENTA': 'PDV',\n 'PDV': 'PDV',\n 'CANAL MODERNO': 'MODERNO',\n 'MODERNO': 'MODERNO',\n 'CANAL TRADICIONAL': 'TRADICIONAL',\n 'TRADICIONAL': 'TRADICIONAL',\n 'DIGITAL': 'ONLINE',\n 'ONLINE': 'ONLINE',\n 'NO APLICA': 'NO APLICA',\n 'N/A': 'NO APLICA'\n };\n\n return synonyms[v] || v;\n}\n\nfunction normalizeTactica(value) {\n const v = upper(value);\n\n const synonyms = {\n 'SAMPLING': 'SAMPLING - MUESTREO',\n 'MUESTREO': 'SAMPLING - MUESTREO',\n 'DEGUSTACION': 'DEGUSTACIÓN',\n 'DEGUSTACIÓN': 'DEGUSTACIÓN',\n 'RULETA DIGITAL': 'RULETA',\n 'RULETA': 'RULETA',\n 'PLINKO': 'PLINKO',\n 'WHATSAPP': 'WHATSAPP',\n 'LANDING': 'LANDING PAGE',\n 'LANDING PAGE': 'LANDING PAGE',\n 'MATERIAL POP': 'POP',\n 'POP': 'POP',\n 'JUEGO DIGITAL': 'JUEGO DIGITAL',\n 'JUEGOS DIGITALES': 'JUEGO DIGITAL',\n 'EXHIBICION': 'EXHIBICIÓN',\n 'EXHIBICIÓN': 'EXHIBICIÓN',\n 'IMPULSO': 'IMPULSO'\n };\n\n return synonyms[v] || v;\n}\n\nfunction normalizeEtiqueta(value) {\n const v = upper(value);\n\n const synonyms = {\n 'ACTIVACION': 'ACTIVACION',\n 'ACTIVACIÓN': 'ACTIVACION',\n 'EXHIBICION': 'EXHIBICION',\n 'EXHIBICIÓN': 'EXHIBICION',\n 'EXHIBIDOR': 'EXHIBIDOR',\n 'DEGUSTACION': 'DEGUSTACION',\n 'DEGUSTACIÓN': 'DEGUSTACION',\n 'PROMOCION': 'PROMOCIÓN',\n 'PROMOCIÓN': 'PROMOCIÓN',\n 'DISENO': 'DISEÑO',\n 'DISEÑO': 'DISEÑO',\n 'CAMPANA': 'CAMPAÑA',\n 'CAMPAÑA': 'CAMPAÑA',\n 'JUEGO DIGITAL': 'JUEGOS DIGITALES',\n 'JUEGOS DIGITALES': 'JUEGOS DIGITALES',\n 'PUNTO DE VENTA': 'PUNTO DE VENTA',\n 'PDV': 'PUNTO DE VENTA',\n 'F1': 'JUEGOS',\n 'SIMULADOR': 'JUEGOS DIGITALES',\n 'EXPERIENCIA': 'ACTIVIDADES',\n 'CORPORATIVO': 'CORPORATIVO',\n 'UNIFORME': 'UNIFORMES',\n 'UNIFORMES': 'UNIFORMES',\n 'POP': 'PUNTO DE VENTA',\n 'MATERIAL POP': 'PUNTO DE VENTA'\n };\n\n return synonyms[v] || v;\n}\n\nfunction normalizeOne(value, allowedList, fallback = 'PENDIENTE', normalizer = upper) {\n const raw = clean(value);\n if (!raw) return fallback;\n\n const mapped = normalizer(raw);\n return allowedList.includes(mapped) ? mapped : fallback;\n}\n\nfunction normalizeMulti(value, allowedList, fallback = 'PENDIENTE', unknownValue = 'OTRO', normalizer = upper) {\n const parts = splitMulti(value);\n if (!parts.length) return fallback;\n\n const result = [];\n\n for (const part of parts) {\n const mapped = normalizer(part);\n const expanded = String(mapped).split(',').map(v => v.trim()).filter(Boolean);\n\n for (const val of expanded) {\n if (allowedList.includes(val)) {\n result.push(val);\n } else {\n result.push(unknownValue);\n }\n }\n }\n\n const finalValues = unique(result);\n if (!finalValues.length) return fallback;\n\n return joinMulti(finalValues);\n}\n\nconst ALLOWED_TIPO_ACCION = [\n 'ACTIVACION EN PDV',\n 'IMPULSO DE VENTA',\n 'ACTIVACION FUERA DE PDV',\n 'EVENTO',\n 'KICK OFF',\n 'FERIA',\n 'STAND',\n 'MUEBLE',\n 'DISENO GRAFICO',\n 'DIGITAL',\n 'PUBLICIDAD',\n 'TUTORIAL',\n 'INFORMATIVA',\n 'LOGOTIPO',\n 'PROMOCION',\n 'UNIFORME',\n 'ACTIVACIÓN',\n 'INAUGURACIÓN',\n 'PARQUE',\n 'TROFEO',\n 'ANAQUEL',\n 'FOOD TRUCK',\n 'EMPAQUES',\n 'CAMPAÑA',\n 'QUICK COUNTER',\n 'DISEÑO',\n 'PPT',\n 'PENDIENTE',\n 'OTRO'\n];\n\nconst ALLOWED_CANAL = [\n 'MODERNO',\n 'TRADICIONAL',\n 'ONLINE',\n 'NO APLICA',\n 'PDV',\n 'PENDIENTE'\n];\n\nconst ALLOWED_TACTICA = [\n 'DEGUSTACIÓN',\n 'RULETA',\n 'SAMPLING - MUESTREO',\n 'PLINKO',\n 'WHATSAPP',\n 'LANDING PAGE',\n 'POP',\n 'PHOTOBOOTH',\n 'CANJE',\n 'SORTEO',\n 'PREMIOS INSTANTÁNEOS',\n 'JUEGO DIGITAL',\n 'TRIVIA',\n 'EXHIBICIÓN',\n 'IMPULSO',\n 'PENDIENTE',\n 'OTRO'\n];\n\nconst ALLOWED_ETIQUETAS = [\n 'ACTIVACION',\n 'ACTIVIDADES',\n 'BANDEJA',\n 'BTS',\n 'CORPORATIVO',\n 'DEGUSTACION',\n 'EXHIBICION',\n 'EXHIBIDOR',\n 'FUERZA DE VENTAS',\n 'JUEGOS',\n 'PREMIOS',\n 'PUNTO DE VENTA',\n 'REFERENCIAS',\n 'REUNIÓN',\n 'RULETA',\n 'SAMPLING',\n 'HALLOWEEN',\n 'LUCES NEON',\n 'INFORMATIVA',\n 'CARRITO DEGUSTACIÓN',\n 'RED SOCIAL/INFLUENCER',\n 'UNIFORMES',\n 'REGALIAS',\n 'STAND',\n 'CAPACITACIÓN',\n 'NAVIDAD',\n 'DÍA DE LAS MADRES',\n 'INDEPENDENCIA',\n 'LOGOTIPO',\n 'QUICK COUNTER',\n 'PPT',\n 'TEMPLATES',\n 'DISEÑO',\n 'PROMOCIÓN',\n 'MODERNO',\n 'MAYORISTA',\n 'PULPERÍAS',\n 'JUEGOS DIGITALES',\n 'WHATSAPP',\n 'CAMPAÑA',\n 'CONCURSO',\n 'REGALOS CORPORATIVOS',\n 'PENDIENTE',\n 'OTRO'\n];\n\nlet nombrePropuesta = clean(ai.nombre_propuesta || original.NOMBRE || original.nombre_limpio || '');\nif (!nombrePropuesta) nombrePropuesta = 'PENDIENTE';\n\nlet tipoAccion = normalizeMulti(\n ai.tipo_accion,\n ALLOWED_TIPO_ACCION,\n 'PENDIENTE',\n 'OTRO',\n normalizeTipoAccion\n);\n\nconst canal = normalizeOne(\n ai.canal,\n ALLOWED_CANAL,\n 'PENDIENTE',\n normalizeCanal\n);\n\nconst tacticaPromocional = normalizeMulti(\n ai.tactica_promocional,\n ALLOWED_TACTICA,\n 'PENDIENTE',\n 'OTRO',\n normalizeTactica\n);\n\nlet etiquetas = normalizeMulti(\n ai.tags,\n ALLOWED_ETIQUETAS,\n 'PENDIENTE',\n 'OTRO',\n normalizeEtiqueta\n);\n\nif (etiquetas.includes('OTRO') && etiquetas !== 'OTRO') {\n etiquetas = etiquetas\n .split(',')\n .map(v => v.trim())\n .filter(v => v !== 'OTRO')\n .join(', ');\n}\n\nconst ambienteCompra = 'PENDIENTE';\nconst aprobada = 'PENDIENTE DE APROBACION';\n\nlet descripcion = clean(ai.descripcion);\nif (!descripcion) {\n descripcion = clean(original.Descripcion || original.NOMBRE || '');\n}\n\nlet requiereRevision = false;\nconst revisionReasons = [];\n\nif (tipoAccion.includes('OTRO') || tipoAccion === 'PENDIENTE') {\n requiereRevision = true;\n revisionReasons.push('tipo_accion_revisar');\n}\n\nif (canal === 'PENDIENTE') {\n requiereRevision = true;\n revisionReasons.push('canal_revisar');\n}\n\nif (tacticaPromocional.includes('OTRO') || tacticaPromocional === 'PENDIENTE') {\n requiereRevision = true;\n revisionReasons.push('tactica_revisar');\n}\n\nif (etiquetas.includes('OTRO') || etiquetas === 'PENDIENTE') {\n requiereRevision = true;\n revisionReasons.push('etiquetas_revisar');\n}\n\nreturn [\n {\n json: {\n NOMBRE: nombrePropuesta,\n 'TIPO DE ACCION': tipoAccion,\n CLIENTE: original.CLIENTE || '',\n MARCA: original.MARCA || '',\n PAIS: original.PAIS || '',\n CANAL: canal,\n 'AMBIENTE DE COMPRA (RE)': ambienteCompra,\n 'TÁCTICA PROMOCIONAL': tacticaPromocional,\n APROBADA: aprobada,\n ETIQUETAS: etiquetas,\n 'AÑO': original['AÑO'] || '',\n 'Enlace a la propuesta': original['Enlace a la propuesta'] || '',\n Descripcion: descripcion,\n\n file_id: original.file_id || '',\n nombre_archivo: original.nombre_archivo || original.NOMBRE || '',\n mime_type: original.mime_type || '',\n fuente_pais: original.fuente_pais || 'Tablero CDC',\n confianza_pais: original.confianza_pais || '',\n requiere_revision: requiereRevision,\n motivos_revision: revisionReasons.join(', '),\n procesado_ia: true,\n ultima_actualizacion: new Date().toISOString(),\n 'Enlaces a propuestas ejecutadas': original['Enlaces a propuestas ejecutadas'] || '',\n\n tablero_sync_key: original.tablero_sync_key || '',\n tablero_project_id: original.tablero_project_id || '',\n tablero_origen: original.tablero_origen || 'Tablero CDC',\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 2416, - -96 - ], - "id": "a183d0af-b313-49ce-9f00-e619a4d576d2", - "name": "Code - Normalizar respuesta IA Tablero" - }, - { - "parameters": { - "content": "## ENTRADA DESDE TABLERO CDC\n\nEste flujo recibe un project_id desde la app Tablero CDC.\n\n1. Recibe el webhook.\n2. Extrae y valida el project_id.\n3. Busca el proyecto en Supabase.\n4. Busca los links asociados al proyecto.\n\nSi falta project_id o el proyecto no existe, el flujo debe detenerse con error.\n", - "height": 416, - "width": 816 - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - -64, - -240 - ], - "id": "2e6ca1d3-9ec6-4e10-b595-f7d89e741d92", - "name": "Sticky Note" - }, - { - "parameters": { - "content": "## PREPARACIÓN PARA BANCO FULGENCIO\n\nEste bloque convierte el proyecto del Tablero en una o varias filas para el Banco de Propuestas de Fulgencio.\n\nRegla principal:\n\n* Solo se escriben links tipo propuesta.\n* Si el proyecto no tiene links de propuesta, no se escribe nada y se responde como skipped.\n* Cada propuesta genera una tablero_sync_key única para evitar duplicados.\n", - "height": 592, - "width": 752, - "color": 5 - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 992, - -336 - ], - "id": "27212676-c05e-4315-90e8-2372b0122e4f", - "name": "Sticky Note1" - }, - { - "parameters": { - "content": "## IA + ESCRITURA EN BANCO FULGENCIO\n\nGemini analiza la información del proyecto y extrae metadata útil para búsqueda:\ntipo de acción, canal, etiquetas, descripción y táctica promocional.\n\nLuego el flujo normaliza los valores para respetar listas permitidas y hace append/update en Google Sheets usando tablero_sync_key.\n\nResultado final:\n\n* Proyecto sincronizado con Banco Fulgencio.\n* Si algo queda dudoso, se marca requiere_revision.\n", - "height": 656, - "width": 1104, - "color": 4 - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - 1936, - -336 - ], - "id": "9e829a95-7b23-42f0-89a9-eb1450fff26c", - "name": "Sticky Note2" - } - ], - "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": "Code - Preparar entrada IA Tablero", - "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 - } - ] - ] - }, - "Code - Preparar entrada IA Tablero": { - "main": [ - [ - { - "node": "Gemini - Extraer metadata Tablero", - "type": "main", - "index": 0 - } - ] - ] - }, - "Gemini - Extraer metadata1": { - "ai_languageModel": [ - [ - { - "node": "Gemini - Extraer metadata Tablero", - "type": "ai_languageModel", - "index": 0 - } - ] - ] - }, - "Gemini - Extraer metadata Tablero": { - "main": [ - [ - { - "node": "Code - Normalizar respuesta IA Tablero", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Normalizar respuesta IA Tablero": { - "main": [ - [ - { - "node": "Google Sheets - Upsert Banco Fulgencio", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "active": true, - "settings": { - "executionOrder": "v1", - "binaryMode": "separate", - "availableInMCP": true, - "timeSavedMode": "fixed", - "errorWorkflow": "puF4LUczoSz3hcek", - "timezone": "America/Santo_Domingo", - "callerPolicy": "workflowsFromSameOwner" - }, - "versionId": "229bf234-9e68-455e-a84d-a47983640b83", - "meta": { - "templateCredsSetupCompleted": true, - "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" - }, - "id": "aJH5uWZx3lUnNBbx", - "tags": [] -} \ No newline at end of file diff --git a/Flujo de n8n: Tablero CDC - Sync Proyecto a Banco Fulgencio.json b/Flujo de n8n: Tablero CDC - Sync Proyecto a Banco Fulgencio.json deleted file mode 100644 index 41c3af9..0000000 --- a/Flujo de n8n: Tablero CDC - Sync Proyecto a Banco Fulgencio.json +++ /dev/null @@ -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": [] -} \ No newline at end of file diff --git a/Flujo de n8n: Tablero CDC - Sync Proyecto a Sheet WEBHOOK.json b/Flujo de n8n: Tablero CDC - Sync Proyecto a Sheet WEBHOOK.json deleted file mode 100644 index aa3f64f..0000000 --- a/Flujo de n8n: Tablero CDC - Sync Proyecto a Sheet WEBHOOK.json +++ /dev/null @@ -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": [] -} \ No newline at end of file diff --git a/Flujo de n8n: Tablero CDC - Sync Tarifario Sheet a Supabase.json b/Flujo de n8n: Tablero CDC - Sync Tarifario Sheet a Supabase.json deleted file mode 100644 index fa8edd4..0000000 --- a/Flujo de n8n: Tablero CDC - Sync Tarifario Sheet a Supabase.json +++ /dev/null @@ -1,449 +0,0 @@ -{ - "name": "Tablero CDC - Sync Tarifario Sheet a Supabase", - "nodes": [ - { - "parameters": { - "jsCode": "function cleanText(value) {\n return String(value ?? \"\").trim();\n}\n\nfunction normalizeForCompare(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\");\n}\n\nfunction slugify(value) {\n return String(value ?? \"\")\n .trim()\n .toLowerCase()\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\")\n .replace(/&/g, \"y\")\n .replace(/ñ/g, \"n\")\n .replace(/[^a-z0-9]+/g, \"_\")\n .replace(/^_+|_+$/g, \"\");\n}\n\nfunction parseAmountRange(value) {\n const raw = cleanText(value);\n\n if (!raw) {\n return {\n reference: \"\",\n reference_min: null,\n reference_max: null,\n };\n }\n\n const reference = raw.replace(/\\s+/g, \" \");\n\n const withoutCurrency = reference\n .replace(/USD/gi, \"\")\n .replace(/US\\$/gi, \"\")\n .replace(/\\$/g, \"\")\n .replace(/\\+/g, \"\")\n .trim();\n\n const matches = withoutCurrency.match(/-?\\d[\\d,]*(\\.\\d+)?/g) || [];\n\n const numbers = matches\n .map((match) => Number(match.replace(/,/g, \"\")))\n .filter((number) => Number.isFinite(number));\n\n if (numbers.length === 0) {\n return {\n reference,\n reference_min: null,\n reference_max: null,\n };\n }\n\n if (numbers.length === 1) {\n return {\n reference,\n reference_min: numbers[0],\n reference_max: numbers[0],\n };\n }\n\n const min = Math.min(numbers[0], numbers[1]);\n const max = Math.max(numbers[0], numbers[1]);\n\n return {\n reference,\n reference_min: min,\n reference_max: max,\n };\n}\n\nfunction normalizeSection(value) {\n const raw = normalizeForCompare(value);\n\n if (!raw) {\n throw new Error(\"La columna Sección está vacía en una fila del tarifario.\");\n }\n\n if (raw === \"tarifario grafico cdc\") {\n return \"grafico\";\n }\n\n if (raw === \"estrategia y creatividad\") {\n return \"estrategia\";\n }\n\n throw new Error(\n `Sección inválida en tarifario: \"${value}\". Usa solo \"Tarifario Gráfico CDC\" o \"Estrategia y Creatividad\".`\n );\n}\n\nfunction buildBaseId(row, section, category, service, workTypeId) {\n const rawCodigoBase = cleanText(row[\"Código base\"]);\n\n if (rawCodigoBase) {\n return slugify(rawCodigoBase);\n }\n\n return slugify(\n [section, category, service, workTypeId].filter(Boolean).join(\"_\")\n );\n}\n\nfunction getSourceRowNumber(row, index) {\n return (\n row.row_number ||\n row.rowNumber ||\n row.__rowNumber ||\n index + 2\n );\n}\n\nconst records = [];\nconst generatedCodeUpdates = [];\n\nitems.forEach((item, index) => {\n const row = item.json;\n\n const rawSection = cleanText(row[\"Sección\"]);\n const rawCategory = cleanText(row[\"Categoría\"]);\n const rawService = cleanText(row[\"Servicio / ítem\"]);\n const rawWorkType = cleanText(row[\"Tipo de trabajo\"] || \"Referencia\");\n const shortLabel = cleanText(row[\"Etiqueta corta\"] || \"Ref.\");\n const hourReference = cleanText(row[\"Hora hombre ref.\"]);\n const notes = cleanText(row[\"Observaciones / incluye\"]);\n\n // Ignora filas totalmente vacías.\n const hasAnyBusinessData = [\n rawSection,\n rawCategory,\n rawService,\n rawWorkType,\n row[\"Nivel 1 nombre\"],\n row[\"Nivel 1 monto/rango\"],\n row[\"Nivel 2 nombre\"],\n row[\"Nivel 2 monto/rango\"],\n row[\"Nivel 3 nombre\"],\n row[\"Nivel 3 monto/rango\"],\n ].some((value) => cleanText(value));\n\n if (!hasAnyBusinessData) {\n return;\n }\n\n const section = normalizeSection(rawSection);\n const category = rawCategory;\n const service = rawService;\n const workTypeLabel = rawWorkType;\n const workTypeId = slugify(workTypeLabel || \"reference\") || \"reference\";\n\n if (!category || !service) {\n throw new Error(\n `Fila ${getSourceRowNumber(row, index)} incompleta: Categoría y Servicio / ítem son obligatorios.`\n );\n }\n\n const catalogItemId = slugify(\n [section, category, service].filter(Boolean).join(\"_\")\n );\n\n const baseId = buildBaseId(row, section, category, service, workTypeId);\n\n if (!baseId) {\n throw new Error(`No se pudo generar Código base en la fila ${getSourceRowNumber(row, index)}.`);\n }\n\n const sourceRowNumber = getSourceRowNumber(row, index);\n\n if (!cleanText(row[\"Código base\"])) {\n generatedCodeUpdates.push({\n row_number: sourceRowNumber,\n codigo_base: baseId,\n });\n }\n\n const levels = [\n {\n number: 1,\n name: cleanText(row[\"Nivel 1 nombre\"]),\n amountRange: cleanText(row[\"Nivel 1 monto/rango\"]),\n note: cleanText(row[\"Nivel 1 nota\"]),\n },\n {\n number: 2,\n name: cleanText(row[\"Nivel 2 nombre\"]),\n amountRange: cleanText(row[\"Nivel 2 monto/rango\"]),\n note: cleanText(row[\"Nivel 2 nota\"]),\n },\n {\n number: 3,\n name: cleanText(row[\"Nivel 3 nombre\"]),\n amountRange: cleanText(row[\"Nivel 3 monto/rango\"]),\n note: cleanText(row[\"Nivel 3 nota\"]),\n },\n ];\n\n const validLevels = levels.filter((level) => level.name && level.amountRange);\n\n if (validLevels.length === 0) {\n throw new Error(\n `Fila ${sourceRowNumber}: agrega al menos un nivel con nombre y monto/rango.`\n );\n }\n\n for (const level of validLevels) {\n const levelLabel = level.name;\n const levelId = slugify(levelLabel || \"project\") || \"project\";\n const amount = parseAmountRange(level.amountRange);\n\n const id = slugify(\n [baseId, levelId].filter(Boolean).join(\"_\")\n );\n\n records.push({\n id,\n catalog_item_id: catalogItemId,\n section,\n category,\n service,\n notes,\n work_type_id: workTypeId,\n work_type_label: workTypeLabel,\n work_type_short_label: shortLabel,\n hour_reference: hourReference,\n level_id: levelId,\n level_label: levelLabel,\n reference: amount.reference,\n reference_min: amount.reference_min,\n reference_max: amount.reference_max,\n level_hint: level.note,\n sort_order: 10000 + index * 10 + level.number,\n is_active: true,\n });\n }\n});\n\nif (records.length === 0) {\n throw new Error(\"No se encontraron tarifas válidas. Se detiene para no afectar el catálogo.\");\n}\n\nconst seen = new Set();\n\nfor (const record of records) {\n if (seen.has(record.id)) {\n throw new Error(`Código único duplicado detectado en tarifario: ${record.id}`);\n }\n\n seen.add(record.id);\n\n if (\n record.reference_min !== null &&\n record.reference_max !== null &&\n record.reference_min > record.reference_max\n ) {\n throw new Error(`La tarifa ${record.id} tiene mínimo mayor que máximo.`);\n }\n}\n\nreturn [\n {\n json: {\n total_source_rows: items.length,\n total_records_to_sync: records.length,\n total_generated_codes: generatedCodeUpdates.length,\n records,\n generated_code_updates: generatedCodeUpdates,\n },\n },\n];" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 416, - 0 - ], - "id": "25030323-2bde-460c-b1ad-02e6260d67da", - "name": "Code - Normalizar Tarifario" - }, - { - "parameters": { - "method": "POST", - "url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_tariff_catalog?on_conflict=id", - "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" - }, - { - "name": "Prefer", - "value": "resolution=merge-duplicates,return=representation" - } - ] - }, - "sendBody": true, - "specifyBody": "json", - "jsonBody": "={{ JSON.stringify($json.records) }}", - "options": {} - }, - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.4, - "position": [ - 624, - 0 - ], - "id": "a21e1648-fd8c-4cf3-acc3-50f91af1e4d9", - "name": "Supabase - Upsert Tarifario" - }, - { - "parameters": { - "documentId": { - "__rl": true, - "value": "1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg", - "mode": "list", - "cachedResultName": "Tarifario de Creatividad - Sep 2004", - "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit?usp=drivesdk" - }, - "sheetName": { - "__rl": true, - "value": 1041164995, - "mode": "list", - "cachedResultName": "Catálogo Tarifario App", - "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit#gid=1041164995" - }, - "options": { - "dataLocationOnSheet": { - "values": { - "rangeDefinition": "detectAutomatically" - } - } - } - }, - "type": "n8n-nodes-base.googleSheets", - "typeVersion": 4.7, - "position": [ - 208, - 0 - ], - "id": "e6d4f9ef-30da-4640-9002-96b43aa48729", - "name": "Google Sheets - Leer Catálogo Tarifario App", - "credentials": { - "googleSheetsOAuth2Api": { - "id": "K0hDZh3a85MpOHCs", - "name": "Google Sheets account 2" - } - } - }, - { - "parameters": { - "jsCode": "const source = $('Code - Normalizar Tarifario').first().json;\n\nconst updates = source.generated_code_updates || [];\n\nreturn updates.map((update) => ({\n json: {\n row_number: update.row_number,\n codigo_base: update.codigo_base,\n },\n}));" - }, - "type": "n8n-nodes-base.code", - "typeVersion": 2, - "position": [ - 832, - 0 - ], - "id": "247f2f76-a1e4-4e51-b35f-a32bdaadf354", - "name": "Code - Preparar códigos generados" - }, - { - "parameters": { - "operation": "update", - "documentId": { - "__rl": true, - "value": "1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg", - "mode": "list", - "cachedResultName": "Tarifario de Creatividad - Sep 2004", - "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit?usp=drivesdk" - }, - "sheetName": { - "__rl": true, - "value": 1041164995, - "mode": "list", - "cachedResultName": "Catálogo Tarifario App", - "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit#gid=1041164995" - }, - "columns": { - "mappingMode": "defineBelow", - "value": { - "row_number": "={{ $json[\"row_number\"] }}", - "Código base": "={{ $json[\"codigo_base\"] }}" - }, - "matchingColumns": [ - "row_number" - ], - "schema": [ - { - "id": "Código base", - "displayName": "Código base", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": false - }, - { - "id": "Sección", - "displayName": "Sección", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Categoría", - "displayName": "Categoría", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Servicio / ítem", - "displayName": "Servicio / ítem", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Tipo de trabajo", - "displayName": "Tipo de trabajo", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Etiqueta corta", - "displayName": "Etiqueta corta", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Hora hombre ref.", - "displayName": "Hora hombre ref.", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Nivel 1 nombre", - "displayName": "Nivel 1 nombre", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Nivel 1 monto/rango", - "displayName": "Nivel 1 monto/rango", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Nivel 1 nota", - "displayName": "Nivel 1 nota", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Nivel 2 nombre", - "displayName": "Nivel 2 nombre", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Nivel 2 monto/rango", - "displayName": "Nivel 2 monto/rango", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Nivel 2 nota", - "displayName": "Nivel 2 nota", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Nivel 3 nombre", - "displayName": "Nivel 3 nombre", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Nivel 3 monto/rango", - "displayName": "Nivel 3 monto/rango", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Nivel 3 nota", - "displayName": "Nivel 3 nota", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "id": "Observaciones / incluye", - "displayName": "Observaciones / incluye", - "required": false, - "defaultMatch": false, - "display": true, - "type": "string", - "canBeUsedToMatch": true, - "removed": true - }, - { - "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": {} - }, - "type": "n8n-nodes-base.googleSheets", - "typeVersion": 4.7, - "position": [ - 1072, - 0 - ], - "id": "60367c04-79f6-4c8a-99d1-bc9369c8ca61", - "name": "Google Sheets - Escribir códigos únicos faltantes", - "credentials": { - "googleSheetsOAuth2Api": { - "id": "K0hDZh3a85MpOHCs", - "name": "Google Sheets account 2" - } - } - }, - { - "parameters": { - "rule": { - "interval": [ - { - "field": "hours", - "hoursInterval": 8 - } - ] - } - }, - "type": "n8n-nodes-base.scheduleTrigger", - "typeVersion": 1.3, - "position": [ - -16, - 0 - ], - "id": "1a45e645-65af-4727-bf4f-15c7000ba2a2", - "name": "Schedule Trigger" - }, - { - "parameters": { - "content": "## 💵 SYNC TARIFARIO CDC → SUPABASE\n\nEste flujo sincroniza automáticamente el tarifario editable desde Google Sheets hacia Supabase para que la app Tablero CDC use el catálogo actualizado.\n\nFrecuencia:\n- Cada 8 horas.\n\nProceso:\n1. Lee el Sheet “Catálogo Tarifario App”.\n2. Normaliza sección, categoría, servicio, tipo de trabajo y niveles.\n3. Convierte “monto/rango” en reference, reference_min y reference_max.\n4. Genera códigos únicos cuando faltan.\n5. Hace upsert en Supabase: tablero_cdc_tariff_catalog.\n6. Escribe de vuelta en el Sheet los códigos base generados.\n\nReglas importantes:\n- No sincroniza filas vacías.\n- Cada fila debe tener categoría, servicio y al menos un nivel con monto/rango.\n- Si hay códigos duplicados o secciones inválidas, el flujo se detiene para proteger el catálogo.\n- El Sheet es la fuente humana editable; Supabase es la fuente usada por la app.", - "height": 640, - "width": 1408, - "color": 5 - }, - "type": "n8n-nodes-base.stickyNote", - "typeVersion": 1, - "position": [ - -64, - -432 - ], - "id": "23c63e0a-2e95-4ec5-b075-d08ae68754fc", - "name": "Sticky Note" - } - ], - "pinData": {}, - "connections": { - "Code - Normalizar Tarifario": { - "main": [ - [ - { - "node": "Supabase - Upsert Tarifario", - "type": "main", - "index": 0 - } - ] - ] - }, - "Supabase - Upsert Tarifario": { - "main": [ - [ - { - "node": "Code - Preparar códigos generados", - "type": "main", - "index": 0 - } - ] - ] - }, - "Google Sheets - Leer Catálogo Tarifario App": { - "main": [ - [ - { - "node": "Code - Normalizar Tarifario", - "type": "main", - "index": 0 - } - ] - ] - }, - "Code - Preparar códigos generados": { - "main": [ - [ - { - "node": "Google Sheets - Escribir códigos únicos faltantes", - "type": "main", - "index": 0 - } - ] - ] - }, - "Schedule Trigger": { - "main": [ - [ - { - "node": "Google Sheets - Leer Catálogo Tarifario App", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "active": true, - "settings": { - "executionOrder": "v1", - "binaryMode": "separate", - "availableInMCP": true, - "timeSavedMode": "fixed", - "errorWorkflow": "puF4LUczoSz3hcek", - "timezone": "America/Santo_Domingo", - "callerPolicy": "workflowsFromSameOwner" - }, - "versionId": "4ea3c2ee-0518-4a55-8c9a-ccffb26f471a", - "meta": { - "templateCredsSetupCompleted": true, - "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70" - }, - "id": "7eyfZEkGuDSUodDX", - "tags": [] -} \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index bffac08..0000000 --- a/README.md +++ /dev/null @@ -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` | - diff --git a/components.json b/components.json deleted file mode 100644 index f0817a8..0000000 --- a/components.json +++ /dev/null @@ -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": {} -} diff --git a/dist/assets/index-COXhoke_.js b/dist/assets/index-COXhoke_.js deleted file mode 100644 index aa12d95..0000000 --- a/dist/assets/index-COXhoke_.js +++ /dev/null @@ -1,433 +0,0 @@ -function FR(e,t){for(var r=0;rs[i]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))s(i);new MutationObserver(i=>{for(const l of i)if(l.type==="childList")for(const u of l.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&s(u)}).observe(document,{childList:!0,subtree:!0});function r(i){const l={};return i.integrity&&(l.integrity=i.integrity),i.referrerPolicy&&(l.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?l.credentials="include":i.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function s(i){if(i.ep)return;i.ep=!0;const l=r(i);fetch(i.href,l)}})();function y_(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Cm={exports:{}},il={};/** - * @license React - * react-jsx-runtime.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var dx;function KR(){if(dx)return il;dx=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.fragment");function r(s,i,l){var u=null;if(l!==void 0&&(u=""+l),i.key!==void 0&&(u=""+i.key),"key"in i){l={};for(var d in i)d!=="key"&&(l[d]=i[d])}else l=i;return i=l.ref,{$$typeof:e,type:s,key:u,ref:i!==void 0?i:null,props:l}}return il.Fragment=t,il.jsx=r,il.jsxs=r,il}var fx;function YR(){return fx||(fx=1,Cm.exports=KR()),Cm.exports}var m=YR(),Tm={exports:{}},Ue={};/** - * @license React - * react.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var hx;function WR(){if(hx)return Ue;hx=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.portal"),r=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),l=Symbol.for("react.consumer"),u=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),f=Symbol.for("react.suspense"),h=Symbol.for("react.memo"),b=Symbol.for("react.lazy"),g=Symbol.for("react.activity"),x=Symbol.iterator;function _(k){return k===null||typeof k!="object"?null:(k=x&&k[x]||k["@@iterator"],typeof k=="function"?k:null)}var E={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},S=Object.assign,T={};function C(k,O,U){this.props=k,this.context=O,this.refs=T,this.updater=U||E}C.prototype.isReactComponent={},C.prototype.setState=function(k,O){if(typeof k!="object"&&typeof k!="function"&&k!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,k,O,"setState")},C.prototype.forceUpdate=function(k){this.updater.enqueueForceUpdate(this,k,"forceUpdate")};function R(){}R.prototype=C.prototype;function A(k,O,U){this.props=k,this.context=O,this.refs=T,this.updater=U||E}var D=A.prototype=new R;D.constructor=A,S(D,C.prototype),D.isPureReactComponent=!0;var B=Array.isArray;function I(){}var L={H:null,A:null,T:null,S:null},H=Object.prototype.hasOwnProperty;function Z(k,O,U){var Y=U.ref;return{$$typeof:e,type:k,key:O,ref:Y!==void 0?Y:null,props:U}}function ue(k,O){return Z(k.type,O,k.props)}function ce(k){return typeof k=="object"&&k!==null&&k.$$typeof===e}function he(k){var O={"=":"=0",":":"=2"};return"$"+k.replace(/[=:]/g,function(U){return O[U]})}var de=/\/+/g;function me(k,O){return typeof k=="object"&&k!==null&&k.key!=null?he(""+k.key):O.toString(36)}function pe(k){switch(k.status){case"fulfilled":return k.value;case"rejected":throw k.reason;default:switch(typeof k.status=="string"?k.then(I,I):(k.status="pending",k.then(function(O){k.status==="pending"&&(k.status="fulfilled",k.value=O)},function(O){k.status==="pending"&&(k.status="rejected",k.reason=O)})),k.status){case"fulfilled":return k.value;case"rejected":throw k.reason}}throw k}function j(k,O,U,Y,$){var re=typeof k;(re==="undefined"||re==="boolean")&&(k=null);var te=!1;if(k===null)te=!0;else switch(re){case"bigint":case"string":case"number":te=!0;break;case"object":switch(k.$$typeof){case e:case t:te=!0;break;case b:return te=k._init,j(te(k._payload),O,U,Y,$)}}if(te)return $=$(k),te=Y===""?"."+me(k,0):Y,B($)?(U="",te!=null&&(U=te.replace(de,"$&/")+"/"),j($,O,U,"",function(Se){return Se})):$!=null&&(ce($)&&($=ue($,U+($.key==null||k&&k.key===$.key?"":(""+$.key).replace(de,"$&/")+"/")+te)),O.push($)),1;te=0;var ee=Y===""?".":Y+":";if(B(k))for(var se=0;se>>1,G=j[J];if(0>>1;Ji(U,W))Yi($,U)?(j[J]=$,j[Y]=W,J=Y):(j[J]=U,j[O]=W,J=O);else if(Yi($,W))j[J]=$,j[Y]=W,J=Y;else break e}}return K}function i(j,K){var W=j.sortIndex-K.sortIndex;return W!==0?W:j.id-K.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var l=performance;e.unstable_now=function(){return l.now()}}else{var u=Date,d=u.now();e.unstable_now=function(){return u.now()-d}}var f=[],h=[],b=1,g=null,x=3,_=!1,E=!1,S=!1,T=!1,C=typeof setTimeout=="function"?setTimeout:null,R=typeof clearTimeout=="function"?clearTimeout:null,A=typeof setImmediate<"u"?setImmediate:null;function D(j){for(var K=r(h);K!==null;){if(K.callback===null)s(h);else if(K.startTime<=j)s(h),K.sortIndex=K.expirationTime,t(f,K);else break;K=r(h)}}function B(j){if(S=!1,D(j),!E)if(r(f)!==null)E=!0,I||(I=!0,he());else{var K=r(h);K!==null&&pe(B,K.startTime-j)}}var I=!1,L=-1,H=5,Z=-1;function ue(){return T?!0:!(e.unstable_now()-Zj&&ue());){var J=g.callback;if(typeof J=="function"){g.callback=null,x=g.priorityLevel;var G=J(g.expirationTime<=j);if(j=e.unstable_now(),typeof G=="function"){g.callback=G,D(j),K=!0;break t}g===r(f)&&s(f),D(j)}else s(f);g=r(f)}if(g!==null)K=!0;else{var k=r(h);k!==null&&pe(B,k.startTime-j),K=!1}}break e}finally{g=null,x=W,_=!1}K=void 0}}finally{K?he():I=!1}}}var he;if(typeof A=="function")he=function(){A(ce)};else if(typeof MessageChannel<"u"){var de=new MessageChannel,me=de.port2;de.port1.onmessage=ce,he=function(){me.postMessage(null)}}else he=function(){C(ce,0)};function pe(j,K){L=C(function(){j(e.unstable_now())},K)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(j){j.callback=null},e.unstable_forceFrameRate=function(j){0>j||125J?(j.sortIndex=W,t(h,j),r(f)===null&&j===r(h)&&(S?(R(L),L=-1):S=!0,pe(B,W-J))):(j.sortIndex=G,t(f,j),E||_||(E=!0,I||(I=!0,he()))),j},e.unstable_shouldYield=ue,e.unstable_wrapCallback=function(j){var K=x;return function(){var W=x;x=K;try{return j.apply(this,arguments)}finally{x=W}}}})(Rm)),Rm}var gx;function JR(){return gx||(gx=1,Am.exports=XR()),Am.exports}var jm={exports:{}},dn={};/** - * @license React - * react-dom.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var vx;function QR(){if(vx)return dn;vx=1;var e=jd();function t(f){var h="https://react.dev/errors/"+f;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),jm.exports=QR(),jm.exports}/** - * @license React - * react-dom-client.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var bx;function ZR(){if(bx)return ol;bx=1;var e=JR(),t=jd(),r=b_();function s(n){var a="https://react.dev/errors/"+n;if(1G||(n.current=J[G],J[G]=null,G--)}function U(n,a){G++,J[G]=n.current,n.current=a}var Y=k(null),$=k(null),re=k(null),te=k(null);function ee(n,a){switch(U(re,a),U($,n),U(Y,null),a.nodeType){case 9:case 11:n=(n=a.documentElement)&&(n=n.namespaceURI)?D0(n):0;break;default:if(n=a.tagName,a=a.namespaceURI)a=D0(a),n=P0(a,n);else switch(n){case"svg":n=1;break;case"math":n=2;break;default:n=0}}O(Y),U(Y,n)}function se(){O(Y),O($),O(re)}function Se(n){n.memoizedState!==null&&U(te,n);var a=Y.current,o=P0(a,n.type);a!==o&&(U($,n),U(Y,o))}function Te(n){$.current===n&&(O(Y),O($)),te.current===n&&(O(te),nl._currentValue=W)}var Re,nt;function Ze(n){if(Re===void 0)try{throw Error()}catch(o){var a=o.stack.trim().match(/\n( *(at )?)/);Re=a&&a[1]||"",nt=-1)":-1p||M[c]!==V[p]){var ne=` -`+M[c].replace(" at new "," at ");return n.displayName&&ne.includes("")&&(ne=ne.replace("",n.displayName)),ne}while(1<=c&&0<=p);break}}}finally{xt=!1,Error.prepareStackTrace=o}return(o=n?n.displayName||n.name:"")?Ze(o):""}function je(n,a){switch(n.tag){case 26:case 27:case 5:return Ze(n.type);case 16:return Ze("Lazy");case 13:return n.child!==a&&a!==null?Ze("Suspense Fallback"):Ze("Suspense");case 19:return Ze("SuspenseList");case 0:case 15:return oe(n.type,!1);case 11:return oe(n.type.render,!1);case 1:return oe(n.type,!0);case 31:return Ze("Activity");default:return""}}function Ve(n){try{var a="",o=null;do a+=je(n,o),o=n,n=n.return;while(n);return a}catch(c){return` -Error generating stack: `+c.message+` -`+c.stack}}var $e=Object.prototype.hasOwnProperty,Nt=e.unstable_scheduleCallback,We=e.unstable_cancelCallback,it=e.unstable_shouldYield,Pt=e.unstable_requestPaint,Ie=e.unstable_now,Q=e.unstable_getCurrentPriorityLevel,Oe=e.unstable_ImmediatePriority,Xe=e.unstable_UserBlockingPriority,Lt=e.unstable_NormalPriority,ln=e.unstable_LowPriority,Kt=e.unstable_IdlePriority,gn=e.log,vn=e.unstable_setDisableYieldValue,yn=null,kt=null;function cn(n){if(typeof gn=="function"&&vn(n),kt&&typeof kt.setStrictMode=="function")try{kt.setStrictMode(yn,n)}catch{}}var Ot=Math.clz32?Math.clz32:le,Ur=Math.log,$r=Math.LN2;function le(n){return n>>>=0,n===0?32:31-(Ur(n)/$r|0)|0}var Me=256,at=262144,ot=4194304;function qt(n){var a=n&42;if(a!==0)return a;switch(n&-n){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return n&261888;case 262144:case 524288:case 1048576:case 2097152:return n&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return n&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return n}}function Pe(n,a,o){var c=n.pendingLanes;if(c===0)return 0;var p=0,v=n.suspendedLanes,w=n.pingedLanes;n=n.warmLanes;var N=c&134217727;return N!==0?(c=N&~v,c!==0?p=qt(c):(w&=N,w!==0?p=qt(w):o||(o=N&~n,o!==0&&(p=qt(o))))):(N=c&~v,N!==0?p=qt(N):w!==0?p=qt(w):o||(o=c&~n,o!==0&&(p=qt(o)))),p===0?0:a!==0&&a!==p&&(a&v)===0&&(v=p&-p,o=a&-a,v>=o||v===32&&(o&4194048)!==0)?a:p}function yt(n,a){return(n.pendingLanes&~(n.suspendedLanes&~n.pingedLanes)&a)===0}function Yt(n,a){switch(n){case 1:case 2:case 4:case 8:case 64:return a+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function un(){var n=ot;return ot<<=1,(ot&62914560)===0&&(ot=4194304),n}function xa(n){for(var a=[],o=0;31>o;o++)a.push(n);return a}function Ct(n,a){n.pendingLanes|=a,a!==268435456&&(n.suspendedLanes=0,n.pingedLanes=0,n.warmLanes=0)}function bn(n,a,o,c,p,v){var w=n.pendingLanes;n.pendingLanes=o,n.suspendedLanes=0,n.pingedLanes=0,n.warmLanes=0,n.expiredLanes&=o,n.entangledLanes&=o,n.errorRecoveryDisabledLanes&=o,n.shellSuspendCounter=0;var N=n.entanglements,M=n.expirationTimes,V=n.hiddenUpdates;for(o=w&~o;0"u")return null;try{return n.activeElement||n.body}catch{return n.body}}var $N=/[\n"\\]/g;function Kn(n){return n.replace($N,function(a){return"\\"+a.charCodeAt(0).toString(16)+" "})}function bf(n,a,o,c,p,v,w,N){n.name="",w!=null&&typeof w!="function"&&typeof w!="symbol"&&typeof w!="boolean"?n.type=w:n.removeAttribute("type"),a!=null?w==="number"?(a===0&&n.value===""||n.value!=a)&&(n.value=""+Fn(a)):n.value!==""+Fn(a)&&(n.value=""+Fn(a)):w!=="submit"&&w!=="reset"||n.removeAttribute("value"),a!=null?xf(n,w,Fn(a)):o!=null?xf(n,w,Fn(o)):c!=null&&n.removeAttribute("value"),p==null&&v!=null&&(n.defaultChecked=!!v),p!=null&&(n.checked=p&&typeof p!="function"&&typeof p!="symbol"),N!=null&&typeof N!="function"&&typeof N!="symbol"&&typeof N!="boolean"?n.name=""+Fn(N):n.removeAttribute("name")}function Av(n,a,o,c,p,v,w,N){if(v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"&&(n.type=v),a!=null||o!=null){if(!(v!=="submit"&&v!=="reset"||a!=null)){yf(n);return}o=o!=null?""+Fn(o):"",a=a!=null?""+Fn(a):o,N||a===n.value||(n.value=a),n.defaultValue=a}c=c??p,c=typeof c!="function"&&typeof c!="symbol"&&!!c,n.checked=N?n.checked:!!c,n.defaultChecked=!!c,w!=null&&typeof w!="function"&&typeof w!="symbol"&&typeof w!="boolean"&&(n.name=w),yf(n)}function xf(n,a,o){a==="number"&&cc(n.ownerDocument)===n||n.defaultValue===""+o||(n.defaultValue=""+o)}function ei(n,a,o,c){if(n=n.options,a){a={};for(var p=0;p"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Cf=!1;if(qr)try{var xo={};Object.defineProperty(xo,"passive",{get:function(){Cf=!0}}),window.addEventListener("test",xo,xo),window.removeEventListener("test",xo,xo)}catch{Cf=!1}var _a=null,Tf=null,dc=null;function Pv(){if(dc)return dc;var n,a=Tf,o=a.length,c,p="value"in _a?_a.value:_a.textContent,v=p.length;for(n=0;n=So),Bv=" ",Hv=!1;function qv(n,a){switch(n){case"keyup":return mA.indexOf(a.keyCode)!==-1;case"keydown":return a.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Vv(n){return n=n.detail,typeof n=="object"&&"data"in n?n.data:null}var ai=!1;function gA(n,a){switch(n){case"compositionend":return Vv(a);case"keypress":return a.which!==32?null:(Hv=!0,Bv);case"textInput":return n=a.data,n===Bv&&Hv?null:n;default:return null}}function vA(n,a){if(ai)return n==="compositionend"||!kf&&qv(n,a)?(n=Pv(),dc=Tf=_a=null,ai=!1,n):null;switch(n){case"paste":return null;case"keypress":if(!(a.ctrlKey||a.altKey||a.metaKey)||a.ctrlKey&&a.altKey){if(a.char&&1=a)return{node:o,offset:a-n};n=c}e:{for(;o;){if(o.nextSibling){o=o.nextSibling;break e}o=o.parentNode}o=void 0}o=Qv(o)}}function ey(n,a){return n&&a?n===a?!0:n&&n.nodeType===3?!1:a&&a.nodeType===3?ey(n,a.parentNode):"contains"in n?n.contains(a):n.compareDocumentPosition?!!(n.compareDocumentPosition(a)&16):!1:!1}function ty(n){n=n!=null&&n.ownerDocument!=null&&n.ownerDocument.defaultView!=null?n.ownerDocument.defaultView:window;for(var a=cc(n.document);a instanceof n.HTMLIFrameElement;){try{var o=typeof a.contentWindow.location.href=="string"}catch{o=!1}if(o)n=a.contentWindow;else break;a=cc(n.document)}return a}function Df(n){var a=n&&n.nodeName&&n.nodeName.toLowerCase();return a&&(a==="input"&&(n.type==="text"||n.type==="search"||n.type==="tel"||n.type==="url"||n.type==="password")||a==="textarea"||n.contentEditable==="true")}var CA=qr&&"documentMode"in document&&11>=document.documentMode,si=null,Pf=null,No=null,Lf=!1;function ny(n,a,o){var c=o.window===o?o.document:o.nodeType===9?o:o.ownerDocument;Lf||si==null||si!==cc(c)||(c=si,"selectionStart"in c&&Df(c)?c={start:c.selectionStart,end:c.selectionEnd}:(c=(c.ownerDocument&&c.ownerDocument.defaultView||window).getSelection(),c={anchorNode:c.anchorNode,anchorOffset:c.anchorOffset,focusNode:c.focusNode,focusOffset:c.focusOffset}),No&&To(No,c)||(No=c,c=au(Pf,"onSelect"),0>=w,p-=w,_r=1<<32-Ot(a)+p|o<Ge?(tt=Ce,Ce=null):tt=Ce.sibling;var ct=F(z,Ce,q[Ge],ae);if(ct===null){Ce===null&&(Ce=tt);break}n&&Ce&&ct.alternate===null&&a(z,Ce),P=v(ct,P,Ge),lt===null?ke=ct:lt.sibling=ct,lt=ct,Ce=tt}if(Ge===q.length)return o(z,Ce),rt&&Gr(z,Ge),ke;if(Ce===null){for(;GeGe?(tt=Ce,Ce=null):tt=Ce.sibling;var Va=F(z,Ce,ct.value,ae);if(Va===null){Ce===null&&(Ce=tt);break}n&&Ce&&Va.alternate===null&&a(z,Ce),P=v(Va,P,Ge),lt===null?ke=Va:lt.sibling=Va,lt=Va,Ce=tt}if(ct.done)return o(z,Ce),rt&&Gr(z,Ge),ke;if(Ce===null){for(;!ct.done;Ge++,ct=q.next())ct=ie(z,ct.value,ae),ct!==null&&(P=v(ct,P,Ge),lt===null?ke=ct:lt.sibling=ct,lt=ct);return rt&&Gr(z,Ge),ke}for(Ce=c(Ce);!ct.done;Ge++,ct=q.next())ct=X(Ce,z,Ge,ct.value,ae),ct!==null&&(n&&ct.alternate!==null&&Ce.delete(ct.key===null?Ge:ct.key),P=v(ct,P,Ge),lt===null?ke=ct:lt.sibling=ct,lt=ct);return n&&Ce.forEach(function(GR){return a(z,GR)}),rt&&Gr(z,Ge),ke}function pt(z,P,q,ae){if(typeof q=="object"&&q!==null&&q.type===S&&q.key===null&&(q=q.props.children),typeof q=="object"&&q!==null){switch(q.$$typeof){case _:e:{for(var ke=q.key;P!==null;){if(P.key===ke){if(ke=q.type,ke===S){if(P.tag===7){o(z,P.sibling),ae=p(P,q.props.children),ae.return=z,z=ae;break e}}else if(P.elementType===ke||typeof ke=="object"&&ke!==null&&ke.$$typeof===H&&ws(ke)===P.type){o(z,P.sibling),ae=p(P,q.props),Mo(ae,q),ae.return=z,z=ae;break e}o(z,P);break}else a(z,P);P=P.sibling}q.type===S?(ae=gs(q.props.children,z.mode,ae,q.key),ae.return=z,z=ae):(ae=wc(q.type,q.key,q.props,null,z.mode,ae),Mo(ae,q),ae.return=z,z=ae)}return w(z);case E:e:{for(ke=q.key;P!==null;){if(P.key===ke)if(P.tag===4&&P.stateNode.containerInfo===q.containerInfo&&P.stateNode.implementation===q.implementation){o(z,P.sibling),ae=p(P,q.children||[]),ae.return=z,z=ae;break e}else{o(z,P);break}else a(z,P);P=P.sibling}ae=qf(q,z.mode,ae),ae.return=z,z=ae}return w(z);case H:return q=ws(q),pt(z,P,q,ae)}if(pe(q))return _e(z,P,q,ae);if(he(q)){if(ke=he(q),typeof ke!="function")throw Error(s(150));return q=ke.call(q),De(z,P,q,ae)}if(typeof q.then=="function")return pt(z,P,Ac(q),ae);if(q.$$typeof===A)return pt(z,P,Ec(z,q),ae);Rc(z,q)}return typeof q=="string"&&q!==""||typeof q=="number"||typeof q=="bigint"?(q=""+q,P!==null&&P.tag===6?(o(z,P.sibling),ae=p(P,q),ae.return=z,z=ae):(o(z,P),ae=Hf(q,z.mode,ae),ae.return=z,z=ae),w(z)):o(z,P)}return function(z,P,q,ae){try{Oo=0;var ke=pt(z,P,q,ae);return gi=null,ke}catch(Ce){if(Ce===pi||Ce===Tc)throw Ce;var lt=Ln(29,Ce,null,z.mode);return lt.lanes=ae,lt.return=z,lt}finally{}}}var Ss=Cy(!0),Ty=Cy(!1),Na=!1;function th(n){n.updateQueue={baseState:n.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function nh(n,a){n=n.updateQueue,a.updateQueue===n&&(a.updateQueue={baseState:n.baseState,firstBaseUpdate:n.firstBaseUpdate,lastBaseUpdate:n.lastBaseUpdate,shared:n.shared,callbacks:null})}function Aa(n){return{lane:n,tag:0,payload:null,callback:null,next:null}}function Ra(n,a,o){var c=n.updateQueue;if(c===null)return null;if(c=c.shared,(ut&2)!==0){var p=c.pending;return p===null?a.next=a:(a.next=p.next,p.next=a),c.pending=a,a=xc(n),cy(n,null,o),a}return bc(n,c,a,o),xc(n)}function Do(n,a,o){if(a=a.updateQueue,a!==null&&(a=a.shared,(o&4194048)!==0)){var c=a.lanes;c&=n.pendingLanes,o|=c,a.lanes=o,xn(n,o)}}function rh(n,a){var o=n.updateQueue,c=n.alternate;if(c!==null&&(c=c.updateQueue,o===c)){var p=null,v=null;if(o=o.firstBaseUpdate,o!==null){do{var w={lane:o.lane,tag:o.tag,payload:o.payload,callback:null,next:null};v===null?p=v=w:v=v.next=w,o=o.next}while(o!==null);v===null?p=v=a:v=v.next=a}else p=v=a;o={baseState:c.baseState,firstBaseUpdate:p,lastBaseUpdate:v,shared:c.shared,callbacks:c.callbacks},n.updateQueue=o;return}n=o.lastBaseUpdate,n===null?o.firstBaseUpdate=a:n.next=a,o.lastBaseUpdate=a}var ah=!1;function Po(){if(ah){var n=mi;if(n!==null)throw n}}function Lo(n,a,o,c){ah=!1;var p=n.updateQueue;Na=!1;var v=p.firstBaseUpdate,w=p.lastBaseUpdate,N=p.shared.pending;if(N!==null){p.shared.pending=null;var M=N,V=M.next;M.next=null,w===null?v=V:w.next=V,w=M;var ne=n.alternate;ne!==null&&(ne=ne.updateQueue,N=ne.lastBaseUpdate,N!==w&&(N===null?ne.firstBaseUpdate=V:N.next=V,ne.lastBaseUpdate=M))}if(v!==null){var ie=p.baseState;w=0,ne=V=M=null,N=v;do{var F=N.lane&-536870913,X=F!==N.lane;if(X?(et&F)===F:(c&F)===F){F!==0&&F===hi&&(ah=!0),ne!==null&&(ne=ne.next={lane:0,tag:N.tag,payload:N.payload,callback:null,next:null});e:{var _e=n,De=N;F=a;var pt=o;switch(De.tag){case 1:if(_e=De.payload,typeof _e=="function"){ie=_e.call(pt,ie,F);break e}ie=_e;break e;case 3:_e.flags=_e.flags&-65537|128;case 0:if(_e=De.payload,F=typeof _e=="function"?_e.call(pt,ie,F):_e,F==null)break e;ie=g({},ie,F);break e;case 2:Na=!0}}F=N.callback,F!==null&&(n.flags|=64,X&&(n.flags|=8192),X=p.callbacks,X===null?p.callbacks=[F]:X.push(F))}else X={lane:F,tag:N.tag,payload:N.payload,callback:N.callback,next:null},ne===null?(V=ne=X,M=ie):ne=ne.next=X,w|=F;if(N=N.next,N===null){if(N=p.shared.pending,N===null)break;X=N,N=X.next,X.next=null,p.lastBaseUpdate=X,p.shared.pending=null}}while(!0);ne===null&&(M=ie),p.baseState=M,p.firstBaseUpdate=V,p.lastBaseUpdate=ne,v===null&&(p.shared.lanes=0),Da|=w,n.lanes=w,n.memoizedState=ie}}function Ny(n,a){if(typeof n!="function")throw Error(s(191,n));n.call(a)}function Ay(n,a){var o=n.callbacks;if(o!==null)for(n.callbacks=null,n=0;nv?v:8;var w=j.T,N={};j.T=N,Sh(n,!1,a,o);try{var M=p(),V=j.S;if(V!==null&&V(N,M),M!==null&&typeof M=="object"&&typeof M.then=="function"){var ne=DA(M,c);Uo(n,a,ne,Bn(n))}else Uo(n,a,c,Bn(n))}catch(ie){Uo(n,a,{then:function(){},status:"rejected",reason:ie},Bn())}finally{K.p=v,w!==null&&N.types!==null&&(w.types=N.types),j.T=w}}function $A(){}function wh(n,a,o,c){if(n.tag!==5)throw Error(s(476));var p=ib(n).queue;sb(n,p,a,W,o===null?$A:function(){return ob(n),o(c)})}function ib(n){var a=n.memoizedState;if(a!==null)return a;a={memoizedState:W,baseState:W,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Wr,lastRenderedState:W},next:null};var o={};return a.next={memoizedState:o,baseState:o,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Wr,lastRenderedState:o},next:null},n.memoizedState=a,n=n.alternate,n!==null&&(n.memoizedState=a),a}function ob(n){var a=ib(n);a.next===null&&(a=n.alternate.memoizedState),Uo(n,a.next.queue,{},Bn())}function _h(){return tn(nl)}function lb(){return Dt().memoizedState}function cb(){return Dt().memoizedState}function BA(n){for(var a=n.return;a!==null;){switch(a.tag){case 24:case 3:var o=Bn();n=Aa(o);var c=Ra(a,n,o);c!==null&&(An(c,a,o),Do(c,a,o)),a={cache:Jf()},n.payload=a;return}a=a.return}}function HA(n,a,o){var c=Bn();o={lane:c,revertLane:0,gesture:null,action:o,hasEagerState:!1,eagerState:null,next:null},Uc(n)?db(a,o):(o=$f(n,a,o,c),o!==null&&(An(o,n,c),fb(o,a,c)))}function ub(n,a,o){var c=Bn();Uo(n,a,o,c)}function Uo(n,a,o,c){var p={lane:c,revertLane:0,gesture:null,action:o,hasEagerState:!1,eagerState:null,next:null};if(Uc(n))db(a,p);else{var v=n.alternate;if(n.lanes===0&&(v===null||v.lanes===0)&&(v=a.lastRenderedReducer,v!==null))try{var w=a.lastRenderedState,N=v(w,o);if(p.hasEagerState=!0,p.eagerState=N,Pn(N,w))return bc(n,a,p,0),vt===null&&yc(),!1}catch{}finally{}if(o=$f(n,a,p,c),o!==null)return An(o,n,c),fb(o,a,c),!0}return!1}function Sh(n,a,o,c){if(c={lane:2,revertLane:tm(),gesture:null,action:c,hasEagerState:!1,eagerState:null,next:null},Uc(n)){if(a)throw Error(s(479))}else a=$f(n,o,c,2),a!==null&&An(a,n,2)}function Uc(n){var a=n.alternate;return n===Be||a!==null&&a===Be}function db(n,a){yi=Oc=!0;var o=n.pending;o===null?a.next=a:(a.next=o.next,o.next=a),n.pending=a}function fb(n,a,o){if((o&4194048)!==0){var c=a.lanes;c&=n.pendingLanes,o|=c,a.lanes=o,xn(n,o)}}var $o={readContext:tn,use:Pc,useCallback:At,useContext:At,useEffect:At,useImperativeHandle:At,useLayoutEffect:At,useInsertionEffect:At,useMemo:At,useReducer:At,useRef:At,useState:At,useDebugValue:At,useDeferredValue:At,useTransition:At,useSyncExternalStore:At,useId:At,useHostTransitionStatus:At,useFormState:At,useActionState:At,useOptimistic:At,useMemoCache:At,useCacheRefresh:At};$o.useEffectEvent=At;var hb={readContext:tn,use:Pc,useCallback:function(n,a){return hn().memoizedState=[n,a===void 0?null:a],n},useContext:tn,useEffect:Xy,useImperativeHandle:function(n,a,o){o=o!=null?o.concat([n]):null,zc(4194308,4,eb.bind(null,a,n),o)},useLayoutEffect:function(n,a){return zc(4194308,4,n,a)},useInsertionEffect:function(n,a){zc(4,2,n,a)},useMemo:function(n,a){var o=hn();a=a===void 0?null:a;var c=n();if(Es){cn(!0);try{n()}finally{cn(!1)}}return o.memoizedState=[c,a],c},useReducer:function(n,a,o){var c=hn();if(o!==void 0){var p=o(a);if(Es){cn(!0);try{o(a)}finally{cn(!1)}}}else p=a;return c.memoizedState=c.baseState=p,n={pending:null,lanes:0,dispatch:null,lastRenderedReducer:n,lastRenderedState:p},c.queue=n,n=n.dispatch=HA.bind(null,Be,n),[c.memoizedState,n]},useRef:function(n){var a=hn();return n={current:n},a.memoizedState=n},useState:function(n){n=gh(n);var a=n.queue,o=ub.bind(null,Be,a);return a.dispatch=o,[n.memoizedState,o]},useDebugValue:bh,useDeferredValue:function(n,a){var o=hn();return xh(o,n,a)},useTransition:function(){var n=gh(!1);return n=sb.bind(null,Be,n.queue,!0,!1),hn().memoizedState=n,[!1,n]},useSyncExternalStore:function(n,a,o){var c=Be,p=hn();if(rt){if(o===void 0)throw Error(s(407));o=o()}else{if(o=a(),vt===null)throw Error(s(349));(et&127)!==0||Dy(c,a,o)}p.memoizedState=o;var v={value:o,getSnapshot:a};return p.queue=v,Xy(Ly.bind(null,c,v,n),[n]),c.flags|=2048,xi(9,{destroy:void 0},Py.bind(null,c,v,o,a),null),o},useId:function(){var n=hn(),a=vt.identifierPrefix;if(rt){var o=Sr,c=_r;o=(c&~(1<<32-Ot(c)-1)).toString(32)+o,a="_"+a+"R_"+o,o=Mc++,0<\/script>",v=v.removeChild(v.firstChild);break;case"select":v=typeof c.is=="string"?w.createElement("select",{is:c.is}):w.createElement("select"),c.multiple?v.multiple=!0:c.size&&(v.size=c.size);break;default:v=typeof c.is=="string"?w.createElement(p,{is:c.is}):w.createElement(p)}}v[Zt]=a,v[_n]=c;e:for(w=a.child;w!==null;){if(w.tag===5||w.tag===6)v.appendChild(w.stateNode);else if(w.tag!==4&&w.tag!==27&&w.child!==null){w.child.return=w,w=w.child;continue}if(w===a)break e;for(;w.sibling===null;){if(w.return===null||w.return===a)break e;w=w.return}w.sibling.return=w.return,w=w.sibling}a.stateNode=v;e:switch(rn(v,p,c),p){case"button":case"input":case"select":case"textarea":c=!!c.autoFocus;break e;case"img":c=!0;break e;default:c=!1}c&&Jr(a)}}return _t(a),zh(a,a.type,n===null?null:n.memoizedProps,a.pendingProps,o),null;case 6:if(n&&a.stateNode!=null)n.memoizedProps!==c&&Jr(a);else{if(typeof c!="string"&&a.stateNode===null)throw Error(s(166));if(n=re.current,di(a)){if(n=a.stateNode,o=a.memoizedProps,c=null,p=en,p!==null)switch(p.tag){case 27:case 5:c=p.memoizedProps}n[Zt]=a,n=!!(n.nodeValue===o||c!==null&&c.suppressHydrationWarning===!0||O0(n.nodeValue,o)),n||Ca(a,!0)}else n=su(n).createTextNode(c),n[Zt]=a,a.stateNode=n}return _t(a),null;case 31:if(o=a.memoizedState,n===null||n.memoizedState!==null){if(c=di(a),o!==null){if(n===null){if(!c)throw Error(s(318));if(n=a.memoizedState,n=n!==null?n.dehydrated:null,!n)throw Error(s(557));n[Zt]=a}else vs(),(a.flags&128)===0&&(a.memoizedState=null),a.flags|=4;_t(a),n=!1}else o=Kf(),n!==null&&n.memoizedState!==null&&(n.memoizedState.hydrationErrors=o),n=!0;if(!n)return a.flags&256?(In(a),a):(In(a),null);if((a.flags&128)!==0)throw Error(s(558))}return _t(a),null;case 13:if(c=a.memoizedState,n===null||n.memoizedState!==null&&n.memoizedState.dehydrated!==null){if(p=di(a),c!==null&&c.dehydrated!==null){if(n===null){if(!p)throw Error(s(318));if(p=a.memoizedState,p=p!==null?p.dehydrated:null,!p)throw Error(s(317));p[Zt]=a}else vs(),(a.flags&128)===0&&(a.memoizedState=null),a.flags|=4;_t(a),p=!1}else p=Kf(),n!==null&&n.memoizedState!==null&&(n.memoizedState.hydrationErrors=p),p=!0;if(!p)return a.flags&256?(In(a),a):(In(a),null)}return In(a),(a.flags&128)!==0?(a.lanes=o,a):(o=c!==null,n=n!==null&&n.memoizedState!==null,o&&(c=a.child,p=null,c.alternate!==null&&c.alternate.memoizedState!==null&&c.alternate.memoizedState.cachePool!==null&&(p=c.alternate.memoizedState.cachePool.pool),v=null,c.memoizedState!==null&&c.memoizedState.cachePool!==null&&(v=c.memoizedState.cachePool.pool),v!==p&&(c.flags|=2048)),o!==n&&o&&(a.child.flags|=8192),Vc(a,a.updateQueue),_t(a),null);case 4:return se(),n===null&&sm(a.stateNode.containerInfo),_t(a),null;case 10:return Kr(a.type),_t(a),null;case 19:if(O(Mt),c=a.memoizedState,c===null)return _t(a),null;if(p=(a.flags&128)!==0,v=c.rendering,v===null)if(p)Ho(c,!1);else{if(Rt!==0||n!==null&&(n.flags&128)!==0)for(n=a.child;n!==null;){if(v=kc(n),v!==null){for(a.flags|=128,Ho(c,!1),n=v.updateQueue,a.updateQueue=n,Vc(a,n),a.subtreeFlags=0,n=o,o=a.child;o!==null;)uy(o,n),o=o.sibling;return U(Mt,Mt.current&1|2),rt&&Gr(a,c.treeForkCount),a.child}n=n.sibling}c.tail!==null&&Ie()>Wc&&(a.flags|=128,p=!0,Ho(c,!1),a.lanes=4194304)}else{if(!p)if(n=kc(v),n!==null){if(a.flags|=128,p=!0,n=n.updateQueue,a.updateQueue=n,Vc(a,n),Ho(c,!0),c.tail===null&&c.tailMode==="hidden"&&!v.alternate&&!rt)return _t(a),null}else 2*Ie()-c.renderingStartTime>Wc&&o!==536870912&&(a.flags|=128,p=!0,Ho(c,!1),a.lanes=4194304);c.isBackwards?(v.sibling=a.child,a.child=v):(n=c.last,n!==null?n.sibling=v:a.child=v,c.last=v)}return c.tail!==null?(n=c.tail,c.rendering=n,c.tail=n.sibling,c.renderingStartTime=Ie(),n.sibling=null,o=Mt.current,U(Mt,p?o&1|2:o&1),rt&&Gr(a,c.treeForkCount),n):(_t(a),null);case 22:case 23:return In(a),ih(),c=a.memoizedState!==null,n!==null?n.memoizedState!==null!==c&&(a.flags|=8192):c&&(a.flags|=8192),c?(o&536870912)!==0&&(a.flags&128)===0&&(_t(a),a.subtreeFlags&6&&(a.flags|=8192)):_t(a),o=a.updateQueue,o!==null&&Vc(a,o.retryQueue),o=null,n!==null&&n.memoizedState!==null&&n.memoizedState.cachePool!==null&&(o=n.memoizedState.cachePool.pool),c=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(c=a.memoizedState.cachePool.pool),c!==o&&(a.flags|=2048),n!==null&&O(xs),null;case 24:return o=null,n!==null&&(o=n.memoizedState.cache),a.memoizedState.cache!==o&&(a.flags|=2048),Kr(zt),_t(a),null;case 25:return null;case 30:return null}throw Error(s(156,a.tag))}function KA(n,a){switch(Gf(a),a.tag){case 1:return n=a.flags,n&65536?(a.flags=n&-65537|128,a):null;case 3:return Kr(zt),se(),n=a.flags,(n&65536)!==0&&(n&128)===0?(a.flags=n&-65537|128,a):null;case 26:case 27:case 5:return Te(a),null;case 31:if(a.memoizedState!==null){if(In(a),a.alternate===null)throw Error(s(340));vs()}return n=a.flags,n&65536?(a.flags=n&-65537|128,a):null;case 13:if(In(a),n=a.memoizedState,n!==null&&n.dehydrated!==null){if(a.alternate===null)throw Error(s(340));vs()}return n=a.flags,n&65536?(a.flags=n&-65537|128,a):null;case 19:return O(Mt),null;case 4:return se(),null;case 10:return Kr(a.type),null;case 22:case 23:return In(a),ih(),n!==null&&O(xs),n=a.flags,n&65536?(a.flags=n&-65537|128,a):null;case 24:return Kr(zt),null;case 25:return null;default:return null}}function zb(n,a){switch(Gf(a),a.tag){case 3:Kr(zt),se();break;case 26:case 27:case 5:Te(a);break;case 4:se();break;case 31:a.memoizedState!==null&&In(a);break;case 13:In(a);break;case 19:O(Mt);break;case 10:Kr(a.type);break;case 22:case 23:In(a),ih(),n!==null&&O(xs);break;case 24:Kr(zt)}}function qo(n,a){try{var o=a.updateQueue,c=o!==null?o.lastEffect:null;if(c!==null){var p=c.next;o=p;do{if((o.tag&n)===n){c=void 0;var v=o.create,w=o.inst;c=v(),w.destroy=c}o=o.next}while(o!==p)}}catch(N){ft(a,a.return,N)}}function Oa(n,a,o){try{var c=a.updateQueue,p=c!==null?c.lastEffect:null;if(p!==null){var v=p.next;c=v;do{if((c.tag&n)===n){var w=c.inst,N=w.destroy;if(N!==void 0){w.destroy=void 0,p=a;var M=o,V=N;try{V()}catch(ne){ft(p,M,ne)}}}c=c.next}while(c!==v)}}catch(ne){ft(a,a.return,ne)}}function Ib(n){var a=n.updateQueue;if(a!==null){var o=n.stateNode;try{Ay(a,o)}catch(c){ft(n,n.return,c)}}}function Ub(n,a,o){o.props=Cs(n.type,n.memoizedProps),o.state=n.memoizedState;try{o.componentWillUnmount()}catch(c){ft(n,a,c)}}function Vo(n,a){try{var o=n.ref;if(o!==null){switch(n.tag){case 26:case 27:case 5:var c=n.stateNode;break;case 30:c=n.stateNode;break;default:c=n.stateNode}typeof o=="function"?n.refCleanup=o(c):o.current=c}}catch(p){ft(n,a,p)}}function Er(n,a){var o=n.ref,c=n.refCleanup;if(o!==null)if(typeof c=="function")try{c()}catch(p){ft(n,a,p)}finally{n.refCleanup=null,n=n.alternate,n!=null&&(n.refCleanup=null)}else if(typeof o=="function")try{o(null)}catch(p){ft(n,a,p)}else o.current=null}function $b(n){var a=n.type,o=n.memoizedProps,c=n.stateNode;try{e:switch(a){case"button":case"input":case"select":case"textarea":o.autoFocus&&c.focus();break e;case"img":o.src?c.src=o.src:o.srcSet&&(c.srcset=o.srcSet)}}catch(p){ft(n,n.return,p)}}function Ih(n,a,o){try{var c=n.stateNode;pR(c,n.type,o,a),c[_n]=a}catch(p){ft(n,n.return,p)}}function Bb(n){return n.tag===5||n.tag===3||n.tag===26||n.tag===27&&Ua(n.type)||n.tag===4}function Uh(n){e:for(;;){for(;n.sibling===null;){if(n.return===null||Bb(n.return))return null;n=n.return}for(n.sibling.return=n.return,n=n.sibling;n.tag!==5&&n.tag!==6&&n.tag!==18;){if(n.tag===27&&Ua(n.type)||n.flags&2||n.child===null||n.tag===4)continue e;n.child.return=n,n=n.child}if(!(n.flags&2))return n.stateNode}}function $h(n,a,o){var c=n.tag;if(c===5||c===6)n=n.stateNode,a?(o.nodeType===9?o.body:o.nodeName==="HTML"?o.ownerDocument.body:o).insertBefore(n,a):(a=o.nodeType===9?o.body:o.nodeName==="HTML"?o.ownerDocument.body:o,a.appendChild(n),o=o._reactRootContainer,o!=null||a.onclick!==null||(a.onclick=Hr));else if(c!==4&&(c===27&&Ua(n.type)&&(o=n.stateNode,a=null),n=n.child,n!==null))for($h(n,a,o),n=n.sibling;n!==null;)$h(n,a,o),n=n.sibling}function Gc(n,a,o){var c=n.tag;if(c===5||c===6)n=n.stateNode,a?o.insertBefore(n,a):o.appendChild(n);else if(c!==4&&(c===27&&Ua(n.type)&&(o=n.stateNode),n=n.child,n!==null))for(Gc(n,a,o),n=n.sibling;n!==null;)Gc(n,a,o),n=n.sibling}function Hb(n){var a=n.stateNode,o=n.memoizedProps;try{for(var c=n.type,p=a.attributes;p.length;)a.removeAttributeNode(p[0]);rn(a,c,o),a[Zt]=n,a[_n]=o}catch(v){ft(n,n.return,v)}}var Qr=!1,$t=!1,Bh=!1,qb=typeof WeakSet=="function"?WeakSet:Set,Xt=null;function YA(n,a){if(n=n.containerInfo,lm=fu,n=ty(n),Df(n)){if("selectionStart"in n)var o={start:n.selectionStart,end:n.selectionEnd};else e:{o=(o=n.ownerDocument)&&o.defaultView||window;var c=o.getSelection&&o.getSelection();if(c&&c.rangeCount!==0){o=c.anchorNode;var p=c.anchorOffset,v=c.focusNode;c=c.focusOffset;try{o.nodeType,v.nodeType}catch{o=null;break e}var w=0,N=-1,M=-1,V=0,ne=0,ie=n,F=null;t:for(;;){for(var X;ie!==o||p!==0&&ie.nodeType!==3||(N=w+p),ie!==v||c!==0&&ie.nodeType!==3||(M=w+c),ie.nodeType===3&&(w+=ie.nodeValue.length),(X=ie.firstChild)!==null;)F=ie,ie=X;for(;;){if(ie===n)break t;if(F===o&&++V===p&&(N=w),F===v&&++ne===c&&(M=w),(X=ie.nextSibling)!==null)break;ie=F,F=ie.parentNode}ie=X}o=N===-1||M===-1?null:{start:N,end:M}}else o=null}o=o||{start:0,end:0}}else o=null;for(cm={focusedElem:n,selectionRange:o},fu=!1,Xt=a;Xt!==null;)if(a=Xt,n=a.child,(a.subtreeFlags&1028)!==0&&n!==null)n.return=a,Xt=n;else for(;Xt!==null;){switch(a=Xt,v=a.alternate,n=a.flags,a.tag){case 0:if((n&4)!==0&&(n=a.updateQueue,n=n!==null?n.events:null,n!==null))for(o=0;o title"))),rn(v,c,o),v[Zt]=n,Wt(v),c=v;break e;case"link":var w=W0("link","href",p).get(c+(o.href||""));if(w){for(var N=0;Npt&&(w=pt,pt=De,De=w);var z=Zv(N,De),P=Zv(N,pt);if(z&&P&&(X.rangeCount!==1||X.anchorNode!==z.node||X.anchorOffset!==z.offset||X.focusNode!==P.node||X.focusOffset!==P.offset)){var q=ie.createRange();q.setStart(z.node,z.offset),X.removeAllRanges(),De>pt?(X.addRange(q),X.extend(P.node,P.offset)):(q.setEnd(P.node,P.offset),X.addRange(q))}}}}for(ie=[],X=N;X=X.parentNode;)X.nodeType===1&&ie.push({element:X,left:X.scrollLeft,top:X.scrollTop});for(typeof N.focus=="function"&&N.focus(),N=0;No?32:o,j.T=null,o=Yh,Yh=null;var v=La,w=ra;if(Vt=0,Ci=La=null,ra=0,(ut&6)!==0)throw Error(s(331));var N=ut;if(ut|=4,e0(v.current),Jb(v,v.current,w,o),ut=N,Xo(0,!1),kt&&typeof kt.onPostCommitFiberRoot=="function")try{kt.onPostCommitFiberRoot(yn,v)}catch{}return!0}finally{K.p=p,j.T=c,y0(n,a)}}function x0(n,a,o){a=Wn(o,a),a=Nh(n.stateNode,a,2),n=Ra(n,a,2),n!==null&&(Ct(n,2),Cr(n))}function ft(n,a,o){if(n.tag===3)x0(n,n,o);else for(;a!==null;){if(a.tag===3){x0(a,n,o);break}else if(a.tag===1){var c=a.stateNode;if(typeof a.type.getDerivedStateFromError=="function"||typeof c.componentDidCatch=="function"&&(Pa===null||!Pa.has(c))){n=Wn(o,n),o=wb(2),c=Ra(a,o,2),c!==null&&(_b(o,c,a,n),Ct(c,2),Cr(c));break}}a=a.return}}function Qh(n,a,o){var c=n.pingCache;if(c===null){c=n.pingCache=new JA;var p=new Set;c.set(a,p)}else p=c.get(a),p===void 0&&(p=new Set,c.set(a,p));p.has(o)||(Vh=!0,p.add(o),n=nR.bind(null,n,a,o),a.then(n,n))}function nR(n,a,o){var c=n.pingCache;c!==null&&c.delete(a),n.pingedLanes|=n.suspendedLanes&o,n.warmLanes&=~o,vt===n&&(et&o)===o&&(Rt===4||Rt===3&&(et&62914560)===et&&300>Ie()-Yc?(ut&2)===0&&Ti(n,0):Gh|=o,Ei===et&&(Ei=0)),Cr(n)}function w0(n,a){a===0&&(a=un()),n=ps(n,a),n!==null&&(Ct(n,a),Cr(n))}function rR(n){var a=n.memoizedState,o=0;a!==null&&(o=a.retryLane),w0(n,o)}function aR(n,a){var o=0;switch(n.tag){case 31:case 13:var c=n.stateNode,p=n.memoizedState;p!==null&&(o=p.retryLane);break;case 19:c=n.stateNode;break;case 22:c=n.stateNode._retryCache;break;default:throw Error(s(314))}c!==null&&c.delete(a),w0(n,o)}function sR(n,a){return Nt(n,a)}var tu=null,Ai=null,Zh=!1,nu=!1,em=!1,Ia=0;function Cr(n){n!==Ai&&n.next===null&&(Ai===null?tu=Ai=n:Ai=Ai.next=n),nu=!0,Zh||(Zh=!0,oR())}function Xo(n,a){if(!em&&nu){em=!0;do for(var o=!1,c=tu;c!==null;){if(n!==0){var p=c.pendingLanes;if(p===0)var v=0;else{var w=c.suspendedLanes,N=c.pingedLanes;v=(1<<31-Ot(42|n)+1)-1,v&=p&~(w&~N),v=v&201326741?v&201326741|1:v?v|2:0}v!==0&&(o=!0,C0(c,v))}else v=et,v=Pe(c,c===vt?v:0,c.cancelPendingCommit!==null||c.timeoutHandle!==-1),(v&3)===0||yt(c,v)||(o=!0,C0(c,v));c=c.next}while(o);em=!1}}function iR(){_0()}function _0(){nu=Zh=!1;var n=0;Ia!==0&&vR()&&(n=Ia);for(var a=Ie(),o=null,c=tu;c!==null;){var p=c.next,v=S0(c,a);v===0?(c.next=null,o===null?tu=p:o.next=p,p===null&&(Ai=o)):(o=c,(n!==0||(v&3)!==0)&&(nu=!0)),c=p}Vt!==0&&Vt!==5||Xo(n),Ia!==0&&(Ia=0)}function S0(n,a){for(var o=n.suspendedLanes,c=n.pingedLanes,p=n.expirationTimes,v=n.pendingLanes&-62914561;0N)break;var ne=M.transferSize,ie=M.initiatorType;ne&&M0(ie)&&(M=M.responseEnd,w+=ne*(M"u"?null:document;function G0(n,a,o){var c=Ri;if(c&&typeof a=="string"&&a){var p=Kn(a);p='link[rel="'+n+'"][href="'+p+'"]',typeof o=="string"&&(p+='[crossorigin="'+o+'"]'),V0.has(p)||(V0.add(p),n={rel:n,crossOrigin:o,href:a},c.querySelector(p)===null&&(a=c.createElement("link"),rn(a,"link",n),Wt(a),c.head.appendChild(a)))}}function TR(n){aa.D(n),G0("dns-prefetch",n,null)}function NR(n,a){aa.C(n,a),G0("preconnect",n,a)}function AR(n,a,o){aa.L(n,a,o);var c=Ri;if(c&&n&&a){var p='link[rel="preload"][as="'+Kn(a)+'"]';a==="image"&&o&&o.imageSrcSet?(p+='[imagesrcset="'+Kn(o.imageSrcSet)+'"]',typeof o.imageSizes=="string"&&(p+='[imagesizes="'+Kn(o.imageSizes)+'"]')):p+='[href="'+Kn(n)+'"]';var v=p;switch(a){case"style":v=ji(n);break;case"script":v=ki(n)}tr.has(v)||(n=g({rel:"preload",href:a==="image"&&o&&o.imageSrcSet?void 0:n,as:a},o),tr.set(v,n),c.querySelector(p)!==null||a==="style"&&c.querySelector(el(v))||a==="script"&&c.querySelector(tl(v))||(a=c.createElement("link"),rn(a,"link",n),Wt(a),c.head.appendChild(a)))}}function RR(n,a){aa.m(n,a);var o=Ri;if(o&&n){var c=a&&typeof a.as=="string"?a.as:"script",p='link[rel="modulepreload"][as="'+Kn(c)+'"][href="'+Kn(n)+'"]',v=p;switch(c){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":v=ki(n)}if(!tr.has(v)&&(n=g({rel:"modulepreload",href:n},a),tr.set(v,n),o.querySelector(p)===null)){switch(c){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(o.querySelector(tl(v)))return}c=o.createElement("link"),rn(c,"link",n),Wt(c),o.head.appendChild(c)}}}function jR(n,a,o){aa.S(n,a,o);var c=Ri;if(c&&n){var p=Qs(c).hoistableStyles,v=ji(n);a=a||"default";var w=p.get(v);if(!w){var N={loading:0,preload:null};if(w=c.querySelector(el(v)))N.loading=5;else{n=g({rel:"stylesheet",href:n,"data-precedence":a},o),(o=tr.get(v))&&gm(n,o);var M=w=c.createElement("link");Wt(M),rn(M,"link",n),M._p=new Promise(function(V,ne){M.onload=V,M.onerror=ne}),M.addEventListener("load",function(){N.loading|=1}),M.addEventListener("error",function(){N.loading|=2}),N.loading|=4,ou(w,a,c)}w={type:"stylesheet",instance:w,count:1,state:N},p.set(v,w)}}}function kR(n,a){aa.X(n,a);var o=Ri;if(o&&n){var c=Qs(o).hoistableScripts,p=ki(n),v=c.get(p);v||(v=o.querySelector(tl(p)),v||(n=g({src:n,async:!0},a),(a=tr.get(p))&&vm(n,a),v=o.createElement("script"),Wt(v),rn(v,"link",n),o.head.appendChild(v)),v={type:"script",instance:v,count:1,state:null},c.set(p,v))}}function OR(n,a){aa.M(n,a);var o=Ri;if(o&&n){var c=Qs(o).hoistableScripts,p=ki(n),v=c.get(p);v||(v=o.querySelector(tl(p)),v||(n=g({src:n,async:!0,type:"module"},a),(a=tr.get(p))&&vm(n,a),v=o.createElement("script"),Wt(v),rn(v,"link",n),o.head.appendChild(v)),v={type:"script",instance:v,count:1,state:null},c.set(p,v))}}function F0(n,a,o,c){var p=(p=re.current)?iu(p):null;if(!p)throw Error(s(446));switch(n){case"meta":case"title":return null;case"style":return typeof o.precedence=="string"&&typeof o.href=="string"?(a=ji(o.href),o=Qs(p).hoistableStyles,c=o.get(a),c||(c={type:"style",instance:null,count:0,state:null},o.set(a,c)),c):{type:"void",instance:null,count:0,state:null};case"link":if(o.rel==="stylesheet"&&typeof o.href=="string"&&typeof o.precedence=="string"){n=ji(o.href);var v=Qs(p).hoistableStyles,w=v.get(n);if(w||(p=p.ownerDocument||p,w={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},v.set(n,w),(v=p.querySelector(el(n)))&&!v._p&&(w.instance=v,w.state.loading=5),tr.has(n)||(o={rel:"preload",as:"style",href:o.href,crossOrigin:o.crossOrigin,integrity:o.integrity,media:o.media,hrefLang:o.hrefLang,referrerPolicy:o.referrerPolicy},tr.set(n,o),v||MR(p,n,o,w.state))),a&&c===null)throw Error(s(528,""));return w}if(a&&c!==null)throw Error(s(529,""));return null;case"script":return a=o.async,o=o.src,typeof o=="string"&&a&&typeof a!="function"&&typeof a!="symbol"?(a=ki(o),o=Qs(p).hoistableScripts,c=o.get(a),c||(c={type:"script",instance:null,count:0,state:null},o.set(a,c)),c):{type:"void",instance:null,count:0,state:null};default:throw Error(s(444,n))}}function ji(n){return'href="'+Kn(n)+'"'}function el(n){return'link[rel="stylesheet"]['+n+"]"}function K0(n){return g({},n,{"data-precedence":n.precedence,precedence:null})}function MR(n,a,o,c){n.querySelector('link[rel="preload"][as="style"]['+a+"]")?c.loading=1:(a=n.createElement("link"),c.preload=a,a.addEventListener("load",function(){return c.loading|=1}),a.addEventListener("error",function(){return c.loading|=2}),rn(a,"link",o),Wt(a),n.head.appendChild(a))}function ki(n){return'[src="'+Kn(n)+'"]'}function tl(n){return"script[async]"+n}function Y0(n,a,o){if(a.count++,a.instance===null)switch(a.type){case"style":var c=n.querySelector('style[data-href~="'+Kn(o.href)+'"]');if(c)return a.instance=c,Wt(c),c;var p=g({},o,{"data-href":o.href,"data-precedence":o.precedence,href:null,precedence:null});return c=(n.ownerDocument||n).createElement("style"),Wt(c),rn(c,"style",p),ou(c,o.precedence,n),a.instance=c;case"stylesheet":p=ji(o.href);var v=n.querySelector(el(p));if(v)return a.state.loading|=4,a.instance=v,Wt(v),v;c=K0(o),(p=tr.get(p))&&gm(c,p),v=(n.ownerDocument||n).createElement("link"),Wt(v);var w=v;return w._p=new Promise(function(N,M){w.onload=N,w.onerror=M}),rn(v,"link",c),a.state.loading|=4,ou(v,o.precedence,n),a.instance=v;case"script":return v=ki(o.src),(p=n.querySelector(tl(v)))?(a.instance=p,Wt(p),p):(c=o,(p=tr.get(v))&&(c=g({},o),vm(c,p)),n=n.ownerDocument||n,p=n.createElement("script"),Wt(p),rn(p,"link",c),n.head.appendChild(p),a.instance=p);case"void":return null;default:throw Error(s(443,a.type))}else a.type==="stylesheet"&&(a.state.loading&4)===0&&(c=a.instance,a.state.loading|=4,ou(c,o.precedence,n));return a.instance}function ou(n,a,o){for(var c=o.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),p=c.length?c[c.length-1]:null,v=p,w=0;w title"):null)}function DR(n,a,o){if(o===1||a.itemProp!=null)return!1;switch(n){case"meta":case"title":return!0;case"style":if(typeof a.precedence!="string"||typeof a.href!="string"||a.href==="")break;return!0;case"link":if(typeof a.rel!="string"||typeof a.href!="string"||a.href===""||a.onLoad||a.onError)break;switch(a.rel){case"stylesheet":return n=a.disabled,typeof a.precedence=="string"&&n==null;default:return!0}case"script":if(a.async&&typeof a.async!="function"&&typeof a.async!="symbol"&&!a.onLoad&&!a.onError&&a.src&&typeof a.src=="string")return!0}return!1}function J0(n){return!(n.type==="stylesheet"&&(n.state.loading&3)===0)}function PR(n,a,o,c){if(o.type==="stylesheet"&&(typeof c.media!="string"||matchMedia(c.media).matches!==!1)&&(o.state.loading&4)===0){if(o.instance===null){var p=ji(c.href),v=a.querySelector(el(p));if(v){a=v._p,a!==null&&typeof a=="object"&&typeof a.then=="function"&&(n.count++,n=cu.bind(n),a.then(n,n)),o.state.loading|=4,o.instance=v,Wt(v);return}v=a.ownerDocument||a,c=K0(c),(p=tr.get(p))&&gm(c,p),v=v.createElement("link"),Wt(v);var w=v;w._p=new Promise(function(N,M){w.onload=N,w.onerror=M}),rn(v,"link",c),o.instance=v}n.stylesheets===null&&(n.stylesheets=new Map),n.stylesheets.set(o,a),(a=o.state.preload)&&(o.state.loading&3)===0&&(n.count++,o=cu.bind(n),a.addEventListener("load",o),a.addEventListener("error",o))}}var ym=0;function LR(n,a){return n.stylesheets&&n.count===0&&du(n,n.stylesheets),0ym?50:800)+a);return n.unsuspend=o,function(){n.unsuspend=null,clearTimeout(c),clearTimeout(p)}}:null}function cu(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)du(this,this.stylesheets);else if(this.unsuspend){var n=this.unsuspend;this.unsuspend=null,n()}}}var uu=null;function du(n,a){n.stylesheets=null,n.unsuspend!==null&&(n.count++,uu=new Map,a.forEach(zR,n),uu=null,cu.call(n))}function zR(n,a){if(!(a.state.loading&4)){var o=uu.get(n);if(o)var c=o.get(null);else{o=new Map,uu.set(n,o);for(var p=n.querySelectorAll("link[data-precedence],style[data-precedence]"),v=0;v"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),Nm.exports=ZR(),Nm.exports}var tj=ej();/** - * react-router v7.15.1 - * - * Copyright (c) Remix Software Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE.md file in the root directory of this source tree. - * - * @license MIT - */var wx="popstate";function _x(e){return typeof e=="object"&&e!=null&&"pathname"in e&&"search"in e&&"hash"in e&&"state"in e&&"key"in e}function nj(e={}){function t(s,i){var h;let l=(h=i.state)==null?void 0:h.masked,{pathname:u,search:d,hash:f}=l||s.location;return fp("",{pathname:u,search:d,hash:f},i.state&&i.state.usr||null,i.state&&i.state.key||"default",l?{pathname:s.location.pathname,search:s.location.search,hash:s.location.hash}:void 0)}function r(s,i){return typeof i=="string"?i:Cl(i)}return aj(t,r,null,e)}function Tt(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function Lr(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function rj(){return Math.random().toString(36).substring(2,10)}function Sx(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function fp(e,t,r=null,s,i){return{pathname:typeof e=="string"?e:e.pathname,search:"",hash:"",...typeof t=="string"?lo(t):t,state:r,key:t&&t.key||s||rj(),mask:i}}function Cl({pathname:e="/",search:t="",hash:r=""}){return t&&t!=="?"&&(e+=t.charAt(0)==="?"?t:"?"+t),r&&r!=="#"&&(e+=r.charAt(0)==="#"?r:"#"+r),e}function lo(e){let t={};if(e){let r=e.indexOf("#");r>=0&&(t.hash=e.substring(r),e=e.substring(0,r));let s=e.indexOf("?");s>=0&&(t.search=e.substring(s),e=e.substring(0,s)),e&&(t.pathname=e)}return t}function aj(e,t,r,s={}){let{window:i=document.defaultView,v5Compat:l=!1}=s,u=i.history,d="POP",f=null,h=b();h==null&&(h=0,u.replaceState({...u.state,idx:h},""));function b(){return(u.state||{idx:null}).idx}function g(){d="POP";let T=b(),C=T==null?null:T-h;h=T,f&&f({action:d,location:S.location,delta:C})}function x(T,C){d="PUSH";let R=_x(T)?T:fp(S.location,T,C);h=b()+1;let A=Sx(R,h),D=S.createHref(R.mask||R);try{u.pushState(A,"",D)}catch(B){if(B instanceof DOMException&&B.name==="DataCloneError")throw B;i.location.assign(D)}l&&f&&f({action:d,location:S.location,delta:1})}function _(T,C){d="REPLACE";let R=_x(T)?T:fp(S.location,T,C);h=b();let A=Sx(R,h),D=S.createHref(R.mask||R);u.replaceState(A,"",D),l&&f&&f({action:d,location:S.location,delta:0})}function E(T){return sj(T)}let S={get action(){return d},get location(){return e(i,u)},listen(T){if(f)throw new Error("A history only accepts one active listener");return i.addEventListener(wx,g),f=T,()=>{i.removeEventListener(wx,g),f=null}},createHref(T){return t(i,T)},createURL:E,encodeLocation(T){let C=E(T);return{pathname:C.pathname,search:C.search,hash:C.hash}},push:x,replace:_,go(T){return u.go(T)}};return S}function sj(e,t=!1){let r="http://localhost";typeof window<"u"&&(r=window.location.origin!=="null"?window.location.origin:window.location.href),Tt(r,"No window.location.(origin|href) available to create URL");let s=typeof e=="string"?e:Cl(e);return s=s.replace(/ $/,"%20"),!t&&s.startsWith("//")&&(s=r+s),new URL(s,r)}function x_(e,t,r="/"){return ij(e,t,r,!1)}function ij(e,t,r,s,i){let l=typeof t=="string"?lo(t):t,u=ua(l.pathname||"/",r);if(u==null)return null;let d=oj(e),f=null,h=bj(u);for(let b=0;f==null&&b{let b={relativePath:h===void 0?u.path||"":h,caseSensitive:u.caseSensitive===!0,childrenIndex:d,route:u};if(b.relativePath.startsWith("/")){if(!b.relativePath.startsWith(s)&&f)return;Tt(b.relativePath.startsWith(s),`Absolute route path "${b.relativePath}" nested under path "${s}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),b.relativePath=b.relativePath.slice(s.length)}let g=yr([s,b.relativePath]),x=r.concat(b);u.children&&u.children.length>0&&(Tt(u.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${g}".`),w_(u.children,t,x,g,f)),!(u.path==null&&!u.index)&&t.push({path:g,score:pj(g,u.index),routesMeta:x})};return e.forEach((u,d)=>{var f;if(u.path===""||!((f=u.path)!=null&&f.includes("?")))l(u,d);else for(let h of __(u.path))l(u,d,!0,h)}),t}function __(e){let t=e.split("/");if(t.length===0)return[];let[r,...s]=t,i=r.endsWith("?"),l=r.replace(/\?$/,"");if(s.length===0)return i?[l,""]:[l];let u=__(s.join("/")),d=[];return d.push(...u.map(f=>f===""?l:[l,f].join("/"))),i&&d.push(...u),d.map(f=>e.startsWith("/")&&f===""?"/":f)}function lj(e){e.sort((t,r)=>t.score!==r.score?r.score-t.score:gj(t.routesMeta.map(s=>s.childrenIndex),r.routesMeta.map(s=>s.childrenIndex)))}var cj=/^:[\w-]+$/,uj=3,dj=2,fj=1,hj=10,mj=-2,Ex=e=>e==="*";function pj(e,t){let r=e.split("/"),s=r.length;return r.some(Ex)&&(s+=mj),t&&(s+=dj),r.filter(i=>!Ex(i)).reduce((i,l)=>i+(cj.test(l)?uj:l===""?fj:hj),s)}function gj(e,t){return e.length===t.length&&e.slice(0,-1).every((s,i)=>s===t[i])?e[e.length-1]-t[t.length-1]:0}function vj(e,t,r=!1){let{routesMeta:s}=e,i={},l="/",u=[];for(let d=0;d{if(b==="*"){let E=d[x]||"";u=l.slice(0,l.length-E.length).replace(/(.)\/+$/,"$1")}const _=d[x];return g&&!_?h[b]=void 0:h[b]=(_||"").replace(/%2F/g,"/"),h},{}),pathname:l,pathnameBase:u,pattern:e}}function yj(e,t=!1,r=!0){Lr(e==="*"||!e.endsWith("*")||e.endsWith("/*"),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,"/*")}".`);let s=[],i="^"+e.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(u,d,f,h,b)=>{if(s.push({paramName:d,isOptional:f!=null}),f){let g=b.charAt(h+u.length);return g&&g!=="/"?"/([^\\/]*)":"(?:/([^\\/]*))?"}return"/([^\\/]+)"}).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return e.endsWith("*")?(s.push({paramName:"*"}),i+=e==="*"||e==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):r?i+="\\/*$":e!==""&&e!=="/"&&(i+="(?:(?=\\/|$))"),[new RegExp(i,t?void 0:"i"),s]}function bj(e){try{return e.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return Lr(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function ua(e,t){if(t==="/")return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let r=t.endsWith("/")?t.length-1:t.length,s=e.charAt(r);return s&&s!=="/"?null:e.slice(r)||"/"}var xj=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function wj(e,t="/"){let{pathname:r,search:s="",hash:i=""}=typeof e=="string"?lo(e):e,l;return r?(r=E_(r),r.startsWith("/")?l=Cx(r.substring(1),"/"):l=Cx(r,t)):l=t,{pathname:l,search:Ej(s),hash:Cj(i)}}function Cx(e,t){let r=sd(t).split("/");return e.split("/").forEach(i=>{i===".."?r.length>1&&r.pop():i!=="."&&r.push(i)}),r.length>1?r.join("/"):"/"}function km(e,t,r,s){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(s)}]. Please separate it out to the \`to.${r}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function _j(e){return e.filter((t,r)=>r===0||t.route.path&&t.route.path.length>0)}function S_(e){let t=_j(e);return t.map((r,s)=>s===t.length-1?r.pathname:r.pathnameBase)}function ag(e,t,r,s=!1){let i;typeof e=="string"?i=lo(e):(i={...e},Tt(!i.pathname||!i.pathname.includes("?"),km("?","pathname","search",i)),Tt(!i.pathname||!i.pathname.includes("#"),km("#","pathname","hash",i)),Tt(!i.search||!i.search.includes("#"),km("#","search","hash",i)));let l=e===""||i.pathname==="",u=l?"/":i.pathname,d;if(u==null)d=r;else{let g=t.length-1;if(!s&&u.startsWith("..")){let x=u.split("/");for(;x[0]==="..";)x.shift(),g-=1;i.pathname=x.join("/")}d=g>=0?t[g]:"/"}let f=wj(i,d),h=u&&u!=="/"&&u.endsWith("/"),b=(l||u===".")&&r.endsWith("/");return!f.pathname.endsWith("/")&&(h||b)&&(f.pathname+="/"),f}var E_=e=>e.replace(/\/\/+/g,"/"),yr=e=>E_(e.join("/")),sd=e=>e.replace(/\/+$/,""),Sj=e=>sd(e).replace(/^\/*/,"/"),Ej=e=>!e||e==="?"?"":e.startsWith("?")?e:"?"+e,Cj=e=>!e||e==="#"?"":e.startsWith("#")?e:"#"+e,Tj=class{constructor(e,t,r,s=!1){this.status=e,this.statusText=t||"",this.internal=s,r instanceof Error?(this.data=r.toString(),this.error=r):this.data=r}};function Nj(e){return e!=null&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.internal=="boolean"&&"data"in e}function Aj(e){let t=e.map(r=>r.route.path).filter(Boolean);return yr(t)||"/"}var C_=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function T_(e,t){let r=e;if(typeof r!="string"||!xj.test(r))return{absoluteURL:void 0,isExternal:!1,to:r};let s=r,i=!1;if(C_)try{let l=new URL(window.location.href),u=r.startsWith("//")?new URL(l.protocol+r):new URL(r),d=ua(u.pathname,t);u.origin===l.origin&&d!=null?r=d+u.search+u.hash:i=!0}catch{Lr(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:s,isExternal:i,to:r}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var N_=["POST","PUT","PATCH","DELETE"];new Set(N_);var Rj=["GET",...N_];new Set(Rj);var co=y.createContext(null);co.displayName="DataRouter";var kd=y.createContext(null);kd.displayName="DataRouterState";var A_=y.createContext(!1);function jj(){return y.useContext(A_)}var R_=y.createContext({isTransitioning:!1});R_.displayName="ViewTransition";var kj=y.createContext(new Map);kj.displayName="Fetchers";var Oj=y.createContext(null);Oj.displayName="Await";var ir=y.createContext(null);ir.displayName="Navigation";var Bl=y.createContext(null);Bl.displayName="Location";var ga=y.createContext({outlet:null,matches:[],isDataRoute:!1});ga.displayName="Route";var sg=y.createContext(null);sg.displayName="RouteError";var j_="REACT_ROUTER_ERROR",Mj="REDIRECT",Dj="ROUTE_ERROR_RESPONSE";function Pj(e){if(e.startsWith(`${j_}:${Mj}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.location=="string"&&typeof t.reloadDocument=="boolean"&&typeof t.replace=="boolean")return t}catch{}}function Lj(e){if(e.startsWith(`${j_}:${Dj}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string")return new Tj(t.status,t.statusText,t.data)}catch{}}function zj(e,{relative:t}={}){Tt(Hl(),"useHref() may be used only in the context of a component.");let{basename:r,navigator:s}=y.useContext(ir),{hash:i,pathname:l,search:u}=ql(e,{relative:t}),d=l;return r!=="/"&&(d=l==="/"?r:yr([r,l])),s.createHref({pathname:d,search:u,hash:i})}function Hl(){return y.useContext(Bl)!=null}function va(){return Tt(Hl(),"useLocation() may be used only in the context of a component."),y.useContext(Bl).location}var k_="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function O_(e){y.useContext(ir).static||y.useLayoutEffect(e)}function Ij(){let{isDataRoute:e}=y.useContext(ga);return e?Jj():Uj()}function Uj(){Tt(Hl(),"useNavigate() may be used only in the context of a component.");let e=y.useContext(co),{basename:t,navigator:r}=y.useContext(ir),{matches:s}=y.useContext(ga),{pathname:i}=va(),l=JSON.stringify(S_(s)),u=y.useRef(!1);return O_(()=>{u.current=!0}),y.useCallback((f,h={})=>{if(Lr(u.current,k_),!u.current)return;if(typeof f=="number"){r.go(f);return}let b=ag(f,JSON.parse(l),i,h.relative==="path");e==null&&t!=="/"&&(b.pathname=b.pathname==="/"?t:yr([t,b.pathname])),(h.replace?r.replace:r.push)(b,h.state,h)},[t,r,l,i,e])}y.createContext(null);function ql(e,{relative:t}={}){let{matches:r}=y.useContext(ga),{pathname:s}=va(),i=JSON.stringify(S_(r));return y.useMemo(()=>ag(e,JSON.parse(i),s,t==="path"),[e,i,s,t])}function $j(e,t){return M_(e,t)}function M_(e,t,r){var T;Tt(Hl(),"useRoutes() may be used only in the context of a component.");let{navigator:s}=y.useContext(ir),{matches:i}=y.useContext(ga),l=i[i.length-1],u=l?l.params:{},d=l?l.pathname:"/",f=l?l.pathnameBase:"/",h=l&&l.route;{let C=h&&h.path||"";P_(d,!h||C.endsWith("*")||C.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${d}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. - -Please change the parent to .`)}let b=va(),g;if(t){let C=typeof t=="string"?lo(t):t;Tt(f==="/"||((T=C.pathname)==null?void 0:T.startsWith(f)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${f}" but pathname "${C.pathname}" was given in the \`location\` prop.`),g=C}else g=b;let x=g.pathname||"/",_=x;if(f!=="/"){let C=f.replace(/^\//,"").split("/");_="/"+x.replace(/^\//,"").split("/").slice(C.length).join("/")}let E=r&&r.state.matches.length?r.state.matches.map(C=>Object.assign(C,{route:r.manifest[C.route.id]||C.route})):x_(e,{pathname:_});Lr(h||E!=null,`No routes matched location "${g.pathname}${g.search}${g.hash}" `),Lr(E==null||E[E.length-1].route.element!==void 0||E[E.length-1].route.Component!==void 0||E[E.length-1].route.lazy!==void 0,`Matched leaf route at location "${g.pathname}${g.search}${g.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let S=Gj(E&&E.map(C=>Object.assign({},C,{params:Object.assign({},u,C.params),pathname:yr([f,s.encodeLocation?s.encodeLocation(C.pathname.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:C.pathname]),pathnameBase:C.pathnameBase==="/"?f:yr([f,s.encodeLocation?s.encodeLocation(C.pathnameBase.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:C.pathnameBase])})),i,r);return t&&S?y.createElement(Bl.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",mask:void 0,...g},navigationType:"POP"}},S):S}function Bj(){let e=Xj(),t=Nj(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),r=e instanceof Error?e.stack:null,s="rgba(200,200,200, 0.5)",i={padding:"0.5rem",backgroundColor:s},l={padding:"2px 4px",backgroundColor:s},u=null;return console.error("Error handled by React Router default ErrorBoundary:",e),u=y.createElement(y.Fragment,null,y.createElement("p",null,"💿 Hey developer 👋"),y.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",y.createElement("code",{style:l},"ErrorBoundary")," or"," ",y.createElement("code",{style:l},"errorElement")," prop on your route.")),y.createElement(y.Fragment,null,y.createElement("h2",null,"Unexpected Application Error!"),y.createElement("h3",{style:{fontStyle:"italic"}},t),r?y.createElement("pre",{style:i},r):null,u)}var Hj=y.createElement(Bj,null),D_=class extends y.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!=="idle"&&e.revalidation==="idle"?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error!==void 0?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error("React Router caught the following error during render",e)}render(){let e=this.state.error;if(this.context&&typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){const r=Lj(e.digest);r&&(e=r)}let t=e!==void 0?y.createElement(ga.Provider,{value:this.props.routeContext},y.createElement(sg.Provider,{value:e,children:this.props.component})):this.props.children;return this.context?y.createElement(qj,{error:e},t):t}};D_.contextType=A_;var Om=new WeakMap;function qj({children:e,error:t}){let{basename:r}=y.useContext(ir);if(typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){let s=Pj(t.digest);if(s){let i=Om.get(t);if(i)throw i;let l=T_(s.location,r);if(C_&&!Om.get(t))if(l.isExternal||s.reloadDocument)window.location.href=l.absoluteURL||l.to;else{const u=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(l.to,{replace:s.replace}));throw Om.set(t,u),u}return y.createElement("meta",{httpEquiv:"refresh",content:`0;url=${l.absoluteURL||l.to}`})}}return e}function Vj({routeContext:e,match:t,children:r}){let s=y.useContext(co);return s&&s.static&&s.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(s.staticContext._deepestRenderedBoundaryId=t.route.id),y.createElement(ga.Provider,{value:e},r)}function Gj(e,t=[],r){let s=r==null?void 0:r.state;if(e==null){if(!s)return null;if(s.errors)e=s.matches;else if(t.length===0&&!s.initialized&&s.matches.length>0)e=s.matches;else return null}let i=e,l=s==null?void 0:s.errors;if(l!=null){let b=i.findIndex(g=>g.route.id&&(l==null?void 0:l[g.route.id])!==void 0);Tt(b>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(l).join(",")}`),i=i.slice(0,Math.min(i.length,b+1))}let u=!1,d=-1;if(r&&s){u=s.renderFallback;for(let b=0;b=0?i=i.slice(0,d+1):i=[i[0]];break}}}}let f=r==null?void 0:r.onError,h=s&&f?(b,g)=>{var x,_;f(b,{location:s.location,params:((_=(x=s.matches)==null?void 0:x[0])==null?void 0:_.params)??{},pattern:Aj(s.matches),errorInfo:g})}:void 0;return i.reduceRight((b,g,x)=>{let _,E=!1,S=null,T=null;s&&(_=l&&g.route.id?l[g.route.id]:void 0,S=g.route.errorElement||Hj,u&&(d<0&&x===0?(P_("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),E=!0,T=null):d===x&&(E=!0,T=g.route.hydrateFallbackElement||null)));let C=t.concat(i.slice(0,x+1)),R=()=>{let A;return _?A=S:E?A=T:g.route.Component?A=y.createElement(g.route.Component,null):g.route.element?A=g.route.element:A=b,y.createElement(Vj,{match:g,routeContext:{outlet:b,matches:C,isDataRoute:s!=null},children:A})};return s&&(g.route.ErrorBoundary||g.route.errorElement||x===0)?y.createElement(D_,{location:s.location,revalidation:s.revalidation,component:S,error:_,children:R(),routeContext:{outlet:null,matches:C,isDataRoute:!0},onError:h}):R()},null)}function ig(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Fj(e){let t=y.useContext(co);return Tt(t,ig(e)),t}function Kj(e){let t=y.useContext(kd);return Tt(t,ig(e)),t}function Yj(e){let t=y.useContext(ga);return Tt(t,ig(e)),t}function og(e){let t=Yj(e),r=t.matches[t.matches.length-1];return Tt(r.route.id,`${e} can only be used on routes that contain a unique "id"`),r.route.id}function Wj(){return og("useRouteId")}function Xj(){var s;let e=y.useContext(sg),t=Kj("useRouteError"),r=og("useRouteError");return e!==void 0?e:(s=t.errors)==null?void 0:s[r]}function Jj(){let{router:e}=Fj("useNavigate"),t=og("useNavigate"),r=y.useRef(!1);return O_(()=>{r.current=!0}),y.useCallback(async(i,l={})=>{Lr(r.current,k_),r.current&&(typeof i=="number"?await e.navigate(i):await e.navigate(i,{fromRouteId:t,...l}))},[e,t])}var Tx={};function P_(e,t,r){!t&&!Tx[e]&&(Tx[e]=!0,Lr(!1,r))}y.memo(Qj);function Qj({routes:e,manifest:t,future:r,state:s,isStatic:i,onError:l}){return M_(e,void 0,{manifest:t,state:s,isStatic:i,onError:l})}function L_(e){Tt(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Zj({basename:e="/",children:t=null,location:r,navigationType:s="POP",navigator:i,static:l=!1,useTransitions:u}){Tt(!Hl(),"You cannot render a inside another . You should never have more than one in your app.");let d=e.replace(/^\/*/,"/"),f=y.useMemo(()=>({basename:d,navigator:i,static:l,useTransitions:u,future:{}}),[d,i,l,u]);typeof r=="string"&&(r=lo(r));let{pathname:h="/",search:b="",hash:g="",state:x=null,key:_="default",mask:E}=r,S=y.useMemo(()=>{let T=ua(h,d);return T==null?null:{location:{pathname:T,search:b,hash:g,state:x,key:_,mask:E},navigationType:s}},[d,h,b,g,x,_,s,E]);return Lr(S!=null,` is not able to match the URL "${h}${b}${g}" because it does not start with the basename, so the won't render anything.`),S==null?null:y.createElement(ir.Provider,{value:f},y.createElement(Bl.Provider,{children:t,value:S}))}function ek({children:e,location:t}){return $j(hp(e),t)}function hp(e,t=[]){let r=[];return y.Children.forEach(e,(s,i)=>{if(!y.isValidElement(s))return;let l=[...t,i];if(s.type===y.Fragment){r.push.apply(r,hp(s.props.children,l));return}Tt(s.type===L_,`[${typeof s.type=="string"?s.type:s.type.name}] is not a component. All component children of must be a or `),Tt(!s.props.index||!s.props.children,"An index route cannot have child routes.");let u={id:s.props.id||l.join("-"),caseSensitive:s.props.caseSensitive,element:s.props.element,Component:s.props.Component,index:s.props.index,path:s.props.path,middleware:s.props.middleware,loader:s.props.loader,action:s.props.action,hydrateFallbackElement:s.props.hydrateFallbackElement,HydrateFallback:s.props.HydrateFallback,errorElement:s.props.errorElement,ErrorBoundary:s.props.ErrorBoundary,hasErrorBoundary:s.props.hasErrorBoundary===!0||s.props.ErrorBoundary!=null||s.props.errorElement!=null,shouldRevalidate:s.props.shouldRevalidate,handle:s.props.handle,lazy:s.props.lazy};s.props.children&&(u.children=hp(s.props.children,l)),r.push(u)}),r}var Ku="get",Yu="application/x-www-form-urlencoded";function Od(e){return typeof HTMLElement<"u"&&e instanceof HTMLElement}function tk(e){return Od(e)&&e.tagName.toLowerCase()==="button"}function nk(e){return Od(e)&&e.tagName.toLowerCase()==="form"}function rk(e){return Od(e)&&e.tagName.toLowerCase()==="input"}function ak(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function sk(e,t){return e.button===0&&(!t||t==="_self")&&!ak(e)}var bu=null;function ik(){if(bu===null)try{new FormData(document.createElement("form"),0),bu=!1}catch{bu=!0}return bu}var ok=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function Mm(e){return e!=null&&!ok.has(e)?(Lr(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Yu}"`),null):e}function lk(e,t){let r,s,i,l,u;if(nk(e)){let d=e.getAttribute("action");s=d?ua(d,t):null,r=e.getAttribute("method")||Ku,i=Mm(e.getAttribute("enctype"))||Yu,l=new FormData(e)}else if(tk(e)||rk(e)&&(e.type==="submit"||e.type==="image")){let d=e.form;if(d==null)throw new Error('Cannot submit a - - - - ); - } - return this.props.children; - } -} - -// ─── Auth Gate ─────────────────────────────────────────────────────────────── - -function AuthGate() { - const { user, loading } = useAuth(); - - if (loading) { - return ( -
-
-
- -
-

Cargando tablero…

-
-
- ); - } - - if (!user) { - return ; - } - - return ( - - } /> - - ); -} - -// ─── App ───────────────────────────────────────────────────────────────────── - -export function App() { - return ( - - - - - - - - - ); -} diff --git a/src/components/GLMLogo.tsx b/src/components/GLMLogo.tsx deleted file mode 100644 index c0e73a1..0000000 --- a/src/components/GLMLogo.tsx +++ /dev/null @@ -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 ( -
- GLM -
- ); -} diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx deleted file mode 100644 index e731610..0000000 --- a/src/components/LoginScreen.tsx +++ /dev/null @@ -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 ( -
-
- {/* Logo / marca */} -
- -

Tablero CDC

-
- - {/* Card */} -
- - - {error && ( -
-

{error}

-
- )} -
- -

- Acceso restringido a usuarios autorizados -

-
-
- ); -} diff --git a/src/components/board/ColorPicker.tsx b/src/components/board/ColorPicker.tsx deleted file mode 100644 index ea9732f..0000000 --- a/src/components/board/ColorPicker.tsx +++ /dev/null @@ -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 ( -
- {COLORS.map((c) => ( - - ))} -
- ); -} diff --git a/src/components/board/LinkList.tsx b/src/components/board/LinkList.tsx deleted file mode 100644 index 20af392..0000000 --- a/src/components/board/LinkList.tsx +++ /dev/null @@ -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 ( -
-
- - {label}{" "} - {prefix && ({prefix})} - - {links.length} -
- - {links.length === 0 && ( -

{emptyText ?? "Sin links aún"}

- )} - -
- {links.map((l, i) => ( -
-
- {prefix ?? "·"} - {i + 1} -
- {readOnly ? ( - - - {l} - - - ) : ( - <> - update(i, e.target.value)} - placeholder="https://…" - className="h-9 text-sm" - /> - - - )} -
- ))} -
- - {!readOnly && ( -
- setDraft(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), add())} - placeholder="Pegar link y Enter" - className="h-9 text-sm" - /> - -
- )} -
- ); -} diff --git a/src/components/board/ProjectCard.tsx b/src/components/board/ProjectCard.tsx deleted file mode 100644 index 1edea5f..0000000 --- a/src/components/board/ProjectCard.tsx +++ /dev/null @@ -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) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onClick(); - } - }; - - return ( -
{ - 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 */} -
-

- {project.nombre || "Sin título"} -

-
- - {/* Cuerpo blanco */} -
- } label="Cliente" value={project.cliente} /> - } label="Marca" value={project.marca} /> - } label="País" value={project.bu} /> - } label="Solicita" value={project.solicitante} /> - -
-
- {project.propuestaLinks.length > 0 && ( - - {project.propuestaLinks.length} - - )} - {project.afLinks.length > 0 && ( - - AF·{project.afLinks.length} - - )} -
- -
- {isGerardo && project.monto != null && ( - - ${project.monto.toLocaleString()} - - )} - {project.status && ( - - {project.status === "Aprobado" && } - {project.status} - - )} -
-
-
-
- ); -} - -function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { - return ( -
- {icon} - {label} - {value || "—"} -
- ); -} diff --git a/src/components/board/ProjectDialog.tsx b/src/components/board/ProjectDialog.tsx deleted file mode 100644 index 8ac933e..0000000 --- a/src/components/board/ProjectDialog.tsx +++ /dev/null @@ -1,1251 +0,0 @@ -import { useCallback, useState, useEffect } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, - DialogDescription, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Badge } from "@/components/ui/badge"; -import { Separator } from "@/components/ui/separator"; -import { - AlertCircle, - Loader2, - Lock, - DollarSign, - Calendar, - Trash2, - Search, - History, - MessageSquarePlus, - Clock3, -} from "lucide-react"; -import { ColorPicker } from "./ColorPicker"; -import { LinkList } from "./LinkList"; -import { SearchableSelect } from "./SearchableSelect"; -import { SearchableMultiSelect } from "./SearchableMultiSelect"; -import { ProjectPricingPanel } from "./ProjectPricingPanel"; -import { MESES, type Status } from "@/data/lists"; -import { useAppLists } from "@/lib/appLists"; -import { - addProjectActivityNote, - loadProjectActivities, - loadProjectPricingItems, - useProjects, - type Project, - type ProjectActivity, - type ProjectPricingItemInput, -} from "@/lib/store"; -import { useAuth } from "@/context/AuthContext"; -import { colorHex } from "@/lib/colors"; -import { cn } from "@/lib/utils"; -import { - canonicalOptionLabel, - dedupeOptions, - ensureOption, - normalizeOptionKey, -} from "@/lib/optionUtils"; -import { toast } from "sonner"; - -interface Props { - open: boolean; - onOpenChange: (v: boolean) => void; - project?: Project | null; // editar; vacío = crear - onDelete?: (project: Project) => void; - deleting?: boolean; -} - -const WALMART_CONNECT_CLIENT = "Walmart Connect WMC"; -const ACTIVITY_PAGE_SIZE = 15; - -function formatActivityDate(timestamp: number) { - return new Intl.DateTimeFormat("es-DO", { - day: "2-digit", - month: "short", - year: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(new Date(timestamp)); -} - -function formatActivityDescription(description: string) { - return description - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); -} - -function getPricingTotal(items: ProjectPricingItemInput[]) { - return items.reduce((sum, item) => sum + Number(item.amount || 0), 0); -} - -function toPricingInputItems( - items: Awaited>, -): ProjectPricingItemInput[] { - return items.map((item) => ({ - id: item.id, - source: item.source, - category: item.category, - serviceName: item.serviceName, - workType: item.workType, - complexityLevel: item.complexityLevel, - referenceLabel: item.referenceLabel, - referenceMin: item.referenceMin, - referenceMax: item.referenceMax, - amount: item.amount, - description: item.description, - })); -} - -function hasManualInternalAmountOverride( - savedAmount: number | null | undefined, - items: ProjectPricingItemInput[], -) { - if (!items.length || savedAmount == null) return false; - - const cleanSavedAmount = Number(savedAmount); - - if (!Number.isFinite(cleanSavedAmount) || cleanSavedAmount <= 0) return false; - - const calculatedTotal = getPricingTotal(items); - - return Math.abs(cleanSavedAmount - calculatedTotal) > 0.01; -} - -function splitBrandValues(value: string | null | undefined, knownBrands: string[] = []) { - const cleanValue = canonicalOptionLabel(value); - - if (!cleanValue) return []; - - const knownByKey = new Map( - dedupeOptions(knownBrands).map((brand) => [normalizeOptionKey(brand), brand] as const), - ); - const exactKnownBrand = knownByKey.get(normalizeOptionKey(cleanValue)); - - if (exactKnownBrand) { - return [exactKnownBrand]; - } - - const parts = cleanValue - .split(/[,;]/) - .map((brand) => canonicalOptionLabel(brand)) - .filter(Boolean); - - if (parts.length <= 1) { - return dedupeOptions([exactKnownBrand || cleanValue]); - } - - const parsedBrands: string[] = []; - let index = 0; - - while (index < parts.length) { - let matchedBrand = ""; - let nextIndex = index + 1; - - for (let end = parts.length; end > index; end -= 1) { - const candidate = parts.slice(index, end).join(", "); - const knownBrand = knownByKey.get(normalizeOptionKey(candidate)); - - if (knownBrand) { - matchedBrand = knownBrand; - nextIndex = end; - break; - } - } - - parsedBrands.push(matchedBrand || parts[index]); - index = nextIndex; - } - - return dedupeOptions(parsedBrands); -} - -function joinBrandValues(values: string[]) { - return dedupeOptions(values.map((brand) => canonicalOptionLabel(brand)).filter(Boolean)).join( - ", ", - ); -} - -function splitCountryValues(value: string | null | undefined, knownCountries: string[] = []) { - const cleanValue = canonicalOptionLabel(value); - - if (!cleanValue) return []; - - const knownByKey = new Map( - dedupeOptions(knownCountries).map((country) => [normalizeOptionKey(country), country] as const), - ); - const exactKnownCountry = knownByKey.get(normalizeOptionKey(cleanValue)); - - if (exactKnownCountry) { - return [exactKnownCountry]; - } - - const parts = cleanValue - .split(/[,;]/) - .map((country) => canonicalOptionLabel(country)) - .filter(Boolean); - - if (parts.length <= 1) { - return dedupeOptions([exactKnownCountry || cleanValue]); - } - - return dedupeOptions( - parts.map((country) => knownByKey.get(normalizeOptionKey(country)) || country), - ); -} - -function joinCountryValues(values: string[]) { - return dedupeOptions(values.map((country) => canonicalOptionLabel(country)).filter(Boolean)).join( - ", ", - ); -} - -function isWalmartConnectClient(client: string | null | undefined) { - return normalizeOptionKey(client) === normalizeOptionKey(WALMART_CONNECT_CLIENT); -} - -const empty = (): Omit => { - const now = new Date(); - return { - nombre: "", - color: "blue", - cliente: "", - bu: "", - cm: "", - marca: "", - solicitante: "", - comentarios: "", - briefLink: "", - propuestaLinks: [], - afLinks: [], - status: "", - monto: null, - mes: MESES[now.getMonth()], - anio: now.getFullYear(), - }; -}; - -export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting = false }: Props) { - const { add, update } = useProjects(); - const { lists, loading: listsLoading, error: listsError, refresh: refreshLists } = useAppLists(); - const { isGerardo, canDeleteProjects, canManageInternalPricing } = useAuth(); - const isEdit = !!project; - - const [form, setForm] = useState>(empty()); - const [selectedWmcCountries, setSelectedWmcCountries] = useState([]); - const [saving, setSaving] = useState(false); - const [wmcCountrySearch, setWmcCountrySearch] = useState(""); - const [savingStep, setSavingStep] = useState(null); - const [formError, setFormError] = useState(null); - const [activityItems, setActivityItems] = useState([]); - const [activityLoading, setActivityLoading] = useState(false); - const [activityError, setActivityError] = useState(null); - const [activityNote, setActivityNote] = useState(""); - const [activityHasMore, setActivityHasMore] = useState(false); - const [activityLoadingMore, setActivityLoadingMore] = useState(false); - const [addingActivityNote, setAddingActivityNote] = useState(false); - const [pricingItems, setPricingItems] = useState([]); - const [pricingLoading, setPricingLoading] = useState(false); - const [pricingError, setPricingError] = useState(null); - const [internalAmountManuallyEdited, setInternalAmountManuallyEdited] = useState(false); - - const selectedBrands = splitBrandValues(form.marca, lists.marcas); - const selectedCountries = splitCountryValues(form.bu, lists.bus); - const clienteOptions = dedupeOptions(ensureOption(lists.clientes, form.cliente)); - const marcaOptions = dedupeOptions([...lists.marcas, ...selectedBrands]); - const buOptions = dedupeOptions([...lists.bus, ...selectedCountries]); - const statusOptions = dedupeOptions(ensureOption(lists.status, form.status)) as Status[]; - const filteredWmcBuOptions = buOptions.filter((b) => - normalizeOptionKey(b).includes(normalizeOptionKey(wmcCountrySearch)), - ); - const pricingTotal = getPricingTotal(pricingItems); - - useEffect(() => { - setFormError(null); - setSavingStep(null); - - if (project) { - const { id: _i, createdAt: _c, ...rest } = project; - const cleanBu = canonicalOptionLabel(rest.bu); - setForm({ - ...rest, - bu: cleanBu, - }); - setSelectedWmcCountries(cleanBu ? [cleanBu] : []); - setWmcCountrySearch(""); - setActivityItems([]); - setActivityHasMore(false); - setActivityError(null); - setActivityNote(""); - setPricingItems([]); - setPricingError(null); - setInternalAmountManuallyEdited(false); - } else if (open) { - setForm(empty()); - setSelectedWmcCountries([]); - setWmcCountrySearch(""); - setActivityItems([]); - setActivityHasMore(false); - setActivityError(null); - setActivityNote(""); - setPricingItems([]); - setPricingError(null); - setInternalAmountManuallyEdited(false); - } - }, [project, open]); - - const refreshActivity = useCallback(async () => { - if (!project?.id) return; - - try { - setActivityLoading(true); - setActivityError(null); - const items = await loadProjectActivities(project.id, ACTIVITY_PAGE_SIZE + 1); - setActivityItems(items.slice(0, ACTIVITY_PAGE_SIZE)); - setActivityHasMore(items.length > ACTIVITY_PAGE_SIZE); - } catch (error) { - console.warn("No se pudo cargar el historial del proyecto:", error); - setActivityError( - "No se pudo cargar el historial. Revisa que el SQL de actividad esté ejecutado en Supabase.", - ); - setActivityItems([]); - setActivityHasMore(false); - } finally { - setActivityLoading(false); - } - }, [project?.id]); - - const loadMoreActivity = useCallback(async () => { - if (!project?.id || activityLoadingMore || !activityHasMore) return; - - try { - setActivityLoadingMore(true); - setActivityError(null); - const items = await loadProjectActivities( - project.id, - ACTIVITY_PAGE_SIZE + 1, - activityItems.length, - ); - setActivityItems((current) => [...current, ...items.slice(0, ACTIVITY_PAGE_SIZE)]); - setActivityHasMore(items.length > ACTIVITY_PAGE_SIZE); - } catch (error) { - console.warn("No se pudo cargar más historial del proyecto:", error); - setActivityError("No se pudo cargar más actividad. Inténtalo nuevamente."); - } finally { - setActivityLoadingMore(false); - } - }, [activityHasMore, activityItems.length, activityLoadingMore, project?.id]); - - useEffect(() => { - if (!open || !project?.id) return; - - void refreshActivity(); - }, [open, project?.id, refreshActivity]); - - useEffect(() => { - if (!open || !project?.id || !canManageInternalPricing) return; - - let cancelled = false; - - const loadPricing = async () => { - try { - setPricingLoading(true); - setPricingError(null); - const items = await loadProjectPricingItems(project.id); - const inputItems = toPricingInputItems(items); - - if (!cancelled) { - setInternalAmountManuallyEdited( - hasManualInternalAmountOverride(project.monto, inputItems), - ); - setPricingItems(inputItems); - } - } catch (error) { - console.warn("No se pudo cargar el tarifario del proyecto:", error); - - if (!cancelled) { - setPricingItems([]); - setPricingError( - "No se pudieron cargar los costos guardados. Revisa que el SQL del tarifario esté ejecutado en Supabase.", - ); - } - } finally { - if (!cancelled) { - setPricingLoading(false); - } - } - }; - - void loadPricing(); - - return () => { - cancelled = true; - }; - }, [canManageInternalPricing, open, project?.id, project?.monto]); - - const set = (k: K, v: (typeof form)[K]) => - setForm((f) => ({ ...f, [k]: v })); - - useEffect(() => { - if (!canManageInternalPricing || !pricingItems.length || internalAmountManuallyEdited) return; - - const nextAmount = Number(pricingTotal.toFixed(2)); - setForm((current) => - current.monto === nextAmount ? current : { ...current, monto: nextAmount }, - ); - }, [canManageInternalPricing, internalAmountManuallyEdited, pricingItems.length, pricingTotal]); - - const getCountryManager = (bu: string) => { - const direct = lists.buCm[bu]; - if (direct) return direct; - - const buKey = normalizeOptionKey(bu); - const match = Object.entries(lists.buCm).find(([key]) => normalizeOptionKey(key) === buKey); - - return match?.[1] ?? ""; - }; - - // BU → Country Manager automático desde Supabase tablero_cdc_app_lists, con fallback local. - const onCountries = (countries: string[]) => { - const cleanCountries = dedupeOptions( - countries.map((country) => canonicalOptionLabel(country)).filter(Boolean), - ); - const managers = dedupeOptions(cleanCountries.map(getCountryManager).filter(Boolean)); - - setForm((f) => ({ - ...f, - bu: joinCountryValues(cleanCountries), - cm: managers.join(", "), - })); - }; - - const onCliente = (cliente: string) => { - const cleanCliente = canonicalOptionLabel(cliente); - - setForm((f) => ({ - ...f, - cliente: cleanCliente, - ...(isWalmartConnectClient(cleanCliente) && !isEdit - ? { bu: "", cm: "" } - : f.cliente && isWalmartConnectClient(f.cliente) - ? { bu: "", cm: "" } - : {}), - })); - - if (!isWalmartConnectClient(cleanCliente)) { - setSelectedWmcCountries([]); - setWmcCountrySearch(""); - } - }; - - const isWalmartConnectCreate = !isEdit && isWalmartConnectClient(form.cliente); - const selectedCreateCountries = isWalmartConnectCreate ? selectedWmcCountries : selectedCountries; - const isMultiCountryCreate = !isEdit && selectedCreateCountries.length > 1; - - const toggleWmcCountry = (country: string, checked: boolean) => { - const cleanCountry = canonicalOptionLabel(country); - - setSelectedWmcCountries((current) => { - if (checked) { - return dedupeOptions([...current, cleanCountry]); - } - - return current.filter( - (item) => normalizeOptionKey(item) !== normalizeOptionKey(cleanCountry), - ); - }); - }; - - const selectedWmcCountryManagers = selectedWmcCountries.map((country) => ({ - country, - manager: getCountryManager(country), - })); - const selectedCountryManagers = selectedCountries.map((country) => ({ - country, - manager: getCountryManager(country), - })); - - const requiredOk = - form.nombre.trim() && - form.cliente && - form.marca && - form.solicitante.trim() && - (isWalmartConnectCreate ? selectedWmcCountries.length > 0 : selectedCountries.length > 0); - - const showSaveToast = (result: Awaited>) => { - if (result.sheetSyncStatus === "synced") { - toast.success(isEdit ? "Proyecto actualizado" : "Proyecto creado", { - description: "Se guardó y se sincronizó con el Google Sheet.", - }); - return; - } - - if (result.sheetSyncStatus === "skipped") { - toast.success(isEdit ? "Proyecto actualizado" : "Proyecto creado", { - description: "Se guardó. La sincronización con Google Sheet no está configurada.", - }); - return; - } - - toast.warning("Proyecto guardado con aviso", { - description: result.sheetSyncMessage, - }); - }; - - const addActivityNote = async () => { - if (!project?.id || !activityNote.trim() || addingActivityNote) return; - - try { - setAddingActivityNote(true); - const result = await addProjectActivityNote(project.id, activityNote); - set("comentarios", result.comments); - setActivityNote(""); - await refreshActivity(); - - if (result.sheetSyncStatus === "synced") { - toast.success("Comentario agregado al historial", { - description: "También se actualizó la columna Comentarios del Google Sheet.", - }); - } else if (result.sheetSyncStatus === "skipped") { - toast.success("Comentario agregado al historial", { - description: - "Se guardó como comentario interno. La sincronización con Sheet no está configurada.", - }); - } else { - toast.warning("Comentario agregado con aviso", { - description: result.sheetSyncMessage, - }); - } - } catch (error) { - console.error("Error agregando comentario al historial:", error); - const message = - error instanceof Error ? error.message : "No se pudo agregar el comentario al historial."; - toast.error("No se pudo agregar el comentario", { description: message }); - } finally { - setAddingActivityNote(false); - } - }; - - const submit = async () => { - if (!requiredOk || saving) return; - - const safeForm = isGerardo - ? form - : canManageInternalPricing - ? { - ...form, - status: project?.status ?? "", - } - : { - ...form, - status: project?.status ?? "", - monto: project?.monto ?? null, - }; - - try { - setSaving(true); - setFormError(null); - setSavingStep("Guardando y sincronizando con Google Sheet…"); - - if (isMultiCountryCreate) { - const countries = selectedCreateCountries; - const results = []; - - for (const [index, country] of countries.entries()) { - setSavingStep(`Guardando país ${index + 1} de ${countries.length}: ${country}…`); - - const countryProject = { - ...safeForm, - bu: country, - cm: getCountryManager(country), - id: crypto.randomUUID(), - createdAt: Date.now() + index, - }; - - results.push( - await add({ - ...countryProject, - pricingItems: canManageInternalPricing ? pricingItems : undefined, - }), - ); - } - - const failedSyncs = results.filter((result) => result.sheetSyncStatus !== "synced"); - - if (failedSyncs.length === 0) { - toast.success("Proyectos multipaís creados", { - description: `Se crearon ${countries.length} proyectos y ${countries.length} filas en Google Sheet. El Interno Cargado se aplicó por país.`, - }); - } else { - toast.warning("Proyectos multipaís creados con aviso", { - description: `${failedSyncs.length} de ${countries.length} sincronizaciones con Google Sheet requieren revisión.`, - }); - } - } else { - const result = - isEdit && project - ? await update(project.id, { - ...safeForm, - pricingItems: canManageInternalPricing ? pricingItems : undefined, - }) - : await add({ - ...safeForm, - id: crypto.randomUUID(), - createdAt: Date.now(), - pricingItems: canManageInternalPricing ? pricingItems : undefined, - }); - - showSaveToast(result); - } - - onOpenChange(false); - } catch (error) { - console.error("Error guardando proyecto:", error); - const message = - error instanceof Error - ? error.message - : "No se pudo guardar el proyecto. Revisa los permisos o intenta de nuevo."; - - setFormError(message); - toast.error("No se pudo guardar el proyecto", { - description: message, - }); - } finally { - setSaving(false); - setSavingStep(null); - } - }; - - const closed = !!form.status; - - return ( - - - {/* Pestaña de color superior */} -
- -
- -
-
- - {isEdit ? form.nombre || "Sin título" : "Nuevo proyecto"} - - - - {form.mes} · {form.anio} - {closed && ( - - Cerrado - - )} - -
-
-
- -
- {/* Nombre */} -
- - set("nombre", e.target.value)} - placeholder="Ej: Promoción Día de las Madres" - className="h-10" - /> -
- - {/* Color */} -
- - set("color", c)} /> -
- - - - {(listsLoading || listsError) && ( -
- - {listsLoading ? ( - <> - - Cargando listas… - - ) : ( - <> - - No se pudieron cargar las listas. Se usaron opciones locales. - - )} - - - {listsError && ( - - )} -
- )} - - {/* Cliente */} -
- - -
- - {/* Marca */} -
- - set("marca", joinBrandValues(values))} - placeholder="Seleccionar una o varias marcas…" - searchPlaceholder="Buscar marca…" - emptyText="No se encontró esa marca." - summaryLabel="marcas seleccionadas" - /> -
- - {/* BU */} - {isWalmartConnectCreate ? ( -
-
- - - {selectedWmcCountries.length} seleccionado(s) - -
-

- Para Walmart Connect WMC puedes seleccionar varios países. La app creará un - proyecto y una fila del Sheet por cada país, como hasta ahora. -

-
- - setWmcCountrySearch(e.target.value)} - placeholder="Buscar país…" - className="h-9 pl-9 bg-card" - /> -
-
- {filteredWmcBuOptions.map((b) => { - const checked = selectedWmcCountries.some( - (country) => normalizeOptionKey(country) === normalizeOptionKey(b), - ); - const manager = getCountryManager(b); - - return ( - - ); - })} -
-
- ) : ( -
- - -

- Si seleccionas varios países al crear, la app creará un proyecto y una fila del - Sheet por cada país. -

-
- )} - - {/* CM autocompletado */} -
- - {isWalmartConnectCreate ? ( -
- {selectedWmcCountryManagers.length === 0 ? ( - Selecciona uno o más países. - ) : ( -
- {selectedWmcCountryManagers.map(({ country, manager }) => ( - - {country}: {manager || "—"} - - ))} -
- )} -
- ) : ( -
- {selectedCountryManagers.length === 0 ? ( - Selecciona uno o más países. - ) : ( -
- {selectedCountryManagers.map(({ country, manager }) => ( - - {country}: {manager || "—"} - - ))} -
- )} -
- )} -
- - {isMultiCountryCreate && canManageInternalPricing && ( -
- Si seleccionas varios países, el Interno Cargado se aplicará por cada país. La app - no divide el monto automáticamente. -
- )} - - {/* Solicitante */} -
- - set("solicitante", e.target.value)} - placeholder="Nombre de quien solicita" - className="h-10" - /> -
- - - - - - {/* Brief link único */} -
- - set("briefLink", e.target.value)} - placeholder="https://drive.google.com/…" - className="h-10" - /> -
- - {/* Propuestas */} -
- set("propuestaLinks", v)} - emptyText="Aún no hay propuestas. Pega tantos links como necesites." - /> -
- - {/* Artes Finales */} -
- set("afLinks", v)} - emptyText="Aún no hay artes finales." - /> -
- - - - {canManageInternalPricing && ( - - )} - - - - {/* Status: Director Creativo edita; el equipo solo visualiza */} -
- - - {isGerardo ? ( - - ) : ( -
- {form.status || "Sin estatus"} -
- )} - - {!isGerardo && ( -

- Solo el Director Creativo puede cambiar el estatus. -

- )} -
- - {/* Monto: SOLO visible para usuarios internos autorizados */} - {canManageInternalPricing && ( -
- - { - setInternalAmountManuallyEdited(true); - set("monto", e.target.value === "" ? null : Number(e.target.value)); - }} - placeholder="0.00" - className="h-10" - /> -

- Privado · solo usuarios internos autorizados. - {internalAmountManuallyEdited && pricingItems.length > 0 - ? " Monto ajustado manualmente; se respetará al guardar." - : pricingItems.length > 0 - ? " El total del tarifario se refleja aquí y puedes ajustarlo." - : ""} -

-
- )} -
- - {(formError || savingStep) && ( -
- {formError ? ( - - ) : ( - - )} - {formError || savingStep} -
- )} -
- - -
-
- {isEdit && project && canDeleteProjects && onDelete && ( - - )} -
- -
- - -
-
-
- -
- ); -} - -function ProjectActivityPanel({ - isEdit, - items, - loading, - error, - note, - hasMore, - loadingMore, - addingNote, - onNoteChange, - onAddNote, - onRefresh, - onLoadMore, -}: { - isEdit: boolean; - items: ProjectActivity[]; - loading: boolean; - error: string | null; - note: string; - hasMore: boolean; - loadingMore: boolean; - addingNote: boolean; - onNoteChange: (value: string) => void; - onAddNote: () => Promise; - onRefresh: () => Promise; - onLoadMore: () => Promise; -}) { - return ( -
-
-
- - - -
-

Actividad del proyecto

-

- Comentarios y movimientos recientes con fecha. -

-
-
- - {isEdit && ( - - )} -
- - {!isEdit ? ( -
- El historial se activará cuando el proyecto sea creado. -
- ) : ( -
-
- -
-