Actualizar Cruce de Cuentas: Panama, Reportar y dist validado
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
-- ============================================================
|
||||
-- CRUCE DE CUENTAS GLM · LIMPIEZA ÚNICA DE HISTÓRICOS PANAMÁ
|
||||
-- ============================================================
|
||||
-- Objetivo:
|
||||
-- Eliminar únicamente los reportes de prueba de Panamá (PA) que ya aparecen
|
||||
-- en "Reportes Históricos" y las correcciones BambooHR asociadas a esos
|
||||
-- reportes. NO elimina aliases BambooHR ni datos de Guatemala/Trinidad.
|
||||
--
|
||||
-- Ejecutar UNA SOLA VEZ en el SQL Editor de Supabase antes de entregar Panamá
|
||||
-- a los usuarios finales.
|
||||
|
||||
begin;
|
||||
|
||||
-- Guardamos temporalmente los reportes PA que serán eliminados para poder
|
||||
-- limpiar también correcciones ligadas por execution_id o report_url.
|
||||
create temporary table tmp_cruce_cuentas_pa_reportes_a_borrar
|
||||
on commit drop
|
||||
as
|
||||
select
|
||||
r.id::text as report_id,
|
||||
r.report_url
|
||||
from public.cruces_cuentas_gt_reportes r
|
||||
where upper(trim(coalesce(r.country, 'GT'))) = 'PA';
|
||||
|
||||
-- Elimina solamente correcciones vinculadas a los reportes PA de prueba.
|
||||
delete from public.cruce_cuentas_bamboo_correcciones c
|
||||
using tmp_cruce_cuentas_pa_reportes_a_borrar r
|
||||
where upper(trim(coalesce(c.pais, ''))) = 'PA'
|
||||
and (
|
||||
trim(coalesce(c.execution_id, '')) = r.report_id
|
||||
or (
|
||||
nullif(trim(coalesce(c.report_url, '')), '') is not null
|
||||
and nullif(trim(coalesce(r.report_url, '')), '') is not null
|
||||
and c.report_url = r.report_url
|
||||
)
|
||||
);
|
||||
|
||||
-- Elimina los reportes históricos de Panamá. Los futuros reportes PA se
|
||||
-- guardarán normalmente después de esta limpieza.
|
||||
delete from public.cruces_cuentas_gt_reportes r
|
||||
where upper(trim(coalesce(r.country, 'GT'))) = 'PA';
|
||||
|
||||
commit;
|
||||
|
||||
-- Validación final: debe devolver 0.
|
||||
select count(*) as reportes_panama_restantes
|
||||
from public.cruces_cuentas_gt_reportes r
|
||||
where upper(trim(coalesce(r.country, 'GT'))) = 'PA';
|
||||
@@ -0,0 +1,425 @@
|
||||
{
|
||||
"name": "Cruce de Cuentas GLM - Reporte de Usuario Seguro",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "cruce-cuentas-reportar",
|
||||
"responseMode": "responseNode",
|
||||
"options": {
|
||||
"allowedOrigins": "*"
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2.1,
|
||||
"position": [
|
||||
-1300,
|
||||
0
|
||||
],
|
||||
"id": "81186759-27e6-4ebe-9ac8-ff366beb688a",
|
||||
"name": "Webhook - Reportar"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const body = $json.body ?? {};\nconst clean = (value, max = 5000) => typeof value === 'string' ? value.trim().slice(0, max) : '';\nlet payload = {};\ntry {\n payload = typeof body.payload === 'string' ? JSON.parse(body.payload) : (body.payload ?? body);\n} catch {\n throw new Error('El reporte contiene datos inválidos.');\n}\nconst accessToken = clean(body.access_token ?? payload.access_token, 12000);\nif (!accessToken) throw new Error('Falta sesión Supabase.');\nconst message = clean(payload.message, 5000);\nif (!message) throw new Error('El reporte no contiene descripción.');\nconst country = clean(payload.country, 10).toUpperCase();\nconst countryName = clean(payload.countryName, 120);\nconst section = clean(payload.section, 160);\nconst pageUrl = clean(payload.pageUrl, 1200);\nconst claimedEmail = clean(payload.userEmail, 320).toLowerCase();\nconst claimedName = clean(payload.userName, 200);\nconst submittedAt = clean(payload.submittedAt, 100) || new Date().toISOString();\nconst appSessionId = clean(payload.appSessionId, 250) || `report-${Date.now()}`;\nreturn [{json:{accessToken,auth:`Bearer ${accessToken}`,message,country,countryName,section,pageUrl,claimedEmail,claimedName,submittedAt,appSessionId}}];"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
-860,
|
||||
0
|
||||
],
|
||||
"id": "27b7c774-8dfa-445f-afc3-11a93e50f560",
|
||||
"name": "Normalizar reporte"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "GET",
|
||||
"url": "https://dbit.digitalcompass.agency/auth/v1/user",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "apikey",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "={{ $json.auth }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"timeout": 20000
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
-660,
|
||||
0
|
||||
],
|
||||
"id": "c2e85c6f-8afc-4fb2-b068-0a6493025778",
|
||||
"name": "Supabase - validar JWT",
|
||||
"continueOnFail": true
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const req = $node[\"Normalizar reporte\"].json;\nconst u = $json ?? {};\nconst id = String(u.id || '');\nconst email = String(u.email || '').trim().toLowerCase();\nconst jwtValid = Boolean(id && email.endsWith('@gomezleemarketing.com') && (!req.claimedEmail || req.claimedEmail === email));\nreturn [{json:{...req,userId:id,verifiedEmail:email,jwtValid}}];"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
-440,
|
||||
0
|
||||
],
|
||||
"id": "05b99e78-b980-43c9-9cf5-3032bd415c1f",
|
||||
"name": "Validar identidad"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "strict",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "a9aae0ad-df78-43c8-8bee-eea1e1575b5b",
|
||||
"leftValue": "={{ $json.jwtValid }}",
|
||||
"rightValue": true,
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
-220,
|
||||
0
|
||||
],
|
||||
"id": "0766f1f7-f2e8-4145-b341-28aa6df055a3",
|
||||
"name": "JWT válido?"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ {ok:false,message:'Sesión inválida.'} }}",
|
||||
"options": {
|
||||
"responseCode": 401
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.4,
|
||||
"position": [
|
||||
0,
|
||||
240
|
||||
],
|
||||
"id": "94e6a9ad-c56c-4668-b10b-ac2a50b2c81e",
|
||||
"name": "Responder 401"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://dbit.digitalcompass.agency/rest/v1/rpc/cruce_cuentas_mi_acceso",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "apikey",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "={{ $json.auth }}"
|
||||
},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"contentType": "raw",
|
||||
"rawContentType": "application/json",
|
||||
"body": "={{ JSON.stringify({}) }}",
|
||||
"options": {
|
||||
"timeout": 20000
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
0,
|
||||
-80
|
||||
],
|
||||
"id": "6d6eec3a-ccb8-45ad-83e5-d3f383515854",
|
||||
"name": "Supabase - acceso",
|
||||
"continueOnFail": true
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const req = $node[\"Validar identidad\"].json;\nconst access = Array.isArray($json) ? ($json[0] ?? {}) : ($json ?? {});\nconst email = String(access.email || '').trim().toLowerCase();\nconst accessAllowed = Boolean(\n access.ok === true &&\n access.autenticado === true &&\n access.autorizado === true &&\n access.activo === true &&\n access.acceso_app === true &&\n (!email || email === req.verifiedEmail)\n);\nconst fullName = String(access.nombre || req.claimedName || req.verifiedEmail).trim();\nreturn [{json:{...req,accessAllowed,fullName}}];"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
-80
|
||||
],
|
||||
"id": "ef8bb308-ff0f-46c9-ae6e-aa42d1b73f18",
|
||||
"name": "Validar acceso"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "strict",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "69a74499-ad7d-4093-9921-133166609367",
|
||||
"leftValue": "={{ $json.accessAllowed }}",
|
||||
"rightValue": true,
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
440,
|
||||
-80
|
||||
],
|
||||
"id": "a52dcf62-a04e-4aa2-bbb1-bca89d4bffbe",
|
||||
"name": "Acceso permitido?"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ {ok:false,message:'No tienes acceso activo a Cruce de Cuentas GLM.'} }}",
|
||||
"options": {
|
||||
"responseCode": 403
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.4,
|
||||
"position": [
|
||||
660,
|
||||
220
|
||||
],
|
||||
"id": "39556e33-18ff-4bd8-a488-50082d26f3f7",
|
||||
"name": "Responder 403"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const x = $node[\"Validar acceso\"].json;\nconst esc = v => String(v ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\"/g,'"').replace(/'/g,''');\nlet date = x.submittedAt;\ntry {\n date = new Intl.DateTimeFormat('es-DO',{dateStyle:'long',timeStyle:'short',timeZone:'America/Santo_Domingo'}).format(new Date(x.submittedAt));\n} catch {}\nconst countryLabel = x.countryName || x.country || 'No especificado';\nconst sectionLabel = x.section || 'No especificada';\nconst pageLabel = x.pageUrl || 'No especificada';\nconst html = `<!doctype html><html lang=\"es\"><body style=\"margin:0;background:#F5F5F5;font-family:Arial,Helvetica,sans-serif;color:#4A4A4A\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"padding:28px 12px;background:#F5F5F5\"><tr><td align=\"center\"><table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"max-width:680px;background:#fff;border:1px solid #D0D0D0\"><tr><td style=\"height:4px;background:#6CC24A\"></td></tr><tr><td style=\"padding:28px 34px 10px\"><img src=\"https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png\" width=\"190\" alt=\"GomezLee Marketing\" style=\"display:block;max-width:100%;height:auto\"></td></tr><tr><td style=\"padding:18px 34px 0\"><div style=\"font-size:11px;font-weight:700;letter-spacing:1.5px;color:#6CC24A\">CRUCE DE CUENTAS GLM · REPORTE DE USUARIO</div><h1 style=\"margin:8px 0 0;font-size:24px;line-height:30px;color:#4F758B\">Nuevo reporte recibido</h1></td></tr><tr><td style=\"padding:24px 34px 0\"><table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:collapse;font-size:13px\"><tr><td style=\"width:130px;padding:9px 12px;background:#EEF6E8;border:1px solid #D0D0D0;font-weight:700;color:#4F758B\">Usuario</td><td style=\"padding:9px 12px;border:1px solid #D0D0D0\">${esc(x.fullName)}</td></tr><tr><td style=\"padding:9px 12px;background:#EEF6E8;border:1px solid #D0D0D0;font-weight:700;color:#4F758B\">Correo</td><td style=\"padding:9px 12px;border:1px solid #D0D0D0\">${esc(x.verifiedEmail)}</td></tr><tr><td style=\"padding:9px 12px;background:#EEF6E8;border:1px solid #D0D0D0;font-weight:700;color:#4F758B\">País</td><td style=\"padding:9px 12px;border:1px solid #D0D0D0\">${esc(countryLabel)}</td></tr><tr><td style=\"padding:9px 12px;background:#EEF6E8;border:1px solid #D0D0D0;font-weight:700;color:#4F758B\">Sección</td><td style=\"padding:9px 12px;border:1px solid #D0D0D0\">${esc(sectionLabel)}</td></tr><tr><td style=\"padding:9px 12px;background:#EEF6E8;border:1px solid #D0D0D0;font-weight:700;color:#4F758B\">Fecha</td><td style=\"padding:9px 12px;border:1px solid #D0D0D0\">${esc(date)}</td></tr><tr><td style=\"padding:9px 12px;background:#EEF6E8;border:1px solid #D0D0D0;font-weight:700;color:#4F758B\">Página</td><td style=\"padding:9px 12px;border:1px solid #D0D0D0;word-break:break-all\">${esc(pageLabel)}</td></tr></table></td></tr><tr><td style=\"padding:24px 34px 0\"><div style=\"font-size:11px;font-weight:700;letter-spacing:1px;color:#4F758B;border-bottom:2px solid #6CC24A;padding-bottom:7px\">DESCRIPCIÓN DEL REPORTE</div><div style=\"margin-top:14px;padding:16px 18px;background:#F5F5F5;border:1px solid #D0D0D0;font-size:14px;line-height:22px\">${esc(x.message).replace(/\\n/g,'<br>')}</div></td></tr><tr><td style=\"padding:28px 34px 30px\"><div style=\"border-top:1px solid #D0D0D0;padding-top:12px;font-size:11px;font-style:italic;color:#6B8FA3\">CONFIDENCIAL — Uso interno GomezLee Marketing · Mensaje generado automáticamente desde Cruce de Cuentas GLM.</div></td></tr></table></td></tr></table></body></html>`;\nreturn [{json:{...x,to:'iaracena@gomezleemarketing.com',subject:`[Cruce de Cuentas GLM] Reporte de usuario - ${x.fullName}`,html}}];"
|
||||
},
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
660,
|
||||
-120
|
||||
],
|
||||
"id": "7a5486b4-6f39-47e8-b09a-ce1ad24f26b5",
|
||||
"name": "Preparar correo GLM"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"sendTo": "={{ $json.to }}",
|
||||
"subject": "={{ $json.subject }}",
|
||||
"emailType": "html",
|
||||
"message": "={{ $json.html }}",
|
||||
"options": {
|
||||
"appendAttribution": false,
|
||||
"senderName": "Cruce de Cuentas GLM"
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.gmail",
|
||||
"typeVersion": 2.1,
|
||||
"position": [
|
||||
880,
|
||||
-120
|
||||
],
|
||||
"id": "09b4ac16-81d2-42c2-bda7-8f39b8ab418a",
|
||||
"name": "Enviar a Isaac",
|
||||
"credentials": {
|
||||
"gmailOAuth2": {
|
||||
"id": "UDcO1FLJqA453V2D",
|
||||
"name": "Gmail account 3"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ {ok:true,message:'Reporte enviado correctamente.'} }}",
|
||||
"options": {
|
||||
"responseCode": 200
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.4,
|
||||
"position": [
|
||||
1320,
|
||||
-120
|
||||
],
|
||||
"id": "de604d57-2d1f-44af-9342-d886166cb9fd",
|
||||
"name": "Responder OK"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook - Reportar": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Normalizar reporte",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Normalizar reporte": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Supabase - validar JWT",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Supabase - validar JWT": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Validar identidad",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Validar identidad": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "JWT válido?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"JWT válido?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Supabase - acceso",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Responder 401",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Supabase - acceso": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Validar acceso",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Validar acceso": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Acceso permitido?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Acceso permitido?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Preparar correo GLM",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Responder 403",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Preparar correo GLM": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Enviar a Isaac",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Enviar a Isaac": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Responder OK",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"active": false,
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": false
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
Vendored
-265
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -9,8 +9,8 @@
|
||||
<title>Portal de Cruce de Cuentas GLM</title>
|
||||
<link rel="icon" type="image/png" href="/cruce-cuentas/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/cruce-cuentas/favicon.png" />
|
||||
<script type="module" crossorigin src="/cruce-cuentas/assets/index-BiDl5Fxc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/cruce-cuentas/assets/index-BybZhu8M.css">
|
||||
<script type="module" crossorigin src="/cruce-cuentas/assets/index-D9yGuvWp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/cruce-cuentas/assets/index-CDcNm7E6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>Portal de Cruce de Cuentas GLM</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||
|
||||
@@ -55,6 +55,7 @@ export default function App() {
|
||||
const [currentView, setCurrentView] = useState<ViewType>('cruce');
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [uploadedPayroll, setUploadedPayroll] = useState<UploadedFile | null>(null);
|
||||
const [uploadedPayrollFiles, setUploadedPayrollFiles] = useState<UploadedFile[]>([]);
|
||||
const [uploadedBankFiles, setUploadedBankFiles] = useState<UploadedFile[]>([]);
|
||||
const [isCruceExecuted, setIsCruceExecuted] = useState(false);
|
||||
const [cruceResponse, setCruceResponse] = useState<ReconciliationResponse | null>(null);
|
||||
@@ -235,6 +236,7 @@ export default function App() {
|
||||
}
|
||||
setCurrentView(draft.currentView);
|
||||
setUploadedPayroll(draft.uploadedPayroll);
|
||||
setUploadedPayrollFiles(Array.isArray(draft.uploadedPayrollFiles) ? draft.uploadedPayrollFiles : []);
|
||||
setUploadedBankFiles(draft.uploadedBankFiles);
|
||||
setIsCruceExecuted(draft.isCruceExecuted);
|
||||
setCruceResponse(draft.cruceResponse);
|
||||
@@ -262,6 +264,7 @@ export default function App() {
|
||||
countryCode: selectedCountryCode,
|
||||
currentView,
|
||||
uploadedPayroll,
|
||||
uploadedPayrollFiles,
|
||||
uploadedBankFiles,
|
||||
isCruceExecuted,
|
||||
cruceResponse,
|
||||
@@ -271,6 +274,7 @@ export default function App() {
|
||||
selectedCountryCode,
|
||||
currentView,
|
||||
uploadedPayroll,
|
||||
uploadedPayrollFiles,
|
||||
uploadedBankFiles,
|
||||
isCruceExecuted,
|
||||
cruceResponse,
|
||||
@@ -278,6 +282,7 @@ export default function App() {
|
||||
|
||||
const clearCurrentCruce = () => {
|
||||
setUploadedPayroll(null);
|
||||
setUploadedPayrollFiles([]);
|
||||
setUploadedBankFiles([]);
|
||||
setIsCruceExecuted(false);
|
||||
setCruceResponse(null);
|
||||
@@ -411,6 +416,8 @@ export default function App() {
|
||||
onShowToast={handleShowToast}
|
||||
uploadedPayroll={uploadedPayroll}
|
||||
setUploadedPayroll={setUploadedPayroll}
|
||||
uploadedPayrollFiles={uploadedPayrollFiles}
|
||||
setUploadedPayrollFiles={setUploadedPayrollFiles}
|
||||
uploadedBankFiles={uploadedBankFiles}
|
||||
setUploadedBankFiles={setUploadedBankFiles}
|
||||
isCruceExecuted={isCruceExecuted}
|
||||
|
||||
@@ -63,13 +63,11 @@ export default function BambooCorrectionModal({
|
||||
setConfirmed(false);
|
||||
}, [row.id, suggested?.employeeName, suggested?.employeeNumber]);
|
||||
|
||||
const recipientLabel = useMemo(
|
||||
() =>
|
||||
country.code === 'TT'
|
||||
? 'Laurele Nicome-Jack'
|
||||
: 'Pablo Gamboa Cú, Sara Meyer Lima y María Morales Adler',
|
||||
[country.code],
|
||||
);
|
||||
const recipientLabel = useMemo(() => {
|
||||
if (country.code === 'TT') return 'Laurele Nicome-Jack';
|
||||
if (country.code === 'PA') return 'Delarys Peñalba y Ariadne Cortéz';
|
||||
return 'Pablo Gamboa Cú, Sara Meyer Lima y María Morales Adler';
|
||||
}, [country.code]);
|
||||
|
||||
const sourceNames = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function CountrySelector({ onSelect }: CountrySelectorProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{COUNTRIES.map((country) => (
|
||||
<button
|
||||
key={country.code}
|
||||
|
||||
+241
-24
@@ -18,6 +18,7 @@ import {
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import BambooCorrectionModal from './BambooCorrectionModal';
|
||||
import ReportIssueButton from './ReportIssueButton';
|
||||
import { createClientRequestId, ensureFreshSupabaseSession, warmRemoteOrigin, withFreshRequestQuery } from '../lib/sessionHealth';
|
||||
import {
|
||||
CountryConfig,
|
||||
@@ -33,6 +34,8 @@ interface DashboardProps {
|
||||
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||
uploadedPayroll: UploadedFile | null;
|
||||
setUploadedPayroll: (file: UploadedFile | null) => void;
|
||||
uploadedPayrollFiles: UploadedFile[];
|
||||
setUploadedPayrollFiles: (files: UploadedFile[]) => void;
|
||||
uploadedBankFiles: UploadedFile[];
|
||||
setUploadedBankFiles: (files: UploadedFile[]) => void;
|
||||
isCruceExecuted: boolean;
|
||||
@@ -74,6 +77,13 @@ function getReconciliationUrl(countryCode: string) {
|
||||
return (import.meta.env.VITE_N8N_TRINIDAD_WEBHOOK_URL as string | undefined) || '';
|
||||
}
|
||||
|
||||
if (countryCode === 'PA') {
|
||||
return (
|
||||
(import.meta.env.VITE_N8N_PANAMA_WEBHOOK_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/nominapa-bamboo-test'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(import.meta.env.VITE_N8N_GUATEMALA_WEBHOOK_URL as string | undefined) ||
|
||||
(import.meta.env.VITE_N8N_WEBHOOK_URL as string | undefined) ||
|
||||
@@ -128,6 +138,15 @@ function isCsvFile(file: File) {
|
||||
return file.name.toLowerCase().endsWith('.csv');
|
||||
}
|
||||
|
||||
function toUploadedPayrollFiles(files: FileList | File[]) {
|
||||
return Array.from(files).map((file) => ({
|
||||
name: file.name,
|
||||
size: formatBytes(file.size),
|
||||
type: 'payroll' as const,
|
||||
rawFile: file,
|
||||
}));
|
||||
}
|
||||
|
||||
function toUploadedBankFiles(files: FileList | File[]) {
|
||||
return Array.from(files).map((file) => ({
|
||||
name: file.name,
|
||||
@@ -141,6 +160,12 @@ function toIsoDate(year: number, month: number, day: number) {
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatPeriodDate(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!match) return value;
|
||||
return `${match[3]}-${match[2]}-${match[1]}`;
|
||||
}
|
||||
|
||||
function getPeriodRange(year: number, month: number, periodType: PeriodType): PeriodRange {
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const monthLabel = MONTHS.find((m) => m.value === month)?.label ?? 'Mes';
|
||||
@@ -746,6 +771,8 @@ export default function Dashboard({
|
||||
onShowToast,
|
||||
uploadedPayroll,
|
||||
setUploadedPayroll,
|
||||
uploadedPayrollFiles,
|
||||
setUploadedPayrollFiles,
|
||||
uploadedBankFiles,
|
||||
setUploadedBankFiles,
|
||||
isCruceExecuted,
|
||||
@@ -759,6 +786,7 @@ export default function Dashboard({
|
||||
country,
|
||||
}: DashboardProps) {
|
||||
const defaultPeriod = useMemo(() => readStoredPeriod(country.code), [country.code]);
|
||||
const isPanama = country.code === 'PA';
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [processingStep, setProcessingStep] = useState('');
|
||||
const [filterMode, setFilterMode] = useState<
|
||||
@@ -926,15 +954,7 @@ export default function Dashboard({
|
||||
setFilterMode('todos');
|
||||
};
|
||||
|
||||
const applyPayrollFile = async (file: File) => {
|
||||
setUploadedPayroll({
|
||||
name: file.name,
|
||||
size: formatBytes(file.size),
|
||||
type: 'payroll',
|
||||
rawFile: file,
|
||||
});
|
||||
resetResults();
|
||||
|
||||
const applyDetectedPayrollPeriod = async (file: File, count = 1) => {
|
||||
const detected = await analyzePayrollFilename(file.name, country);
|
||||
const completeDetection =
|
||||
Number.isInteger(detected.year) &&
|
||||
@@ -946,18 +966,69 @@ export default function Dashboard({
|
||||
setSelectedMonth(detected.month!);
|
||||
setPeriodType(detected.periodType!);
|
||||
const detectedRange = getPeriodRange(detected.year!, detected.month!, detected.periodType!);
|
||||
const prefix = count > 1
|
||||
? `${count} archivos de nómina cargados.`
|
||||
: 'Nómina cargada.';
|
||||
|
||||
if (detected.requiresManualReview) {
|
||||
onShowToast(
|
||||
`Nómina cargada. Período sugerido: ${detectedRange.label}. Confirma los datos antes de ejecutar.`,
|
||||
`${prefix} Período sugerido: ${detectedRange.label}. Confirma los datos antes de ejecutar.`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
onShowToast(`Nómina cargada. Período detectado: ${detectedRange.label}.`, 'success');
|
||||
onShowToast(`${prefix} Período detectado: ${detectedRange.label}.`, 'success');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const applyPayrollFile = async (file: File) => {
|
||||
setUploadedPayroll({
|
||||
name: file.name,
|
||||
size: formatBytes(file.size),
|
||||
type: 'payroll',
|
||||
rawFile: file,
|
||||
});
|
||||
setUploadedPayrollFiles([]);
|
||||
resetResults();
|
||||
|
||||
const detected = await applyDetectedPayrollPeriod(file);
|
||||
if (!detected) {
|
||||
onShowToast('Archivo de nómina cargado con éxito.', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const addPanamaPayrollFiles = async (files: File[]) => {
|
||||
const excelFiles = files.filter(isExcelFile);
|
||||
|
||||
if (excelFiles.length === 0) {
|
||||
onShowToast('Formato inválido. Panamá requiere uno o varios archivos Excel (.xlsx, .xls).', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
onShowToast('Archivo de nómina cargado con éxito.', 'success');
|
||||
const existingKeys = new Set(
|
||||
uploadedPayrollFiles.map((file) => `${file.name}|${file.rawFile?.size ?? file.size}`)
|
||||
);
|
||||
const newFiles = toUploadedPayrollFiles(excelFiles).filter(
|
||||
(file) => !existingKeys.has(`${file.name}|${file.rawFile?.size ?? file.size}`)
|
||||
);
|
||||
|
||||
const combined = [...uploadedPayrollFiles, ...newFiles];
|
||||
setUploadedPayrollFiles(combined);
|
||||
setUploadedPayroll(null);
|
||||
resetResults();
|
||||
|
||||
const detectionFile =
|
||||
excelFiles.find((file) => /quincenal/i.test(file.name)) ||
|
||||
excelFiles[0];
|
||||
|
||||
const detected = await applyDetectedPayrollPeriod(detectionFile, combined.length);
|
||||
|
||||
if (!detected) {
|
||||
onShowToast(`${newFiles.length} archivo(s) de nómina de Panamá cargado(s) con éxito.`, 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
@@ -966,9 +1037,15 @@ export default function Dashboard({
|
||||
|
||||
const handlePayrollDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
const files = Array.from(e.dataTransfer.files) as File[];
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (isPanama) {
|
||||
await addPanamaPayrollFiles(files);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
if (isExcelFile(file)) {
|
||||
await applyPayrollFile(file);
|
||||
} else {
|
||||
@@ -992,9 +1069,15 @@ export default function Dashboard({
|
||||
};
|
||||
|
||||
const handlePayrollSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const files = Array.from(e.target.files || []) as File[];
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (isPanama) {
|
||||
await addPanamaPayrollFiles(files);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
if (isExcelFile(file)) {
|
||||
await applyPayrollFile(file);
|
||||
} else {
|
||||
@@ -1002,6 +1085,15 @@ export default function Dashboard({
|
||||
}
|
||||
};
|
||||
|
||||
const removePanamaPayrollFile = (indexToRemove: number) => {
|
||||
setUploadedPayrollFiles(
|
||||
uploadedPayrollFiles.filter((_, index) => index !== indexToRemove)
|
||||
);
|
||||
if (payrollInputRef.current) payrollInputRef.current.value = '';
|
||||
resetResults();
|
||||
onShowToast('Archivo de nómina removido.', 'info');
|
||||
};
|
||||
|
||||
const handleBankSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -1025,6 +1117,17 @@ export default function Dashboard({
|
||||
onShowToast('Planilla de banco removida.', 'info');
|
||||
};
|
||||
|
||||
const clearPanamaUploadedFiles = () => {
|
||||
setUploadedPayroll(null);
|
||||
setUploadedPayrollFiles([]);
|
||||
setUploadedBankFiles([]);
|
||||
setBankFileSearch('');
|
||||
if (payrollInputRef.current) payrollInputRef.current.value = '';
|
||||
if (bankInputRef.current) bankInputRef.current.value = '';
|
||||
resetResults();
|
||||
onShowToast('Todos los archivos cargados fueron eliminados.', 'info');
|
||||
};
|
||||
|
||||
|
||||
const applyBonus14PayrollFile = (file: File) => {
|
||||
setUploadedBonus14Payroll({
|
||||
@@ -1105,8 +1208,20 @@ export default function Dashboard({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uploadedPayroll?.rawFile) {
|
||||
onShowToast('Debes cargar la nómina antes de ejecutar el cruce.', 'error');
|
||||
const payrollFilesForRun = isPanama
|
||||
? uploadedPayrollFiles
|
||||
: (uploadedPayroll ? [uploadedPayroll] : []);
|
||||
|
||||
if (
|
||||
payrollFilesForRun.length === 0 ||
|
||||
payrollFilesForRun.some((file) => !file.rawFile)
|
||||
) {
|
||||
onShowToast(
|
||||
isPanama
|
||||
? 'Debes cargar la nómina principal y los suplementos de Panamá antes de ejecutar el cruce.'
|
||||
: 'Debes cargar la nómina antes de ejecutar el cruce.',
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1152,6 +1267,10 @@ export default function Dashboard({
|
||||
clientRequestId,
|
||||
);
|
||||
|
||||
const mainPayrollFile =
|
||||
payrollFilesForRun.find((file) => /quincenal/i.test(file.name)) ||
|
||||
payrollFilesForRun[0];
|
||||
|
||||
const metadata = {
|
||||
country: country.code,
|
||||
country_name: country.name,
|
||||
@@ -1161,7 +1280,8 @@ export default function Dashboard({
|
||||
period_label: periodRange.label,
|
||||
period_start: periodRange.start,
|
||||
period_end: periodRange.end,
|
||||
payroll_file_name: uploadedPayroll.name,
|
||||
payroll_file_name: mainPayrollFile.name,
|
||||
payroll_file_names: payrollFilesForRun.map((file) => file.name),
|
||||
bank_file_names: uploadedBankFiles.map((file) => file.name),
|
||||
requested_by_name: operatorName,
|
||||
requested_by_email: operatorEmail,
|
||||
@@ -1177,7 +1297,19 @@ export default function Dashboard({
|
||||
Object.entries(metadata).forEach(([key, value]) => {
|
||||
formData.append(key, Array.isArray(value) ? JSON.stringify(value) : String(value));
|
||||
});
|
||||
formData.append('payroll_file', uploadedPayroll.rawFile, uploadedPayroll.name);
|
||||
if (isPanama) {
|
||||
payrollFilesForRun.forEach((file, index) => {
|
||||
if (!file.rawFile) return;
|
||||
formData.append('payroll_files', file.rawFile, file.name);
|
||||
formData.append(`payroll_file_${index + 1}`, file.rawFile, file.name);
|
||||
});
|
||||
if (mainPayrollFile.rawFile) {
|
||||
formData.append('payroll_file', mainPayrollFile.rawFile, mainPayrollFile.name);
|
||||
}
|
||||
} else if (mainPayrollFile.rawFile) {
|
||||
formData.append('payroll_file', mainPayrollFile.rawFile, mainPayrollFile.name);
|
||||
}
|
||||
|
||||
uploadedBankFiles.forEach((file, index) => {
|
||||
if (file.rawFile) {
|
||||
formData.append('bank_files', file.rawFile, file.name);
|
||||
@@ -1415,8 +1547,15 @@ export default function Dashboard({
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 flex items-center" aria-label={`Bandera de ${country.name}`}>
|
||||
<div className="border border-neutral-200 bg-white px-2.5 py-2 shadow-sm">
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<ReportIssueButton
|
||||
country={country}
|
||||
operatorName={operatorName}
|
||||
operatorEmail={operatorEmail}
|
||||
section="Cruce de Cuentas"
|
||||
onShowToast={onShowToast}
|
||||
/>
|
||||
<div className="border border-neutral-200 bg-white px-2.5 py-2 shadow-sm" aria-label={`Bandera de ${country.name}`}>
|
||||
<img src={country.flag} alt={`Bandera de ${country.name}`} className="h-5 sm:h-6 w-auto object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1490,7 +1629,7 @@ export default function Dashboard({
|
||||
<CalendarDays className="w-4 h-4 text-[#4F758B] mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-black text-[#4F758B]">Período calculado:</span>{' '}
|
||||
{periodRange.start} al {periodRange.end}
|
||||
{formatPeriodDate(periodRange.start)} al {formatPeriodDate(periodRange.end)}
|
||||
<p className="text-[10px] text-neutral-500 mt-0.5">{selectedPeriodDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1500,6 +1639,21 @@ export default function Dashboard({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isPanama && (uploadedPayrollFiles.length > 0 || uploadedBankFiles.length > 0) && (
|
||||
<div className="flex justify-end" style={{ marginBottom: '-20px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearPanamaUploadedFiles}
|
||||
className="inline-flex items-center gap-2 border border-red-100 bg-white px-3 py-2 text-[10px] font-black text-red-600 hover:bg-red-50 transition-colors uppercase tracking-wide shadow-sm"
|
||||
aria-label="Borrar todos los archivos cargados"
|
||||
title="Eliminar de una vez todas las nóminas y planillas del banco cargadas"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Borrar todo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white border border-[#D0D0D0] p-6 flex flex-col justify-between relative shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-[#4F758B]" />
|
||||
@@ -1514,10 +1668,72 @@ export default function Dashboard({
|
||||
ref={payrollInputRef}
|
||||
onChange={handlePayrollSelect}
|
||||
accept=".xlsx,.xls"
|
||||
multiple={isPanama}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{uploadedPayroll ? (
|
||||
{isPanama ? (
|
||||
uploadedPayrollFiles.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="border border-[#4F758B]/20 bg-[#4F758B]/5 px-4 py-3">
|
||||
<p className="text-[10px] font-black uppercase tracking-wider text-[#4F758B]">
|
||||
{uploadedPayrollFiles.length} archivo(s) de nómina cargado(s)
|
||||
</p>
|
||||
<p className="text-[10px] text-neutral-500 mt-1">
|
||||
Incluye la quincenal y todas las planillas extraordinarias, vacaciones o XIII Mes que correspondan al período.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[260px] overflow-y-auto border border-neutral-200 divide-y divide-neutral-100">
|
||||
{uploadedPayrollFiles.map((file, index) => (
|
||||
<div key={`${file.name}-${index}`} className="bg-white px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<FileSpreadsheet className="w-6 h-6 text-[#6CC24A] flex-shrink-0" />
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-xs font-bold text-neutral-700 truncate max-w-[290px]" title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-neutral-400 font-mono mt-0.5">{file.size} • Excel</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removePanamaPayrollFile(index)}
|
||||
className="p-1.5 text-neutral-400 hover:text-red-500 hover:bg-neutral-100 transition-all rounded"
|
||||
aria-label={`Remover ${file.name}`}
|
||||
>
|
||||
<Trash2 className="w-4.5 h-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => payrollInputRef.current?.click()}
|
||||
className="w-full border border-dashed border-[#4F758B]/40 bg-white px-4 py-2.5 text-[10px] font-black text-[#4F758B] hover:bg-[#4F758B]/5 transition-colors uppercase tracking-wide"
|
||||
>
|
||||
Agregar más archivos de nómina
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handlePayrollDrop}
|
||||
onClick={() => payrollInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-[#D0D0D0] bg-neutral-50 hover:bg-[#4F758B]/5 hover:border-[#4F758B]/40 transition-colors flex flex-col items-center justify-center py-10 px-4 text-center cursor-pointer min-h-[190px]"
|
||||
>
|
||||
<Upload className="w-8 h-8 text-neutral-300 mb-2" />
|
||||
<p className="text-xs font-bold text-neutral-700">Arrastre la nómina y sus suplementos</p>
|
||||
<p className="text-[10px] text-neutral-400 mt-1 font-medium">Puede seleccionar varios .xlsx / .xls a la vez</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 border border-[#D0D0D0] bg-white px-4 py-1.5 text-[10px] font-bold text-neutral-600 hover:border-[#4F758B] hover:text-[#4F758B] transition-colors shadow-sm uppercase tracking-wide"
|
||||
>
|
||||
Seleccionar Archivos
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : uploadedPayroll ? (
|
||||
<div className="border border-[#6CC24A]/30 bg-[#6CC24A]/5 p-5 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<FileSpreadsheet className="w-8 h-8 text-[#6CC24A] flex-shrink-0" />
|
||||
@@ -1557,6 +1773,7 @@ export default function Dashboard({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { CountryConfig, HistoricalReport, PeriodRange, ReconciliationRow } from '../types';
|
||||
import BambooCorrectionModal from './BambooCorrectionModal';
|
||||
import ReportIssueButton from './ReportIssueButton';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface HistoryProps {
|
||||
@@ -32,6 +33,13 @@ function getHistoryUrl(countryCode: string) {
|
||||
return (import.meta.env.VITE_HISTORICOS_TT_URL as string | undefined) || '';
|
||||
}
|
||||
|
||||
if (countryCode === 'PA') {
|
||||
return (
|
||||
(import.meta.env.VITE_HISTORICOS_PA_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-pa-historicos'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(import.meta.env.VITE_HISTORICOS_GT_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-historicos'
|
||||
@@ -43,6 +51,13 @@ function getMarkResolvedUrl(countryCode: string) {
|
||||
return (import.meta.env.VITE_MARCAR_RESUELTO_TT_URL as string | undefined) || '';
|
||||
}
|
||||
|
||||
if (countryCode === 'PA') {
|
||||
return (
|
||||
(import.meta.env.VITE_MARCAR_RESUELTO_PA_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-pa-marcar-resuelto'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(import.meta.env.VITE_MARCAR_RESUELTO_GT_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-marcar-resuelto'
|
||||
@@ -128,7 +143,7 @@ function normalizeReports(input: unknown, country: CountryConfig): HistoricalRep
|
||||
|
||||
return {
|
||||
id: String(report.id ?? `reporte-${index + 1}`),
|
||||
country: (rawCountry === 'TT' ? 'TT' : 'GT') as HistoricalReport['country'],
|
||||
country: (rawCountry === 'TT' ? 'TT' : rawCountry === 'PA' ? 'PA' : 'GT') as HistoricalReport['country'],
|
||||
date: formatDate(report.fecha ?? report.period_end ?? rawExecutionDate, country.locale),
|
||||
executionDate: formatDateTime(rawExecutionDate, country.locale),
|
||||
period: String(report.periodo ?? report.period_label ?? report.period ?? 'Período no especificado'),
|
||||
@@ -442,7 +457,7 @@ export default function History({
|
||||
if (!isCurrentRequest()) return;
|
||||
|
||||
// Respaldo temporal: webhook histórico existente. También se envía el país
|
||||
// para impedir que se mezclen reportes de Guatemala y Trinidad y Tobago.
|
||||
// para impedir que se mezclen reportes de Guatemala, Trinidad y Tobago y Panamá.
|
||||
const historyUrl = getHistoryUrl(country.code);
|
||||
if (!historyUrl) {
|
||||
throw new Error(`No está configurado el origen histórico de ${country.name}.`);
|
||||
@@ -708,7 +723,14 @@ export default function History({
|
||||
<span className="truncate">Reportes Históricos - {country.name}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<ReportIssueButton
|
||||
country={country}
|
||||
operatorName={operatorName}
|
||||
operatorEmail={operatorEmail}
|
||||
section="Reportes Históricos"
|
||||
onShowToast={onShowToast}
|
||||
/>
|
||||
<div className="hidden sm:flex items-center border border-neutral-200 bg-white px-2.5 py-2 shadow-sm" aria-label={`Bandera de ${country.name}`}>
|
||||
<img src={country.flag} alt={`Bandera de ${country.name}`} className="h-5 sm:h-6 w-auto object-contain" />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { getAuthRedirectUrl, isSupabaseConfigured, supabase } from '../lib/supabase';
|
||||
import {
|
||||
getAuthRedirectUrl,
|
||||
isSupabaseConfigured,
|
||||
refreshAuthEntryDocumentCache,
|
||||
supabase,
|
||||
} from '../lib/supabase';
|
||||
|
||||
interface LoginProps {
|
||||
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||
@@ -18,6 +23,12 @@ export default function Login({ onShowToast }: LoginProps) {
|
||||
|
||||
setIsLoggingIn(true);
|
||||
|
||||
// El OAuth vuelve a la misma URL base de la aplicación. Antes de salir,
|
||||
// refrescamos esa entrada en la caché HTTP del navegador para evitar que el
|
||||
// retorno de Google restaure un index.html perteneciente a un despliegue
|
||||
// anterior. Si la revalidación falla, el login continúa de todos modos.
|
||||
await refreshAuthEntryDocumentCache();
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Loader2, MessageSquareWarning, Send, X } from 'lucide-react';
|
||||
import { CountryConfig } from '../types';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { ensureFreshSupabaseSession } from '../lib/sessionHealth';
|
||||
|
||||
const REPORT_APP_URL =
|
||||
(import.meta.env.VITE_REPORTAR_APP_URL as string | undefined) ||
|
||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-reportar';
|
||||
|
||||
interface ReportIssueButtonProps {
|
||||
country: CountryConfig;
|
||||
operatorName: string;
|
||||
operatorEmail: string;
|
||||
section: string;
|
||||
onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
function parseJsonSafely(text: string) {
|
||||
try {
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createReportSessionId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `report-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export default function ReportIssueButton({
|
||||
country,
|
||||
operatorName,
|
||||
operatorEmail,
|
||||
section,
|
||||
onShowToast,
|
||||
}: ReportIssueButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const closeModal = () => {
|
||||
if (isSubmitting) return;
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const description = message.trim();
|
||||
|
||||
if (!description) {
|
||||
onShowToast('Escribe una descripción del reporte.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!REPORT_APP_URL) {
|
||||
onShowToast('Falta configurar VITE_REPORTAR_APP_URL.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
onShowToast('Supabase no está configurado en esta aplicación.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const session = await ensureFreshSupabaseSession(300);
|
||||
const accessToken = session?.access_token;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Tu sesión expiró. Inicia sesión nuevamente antes de enviar el reporte.');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
message: description.slice(0, 5000),
|
||||
userName: operatorName,
|
||||
userEmail: operatorEmail,
|
||||
country: country.code,
|
||||
countryName: country.name,
|
||||
section,
|
||||
submittedAt: new Date().toISOString(),
|
||||
pageUrl: window.location.href,
|
||||
appSessionId: createReportSessionId(),
|
||||
};
|
||||
|
||||
// FormData evita una preflight CORS innecesaria. El token viaja únicamente
|
||||
// por HTTPS y el workflow de n8n lo valida contra Supabase.
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('access_token', accessToken);
|
||||
|
||||
const response = await fetch(REPORT_APP_URL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
const responsePayload = parseJsonSafely(responseText);
|
||||
|
||||
if (!response.ok || responsePayload?.ok === false) {
|
||||
const errorMessage =
|
||||
responsePayload?.message ||
|
||||
responsePayload?.error ||
|
||||
responseText ||
|
||||
'No se pudo enviar el reporte.';
|
||||
throw new Error(String(errorMessage));
|
||||
}
|
||||
|
||||
setMessage('');
|
||||
setIsOpen(false);
|
||||
onShowToast('Reporte enviado correctamente.', 'success');
|
||||
} catch (error) {
|
||||
onShowToast(
|
||||
error instanceof Error ? error.message : 'No se pudo enviar el reporte.',
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex items-center justify-center gap-2 border border-[#4F758B]/25 bg-white text-[#4F758B] h-9 px-2.5 sm:px-3 text-xs font-black hover:border-[#4F758B]/55 hover:bg-[#4F758B]/5 transition-colors shadow-sm"
|
||||
title="Reportar un error o incidencia"
|
||||
aria-label="Reportar"
|
||||
>
|
||||
<MessageSquareWarning className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Reportar</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center px-4 py-6">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/45"
|
||||
onClick={closeModal}
|
||||
aria-label="Cerrar reporte"
|
||||
/>
|
||||
|
||||
<div className="relative w-full max-w-2xl bg-white border border-[#D0D0D0] shadow-2xl">
|
||||
<div className="h-1.5 bg-[#6CC24A]" />
|
||||
|
||||
<div className="px-6 py-5 border-b border-neutral-200 flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-black text-[#4F758B]">Reporte</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
disabled={isSubmitting}
|
||||
className="p-2 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 disabled:opacity-40"
|
||||
aria-label="Cerrar modal"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<label className="block">
|
||||
<span className="block text-[10px] uppercase tracking-wider font-black text-[#4F758B] mb-1.5">
|
||||
Descripción del Reporte *
|
||||
</span>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value.slice(0, 5000))}
|
||||
rows={7}
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
placeholder="Escribe aquí el error, incidencia o mejora que deseas reportar..."
|
||||
className="w-full resize-y min-h-[150px] border border-[#D0D0D0] bg-white px-3.5 py-3 text-sm text-neutral-700 placeholder-neutral-400 focus:outline-none focus:border-[#4F758B] disabled:bg-neutral-50"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-1.5 text-right text-[10px] text-neutral-400">
|
||||
{message.length}/5000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-neutral-200 bg-neutral-50 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
disabled={isSubmitting}
|
||||
className="border border-neutral-300 bg-white text-neutral-600 px-4 py-2.5 text-xs font-bold hover:bg-neutral-100 disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isSubmitting || !message.trim()}
|
||||
className="bg-[#4F758B] hover:bg-[#3F6174] text-white px-4 py-2.5 text-xs font-black inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
<span>{isSubmitting ? 'Enviando...' : 'Enviar reporte'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,14 @@ export const COUNTRIES: CountryConfig[] = [
|
||||
locale: 'en-TT',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
code: 'PA',
|
||||
name: 'Panamá',
|
||||
flag: 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Panama.svg',
|
||||
currency: 'USD',
|
||||
locale: 'es-PA',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_COUNTRY_CODE: CountryCode = 'GT';
|
||||
|
||||
@@ -18,3 +18,44 @@ export const supabase = isSupabaseConfigured
|
||||
export function getAuthRedirectUrl() {
|
||||
return new URL(import.meta.env.BASE_URL, window.location.origin).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuerza una revalidación del documento de entrada justo antes de abandonar la
|
||||
* app para iniciar OAuth. El callback vuelve exactamente a la misma URL y, si el
|
||||
* navegador conserva un index.html anterior en su caché HTTP, puede mostrar por
|
||||
* unos segundos/una navegación una versión vieja del frontend hasta que el
|
||||
* usuario recarga manualmente.
|
||||
*
|
||||
* `cache: 'reload'` pide el HTML al servidor ignorando la copia almacenada y
|
||||
* permite que el navegador refresque su caché con la versión actualmente
|
||||
* desplegada. Es una operación best-effort: un fallo aquí nunca bloquea el
|
||||
* inicio de sesión; el OAuth continúa normalmente.
|
||||
*/
|
||||
export async function refreshAuthEntryDocumentCache() {
|
||||
if (typeof window === 'undefined' || typeof fetch === 'undefined') return;
|
||||
|
||||
const entryUrl = getAuthRedirectUrl();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
try {
|
||||
const response = await fetch(entryUrl, {
|
||||
method: 'GET',
|
||||
cache: 'reload',
|
||||
credentials: 'same-origin',
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Consumir el body completa la petición y evita dejar una respuesta
|
||||
// pendiente antes de que el navegador salga hacia Google.
|
||||
await response.text();
|
||||
} catch (error) {
|
||||
console.warn('No se pudo refrescar preventivamente la entrada de la app:', error);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface TabSessionDraft {
|
||||
countryCode: CountryCode | null;
|
||||
currentView: ViewType;
|
||||
uploadedPayroll: UploadedFile | null;
|
||||
uploadedPayrollFiles: UploadedFile[];
|
||||
uploadedBankFiles: UploadedFile[];
|
||||
isCruceExecuted: boolean;
|
||||
cruceResponse: ReconciliationResponse | null;
|
||||
|
||||
@@ -3,6 +3,16 @@ import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
// Algunos navegadores pueden restaurar una página completa desde BFCache al
|
||||
// volver de un proveedor OAuth. Si eso ocurre, recargamos una sola vez para que
|
||||
// la aplicación use siempre los assets del despliegue vigente. Una recarga
|
||||
// normal no vuelve a entrar aquí porque `persisted` será false.
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export type ViewType = 'cruce' | 'historial';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
export type CountryCode = 'GT' | 'TT';
|
||||
export type CountryCode = 'GT' | 'TT' | 'PA';
|
||||
|
||||
export interface CountryConfig {
|
||||
code: CountryCode;
|
||||
|
||||
Vendored
+4
@@ -4,12 +4,16 @@ interface ImportMetaEnv {
|
||||
readonly VITE_N8N_WEBHOOK_URL?: string;
|
||||
readonly VITE_N8N_GUATEMALA_WEBHOOK_URL?: string;
|
||||
readonly VITE_N8N_TRINIDAD_WEBHOOK_URL?: string;
|
||||
readonly VITE_N8N_PANAMA_WEBHOOK_URL?: string;
|
||||
readonly VITE_HISTORICOS_GT_URL?: string;
|
||||
readonly VITE_MARCAR_RESUELTO_GT_URL?: string;
|
||||
readonly VITE_HISTORICOS_TT_URL?: string;
|
||||
readonly VITE_MARCAR_RESUELTO_TT_URL?: string;
|
||||
readonly VITE_HISTORICOS_PA_URL?: string;
|
||||
readonly VITE_MARCAR_RESUELTO_PA_URL?: string;
|
||||
readonly VITE_ANALIZAR_NOMINA_URL?: string;
|
||||
readonly VITE_REPORTAR_BAMBOO_URL?: string;
|
||||
readonly VITE_REPORTAR_APP_URL?: string;
|
||||
readonly VITE_ENABLE_SUPABASE_AUTH?: string;
|
||||
readonly VITE_SUPABASE_URL?: string;
|
||||
readonly VITE_SUPABASE_ANON_KEY?: string;
|
||||
|
||||
Reference in New Issue
Block a user