Files
cruce-cuentas-glm-centralizado/src/components/BambooCorrectionModal.tsx
T

337 lines
13 KiB
TypeScript

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(() => {
if (country.code === 'TT') return 'Laurele Nicome-Jack';
if (country.code === 'PA') return 'Delarys Peñalba y Ariadne Cortéz';
return 'Pablo Gamboa Cú, Sara Meyer Lima y María Morales Adler';
}, [country.code]);
const sourceNames = useMemo(
() =>
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>
);
}