Agregar Bono 14 Guatemala y actualizar dist

This commit is contained in:
2026-08-12 09:13:40 -04:00
parent 65ab888c00
commit 6652877319
33 changed files with 3983 additions and 4833 deletions
+555 -53
View File
@@ -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