Actualizar Cruce de Cuentas: Panama, Reportar y dist validado

This commit is contained in:
2026-09-07 10:34:59 -04:00
parent c67cafa7aa
commit 8c841d9b67
19 changed files with 1047 additions and 305 deletions
@@ -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';
+425
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&#039;');\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": []
}
-265
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
View File
@@ -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" />
+7
View File
@@ -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}
+5 -7
View File
@@ -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(
() => () =>
+1 -1
View File
@@ -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
View File
@@ -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>
+25 -3
View File
@@ -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>
+12 -1
View File
@@ -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: {
+213
View File
@@ -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>
)}
</>
);
}
+8
View File
@@ -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';
+41
View File
@@ -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);
}
}
+1
View File
@@ -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;
+10
View File
@@ -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
View File
@@ -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;
+4
View File
@@ -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;