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>
|
<title>Portal de Cruce de Cuentas GLM</title>
|
||||||
<link rel="icon" type="image/png" href="/cruce-cuentas/favicon.png" />
|
<link rel="icon" type="image/png" href="/cruce-cuentas/favicon.png" />
|
||||||
<link rel="apple-touch-icon" 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>
|
<script type="module" crossorigin src="/cruce-cuentas/assets/index-D9yGuvWp.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/cruce-cuentas/assets/index-BybZhu8M.css">
|
<link rel="stylesheet" crossorigin href="/cruce-cuentas/assets/index-CDcNm7E6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>Portal de Cruce de Cuentas GLM</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link rel="apple-touch-icon" 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 [currentView, setCurrentView] = useState<ViewType>('cruce');
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const [uploadedPayroll, setUploadedPayroll] = useState<UploadedFile | null>(null);
|
const [uploadedPayroll, setUploadedPayroll] = useState<UploadedFile | null>(null);
|
||||||
|
const [uploadedPayrollFiles, setUploadedPayrollFiles] = useState<UploadedFile[]>([]);
|
||||||
const [uploadedBankFiles, setUploadedBankFiles] = useState<UploadedFile[]>([]);
|
const [uploadedBankFiles, setUploadedBankFiles] = useState<UploadedFile[]>([]);
|
||||||
const [isCruceExecuted, setIsCruceExecuted] = useState(false);
|
const [isCruceExecuted, setIsCruceExecuted] = useState(false);
|
||||||
const [cruceResponse, setCruceResponse] = useState<ReconciliationResponse | null>(null);
|
const [cruceResponse, setCruceResponse] = useState<ReconciliationResponse | null>(null);
|
||||||
@@ -235,6 +236,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
setCurrentView(draft.currentView);
|
setCurrentView(draft.currentView);
|
||||||
setUploadedPayroll(draft.uploadedPayroll);
|
setUploadedPayroll(draft.uploadedPayroll);
|
||||||
|
setUploadedPayrollFiles(Array.isArray(draft.uploadedPayrollFiles) ? draft.uploadedPayrollFiles : []);
|
||||||
setUploadedBankFiles(draft.uploadedBankFiles);
|
setUploadedBankFiles(draft.uploadedBankFiles);
|
||||||
setIsCruceExecuted(draft.isCruceExecuted);
|
setIsCruceExecuted(draft.isCruceExecuted);
|
||||||
setCruceResponse(draft.cruceResponse);
|
setCruceResponse(draft.cruceResponse);
|
||||||
@@ -262,6 +264,7 @@ export default function App() {
|
|||||||
countryCode: selectedCountryCode,
|
countryCode: selectedCountryCode,
|
||||||
currentView,
|
currentView,
|
||||||
uploadedPayroll,
|
uploadedPayroll,
|
||||||
|
uploadedPayrollFiles,
|
||||||
uploadedBankFiles,
|
uploadedBankFiles,
|
||||||
isCruceExecuted,
|
isCruceExecuted,
|
||||||
cruceResponse,
|
cruceResponse,
|
||||||
@@ -271,6 +274,7 @@ export default function App() {
|
|||||||
selectedCountryCode,
|
selectedCountryCode,
|
||||||
currentView,
|
currentView,
|
||||||
uploadedPayroll,
|
uploadedPayroll,
|
||||||
|
uploadedPayrollFiles,
|
||||||
uploadedBankFiles,
|
uploadedBankFiles,
|
||||||
isCruceExecuted,
|
isCruceExecuted,
|
||||||
cruceResponse,
|
cruceResponse,
|
||||||
@@ -278,6 +282,7 @@ export default function App() {
|
|||||||
|
|
||||||
const clearCurrentCruce = () => {
|
const clearCurrentCruce = () => {
|
||||||
setUploadedPayroll(null);
|
setUploadedPayroll(null);
|
||||||
|
setUploadedPayrollFiles([]);
|
||||||
setUploadedBankFiles([]);
|
setUploadedBankFiles([]);
|
||||||
setIsCruceExecuted(false);
|
setIsCruceExecuted(false);
|
||||||
setCruceResponse(null);
|
setCruceResponse(null);
|
||||||
@@ -411,6 +416,8 @@ export default function App() {
|
|||||||
onShowToast={handleShowToast}
|
onShowToast={handleShowToast}
|
||||||
uploadedPayroll={uploadedPayroll}
|
uploadedPayroll={uploadedPayroll}
|
||||||
setUploadedPayroll={setUploadedPayroll}
|
setUploadedPayroll={setUploadedPayroll}
|
||||||
|
uploadedPayrollFiles={uploadedPayrollFiles}
|
||||||
|
setUploadedPayrollFiles={setUploadedPayrollFiles}
|
||||||
uploadedBankFiles={uploadedBankFiles}
|
uploadedBankFiles={uploadedBankFiles}
|
||||||
setUploadedBankFiles={setUploadedBankFiles}
|
setUploadedBankFiles={setUploadedBankFiles}
|
||||||
isCruceExecuted={isCruceExecuted}
|
isCruceExecuted={isCruceExecuted}
|
||||||
|
|||||||
@@ -63,13 +63,11 @@ export default function BambooCorrectionModal({
|
|||||||
setConfirmed(false);
|
setConfirmed(false);
|
||||||
}, [row.id, suggested?.employeeName, suggested?.employeeNumber]);
|
}, [row.id, suggested?.employeeName, suggested?.employeeNumber]);
|
||||||
|
|
||||||
const recipientLabel = useMemo(
|
const recipientLabel = useMemo(() => {
|
||||||
() =>
|
if (country.code === 'TT') return 'Laurele Nicome-Jack';
|
||||||
country.code === 'TT'
|
if (country.code === 'PA') return 'Delarys Peñalba y Ariadne Cortéz';
|
||||||
? 'Laurele Nicome-Jack'
|
return 'Pablo Gamboa Cú, Sara Meyer Lima y María Morales Adler';
|
||||||
: 'Pablo Gamboa Cú, Sara Meyer Lima y María Morales Adler',
|
}, [country.code]);
|
||||||
[country.code],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sourceNames = useMemo(
|
const sourceNames = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function CountrySelector({ onSelect }: CountrySelectorProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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) => (
|
{COUNTRIES.map((country) => (
|
||||||
<button
|
<button
|
||||||
key={country.code}
|
key={country.code}
|
||||||
|
|||||||
+241
-24
@@ -18,6 +18,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import BambooCorrectionModal from './BambooCorrectionModal';
|
import BambooCorrectionModal from './BambooCorrectionModal';
|
||||||
|
import ReportIssueButton from './ReportIssueButton';
|
||||||
import { createClientRequestId, ensureFreshSupabaseSession, warmRemoteOrigin, withFreshRequestQuery } from '../lib/sessionHealth';
|
import { createClientRequestId, ensureFreshSupabaseSession, warmRemoteOrigin, withFreshRequestQuery } from '../lib/sessionHealth';
|
||||||
import {
|
import {
|
||||||
CountryConfig,
|
CountryConfig,
|
||||||
@@ -33,6 +34,8 @@ interface DashboardProps {
|
|||||||
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||||
uploadedPayroll: UploadedFile | null;
|
uploadedPayroll: UploadedFile | null;
|
||||||
setUploadedPayroll: (file: UploadedFile | null) => void;
|
setUploadedPayroll: (file: UploadedFile | null) => void;
|
||||||
|
uploadedPayrollFiles: UploadedFile[];
|
||||||
|
setUploadedPayrollFiles: (files: UploadedFile[]) => void;
|
||||||
uploadedBankFiles: UploadedFile[];
|
uploadedBankFiles: UploadedFile[];
|
||||||
setUploadedBankFiles: (files: UploadedFile[]) => void;
|
setUploadedBankFiles: (files: UploadedFile[]) => void;
|
||||||
isCruceExecuted: boolean;
|
isCruceExecuted: boolean;
|
||||||
@@ -74,6 +77,13 @@ function getReconciliationUrl(countryCode: string) {
|
|||||||
return (import.meta.env.VITE_N8N_TRINIDAD_WEBHOOK_URL as string | undefined) || '';
|
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 (
|
return (
|
||||||
(import.meta.env.VITE_N8N_GUATEMALA_WEBHOOK_URL as string | undefined) ||
|
(import.meta.env.VITE_N8N_GUATEMALA_WEBHOOK_URL as string | undefined) ||
|
||||||
(import.meta.env.VITE_N8N_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');
|
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[]) {
|
function toUploadedBankFiles(files: FileList | File[]) {
|
||||||
return Array.from(files).map((file) => ({
|
return Array.from(files).map((file) => ({
|
||||||
name: file.name,
|
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')}`;
|
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 {
|
function getPeriodRange(year: number, month: number, periodType: PeriodType): PeriodRange {
|
||||||
const lastDay = new Date(year, month, 0).getDate();
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
const monthLabel = MONTHS.find((m) => m.value === month)?.label ?? 'Mes';
|
const monthLabel = MONTHS.find((m) => m.value === month)?.label ?? 'Mes';
|
||||||
@@ -746,6 +771,8 @@ export default function Dashboard({
|
|||||||
onShowToast,
|
onShowToast,
|
||||||
uploadedPayroll,
|
uploadedPayroll,
|
||||||
setUploadedPayroll,
|
setUploadedPayroll,
|
||||||
|
uploadedPayrollFiles,
|
||||||
|
setUploadedPayrollFiles,
|
||||||
uploadedBankFiles,
|
uploadedBankFiles,
|
||||||
setUploadedBankFiles,
|
setUploadedBankFiles,
|
||||||
isCruceExecuted,
|
isCruceExecuted,
|
||||||
@@ -759,6 +786,7 @@ export default function Dashboard({
|
|||||||
country,
|
country,
|
||||||
}: DashboardProps) {
|
}: DashboardProps) {
|
||||||
const defaultPeriod = useMemo(() => readStoredPeriod(country.code), [country.code]);
|
const defaultPeriod = useMemo(() => readStoredPeriod(country.code), [country.code]);
|
||||||
|
const isPanama = country.code === 'PA';
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [processingStep, setProcessingStep] = useState('');
|
const [processingStep, setProcessingStep] = useState('');
|
||||||
const [filterMode, setFilterMode] = useState<
|
const [filterMode, setFilterMode] = useState<
|
||||||
@@ -926,15 +954,7 @@ export default function Dashboard({
|
|||||||
setFilterMode('todos');
|
setFilterMode('todos');
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPayrollFile = async (file: File) => {
|
const applyDetectedPayrollPeriod = async (file: File, count = 1) => {
|
||||||
setUploadedPayroll({
|
|
||||||
name: file.name,
|
|
||||||
size: formatBytes(file.size),
|
|
||||||
type: 'payroll',
|
|
||||||
rawFile: file,
|
|
||||||
});
|
|
||||||
resetResults();
|
|
||||||
|
|
||||||
const detected = await analyzePayrollFilename(file.name, country);
|
const detected = await analyzePayrollFilename(file.name, country);
|
||||||
const completeDetection =
|
const completeDetection =
|
||||||
Number.isInteger(detected.year) &&
|
Number.isInteger(detected.year) &&
|
||||||
@@ -946,18 +966,69 @@ export default function Dashboard({
|
|||||||
setSelectedMonth(detected.month!);
|
setSelectedMonth(detected.month!);
|
||||||
setPeriodType(detected.periodType!);
|
setPeriodType(detected.periodType!);
|
||||||
const detectedRange = getPeriodRange(detected.year!, detected.month!, 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) {
|
if (detected.requiresManualReview) {
|
||||||
onShowToast(
|
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'
|
'info'
|
||||||
);
|
);
|
||||||
} else {
|
} 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;
|
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) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
@@ -966,9 +1037,15 @@ export default function Dashboard({
|
|||||||
|
|
||||||
const handlePayrollDrop = async (e: React.DragEvent) => {
|
const handlePayrollDrop = async (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = e.dataTransfer.files[0];
|
const files = Array.from(e.dataTransfer.files) as File[];
|
||||||
if (!file) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
if (isPanama) {
|
||||||
|
await addPanamaPayrollFiles(files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = files[0];
|
||||||
if (isExcelFile(file)) {
|
if (isExcelFile(file)) {
|
||||||
await applyPayrollFile(file);
|
await applyPayrollFile(file);
|
||||||
} else {
|
} else {
|
||||||
@@ -992,9 +1069,15 @@ export default function Dashboard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePayrollSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handlePayrollSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const files = Array.from(e.target.files || []) as File[];
|
||||||
if (!file) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
if (isPanama) {
|
||||||
|
await addPanamaPayrollFiles(files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = files[0];
|
||||||
if (isExcelFile(file)) {
|
if (isExcelFile(file)) {
|
||||||
await applyPayrollFile(file);
|
await applyPayrollFile(file);
|
||||||
} else {
|
} 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 handleBankSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
@@ -1025,6 +1117,17 @@ export default function Dashboard({
|
|||||||
onShowToast('Planilla de banco removida.', 'info');
|
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) => {
|
const applyBonus14PayrollFile = (file: File) => {
|
||||||
setUploadedBonus14Payroll({
|
setUploadedBonus14Payroll({
|
||||||
@@ -1105,8 +1208,20 @@ export default function Dashboard({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!uploadedPayroll?.rawFile) {
|
const payrollFilesForRun = isPanama
|
||||||
onShowToast('Debes cargar la nómina antes de ejecutar el cruce.', 'error');
|
? 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1152,6 +1267,10 @@ export default function Dashboard({
|
|||||||
clientRequestId,
|
clientRequestId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mainPayrollFile =
|
||||||
|
payrollFilesForRun.find((file) => /quincenal/i.test(file.name)) ||
|
||||||
|
payrollFilesForRun[0];
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
country: country.code,
|
country: country.code,
|
||||||
country_name: country.name,
|
country_name: country.name,
|
||||||
@@ -1161,7 +1280,8 @@ export default function Dashboard({
|
|||||||
period_label: periodRange.label,
|
period_label: periodRange.label,
|
||||||
period_start: periodRange.start,
|
period_start: periodRange.start,
|
||||||
period_end: periodRange.end,
|
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),
|
bank_file_names: uploadedBankFiles.map((file) => file.name),
|
||||||
requested_by_name: operatorName,
|
requested_by_name: operatorName,
|
||||||
requested_by_email: operatorEmail,
|
requested_by_email: operatorEmail,
|
||||||
@@ -1177,7 +1297,19 @@ export default function Dashboard({
|
|||||||
Object.entries(metadata).forEach(([key, value]) => {
|
Object.entries(metadata).forEach(([key, value]) => {
|
||||||
formData.append(key, Array.isArray(value) ? JSON.stringify(value) : String(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) => {
|
uploadedBankFiles.forEach((file, index) => {
|
||||||
if (file.rawFile) {
|
if (file.rawFile) {
|
||||||
formData.append('bank_files', file.rawFile, file.name);
|
formData.append('bank_files', file.rawFile, file.name);
|
||||||
@@ -1415,8 +1547,15 @@ export default function Dashboard({
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0 flex items-center" aria-label={`Bandera de ${country.name}`}>
|
<div className="flex-shrink-0 flex items-center gap-2">
|
||||||
<div className="border border-neutral-200 bg-white px-2.5 py-2 shadow-sm">
|
<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" />
|
<img src={country.flag} alt={`Bandera de ${country.name}`} className="h-5 sm:h-6 w-auto object-contain" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1490,7 +1629,7 @@ export default function Dashboard({
|
|||||||
<CalendarDays className="w-4 h-4 text-[#4F758B] mt-0.5 flex-shrink-0" />
|
<CalendarDays className="w-4 h-4 text-[#4F758B] mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-black text-[#4F758B]">Período calculado:</span>{' '}
|
<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>
|
<p className="text-[10px] text-neutral-500 mt-0.5">{selectedPeriodDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1500,6 +1639,21 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<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="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]" />
|
<div className="absolute top-0 left-0 w-full h-1 bg-[#4F758B]" />
|
||||||
@@ -1514,10 +1668,72 @@ export default function Dashboard({
|
|||||||
ref={payrollInputRef}
|
ref={payrollInputRef}
|
||||||
onChange={handlePayrollSelect}
|
onChange={handlePayrollSelect}
|
||||||
accept=".xlsx,.xls"
|
accept=".xlsx,.xls"
|
||||||
|
multiple={isPanama}
|
||||||
className="hidden"
|
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="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">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<FileSpreadsheet className="w-8 h-8 text-[#6CC24A] flex-shrink-0" />
|
<FileSpreadsheet className="w-8 h-8 text-[#6CC24A] flex-shrink-0" />
|
||||||
@@ -1557,6 +1773,7 @@ export default function Dashboard({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { CountryConfig, HistoricalReport, PeriodRange, ReconciliationRow } from '../types';
|
import { CountryConfig, HistoricalReport, PeriodRange, ReconciliationRow } from '../types';
|
||||||
import BambooCorrectionModal from './BambooCorrectionModal';
|
import BambooCorrectionModal from './BambooCorrectionModal';
|
||||||
|
import ReportIssueButton from './ReportIssueButton';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
interface HistoryProps {
|
interface HistoryProps {
|
||||||
@@ -32,6 +33,13 @@ function getHistoryUrl(countryCode: string) {
|
|||||||
return (import.meta.env.VITE_HISTORICOS_TT_URL as string | undefined) || '';
|
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 (
|
return (
|
||||||
(import.meta.env.VITE_HISTORICOS_GT_URL as string | undefined) ||
|
(import.meta.env.VITE_HISTORICOS_GT_URL as string | undefined) ||
|
||||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-historicos'
|
'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) || '';
|
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 (
|
return (
|
||||||
(import.meta.env.VITE_MARCAR_RESUELTO_GT_URL as string | undefined) ||
|
(import.meta.env.VITE_MARCAR_RESUELTO_GT_URL as string | undefined) ||
|
||||||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-marcar-resuelto'
|
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-marcar-resuelto'
|
||||||
@@ -128,7 +143,7 @@ function normalizeReports(input: unknown, country: CountryConfig): HistoricalRep
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(report.id ?? `reporte-${index + 1}`),
|
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),
|
date: formatDate(report.fecha ?? report.period_end ?? rawExecutionDate, country.locale),
|
||||||
executionDate: formatDateTime(rawExecutionDate, country.locale),
|
executionDate: formatDateTime(rawExecutionDate, country.locale),
|
||||||
period: String(report.periodo ?? report.period_label ?? report.period ?? 'Período no especificado'),
|
period: String(report.periodo ?? report.period_label ?? report.period ?? 'Período no especificado'),
|
||||||
@@ -442,7 +457,7 @@ export default function History({
|
|||||||
if (!isCurrentRequest()) return;
|
if (!isCurrentRequest()) return;
|
||||||
|
|
||||||
// Respaldo temporal: webhook histórico existente. También se envía el país
|
// 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);
|
const historyUrl = getHistoryUrl(country.code);
|
||||||
if (!historyUrl) {
|
if (!historyUrl) {
|
||||||
throw new Error(`No está configurado el origen histórico de ${country.name}.`);
|
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>
|
<span className="truncate">Reportes Históricos - {country.name}</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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}`}>
|
<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" />
|
<img src={country.flag} alt={`Bandera de ${country.name}`} className="h-5 sm:h-6 w-auto object-contain" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { getAuthRedirectUrl, isSupabaseConfigured, supabase } from '../lib/supabase';
|
import {
|
||||||
|
getAuthRedirectUrl,
|
||||||
|
isSupabaseConfigured,
|
||||||
|
refreshAuthEntryDocumentCache,
|
||||||
|
supabase,
|
||||||
|
} from '../lib/supabase';
|
||||||
|
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||||
@@ -18,6 +23,12 @@ export default function Login({ onShowToast }: LoginProps) {
|
|||||||
|
|
||||||
setIsLoggingIn(true);
|
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({
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
provider: 'google',
|
provider: 'google',
|
||||||
options: {
|
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',
|
locale: 'en-TT',
|
||||||
enabled: true,
|
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';
|
export const DEFAULT_COUNTRY_CODE: CountryCode = 'GT';
|
||||||
|
|||||||
@@ -18,3 +18,44 @@ export const supabase = isSupabaseConfigured
|
|||||||
export function getAuthRedirectUrl() {
|
export function getAuthRedirectUrl() {
|
||||||
return new URL(import.meta.env.BASE_URL, window.location.origin).toString();
|
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;
|
countryCode: CountryCode | null;
|
||||||
currentView: ViewType;
|
currentView: ViewType;
|
||||||
uploadedPayroll: UploadedFile | null;
|
uploadedPayroll: UploadedFile | null;
|
||||||
|
uploadedPayrollFiles: UploadedFile[];
|
||||||
uploadedBankFiles: UploadedFile[];
|
uploadedBankFiles: UploadedFile[];
|
||||||
isCruceExecuted: boolean;
|
isCruceExecuted: boolean;
|
||||||
cruceResponse: ReconciliationResponse | null;
|
cruceResponse: ReconciliationResponse | null;
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import {createRoot} from 'react-dom/client';
|
|||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './index.css';
|
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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ export type ViewType = 'cruce' | 'historial';
|
|||||||
|
|
||||||
export type ToastType = 'success' | 'error' | 'info';
|
export type ToastType = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
export type CountryCode = 'GT' | 'TT';
|
export type CountryCode = 'GT' | 'TT' | 'PA';
|
||||||
|
|
||||||
export interface CountryConfig {
|
export interface CountryConfig {
|
||||||
code: CountryCode;
|
code: CountryCode;
|
||||||
|
|||||||
Vendored
+4
@@ -4,12 +4,16 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_N8N_WEBHOOK_URL?: string;
|
readonly VITE_N8N_WEBHOOK_URL?: string;
|
||||||
readonly VITE_N8N_GUATEMALA_WEBHOOK_URL?: string;
|
readonly VITE_N8N_GUATEMALA_WEBHOOK_URL?: string;
|
||||||
readonly VITE_N8N_TRINIDAD_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_HISTORICOS_GT_URL?: string;
|
||||||
readonly VITE_MARCAR_RESUELTO_GT_URL?: string;
|
readonly VITE_MARCAR_RESUELTO_GT_URL?: string;
|
||||||
readonly VITE_HISTORICOS_TT_URL?: string;
|
readonly VITE_HISTORICOS_TT_URL?: string;
|
||||||
readonly VITE_MARCAR_RESUELTO_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_ANALIZAR_NOMINA_URL?: string;
|
||||||
readonly VITE_REPORTAR_BAMBOO_URL?: string;
|
readonly VITE_REPORTAR_BAMBOO_URL?: string;
|
||||||
|
readonly VITE_REPORTAR_APP_URL?: string;
|
||||||
readonly VITE_ENABLE_SUPABASE_AUTH?: string;
|
readonly VITE_ENABLE_SUPABASE_AUTH?: string;
|
||||||
readonly VITE_SUPABASE_URL?: string;
|
readonly VITE_SUPABASE_URL?: string;
|
||||||
readonly VITE_SUPABASE_ANON_KEY?: string;
|
readonly VITE_SUPABASE_ANON_KEY?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user