+
{COUNTRIES.map((country) => (
-
-
+
@@ -1490,7 +1629,7 @@ export default function Dashboard({
Período calculado:{' '}
- {periodRange.start} al {periodRange.end}
+ {formatPeriodDate(periodRange.start)} al {formatPeriodDate(periodRange.end)}
{selectedPeriodDescription}
@@ -1500,6 +1639,21 @@ export default function Dashboard({
+ {isPanama && (uploadedPayrollFiles.length > 0 || uploadedBankFiles.length > 0) && (
+
+
+
+ )}
+
@@ -1514,10 +1668,72 @@ export default function Dashboard({
ref={payrollInputRef}
onChange={handlePayrollSelect}
accept=".xlsx,.xls"
+ multiple={isPanama}
className="hidden"
/>
- {uploadedPayroll ? (
+ {isPanama ? (
+ uploadedPayrollFiles.length > 0 ? (
+
+
+
+ {uploadedPayrollFiles.length} archivo(s) de nómina cargado(s)
+
+
+ Incluye la quincenal y todas las planillas extraordinarias, vacaciones o XIII Mes que correspondan al período.
+
+
+
+
+ {uploadedPayrollFiles.map((file, index) => (
+
+
+
+
+
+ {file.name}
+
+
{file.size} • Excel
+
+
+
+
+ ))}
+
+
+
+
+ ) : (
+
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]"
+ >
+
+
Arrastre la nómina y sus suplementos
+
Puede seleccionar varios .xlsx / .xls a la vez
+
+
+ )
+ ) : uploadedPayroll ? (
@@ -1557,6 +1773,7 @@ export default function Dashboard({
)}
+
diff --git a/src/components/History.tsx b/src/components/History.tsx
index b17f684..1d56dec 100644
--- a/src/components/History.tsx
+++ b/src/components/History.tsx
@@ -15,6 +15,7 @@ import {
} from 'lucide-react';
import { CountryConfig, HistoricalReport, PeriodRange, ReconciliationRow } from '../types';
import BambooCorrectionModal from './BambooCorrectionModal';
+import ReportIssueButton from './ReportIssueButton';
import { supabase } from '../lib/supabase';
interface HistoryProps {
@@ -32,6 +33,13 @@ function getHistoryUrl(countryCode: string) {
return (import.meta.env.VITE_HISTORICOS_TT_URL as string | undefined) || '';
}
+ if (countryCode === 'PA') {
+ return (
+ (import.meta.env.VITE_HISTORICOS_PA_URL as string | undefined) ||
+ 'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-pa-historicos'
+ );
+ }
+
return (
(import.meta.env.VITE_HISTORICOS_GT_URL as string | undefined) ||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-historicos'
@@ -43,6 +51,13 @@ function getMarkResolvedUrl(countryCode: string) {
return (import.meta.env.VITE_MARCAR_RESUELTO_TT_URL as string | undefined) || '';
}
+ if (countryCode === 'PA') {
+ return (
+ (import.meta.env.VITE_MARCAR_RESUELTO_PA_URL as string | undefined) ||
+ 'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-pa-marcar-resuelto'
+ );
+ }
+
return (
(import.meta.env.VITE_MARCAR_RESUELTO_GT_URL as string | undefined) ||
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-marcar-resuelto'
@@ -128,7 +143,7 @@ function normalizeReports(input: unknown, country: CountryConfig): HistoricalRep
return {
id: String(report.id ?? `reporte-${index + 1}`),
- country: (rawCountry === 'TT' ? 'TT' : 'GT') as HistoricalReport['country'],
+ country: (rawCountry === 'TT' ? 'TT' : rawCountry === 'PA' ? 'PA' : 'GT') as HistoricalReport['country'],
date: formatDate(report.fecha ?? report.period_end ?? rawExecutionDate, country.locale),
executionDate: formatDateTime(rawExecutionDate, country.locale),
period: String(report.periodo ?? report.period_label ?? report.period ?? 'Período no especificado'),
@@ -442,7 +457,7 @@ export default function History({
if (!isCurrentRequest()) return;
// Respaldo temporal: webhook histórico existente. También se envía el país
- // para impedir que se mezclen reportes de Guatemala y Trinidad y Tobago.
+ // para impedir que se mezclen reportes de Guatemala, Trinidad y Tobago y Panamá.
const historyUrl = getHistoryUrl(country.code);
if (!historyUrl) {
throw new Error(`No está configurado el origen histórico de ${country.name}.`);
@@ -708,7 +723,14 @@ export default function History({
Reportes Históricos - {country.name}
-
+
+
diff --git a/src/components/Login.tsx b/src/components/Login.tsx
index a98868c..1e946b0 100644
--- a/src/components/Login.tsx
+++ b/src/components/Login.tsx
@@ -1,7 +1,12 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
import { AlertTriangle } from 'lucide-react';
-import { getAuthRedirectUrl, isSupabaseConfigured, supabase } from '../lib/supabase';
+import {
+ getAuthRedirectUrl,
+ isSupabaseConfigured,
+ refreshAuthEntryDocumentCache,
+ supabase,
+} from '../lib/supabase';
interface LoginProps {
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
@@ -18,6 +23,12 @@ export default function Login({ onShowToast }: LoginProps) {
setIsLoggingIn(true);
+ // El OAuth vuelve a la misma URL base de la aplicación. Antes de salir,
+ // refrescamos esa entrada en la caché HTTP del navegador para evitar que el
+ // retorno de Google restaure un index.html perteneciente a un despliegue
+ // anterior. Si la revalidación falla, el login continúa de todos modos.
+ await refreshAuthEntryDocumentCache();
+
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
diff --git a/src/components/ReportIssueButton.tsx b/src/components/ReportIssueButton.tsx
new file mode 100644
index 0000000..ca667af
--- /dev/null
+++ b/src/components/ReportIssueButton.tsx
@@ -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 (
+ <>
+
+
+ {isOpen && (
+
+
+
+
+
+
+
+
Reporte
+
+
+
+
+
+
+ {message.length}/5000
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/config/countries.ts b/src/config/countries.ts
index 4c12374..b4f086a 100644
--- a/src/config/countries.ts
+++ b/src/config/countries.ts
@@ -17,6 +17,14 @@ export const COUNTRIES: CountryConfig[] = [
locale: 'en-TT',
enabled: true,
},
+ {
+ code: 'PA',
+ name: 'Panamá',
+ flag: 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/Flag_of_Panama.svg',
+ currency: 'USD',
+ locale: 'es-PA',
+ enabled: true,
+ },
];
export const DEFAULT_COUNTRY_CODE: CountryCode = 'GT';
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
index cbfdf89..353e87b 100644
--- a/src/lib/supabase.ts
+++ b/src/lib/supabase.ts
@@ -18,3 +18,44 @@ export const supabase = isSupabaseConfigured
export function getAuthRedirectUrl() {
return new URL(import.meta.env.BASE_URL, window.location.origin).toString();
}
+
+/**
+ * Fuerza una revalidación del documento de entrada justo antes de abandonar la
+ * app para iniciar OAuth. El callback vuelve exactamente a la misma URL y, si el
+ * navegador conserva un index.html anterior en su caché HTTP, puede mostrar por
+ * unos segundos/una navegación una versión vieja del frontend hasta que el
+ * usuario recarga manualmente.
+ *
+ * `cache: 'reload'` pide el HTML al servidor ignorando la copia almacenada y
+ * permite que el navegador refresque su caché con la versión actualmente
+ * desplegada. Es una operación best-effort: un fallo aquí nunca bloquea el
+ * inicio de sesión; el OAuth continúa normalmente.
+ */
+export async function refreshAuthEntryDocumentCache() {
+ if (typeof window === 'undefined' || typeof fetch === 'undefined') return;
+
+ const entryUrl = getAuthRedirectUrl();
+ const controller = new AbortController();
+ const timeoutId = window.setTimeout(() => controller.abort(), 8000);
+
+ try {
+ const response = await fetch(entryUrl, {
+ method: 'GET',
+ cache: 'reload',
+ credentials: 'same-origin',
+ redirect: 'follow',
+ headers: {
+ Accept: 'text/html,application/xhtml+xml',
+ },
+ signal: controller.signal,
+ });
+
+ // Consumir el body completa la petición y evita dejar una respuesta
+ // pendiente antes de que el navegador salga hacia Google.
+ await response.text();
+ } catch (error) {
+ console.warn('No se pudo refrescar preventivamente la entrada de la app:', error);
+ } finally {
+ window.clearTimeout(timeoutId);
+ }
+}
diff --git a/src/lib/tabSessionState.ts b/src/lib/tabSessionState.ts
index 41ef39e..2b6c535 100644
--- a/src/lib/tabSessionState.ts
+++ b/src/lib/tabSessionState.ts
@@ -19,6 +19,7 @@ export interface TabSessionDraft {
countryCode: CountryCode | null;
currentView: ViewType;
uploadedPayroll: UploadedFile | null;
+ uploadedPayrollFiles: UploadedFile[];
uploadedBankFiles: UploadedFile[];
isCruceExecuted: boolean;
cruceResponse: ReconciliationResponse | null;
diff --git a/src/main.tsx b/src/main.tsx
index 080dac3..8914827 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,6 +3,16 @@ import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
+// Algunos navegadores pueden restaurar una página completa desde BFCache al
+// volver de un proveedor OAuth. Si eso ocurre, recargamos una sola vez para que
+// la aplicación use siempre los assets del despliegue vigente. Una recarga
+// normal no vuelve a entrar aquí porque `persisted` será false.
+window.addEventListener('pageshow', (event) => {
+ if (event.persisted) {
+ window.location.reload();
+ }
+});
+
createRoot(document.getElementById('root')!).render(
diff --git a/src/types.ts b/src/types.ts
index 75b1df0..d4fe0af 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -4,7 +4,7 @@ export type ViewType = 'cruce' | 'historial';
export type ToastType = 'success' | 'error' | 'info';
-export type CountryCode = 'GT' | 'TT';
+export type CountryCode = 'GT' | 'TT' | 'PA';
export interface CountryConfig {
code: CountryCode;
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index bd9ceef..a036991 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -4,12 +4,16 @@ interface ImportMetaEnv {
readonly VITE_N8N_WEBHOOK_URL?: string;
readonly VITE_N8N_GUATEMALA_WEBHOOK_URL?: string;
readonly VITE_N8N_TRINIDAD_WEBHOOK_URL?: string;
+ readonly VITE_N8N_PANAMA_WEBHOOK_URL?: string;
readonly VITE_HISTORICOS_GT_URL?: string;
readonly VITE_MARCAR_RESUELTO_GT_URL?: string;
readonly VITE_HISTORICOS_TT_URL?: string;
readonly VITE_MARCAR_RESUELTO_TT_URL?: string;
+ readonly VITE_HISTORICOS_PA_URL?: string;
+ readonly VITE_MARCAR_RESUELTO_PA_URL?: string;
readonly VITE_ANALIZAR_NOMINA_URL?: string;
readonly VITE_REPORTAR_BAMBOO_URL?: string;
+ readonly VITE_REPORTAR_APP_URL?: string;
readonly VITE_ENABLE_SUPABASE_AUTH?: string;
readonly VITE_SUPABASE_URL?: string;
readonly VITE_SUPABASE_ANON_KEY?: string;