Agregar Bono 14 Guatemala y actualizar dist
This commit is contained in:
+184
-20
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Dashboard from './components/Dashboard';
|
||||
@@ -14,8 +14,14 @@ import {
|
||||
UploadedFile,
|
||||
ViewType,
|
||||
} from './types';
|
||||
import { isAllowedEmail } from './lib/auth';
|
||||
import { getCurrentUserAccess, normalizeEmail } from './lib/auth';
|
||||
import { ensureFreshSupabaseSession } from './lib/sessionHealth';
|
||||
import { isSupabaseConfigured, supabase } from './lib/supabase';
|
||||
import {
|
||||
clearTabSessionDraft,
|
||||
loadTabSessionDraft,
|
||||
saveTabSessionDraft,
|
||||
} from './lib/tabSessionState';
|
||||
import { COUNTRIES, getCountryConfig } from './config/countries';
|
||||
|
||||
const AUTH_ENABLED = import.meta.env.VITE_ENABLE_SUPABASE_AUTH === 'true';
|
||||
@@ -56,7 +62,9 @@ export default function App() {
|
||||
const [selectedCountryCode, setSelectedCountryCode] = useState<CountryCode | null>(() => getStoredCountry());
|
||||
const [currentUser, setCurrentUser] = useState<AppUser | null>(AUTH_ENABLED ? null : TEMPORARY_USER);
|
||||
const [authLoading, setAuthLoading] = useState(AUTH_ENABLED);
|
||||
const [tabDraftHydrated, setTabDraftHydrated] = useState(false);
|
||||
const [toast, setToast] = useState<{ message: string; type: ToastType } | null>(null);
|
||||
const authValidationIdRef = useRef(0);
|
||||
|
||||
const authReady = useMemo(() => AUTH_ENABLED && isSupabaseConfigured && supabase, []);
|
||||
const selectedCountry = selectedCountryCode ? getCountryConfig(selectedCountryCode) : null;
|
||||
@@ -76,43 +84,198 @@ export default function App() {
|
||||
let isMounted = true;
|
||||
|
||||
const applySessionUser = async (sessionUser: any) => {
|
||||
const validationId = ++authValidationIdRef.current;
|
||||
|
||||
if (!sessionUser?.email) {
|
||||
setCurrentUser(null);
|
||||
if (isMounted && validationId === authValidationIdRef.current) {
|
||||
setCurrentUser(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAllowedEmail(sessionUser.email)) {
|
||||
await supabase.auth.signOut();
|
||||
setCurrentUser(null);
|
||||
handleShowToast('Tu usuario no tiene acceso autorizado a este módulo.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const access = await getCurrentUserAccess();
|
||||
|
||||
setCurrentUser({
|
||||
name: getDisplayNameFromSessionUser(sessionUser),
|
||||
email: sessionUser.email,
|
||||
});
|
||||
if (!isMounted || validationId !== authValidationIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionEmail = normalizeEmail(sessionUser.email);
|
||||
const accessEmailMatchesSession =
|
||||
Boolean(access.email) && access.email === sessionEmail;
|
||||
|
||||
if (
|
||||
!access.ok ||
|
||||
!access.authenticated ||
|
||||
!access.authorized ||
|
||||
!access.active ||
|
||||
!access.appAccess ||
|
||||
!accessEmailMatchesSession
|
||||
) {
|
||||
setCurrentUser(null);
|
||||
handleShowToast(
|
||||
access.reason || 'Tu usuario no tiene acceso autorizado a este módulo.',
|
||||
'error',
|
||||
);
|
||||
void supabase.auth.signOut();
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentUser({
|
||||
name: access.name || getDisplayNameFromSessionUser(sessionUser),
|
||||
email: sessionEmail,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isMounted || validationId !== authValidationIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('No se pudo validar el acceso en Supabase:', error);
|
||||
setCurrentUser(null);
|
||||
handleShowToast(
|
||||
'No se pudo validar tu acceso en Supabase. Intenta iniciar sesión nuevamente.',
|
||||
'error',
|
||||
);
|
||||
void supabase.auth.signOut();
|
||||
}
|
||||
};
|
||||
|
||||
const loadSession = async () => {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
if (!isMounted) return;
|
||||
await applySessionUser(data.session?.user);
|
||||
setAuthLoading(false);
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
if (error) throw error;
|
||||
if (!isMounted) return;
|
||||
await applySessionUser(data.session?.user);
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
console.error('No se pudo recuperar la sesión de Supabase:', error);
|
||||
setCurrentUser(null);
|
||||
handleShowToast(
|
||||
'No se pudo recuperar tu sesión. Intenta iniciar sesión nuevamente.',
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
if (isMounted) setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSession();
|
||||
void loadSession();
|
||||
|
||||
const { data: listener } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||
await applySessionUser(session?.user);
|
||||
const { data: listener } = supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (!isMounted) return;
|
||||
|
||||
if (session?.user) {
|
||||
// Supabase recomienda no encadenar llamadas asíncronas al cliente dentro
|
||||
// del callback. Se difiere la RPC para evitar bloqueos durante el OAuth o
|
||||
// la renovación automática del token.
|
||||
window.setTimeout(() => {
|
||||
if (isMounted) void applySessionUser(session.user);
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Evita desmontar toda la interfaz por un estado nulo transitorio durante
|
||||
// la reanudación o renovación de la sesión al volver a una pestaña inactiva.
|
||||
if (event === 'SIGNED_OUT') {
|
||||
authValidationIdRef.current += 1;
|
||||
setCurrentUser(null);
|
||||
setAuthLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
authValidationIdRef.current += 1;
|
||||
listener.subscription.unsubscribe();
|
||||
};
|
||||
}, [authReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!AUTH_ENABLED || !authReady || !supabase) return;
|
||||
|
||||
let lastResumeAttempt = 0;
|
||||
|
||||
const resumeSession = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastResumeAttempt < 5000) return;
|
||||
lastResumeAttempt = now;
|
||||
|
||||
// Una pestaña suspendida varias horas puede volver antes de que Supabase
|
||||
// complete su renovación automática. La revalidación es silenciosa y no
|
||||
// desmonta la interfaz por errores transitorios de red.
|
||||
void ensureFreshSupabaseSession(300).catch((error) => {
|
||||
console.warn('No se pudo revalidar la sesión al reanudar la pestaña:', error);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', resumeSession);
|
||||
window.addEventListener('focus', resumeSession);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', resumeSession);
|
||||
window.removeEventListener('focus', resumeSession);
|
||||
};
|
||||
}, [authReady]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const restoreTabDraft = async () => {
|
||||
const draft = await loadTabSessionDraft();
|
||||
if (!isMounted) return;
|
||||
|
||||
if (draft) {
|
||||
// Si el país fue cambiado explícitamente en otra parte de la app, no se
|
||||
// restaura un borrador perteneciente a otro módulo.
|
||||
if (!selectedCountryCode || draft.countryCode === selectedCountryCode) {
|
||||
if (draft.countryCode) {
|
||||
setSelectedCountryCode(draft.countryCode);
|
||||
}
|
||||
setCurrentView(draft.currentView);
|
||||
setUploadedPayroll(draft.uploadedPayroll);
|
||||
setUploadedBankFiles(draft.uploadedBankFiles);
|
||||
setIsCruceExecuted(draft.isCruceExecuted);
|
||||
setCruceResponse(draft.cruceResponse);
|
||||
} else {
|
||||
await clearTabSessionDraft();
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted) setTabDraftHydrated(true);
|
||||
};
|
||||
|
||||
void restoreTabDraft();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
// La restauración ocurre una sola vez usando los valores iniciales del tab.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tabDraftHydrated) return;
|
||||
|
||||
void saveTabSessionDraft({
|
||||
countryCode: selectedCountryCode,
|
||||
currentView,
|
||||
uploadedPayroll,
|
||||
uploadedBankFiles,
|
||||
isCruceExecuted,
|
||||
cruceResponse,
|
||||
});
|
||||
}, [
|
||||
tabDraftHydrated,
|
||||
selectedCountryCode,
|
||||
currentView,
|
||||
uploadedPayroll,
|
||||
uploadedBankFiles,
|
||||
isCruceExecuted,
|
||||
cruceResponse,
|
||||
]);
|
||||
|
||||
const clearCurrentCruce = () => {
|
||||
setUploadedPayroll(null);
|
||||
setUploadedBankFiles([]);
|
||||
@@ -122,6 +285,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await clearTabSessionDraft();
|
||||
if (supabase) await supabase.auth.signOut();
|
||||
setCurrentUser(AUTH_ENABLED ? null : TEMPORARY_USER);
|
||||
setSelectedCountryCode(null);
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, Send, X } from 'lucide-react';
|
||||
import { CountryConfig, PeriodRange, ReconciliationRow } from '../types';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { ensureFreshSupabaseSession } from '../lib/sessionHealth';
|
||||
|
||||
const REPORT_BAMBOO_URL =
|
||||
(import.meta.env.VITE_REPORTAR_BAMBOO_URL as string | undefined) || '';
|
||||
|
||||
interface BambooCorrectionModalProps {
|
||||
row: ReconciliationRow;
|
||||
country: CountryConfig;
|
||||
periodRange: PeriodRange;
|
||||
operatorName: string;
|
||||
operatorEmail: string;
|
||||
executionId?: string;
|
||||
reportUrl?: string;
|
||||
onClose: () => void;
|
||||
onSuccess: (rowId: string) => void;
|
||||
onShowToast: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
const REASONS = [
|
||||
'Nombre mal escrito en BambooHR',
|
||||
'Apodo o nombre de uso',
|
||||
'Nombre incompleto / nombres adicionales',
|
||||
'Otro',
|
||||
];
|
||||
|
||||
function parseJsonSafely(text: string) {
|
||||
try {
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function BambooCorrectionModal({
|
||||
row,
|
||||
country,
|
||||
periodRange,
|
||||
operatorName,
|
||||
operatorEmail,
|
||||
executionId,
|
||||
reportUrl,
|
||||
onClose,
|
||||
onSuccess,
|
||||
onShowToast,
|
||||
}: BambooCorrectionModalProps) {
|
||||
const suggested = row.bestBambooCandidate;
|
||||
const [bambooName, setBambooName] = useState(suggested?.employeeName || '');
|
||||
const [employeeNumber, setEmployeeNumber] = useState(suggested?.employeeNumber || '');
|
||||
const [reason, setReason] = useState(REASONS[0]);
|
||||
const [comment, setComment] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBambooName(suggested?.employeeName || '');
|
||||
setEmployeeNumber(suggested?.employeeNumber || '');
|
||||
setReason(REASONS[0]);
|
||||
setComment('');
|
||||
setConfirmed(false);
|
||||
}, [row.id, suggested?.employeeName, suggested?.employeeNumber]);
|
||||
|
||||
const recipientLabel = useMemo(
|
||||
() =>
|
||||
country.code === 'TT'
|
||||
? 'Laurele Nicome-Jack'
|
||||
: 'Pablo Gamboa Cú, Sara Meyer Lima y María Morales Adler',
|
||||
[country.code],
|
||||
);
|
||||
|
||||
const sourceNames = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
[row.employee, row.bankNameFile, row.bankAccountHolder]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
),
|
||||
[row.employee, row.bankNameFile, row.bankAccountHolder],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!REPORT_BAMBOO_URL) {
|
||||
onShowToast('Falta configurar VITE_REPORTAR_BAMBOO_URL.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bambooName.trim()) {
|
||||
onShowToast('Escribe el nombre correcto del empleado en BambooHR.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
onShowToast('Confirma que se trata de la misma persona antes de enviar el caso.', '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 reportar el caso.');
|
||||
}
|
||||
|
||||
const correctionPayload = {
|
||||
country: country.code,
|
||||
country_name: country.name,
|
||||
source_name: row.employee,
|
||||
source_names: sourceNames,
|
||||
bank_name_file: row.bankNameFile || '',
|
||||
bank_account_holder: row.bankAccountHolder || '',
|
||||
bank_account: row.account || '',
|
||||
bank_amount: row.amountBank,
|
||||
currency: country.currency,
|
||||
bamboo_name: bambooName.trim(),
|
||||
bamboo_employee_number: employeeNumber.trim(),
|
||||
reason,
|
||||
comment: comment.trim(),
|
||||
confirmed_same_person: true,
|
||||
execution_id: executionId || '',
|
||||
report_url: reportUrl || '',
|
||||
period_label: periodRange.label,
|
||||
period_start: periodRange.start,
|
||||
period_end: periodRange.end,
|
||||
requested_by_name: operatorName,
|
||||
requested_by_email: operatorEmail,
|
||||
};
|
||||
|
||||
// FormData evita una preflight CORS innecesaria al llamar el webhook de n8n.
|
||||
// El JWT sigue viajando únicamente por HTTPS y n8n lo valida contra Supabase.
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(correctionPayload));
|
||||
formData.append('access_token', accessToken);
|
||||
|
||||
const response = await fetch(REPORT_BAMBOO_URL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
const payload = parseJsonSafely(responseText);
|
||||
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
const message =
|
||||
payload?.message ||
|
||||
payload?.error ||
|
||||
payload?.errors?.[0] ||
|
||||
responseText ||
|
||||
'No se pudo enviar el caso a RRHH.';
|
||||
throw new Error(String(message));
|
||||
}
|
||||
|
||||
onSuccess(row.id);
|
||||
onShowToast(
|
||||
`Caso enviado a RRHH de ${country.name}. La equivalencia quedó registrada para futuros cruces.`,
|
||||
'success',
|
||||
);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'No se pudo enviar el caso a RRHH.';
|
||||
onShowToast(message, 'error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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={isSubmitting ? undefined : onClose}
|
||||
aria-label="Cerrar"
|
||||
/>
|
||||
|
||||
<div className="relative w-full max-w-2xl bg-white border border-[#D0D0D0] shadow-2xl max-h-[92vh] overflow-y-auto">
|
||||
<div className="h-1.5 bg-[#6CC24A]" />
|
||||
|
||||
<div className="px-6 py-5 border-b border-neutral-200 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-[0.18em] font-black text-[#6B8FA3]">Banco sin BambooHR</p>
|
||||
<h2 className="text-xl font-black text-[#4F758B] mt-1">Reportar corrección de identidad a RRHH</h2>
|
||||
<p className="text-xs text-neutral-500 mt-2 leading-relaxed">
|
||||
Esta validación crea una equivalencia permanente para que el mismo falso positivo no vuelva a aparecer mientras RRHH corrige BambooHR.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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 space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 bg-[#F5F5F5] border border-neutral-200 p-4">
|
||||
<div>
|
||||
<p className="text-[9px] uppercase tracking-wider font-black text-[#6B8FA3]">Nombre detectado</p>
|
||||
<p className="text-sm font-black text-neutral-800 mt-1">{row.employee}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] uppercase tracking-wider font-black text-[#6B8FA3]">Cuenta bancaria</p>
|
||||
<p className="text-sm font-mono font-bold text-neutral-700 mt-1">{row.account || '—'}</p>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<p className="text-[9px] uppercase tracking-wider font-black text-[#6B8FA3]">Se notificará a</p>
|
||||
<p className="text-xs font-bold text-neutral-700 mt-1">{recipientLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{suggested?.employeeName && (
|
||||
<div className="border border-[#D6E8F4] bg-[#F7FBFD] p-4 flex items-start gap-3">
|
||||
<AlertTriangle className="w-4 h-4 text-[#4F758B] mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-neutral-600 leading-relaxed">
|
||||
<strong className="text-[#4F758B]">Candidato sugerido por el cruce:</strong>{' '}
|
||||
{suggested.employeeName}
|
||||
{suggested.employeeNumber ? ` · Employee Number ${suggested.employeeNumber}` : ''}.
|
||||
Verifícalo manualmente antes de confirmar.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<label className="sm:col-span-2">
|
||||
<span className="block text-[10px] uppercase tracking-wider font-black text-[#4F758B] mb-1.5">
|
||||
Nombre correcto en BambooHR *
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={bambooName}
|
||||
onChange={(event) => setBambooName(event.target.value)}
|
||||
placeholder="Ej.: Jonathan Alexander Pérez"
|
||||
className="w-full border border-[#D0D0D0] px-3 py-2.5 text-sm font-medium focus:outline-none focus:border-[#4F758B]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="block text-[10px] uppercase tracking-wider font-black text-[#4F758B] mb-1.5">
|
||||
Employee Number
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={employeeNumber}
|
||||
onChange={(event) => setEmployeeNumber(event.target.value)}
|
||||
placeholder="Recomendado para mayor seguridad"
|
||||
className="w-full border border-[#D0D0D0] px-3 py-2.5 text-sm font-medium focus:outline-none focus:border-[#4F758B]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="block text-[10px] uppercase tracking-wider font-black text-[#4F758B] mb-1.5">
|
||||
Motivo *
|
||||
</span>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
className="w-full border border-[#D0D0D0] px-3 py-2.5 text-sm font-medium focus:outline-none focus:border-[#4F758B] bg-white"
|
||||
>
|
||||
{REASONS.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sm:col-span-2">
|
||||
<span className="block text-[10px] uppercase tracking-wider font-black text-[#4F758B] mb-1.5">
|
||||
Comentario para RRHH
|
||||
</span>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="Detalle adicional que ayude a RRHH a corregir el registro."
|
||||
className="w-full border border-[#D0D0D0] px-3 py-2.5 text-sm font-medium focus:outline-none focus:border-[#4F758B] resize-y"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 border border-[#D0D0D0] p-4 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(event) => setConfirmed(event.target.checked)}
|
||||
className="mt-0.5 accent-[#6CC24A]"
|
||||
/>
|
||||
<span className="text-xs text-neutral-600 leading-relaxed">
|
||||
Confirmo que revisé el caso manualmente y que <strong>{row.employee}</strong> corresponde a la misma persona indicada arriba en BambooHR.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-neutral-200 bg-neutral-50 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2.5 border border-neutral-300 bg-white text-neutral-600 text-xs font-black uppercase tracking-wider disabled:opacity-40"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !confirmed || !bambooName.trim()}
|
||||
className="px-5 py-2.5 bg-[#4F758B] text-white text-xs font-black uppercase tracking-wider inline-flex items-center justify-center gap-2 border-l-4 border-[#6CC24A] disabled:bg-neutral-300 disabled:border-neutral-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
||||
Enviando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Enviar a RRHH
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+756
-39
File diff suppressed because it is too large
Load Diff
+555
-53
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { CountryConfig, HistoricalReport } from '../types';
|
||||
import { CountryConfig, HistoricalReport, PeriodRange, ReconciliationRow } from '../types';
|
||||
import BambooCorrectionModal from './BambooCorrectionModal';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface HistoryProps {
|
||||
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
||||
@@ -122,7 +124,7 @@ function normalizeReports(input: unknown, country: CountryConfig): HistoricalRep
|
||||
: Array.isArray(report.bank_file_names)
|
||||
? report.bank_file_names.map(String)
|
||||
: [];
|
||||
const rawExecutionDate = report.fechaEjecucion ?? report.ejecutado_en ?? report.created_at;
|
||||
const rawExecutionDate = report.updated_at ?? report.fechaEjecucion ?? report.ejecutado_en ?? report.created_at;
|
||||
|
||||
return {
|
||||
id: String(report.id ?? `reporte-${index + 1}`),
|
||||
@@ -130,6 +132,8 @@ function normalizeReports(input: unknown, country: CountryConfig): HistoricalRep
|
||||
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'),
|
||||
periodStart: report.period_start ? String(report.period_start) : undefined,
|
||||
periodEnd: report.period_end ? String(report.period_end) : undefined,
|
||||
payrollFile: String(report.archivoNomina ?? report.payroll_file_name ?? report.payrollFile ?? ''),
|
||||
bankFiles,
|
||||
matches: toNumber(report.coincidencias ?? report.matches),
|
||||
@@ -171,6 +175,170 @@ function normalizeReports(input: unknown, country: CountryConfig): HistoricalRep
|
||||
.map(({ _sortTime: _ignored, ...report }) => report);
|
||||
}
|
||||
|
||||
|
||||
function normalizeIdentityText(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9 ]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeAccount(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.replace(/\.0$/g, '')
|
||||
.replace(/\D/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractCaseNumber(item: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = item[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(
|
||||
value
|
||||
.replace(/[A-Za-z$Q,\s]/g, '')
|
||||
.trim(),
|
||||
);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeHistoricalBambooCases(input: unknown): ReconciliationRow[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
return input.map((raw, index) => {
|
||||
const item = (raw ?? {}) as Record<string, unknown>;
|
||||
const sourceName = String(
|
||||
item.employee ??
|
||||
item.employee_name ??
|
||||
item.bank_name_file ??
|
||||
item.bank_account_holder ??
|
||||
'Sin nombre',
|
||||
);
|
||||
|
||||
const amountBank = extractCaseNumber(item, [
|
||||
'bankAmount',
|
||||
'bank_amount',
|
||||
'amount',
|
||||
'monto_banco',
|
||||
]);
|
||||
|
||||
const bestCandidate =
|
||||
item.best_bamboo_candidate &&
|
||||
typeof item.best_bamboo_candidate === 'object'
|
||||
? item.best_bamboo_candidate as Record<string, unknown>
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: String(
|
||||
item.id ??
|
||||
`historical-bamboo-${normalizeIdentityText(sourceName)}-${normalizeAccount(item.account ?? item.bank_account)}-${index + 1}`,
|
||||
),
|
||||
employee: sourceName,
|
||||
employeeNumber: '',
|
||||
account: String(item.account ?? item.bank_account ?? item.bankAccount ?? ''),
|
||||
amountPayroll: 0,
|
||||
amountBank,
|
||||
difference: amountBank === null ? null : 0 - amountBank,
|
||||
status: 'Pendiente revisión',
|
||||
category: 'banco_sin_bamboo',
|
||||
source: 'banco_sin_bamboo',
|
||||
observation: String(
|
||||
item.observation ??
|
||||
item.observacion ??
|
||||
'Pago bancario sin empleado identificado de forma confiable en BambooHR.',
|
||||
),
|
||||
bankNameFile: String(item.bank_name_file ?? item.bankNameFile ?? ''),
|
||||
bankAccountHolder: String(item.bank_account_holder ?? item.bankAccountHolder ?? ''),
|
||||
bestBambooCandidate: bestCandidate
|
||||
? {
|
||||
employeeNumber: String(bestCandidate.employee_number ?? bestCandidate.employeeNumber ?? ''),
|
||||
employeeName: String(bestCandidate.employee_name ?? bestCandidate.employeeName ?? ''),
|
||||
score: extractCaseNumber(bestCandidate, ['score']),
|
||||
}
|
||||
: null,
|
||||
ambiguousBambooMatch: Boolean(item.ambiguous_bamboo_match),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface HistoricalBambooCorrection {
|
||||
id: string;
|
||||
sourceName: string;
|
||||
sourceNames: string[];
|
||||
bankAccount: string;
|
||||
status: string;
|
||||
emailSent: boolean;
|
||||
}
|
||||
|
||||
function normalizeHistoricalBambooCorrections(input: unknown): HistoricalBambooCorrection[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
return input.map((raw) => {
|
||||
const item = (raw ?? {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
id: String(item.id ?? ''),
|
||||
sourceName: String(item.nombre_origen_principal ?? item.source_name ?? ''),
|
||||
sourceNames: Array.isArray(item.nombres_origen)
|
||||
? item.nombres_origen.map(String)
|
||||
: [],
|
||||
bankAccount: String(item.cuenta_bancaria ?? item.bank_account ?? ''),
|
||||
status: String(item.estado ?? ''),
|
||||
emailSent: item.correo_enviado === true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function correctionMatchesRow(
|
||||
correction: HistoricalBambooCorrection,
|
||||
row: ReconciliationRow,
|
||||
) {
|
||||
const normalizedStatus = correction.status.toLowerCase();
|
||||
const wasActuallyNotified =
|
||||
correction.emailSent ||
|
||||
normalizedStatus === 'notificado_rrhh' ||
|
||||
normalizedStatus === 'corregido_bamboo';
|
||||
|
||||
// Si n8n logró registrar el intento pero el correo falló, se permite volver
|
||||
// a enviarlo desde Históricos en vez de bloquear el caso por error.
|
||||
if (!wasActuallyNotified) return false;
|
||||
|
||||
const correctionAccount = normalizeAccount(correction.bankAccount);
|
||||
const rowAccount = normalizeAccount(row.account);
|
||||
|
||||
if (
|
||||
correctionAccount &&
|
||||
rowAccount &&
|
||||
correctionAccount !== rowAccount
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const correctionNames = new Set(
|
||||
[correction.sourceName, ...correction.sourceNames]
|
||||
.map(normalizeIdentityText)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const rowNames = [
|
||||
row.employee,
|
||||
row.bankNameFile,
|
||||
row.bankAccountHolder,
|
||||
]
|
||||
.map(normalizeIdentityText)
|
||||
.filter(Boolean);
|
||||
|
||||
return rowNames.some((name) => correctionNames.has(name));
|
||||
}
|
||||
|
||||
function statusClass(status: HistoricalReport['status']) {
|
||||
if (status === 'Resuelto') return 'bg-blue-50 text-blue-800 border-blue-200';
|
||||
return 'bg-amber-50 text-amber-800 border-amber-200';
|
||||
@@ -197,32 +365,20 @@ export default function History({
|
||||
const [selectedReport, setSelectedReport] = useState<HistoricalReport | null>(null);
|
||||
const [resolutionComment, setResolutionComment] = useState('');
|
||||
|
||||
const filteredReports = useMemo(() => {
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
const [bambooReviewReport, setBambooReviewReport] = useState<HistoricalReport | null>(null);
|
||||
const [bambooCases, setBambooCases] = useState<ReconciliationRow[]>([]);
|
||||
const [reportedBambooRows, setReportedBambooRows] = useState<Set<string>>(new Set());
|
||||
const [isLoadingBambooCases, setIsLoadingBambooCases] = useState(false);
|
||||
const [bambooCasesError, setBambooCasesError] = useState('');
|
||||
const [bambooCorrectionRow, setBambooCorrectionRow] = useState<ReconciliationRow | null>(null);
|
||||
|
||||
return reports.filter((report) => {
|
||||
const matchesStatus =
|
||||
statusFilter === 'todos' ||
|
||||
(statusFilter === 'pendiente' && report.status === 'Pendiente revisión') ||
|
||||
(statusFilter === 'resuelto' && report.status === 'Resuelto');
|
||||
const [totalReports, setTotalReports] = useState(0);
|
||||
const [totalFilteredReports, setTotalFilteredReports] = useState(0);
|
||||
const requestSequenceRef = useRef(0);
|
||||
const activeRequestControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const matchesSearch =
|
||||
!normalizedSearch ||
|
||||
report.period.toLowerCase().includes(normalizedSearch) ||
|
||||
report.payrollFile.toLowerCase().includes(normalizedSearch) ||
|
||||
report.id.toLowerCase().includes(normalizedSearch) ||
|
||||
(report.resolvedBy ?? '').toLowerCase().includes(normalizedSearch) ||
|
||||
(report.executedBy ?? '').toLowerCase().includes(normalizedSearch);
|
||||
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
}, [reports, searchTerm, statusFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredReports.length / REPORTS_PER_PAGE));
|
||||
const paginatedReports = filteredReports.slice(
|
||||
(currentPage - 1) * REPORTS_PER_PAGE,
|
||||
currentPage * REPORTS_PER_PAGE
|
||||
);
|
||||
const totalPages = Math.max(1, Math.ceil(totalFilteredReports / REPORTS_PER_PAGE));
|
||||
const paginatedReports = reports;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
@@ -233,39 +389,220 @@ export default function History({
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const loadReports = async () => {
|
||||
const historyUrl = getHistoryUrl(country.code);
|
||||
if (!historyUrl) {
|
||||
setReports([]);
|
||||
setLoadError(`Los reportes de ${country.name} estarán disponibles próximamente.`);
|
||||
return;
|
||||
}
|
||||
const requestId = ++requestSequenceRef.current;
|
||||
activeRequestControllerRef.current?.abort();
|
||||
|
||||
const controller = new AbortController();
|
||||
activeRequestControllerRef.current = controller;
|
||||
|
||||
const isCurrentRequest = () =>
|
||||
requestSequenceRef.current === requestId && !controller.signal.aborted;
|
||||
|
||||
setIsLoading(true);
|
||||
setLoadError('');
|
||||
|
||||
try {
|
||||
// Camino principal: RPC paginada en Supabase. Cada respuesta se valida
|
||||
// contra un identificador monotónico para que una solicitud vieja nunca
|
||||
// pueda sobrescribir el filtro seleccionado más recientemente.
|
||||
if (supabase) {
|
||||
const rpcStatus =
|
||||
statusFilter === 'pendiente'
|
||||
? 'pendiente_revision'
|
||||
: statusFilter === 'resuelto'
|
||||
? 'resuelto'
|
||||
: null;
|
||||
|
||||
const { data: rpcData, error: rpcError } = await supabase.rpc(
|
||||
'cruce_cuentas_get_historicos',
|
||||
{
|
||||
p_country: country.code,
|
||||
p_page: currentPage,
|
||||
p_page_size: REPORTS_PER_PAGE,
|
||||
p_search: searchTerm.trim() || null,
|
||||
p_status: rpcStatus,
|
||||
}
|
||||
);
|
||||
|
||||
if (!isCurrentRequest()) return;
|
||||
|
||||
if (!rpcError && rpcData) {
|
||||
const payload = Array.isArray(rpcData) ? rpcData[0] : rpcData;
|
||||
const normalized = normalizeReports(payload, country);
|
||||
const total = toNumber((payload as any)?.total);
|
||||
const filteredTotal = toNumber((payload as any)?.filtered_total ?? total);
|
||||
|
||||
setReports(normalized);
|
||||
setTotalReports(total);
|
||||
setTotalFilteredReports(filteredTotal);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
const historyUrl = getHistoryUrl(country.code);
|
||||
if (!historyUrl) {
|
||||
throw new Error(`No está configurado el origen histórico de ${country.name}.`);
|
||||
}
|
||||
|
||||
const url = new URL(historyUrl);
|
||||
url.searchParams.set('country', country.code);
|
||||
const response = await fetch(url.toString(), { method: 'GET' });
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!isCurrentRequest()) return;
|
||||
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
throw new Error('No se pudieron cargar los reportes históricos.');
|
||||
}
|
||||
|
||||
setReports(normalizeReports(payload, country));
|
||||
setCurrentPage(1);
|
||||
} catch {
|
||||
setLoadError('No se pudieron cargar los reportes históricos. Intenta nuevamente.');
|
||||
const allCountryReports = normalizeReports(payload, country);
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
const filtered = allCountryReports.filter((report) => {
|
||||
const matchesStatus =
|
||||
statusFilter === 'todos' ||
|
||||
(statusFilter === 'pendiente' && report.status === 'Pendiente revisión') ||
|
||||
(statusFilter === 'resuelto' && report.status === 'Resuelto');
|
||||
|
||||
const matchesSearch =
|
||||
!normalizedSearch ||
|
||||
report.period.toLowerCase().includes(normalizedSearch) ||
|
||||
report.payrollFile.toLowerCase().includes(normalizedSearch) ||
|
||||
report.id.toLowerCase().includes(normalizedSearch) ||
|
||||
(report.resolvedBy ?? '').toLowerCase().includes(normalizedSearch) ||
|
||||
(report.executedBy ?? '').toLowerCase().includes(normalizedSearch);
|
||||
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
|
||||
if (!isCurrentRequest()) return;
|
||||
|
||||
const offset = (currentPage - 1) * REPORTS_PER_PAGE;
|
||||
setReports(filtered.slice(offset, offset + REPORTS_PER_PAGE));
|
||||
setTotalReports(allCountryReports.length);
|
||||
setTotalFilteredReports(filtered.length);
|
||||
} catch (error) {
|
||||
if (
|
||||
!isCurrentRequest() ||
|
||||
(error instanceof DOMException && error.name === 'AbortError')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReports([]);
|
||||
setTotalReports(0);
|
||||
setTotalFilteredReports(0);
|
||||
setLoadError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'No se pudieron cargar los reportes históricos. Intenta nuevamente.'
|
||||
);
|
||||
onShowToast('No se pudieron cargar los reportes históricos.', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (isCurrentRequest()) {
|
||||
setIsLoading(false);
|
||||
if (activeRequestControllerRef.current === controller) {
|
||||
activeRequestControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadReports();
|
||||
}, [country.code]);
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadReports();
|
||||
}, searchTerm.trim() ? 300 : 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
requestSequenceRef.current += 1;
|
||||
activeRequestControllerRef.current?.abort();
|
||||
activeRequestControllerRef.current = null;
|
||||
};
|
||||
}, [country.code, currentPage, searchTerm, statusFilter]);
|
||||
|
||||
const closeBambooReview = () => {
|
||||
setBambooCorrectionRow(null);
|
||||
setBambooReviewReport(null);
|
||||
setBambooCases([]);
|
||||
setReportedBambooRows(new Set());
|
||||
setBambooCasesError('');
|
||||
};
|
||||
|
||||
const handleOpenBambooCases = async (report: HistoricalReport) => {
|
||||
if (report.bankWithoutBamboo <= 0) {
|
||||
onShowToast('Este reporte no tiene casos de Banco sin Bamboo.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
onShowToast('Supabase no está configurado en esta aplicación.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setBambooReviewReport(report);
|
||||
setBambooCorrectionRow(null);
|
||||
setBambooCases([]);
|
||||
setReportedBambooRows(new Set());
|
||||
setBambooCasesError('');
|
||||
setIsLoadingBambooCases(true);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.rpc(
|
||||
'cruce_cuentas_get_banco_sin_bamboo_reporte',
|
||||
{
|
||||
p_report_id: report.id,
|
||||
p_country: country.code,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const payload = Array.isArray(data) ? data[0] : data;
|
||||
if (!payload || payload.ok === false) {
|
||||
throw new Error(
|
||||
String(payload?.message || 'No se pudieron recuperar los casos de Banco sin Bamboo.'),
|
||||
);
|
||||
}
|
||||
|
||||
const cases = normalizeHistoricalBambooCases(payload.cases);
|
||||
const corrections = normalizeHistoricalBambooCorrections(payload.corrections);
|
||||
const alreadyReported = new Set(
|
||||
cases
|
||||
.filter((row) => corrections.some((correction) => correctionMatchesRow(correction, row)))
|
||||
.map((row) => row.id),
|
||||
);
|
||||
|
||||
setBambooCases(cases);
|
||||
setReportedBambooRows(alreadyReported);
|
||||
|
||||
if (cases.length === 0) {
|
||||
setBambooCasesError(
|
||||
'Este reporte fue creado antes de que se guardara el detalle de Banco sin Bamboo. Los reportes nuevos conservarán los casos para revisarlos desde Históricos.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: 'No se pudieron recuperar los casos de Banco sin Bamboo.';
|
||||
setBambooCasesError(message);
|
||||
onShowToast(message, 'error');
|
||||
} finally {
|
||||
setIsLoadingBambooCases(false);
|
||||
}
|
||||
};
|
||||
|
||||
const bambooPeriodRange: PeriodRange = {
|
||||
label: bambooReviewReport?.period || 'Período no especificado',
|
||||
start: bambooReviewReport?.periodStart || '',
|
||||
end: bambooReviewReport?.periodEnd || '',
|
||||
};
|
||||
|
||||
const handleViewReport = (report: HistoricalReport) => {
|
||||
if (report.reportUrl) {
|
||||
@@ -420,7 +757,7 @@ export default function History({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-neutral-500">
|
||||
<Filter className="w-4 h-4 text-neutral-400" />
|
||||
<span>Mostrando: {filteredReports.length} de {reports.length} reportes</span>
|
||||
<span>Mostrando: {totalFilteredReports} de {totalReports} reportes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -439,7 +776,7 @@ export default function History({
|
||||
<tr className="bg-[#4F758B] text-white text-xs font-bold uppercase tracking-wider">
|
||||
<th className="py-3 px-3 xl:px-4">Fecha</th>
|
||||
<th className="py-3 px-3 xl:px-4">Período</th>
|
||||
<th className="py-3 px-3 xl:px-4">Archivo de Nómina</th>
|
||||
<th className="py-3 px-3 xl:px-4">Archivo de Nómina / Bono 14</th>
|
||||
<th className="py-3 px-3 xl:px-4 text-center">Coincidencias</th>
|
||||
<th className="py-3 px-3 xl:px-4 text-center">Banco sin Bamboo</th>
|
||||
<th className="py-3 px-3 xl:px-4 text-center">Pendientes</th>
|
||||
@@ -484,15 +821,21 @@ export default function History({
|
||||
</td>
|
||||
<td className="py-3 px-3 xl:px-4 text-center font-black text-emerald-800">{report.matches}</td>
|
||||
<td className="py-3 px-3 xl:px-4 text-center">
|
||||
<span
|
||||
className={`inline-block min-w-8 px-2.5 py-1 text-[10px] font-black border ${
|
||||
report.bankWithoutBamboo > 0
|
||||
? 'bg-orange-50 text-orange-800 border-orange-200'
|
||||
: 'bg-neutral-50 text-neutral-400 border-neutral-200'
|
||||
}`}
|
||||
>
|
||||
{report.bankWithoutBamboo}
|
||||
</span>
|
||||
{report.bankWithoutBamboo > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpenBambooCases(report)}
|
||||
className="inline-flex items-center gap-1.5 min-w-8 px-2.5 py-1 text-[10px] font-black border bg-orange-50 text-orange-800 border-orange-200 hover:bg-orange-100 hover:border-orange-300 transition-colors"
|
||||
title="Revisar casos y reportar correcciones a RRHH"
|
||||
>
|
||||
<span>{report.bankWithoutBamboo}</span>
|
||||
<span className="hidden 2xl:inline">Revisar</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-block min-w-8 px-2.5 py-1 text-[10px] font-black border bg-neutral-50 text-neutral-400 border-neutral-200">
|
||||
0
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-3 xl:px-4 text-center">
|
||||
<span
|
||||
@@ -556,7 +899,7 @@ export default function History({
|
||||
|
||||
<div className="p-4 border-t border-neutral-200 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs text-neutral-500">
|
||||
<span>
|
||||
Mostrando {paginatedReports.length} de {filteredReports.length} reporte(s).
|
||||
Mostrando {paginatedReports.length} de {totalFilteredReports} reporte(s).
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -579,6 +922,165 @@ export default function History({
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AnimatePresence>
|
||||
{bambooReviewReport && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/45 px-4 py-6"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
className="w-full max-w-5xl bg-white border border-[#D0D0D0] shadow-2xl max-h-[92vh] flex flex-col"
|
||||
>
|
||||
<div className="h-1.5 bg-[#6CC24A] flex-shrink-0" />
|
||||
<div className="border-b border-neutral-200 px-5 sm:px-6 py-4 flex items-start justify-between gap-4 flex-shrink-0">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-[0.18em] font-black text-[#6B8FA3]">Histórico · Banco sin BambooHR</p>
|
||||
<h2 className="text-lg sm:text-xl font-black text-[#4F758B] mt-1">{bambooReviewReport.period}</h2>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{country.name} · {bambooReviewReport.bankWithoutBamboo} caso{bambooReviewReport.bankWithoutBamboo === 1 ? '' : 's'} guardado{bambooReviewReport.bankWithoutBamboo === 1 ? '' : 's'} en este reporte
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeBambooReview}
|
||||
className="p-2 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="Cerrar revisión de Banco sin Bamboo"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 sm:px-6 py-4 overflow-y-auto">
|
||||
<div className="border border-[#D6E8F4] bg-[#F7FBFD] px-4 py-3 text-xs text-neutral-600 leading-relaxed mb-4">
|
||||
Aquí puedes volver días después al reporte y enviar a RRHH únicamente los falsos positivos que ya verificaste manualmente. Los casos previamente enviados se muestran como <strong>Reportado a RRHH</strong>.
|
||||
</div>
|
||||
|
||||
{isLoadingBambooCases ? (
|
||||
<div className="py-14 text-center text-neutral-500 text-sm">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2 text-[#4F758B]" />
|
||||
Recuperando casos del reporte...
|
||||
</div>
|
||||
) : bambooCasesError ? (
|
||||
<div className="border border-amber-200 bg-amber-50 text-amber-800 px-4 py-3 text-xs font-medium flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{bambooCasesError}</span>
|
||||
</div>
|
||||
) : bambooCases.length > 0 ? (
|
||||
<div className="overflow-x-auto border border-neutral-200">
|
||||
<table className="w-full text-left border-collapse min-w-[760px]">
|
||||
<thead>
|
||||
<tr className="bg-[#4F758B] text-white text-[10px] font-black uppercase tracking-wider">
|
||||
<th className="px-3 py-3">Nombre detectado</th>
|
||||
<th className="px-3 py-3">Cuenta</th>
|
||||
<th className="px-3 py-3 text-right">Monto banco</th>
|
||||
<th className="px-3 py-3">Candidato BambooHR</th>
|
||||
<th className="px-3 py-3 text-center">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 text-xs">
|
||||
{bambooCases.map((row) => {
|
||||
const isReported = reportedBambooRows.has(row.id);
|
||||
const amount = row.amountBank;
|
||||
return (
|
||||
<tr key={row.id} className="hover:bg-neutral-50">
|
||||
<td className="px-3 py-3">
|
||||
<p className="font-black text-neutral-800">{row.employee}</p>
|
||||
{(row.bankNameFile || row.bankAccountHolder) && (
|
||||
<p className="text-[10px] text-neutral-400 mt-1">
|
||||
{[row.bankNameFile, row.bankAccountHolder].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 font-mono font-bold text-neutral-600">{row.account || '—'}</td>
|
||||
<td className="px-3 py-3 text-right font-black text-neutral-700">
|
||||
{amount === null
|
||||
? '—'
|
||||
: new Intl.NumberFormat(country.locale, {
|
||||
style: 'currency',
|
||||
currency: country.currency,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-neutral-600 max-w-[260px]">
|
||||
{row.bestBambooCandidate?.employeeName ? (
|
||||
<div>
|
||||
<p className="font-bold text-[#4F758B]">{row.bestBambooCandidate.employeeName}</p>
|
||||
{row.bestBambooCandidate.employeeNumber && (
|
||||
<p className="text-[10px] text-neutral-400 mt-0.5">Employee Number {row.bestBambooCandidate.employeeNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-neutral-400">Sin candidato confiable</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
{isReported ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 border border-emerald-200 bg-emerald-50 text-emerald-800 text-[10px] font-black uppercase tracking-wider">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
Reportado a RRHH
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBambooCorrectionRow(row)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-[#6CC24A] hover:bg-[#5aaa3f] text-white text-[10px] font-black uppercase tracking-wider transition-colors"
|
||||
>
|
||||
Reportar a RRHH
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-neutral-400 text-sm">No hay casos guardados en este reporte.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 px-5 sm:px-6 py-4 flex items-center justify-between gap-3 flex-shrink-0">
|
||||
<span className="text-[10px] text-neutral-400">ID del reporte: {bambooReviewReport.id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeBambooReview}
|
||||
className="border border-neutral-300 bg-white px-4 py-2 text-xs font-bold text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{bambooCorrectionRow && bambooReviewReport && (
|
||||
<BambooCorrectionModal
|
||||
row={bambooCorrectionRow}
|
||||
country={country}
|
||||
periodRange={bambooPeriodRange}
|
||||
operatorName={operatorName}
|
||||
operatorEmail={operatorEmail}
|
||||
executionId={bambooReviewReport.id}
|
||||
reportUrl={bambooReviewReport.reportUrl}
|
||||
onClose={() => setBambooCorrectionRow(null)}
|
||||
onSuccess={(rowId) => {
|
||||
setReportedBambooRows((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(rowId);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onShowToast={onShowToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedReport && (
|
||||
<motion.div
|
||||
|
||||
@@ -15,7 +15,7 @@ export const COUNTRIES: CountryConfig[] = [
|
||||
flag: import.meta.env.BASE_URL + 'tt-flag.png',
|
||||
currency: 'TTD',
|
||||
locale: 'en-TT',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+108
-8
@@ -1,14 +1,114 @@
|
||||
export const ALLOWED_EMAILS = [
|
||||
'ymadera@gomezleemarketing.com',
|
||||
'iaracena@gomezleemarketing.com',
|
||||
'mgomez@gomezleemarketing.com',
|
||||
'jgomez@gomezleemarketing.com',
|
||||
] as const;
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const ACCESS_RPC_NAME = 'cruce_cuentas_mi_acceso';
|
||||
const ACCESS_RPC_MAX_ATTEMPTS = 2;
|
||||
|
||||
interface AccessRpcPayload {
|
||||
ok?: unknown;
|
||||
autenticado?: unknown;
|
||||
autorizado?: unknown;
|
||||
email?: unknown;
|
||||
nombre?: unknown;
|
||||
activo?: unknown;
|
||||
acceso_app?: unknown;
|
||||
acceso_historicos?: unknown;
|
||||
recibe_reportes_generados?: unknown;
|
||||
recibe_correos_resuelto?: unknown;
|
||||
paises?: unknown;
|
||||
motivo?: unknown;
|
||||
}
|
||||
|
||||
export interface CurrentUserAccess {
|
||||
ok: boolean;
|
||||
authenticated: boolean;
|
||||
authorized: boolean;
|
||||
email: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
appAccess: boolean;
|
||||
historyAccess: boolean;
|
||||
receivesGeneratedReports: boolean;
|
||||
receivesResolvedEmails: boolean;
|
||||
countries: string[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function normalizeEmail(email?: string | null) {
|
||||
return (email ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isAllowedEmail(email?: string | null) {
|
||||
return ALLOWED_EMAILS.includes(normalizeEmail(email) as (typeof ALLOWED_EMAILS)[number]);
|
||||
function normalizeCountries(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map((country) => String(country ?? '').trim().toUpperCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAccessPayload(value: unknown): CurrentUserAccess {
|
||||
const payload = (
|
||||
Array.isArray(value) ? value[0] : value
|
||||
) as AccessRpcPayload | null | undefined;
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new Error('Supabase no devolvió una respuesta válida al comprobar el acceso.');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: payload.ok === true,
|
||||
authenticated: payload.autenticado === true,
|
||||
authorized: payload.autorizado === true,
|
||||
email: normalizeEmail(typeof payload.email === 'string' ? payload.email : ''),
|
||||
name: typeof payload.nombre === 'string' ? payload.nombre.trim() : '',
|
||||
active: payload.activo === true,
|
||||
appAccess: payload.acceso_app === true,
|
||||
historyAccess: payload.acceso_historicos === true,
|
||||
receivesGeneratedReports: payload.recibe_reportes_generados === true,
|
||||
receivesResolvedEmails: payload.recibe_correos_resuelto === true,
|
||||
countries: normalizeCountries(payload.paises),
|
||||
reason: typeof payload.motivo === 'string' ? payload.motivo.trim() : '',
|
||||
};
|
||||
}
|
||||
|
||||
function wait(milliseconds: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprueba el acceso usando exclusivamente la sesión autenticada y la RPC
|
||||
* security-definer de Supabase. La lista de correos ya no vive en el frontend.
|
||||
*/
|
||||
export async function getCurrentUserAccess(): Promise<CurrentUserAccess> {
|
||||
if (!supabase) {
|
||||
throw new Error('Supabase no está configurado.');
|
||||
}
|
||||
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= ACCESS_RPC_MAX_ATTEMPTS; attempt += 1) {
|
||||
const { data, error } = await supabase.rpc(ACCESS_RPC_NAME);
|
||||
|
||||
if (!error) {
|
||||
return normalizeAccessPayload(data);
|
||||
}
|
||||
|
||||
lastError = error;
|
||||
|
||||
if (attempt < ACCESS_RPC_MAX_ATTEMPTS) {
|
||||
await wait(300);
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
lastError && typeof lastError === 'object' && 'message' in lastError
|
||||
? String((lastError as { message?: unknown }).message ?? '')
|
||||
: '';
|
||||
|
||||
throw new Error(
|
||||
message || 'No se pudo validar el acceso del usuario en Supabase.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const DEFAULT_MIN_VALIDITY_SECONDS = 180;
|
||||
const WARMUP_TIMEOUT_MS = 8000;
|
||||
|
||||
/**
|
||||
* Revalida la sesión antes de operaciones importantes. Supabase normalmente
|
||||
* renueva el token por sí solo, pero una pestaña suspendida por varias horas
|
||||
* puede reanudarse antes de que el ciclo automático termine.
|
||||
*/
|
||||
export async function ensureFreshSupabaseSession(
|
||||
minValiditySeconds = DEFAULT_MIN_VALIDITY_SECONDS,
|
||||
) {
|
||||
if (!supabase) return null;
|
||||
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
if (error) throw error;
|
||||
|
||||
let session = data.session;
|
||||
if (!session) {
|
||||
throw new Error('Tu sesión expiró. Inicia sesión nuevamente antes de continuar.');
|
||||
}
|
||||
|
||||
const expiresAtMs = Number(session.expires_at || 0) * 1000;
|
||||
const needsRefresh =
|
||||
!expiresAtMs || expiresAtMs - Date.now() <= minValiditySeconds * 1000;
|
||||
|
||||
if (needsRefresh) {
|
||||
const refreshed = await supabase.auth.refreshSession();
|
||||
if (refreshed.error) throw refreshed.error;
|
||||
session = refreshed.data.session;
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Tu sesión expiró. Inicia sesión nuevamente antes de continuar.');
|
||||
}
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hace una petición mínima al mismo origen de n8n antes de enviar archivos
|
||||
* grandes. Es deliberadamente no-cors y no ejecuta el webhook: solo obliga al
|
||||
* navegador/proxy a reabrir una conexión después de una pestaña inactiva.
|
||||
*/
|
||||
export async function warmRemoteOrigin(targetUrl: string) {
|
||||
if (typeof window === 'undefined' || typeof fetch === 'undefined') return;
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
throw new Error('No hay conexión a Internet. Verifica tu conexión e intenta nuevamente.');
|
||||
}
|
||||
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(targetUrl, window.location.origin);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.origin === window.location.origin) return;
|
||||
|
||||
const warmUrl = new URL('/favicon.ico', target.origin);
|
||||
warmUrl.searchParams.set('_glm_resume', String(Date.now()));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => controller.abort(), WARMUP_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
await fetch(warmUrl.toString(), {
|
||||
method: 'GET',
|
||||
mode: 'no-cors',
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
// Si el navegador declara estar offline, sí detenemos la ejecución. Para
|
||||
// cualquier otro fallo del favicon continuamos: el POST real decidirá si
|
||||
// el servicio está disponible y evitamos falsos bloqueos por un 404/CDN.
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
throw new Error('No hay conexión a Internet. Verifica tu conexión e intenta nuevamente.');
|
||||
}
|
||||
console.warn('No se pudo precalentar la conexión con n8n:', error);
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export function createClientRequestId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `glm-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
}
|
||||
|
||||
export function withFreshRequestQuery(targetUrl: string, requestId: string) {
|
||||
try {
|
||||
const url = new URL(targetUrl, window.location.origin);
|
||||
url.searchParams.set('client_request_id', requestId);
|
||||
url.searchParams.set('_ts', String(Date.now()));
|
||||
return url.toString();
|
||||
} catch {
|
||||
return targetUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
CountryCode,
|
||||
ReconciliationResponse,
|
||||
UploadedFile,
|
||||
ViewType,
|
||||
} from '../types';
|
||||
|
||||
const DATABASE_NAME = 'glm_cruce_cuentas_tab_state';
|
||||
const DATABASE_VERSION = 1;
|
||||
const STORE_NAME = 'drafts';
|
||||
const TAB_SESSION_KEY = 'glm_cruce_cuentas_tab_session_id';
|
||||
const DRAFT_VERSION = 1;
|
||||
const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface TabSessionDraft {
|
||||
version: number;
|
||||
sessionId: string;
|
||||
updatedAt: number;
|
||||
countryCode: CountryCode | null;
|
||||
currentView: ViewType;
|
||||
uploadedPayroll: UploadedFile | null;
|
||||
uploadedBankFiles: UploadedFile[];
|
||||
isCruceExecuted: boolean;
|
||||
cruceResponse: ReconciliationResponse | null;
|
||||
}
|
||||
|
||||
let databasePromise: Promise<IDBDatabase> | null = null;
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
let fallbackSessionId: string | null = null;
|
||||
|
||||
function createSessionId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
}
|
||||
|
||||
export function getTabSessionId() {
|
||||
try {
|
||||
const existing = window.sessionStorage.getItem(TAB_SESSION_KEY);
|
||||
if (existing) return existing;
|
||||
|
||||
const created = createSessionId();
|
||||
window.sessionStorage.setItem(TAB_SESSION_KEY, created);
|
||||
return created;
|
||||
} catch {
|
||||
// Fallback defensivo para navegadores que bloqueen sessionStorage.
|
||||
fallbackSessionId ??= createSessionId();
|
||||
return fallbackSessionId;
|
||||
}
|
||||
}
|
||||
|
||||
function openDatabase() {
|
||||
if (databasePromise) return databasePromise;
|
||||
|
||||
databasePromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
reject(new Error('IndexedDB no está disponible en este navegador.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME, { keyPath: 'sessionId' });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error('No se pudo abrir IndexedDB.'));
|
||||
request.onblocked = () => reject(new Error('La base local está bloqueada por otra pestaña.'));
|
||||
});
|
||||
|
||||
databasePromise.catch(() => {
|
||||
databasePromise = null;
|
||||
});
|
||||
|
||||
return databasePromise;
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error('Falló una operación local.'));
|
||||
});
|
||||
}
|
||||
|
||||
function transactionDone(transaction: IDBTransaction) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error ?? new Error('Falló la transacción local.'));
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error('La transacción local fue cancelada.'));
|
||||
});
|
||||
}
|
||||
|
||||
function isValidDraft(value: unknown): value is TabSessionDraft {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
|
||||
const draft = value as Partial<TabSessionDraft>;
|
||||
return (
|
||||
draft.version === DRAFT_VERSION &&
|
||||
typeof draft.sessionId === 'string' &&
|
||||
typeof draft.updatedAt === 'number' &&
|
||||
(draft.currentView === 'cruce' || draft.currentView === 'historial') &&
|
||||
Array.isArray(draft.uploadedBankFiles) &&
|
||||
typeof draft.isCruceExecuted === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupExpiredDrafts(database: IDBDatabase, currentSessionId: string) {
|
||||
const cutoff = Date.now() - MAX_DRAFT_AGE_MS;
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||
const done = transactionDone(transaction);
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.openCursor();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const draft = cursor.value as Partial<TabSessionDraft>;
|
||||
if (
|
||||
draft.sessionId !== currentSessionId &&
|
||||
typeof draft.updatedAt === 'number' &&
|
||||
draft.updatedAt < cutoff
|
||||
) {
|
||||
cursor.delete();
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error ?? new Error('No se pudieron limpiar borradores antiguos.'));
|
||||
});
|
||||
|
||||
await done;
|
||||
}
|
||||
|
||||
export async function loadTabSessionDraft() {
|
||||
const sessionId = getTabSessionId();
|
||||
|
||||
try {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction(STORE_NAME, 'readonly');
|
||||
const done = transactionDone(transaction);
|
||||
const stored = await requestToPromise(
|
||||
transaction.objectStore(STORE_NAME).get(sessionId)
|
||||
);
|
||||
await done;
|
||||
|
||||
// La limpieza es secundaria y nunca debe impedir la restauración.
|
||||
void cleanupExpiredDrafts(database, sessionId).catch(() => undefined);
|
||||
|
||||
return isValidDraft(stored) ? stored : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTabSessionDraft(
|
||||
draft: Omit<TabSessionDraft, 'version' | 'sessionId' | 'updatedAt'>
|
||||
) {
|
||||
const sessionId = getTabSessionId();
|
||||
const record: TabSessionDraft = {
|
||||
...draft,
|
||||
version: DRAFT_VERSION,
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Serializar las escrituras evita que una operación anterior con archivos
|
||||
// pesados termine después y sobrescriba un estado más reciente.
|
||||
writeQueue = writeQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||
const done = transactionDone(transaction);
|
||||
transaction.objectStore(STORE_NAME).put(record);
|
||||
await done;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return writeQueue;
|
||||
}
|
||||
|
||||
export function clearTabSessionDraft() {
|
||||
const sessionId = getTabSessionId();
|
||||
|
||||
writeQueue = writeQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
||||
const done = transactionDone(transaction);
|
||||
transaction.objectStore(STORE_NAME).delete(sessionId);
|
||||
await done;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return writeQueue;
|
||||
}
|
||||
@@ -45,11 +45,18 @@ export interface PeriodRange {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface BambooCandidate {
|
||||
employeeNumber: string;
|
||||
employeeName: string;
|
||||
score: number | null;
|
||||
}
|
||||
|
||||
export interface ReconciliationRow {
|
||||
id: string;
|
||||
employee: string;
|
||||
employeeNumber: string;
|
||||
account?: string;
|
||||
currency?: string;
|
||||
amountPayroll: number | null;
|
||||
amountBank: number | null;
|
||||
difference: number | null;
|
||||
@@ -57,6 +64,10 @@ export interface ReconciliationRow {
|
||||
category?: string;
|
||||
observation: string;
|
||||
source?: ReconciliationSource;
|
||||
bankNameFile?: string;
|
||||
bankAccountHolder?: string;
|
||||
bestBambooCandidate?: BambooCandidate | null;
|
||||
ambiguousBambooMatch?: boolean;
|
||||
}
|
||||
|
||||
export interface ReconciliationSummary {
|
||||
@@ -76,6 +87,7 @@ export interface ReconciliationSummary {
|
||||
|
||||
export interface ReconciliationResponse {
|
||||
ok: boolean;
|
||||
reportKind?: 'nomina' | 'bono14';
|
||||
executionId?: string;
|
||||
reportUrl?: string;
|
||||
summary: ReconciliationSummary;
|
||||
@@ -89,6 +101,8 @@ export interface HistoricalReport {
|
||||
date: string;
|
||||
executionDate?: string;
|
||||
period: string;
|
||||
periodStart?: string;
|
||||
periodEnd?: string;
|
||||
payrollFile: string;
|
||||
bankFiles: string[];
|
||||
matches: number;
|
||||
|
||||
Vendored
+1
@@ -9,6 +9,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_HISTORICOS_TT_URL?: string;
|
||||
readonly VITE_MARCAR_RESUELTO_TT_URL?: string;
|
||||
readonly VITE_ANALIZAR_NOMINA_URL?: string;
|
||||
readonly VITE_REPORTAR_BAMBOO_URL?: string;
|
||||
readonly VITE_ENABLE_SUPABASE_AUTH?: string;
|
||||
readonly VITE_SUPABASE_URL?: string;
|
||||
readonly VITE_SUPABASE_ANON_KEY?: string;
|
||||
|
||||
Reference in New Issue
Block a user