1203 lines
50 KiB
TypeScript
1203 lines
50 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import {
|
|
AlertTriangle,
|
|
Calendar,
|
|
CheckCircle2,
|
|
Eye,
|
|
FileSpreadsheet,
|
|
Filter,
|
|
Loader2,
|
|
Menu,
|
|
RefreshCw,
|
|
Search,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { CountryConfig, HistoricalReport, PeriodRange, ReconciliationRow } from '../types';
|
|
import BambooCorrectionModal from './BambooCorrectionModal';
|
|
import ReportIssueButton from './ReportIssueButton';
|
|
import { supabase } from '../lib/supabase';
|
|
|
|
interface HistoryProps {
|
|
onShowToast: (msg: string, type: 'success' | 'error' | 'info') => void;
|
|
onOpenSidebar?: () => void;
|
|
operatorName: string;
|
|
operatorEmail: string;
|
|
country: CountryConfig;
|
|
}
|
|
|
|
const REPORTS_PER_PAGE = 10;
|
|
|
|
function getHistoryUrl(countryCode: string) {
|
|
if (countryCode === 'TT') {
|
|
return (import.meta.env.VITE_HISTORICOS_TT_URL as string | undefined) || '';
|
|
}
|
|
|
|
if (countryCode === 'PA') {
|
|
return (
|
|
(import.meta.env.VITE_HISTORICOS_PA_URL as string | undefined) ||
|
|
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-pa-historicos'
|
|
);
|
|
}
|
|
|
|
if (countryCode === 'SV') {
|
|
return (
|
|
(import.meta.env.VITE_HISTORICOS_SV_URL as string | undefined) ||
|
|
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-sv-historicos'
|
|
);
|
|
}
|
|
|
|
if (countryCode === 'HN') {
|
|
return (
|
|
(import.meta.env.VITE_HISTORICOS_HN_URL as string | undefined) ||
|
|
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-hn-historicos'
|
|
);
|
|
}
|
|
|
|
return (
|
|
(import.meta.env.VITE_HISTORICOS_GT_URL as string | undefined) ||
|
|
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-historicos'
|
|
);
|
|
}
|
|
|
|
function getMarkResolvedUrl(countryCode: string) {
|
|
if (countryCode === 'TT') {
|
|
return (import.meta.env.VITE_MARCAR_RESUELTO_TT_URL as string | undefined) || '';
|
|
}
|
|
|
|
if (countryCode === 'PA') {
|
|
return (
|
|
(import.meta.env.VITE_MARCAR_RESUELTO_PA_URL as string | undefined) ||
|
|
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-pa-marcar-resuelto'
|
|
);
|
|
}
|
|
|
|
if (countryCode === 'SV') {
|
|
return (
|
|
(import.meta.env.VITE_MARCAR_RESUELTO_SV_URL as string | undefined) ||
|
|
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-sv-marcar-resuelto'
|
|
);
|
|
}
|
|
|
|
if (countryCode === 'HN') {
|
|
return (
|
|
(import.meta.env.VITE_MARCAR_RESUELTO_HN_URL as string | undefined) ||
|
|
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-hn-marcar-resuelto'
|
|
);
|
|
}
|
|
|
|
return (
|
|
(import.meta.env.VITE_MARCAR_RESUELTO_GT_URL as string | undefined) ||
|
|
'https://agenteit.digitalcompass.agency/webhook/cruce-cuentas-gt-marcar-resuelto'
|
|
);
|
|
}
|
|
|
|
function toNumber(value: unknown) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
function formatDate(value: unknown, locale: string) {
|
|
if (!value) return '';
|
|
const date = new Date(String(value));
|
|
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
|
|
return date.toLocaleDateString(locale, {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
});
|
|
}
|
|
|
|
function formatDateTime(value: unknown, locale: string) {
|
|
if (!value) return '';
|
|
const date = new Date(String(value));
|
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
return date.toLocaleString(locale, {
|
|
dateStyle: 'short',
|
|
timeStyle: 'short',
|
|
});
|
|
}
|
|
|
|
function normalizeStatus(value: unknown): HistoricalReport['status'] {
|
|
return String(value ?? '').toLowerCase().includes('resuelto')
|
|
? 'Resuelto'
|
|
: 'Pendiente revisión';
|
|
}
|
|
|
|
function normalizeReports(input: unknown, country: CountryConfig): HistoricalReport[] {
|
|
const payload = input as Record<string, unknown> | unknown[] | null | undefined;
|
|
const data = (!Array.isArray(payload) && payload ? payload : {}) as Record<string, unknown>;
|
|
const rawReports = Array.isArray(payload)
|
|
? payload
|
|
: Array.isArray(data.reports)
|
|
? data.reports
|
|
: [];
|
|
|
|
return rawReports
|
|
.map((raw, index) => {
|
|
const report = raw as Record<string, unknown>;
|
|
const discrepancies = toNumber(report.discrepancias ?? report.discrepancies);
|
|
const bankWithoutPayroll = toNumber(
|
|
report.bancoSinNomina ??
|
|
report.banco_sin_nomina ??
|
|
report.bankWithoutPayroll
|
|
);
|
|
const bankWithoutBamboo = toNumber(
|
|
report.bancoSinBamboo ??
|
|
report.banco_sin_bamboo ??
|
|
report.bankWithoutBamboo
|
|
);
|
|
const payrollWithoutAccount = toNumber(
|
|
report.nominaSinCuenta ?? report.nomina_sin_cuenta ?? report.payrollWithoutAccount
|
|
);
|
|
const nameDifferences = toNumber(
|
|
report.diferenciasNombreBanco ??
|
|
report.diferencias_nombre_banco ??
|
|
report.nameDifferences
|
|
);
|
|
const totalPending =
|
|
discrepancies +
|
|
bankWithoutPayroll +
|
|
bankWithoutBamboo +
|
|
payrollWithoutAccount +
|
|
nameDifferences;
|
|
const rawCountry = String(report.country ?? report.pais ?? report.country_code ?? country.code).toUpperCase();
|
|
const bankFiles = Array.isArray(report.archivosBanco)
|
|
? report.archivosBanco.map(String)
|
|
: Array.isArray(report.bank_file_names)
|
|
? report.bank_file_names.map(String)
|
|
: [];
|
|
const rawExecutionDate = report.updated_at ?? report.fechaEjecucion ?? report.ejecutado_en ?? report.created_at;
|
|
|
|
return {
|
|
id: String(report.id ?? `reporte-${index + 1}`),
|
|
country: (rawCountry === 'TT' ? 'TT' : rawCountry === 'PA' ? 'PA' : rawCountry === 'SV' ? 'SV' : rawCountry === 'HN' ? 'HN' : 'GT') as HistoricalReport['country'],
|
|
date: formatDate(report.fecha ?? report.period_end ?? rawExecutionDate, country.locale),
|
|
executionDate: formatDateTime(rawExecutionDate, country.locale),
|
|
period: String(report.periodo ?? report.period_label ?? report.period ?? 'Período no especificado'),
|
|
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),
|
|
discrepancies,
|
|
bankWithoutPayroll,
|
|
bankWithoutBamboo,
|
|
payrollWithoutAccount,
|
|
nameDifferences,
|
|
totalPending,
|
|
totalPayroll: toNumber(report.totalNomina ?? report.total_nomina),
|
|
totalBank: toNumber(report.totalBanco ?? report.total_banco),
|
|
totalDifference: toNumber(report.diferenciaTotal ?? report.diferencia_total),
|
|
status: normalizeStatus(report.estado ?? report.status ?? report.estadoLabel),
|
|
reportUrl: report.reportUrl || report.report_url ? String(report.reportUrl ?? report.report_url) : undefined,
|
|
spreadsheetId: report.spreadsheetId || report.spreadsheet_id
|
|
? String(report.spreadsheetId ?? report.spreadsheet_id)
|
|
: undefined,
|
|
executedBy: report.ejecutadoPorNombre || report.ejecutado_por_nombre
|
|
? String(report.ejecutadoPorNombre ?? report.ejecutado_por_nombre)
|
|
: undefined,
|
|
executedByEmail: report.ejecutadoPorEmail || report.ejecutado_por_email
|
|
? String(report.ejecutadoPorEmail ?? report.ejecutado_por_email)
|
|
: undefined,
|
|
resolvedBy: report.resueltoPorNombre || report.resuelto_por_nombre
|
|
? String(report.resueltoPorNombre ?? report.resuelto_por_nombre)
|
|
: undefined,
|
|
resolvedByEmail: report.resueltoPorEmail || report.resuelto_por_email
|
|
? String(report.resueltoPorEmail ?? report.resuelto_por_email)
|
|
: undefined,
|
|
resolvedAt: formatDateTime(report.resueltoEn ?? report.resuelto_en, country.locale),
|
|
resolutionComment: report.comentarioResolucion || report.comentario_resolucion
|
|
? String(report.comentarioResolucion ?? report.comentario_resolucion)
|
|
: undefined,
|
|
_sortTime: new Date(String(rawExecutionDate ?? report.period_end ?? '')).getTime() || 0,
|
|
} as HistoricalReport & { _sortTime: number };
|
|
})
|
|
.filter((report) => report.country === country.code)
|
|
.sort((a, b) => b._sortTime - a._sortTime)
|
|
.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';
|
|
}
|
|
|
|
function statusText(status: HistoricalReport['status']) {
|
|
return status === 'Resuelto' ? 'RESUELTO' : 'PENDIENTE REVISIÓN';
|
|
}
|
|
|
|
export default function History({
|
|
onShowToast,
|
|
onOpenSidebar,
|
|
operatorName,
|
|
operatorEmail,
|
|
country,
|
|
}: HistoryProps) {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<'todos' | 'pendiente' | 'resuelto'>('todos');
|
|
const [reports, setReports] = useState<HistoricalReport[]>([]);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isSavingResolution, setIsSavingResolution] = useState(false);
|
|
const [loadError, setLoadError] = useState('');
|
|
const [selectedReport, setSelectedReport] = useState<HistoricalReport | null>(null);
|
|
const [resolutionComment, setResolutionComment] = useState('');
|
|
|
|
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);
|
|
|
|
const [totalReports, setTotalReports] = useState(0);
|
|
const [totalFilteredReports, setTotalFilteredReports] = useState(0);
|
|
const requestSequenceRef = useRef(0);
|
|
const activeRequestControllerRef = useRef<AbortController | null>(null);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalFilteredReports / REPORTS_PER_PAGE));
|
|
const paginatedReports = reports;
|
|
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [searchTerm, statusFilter, country.code]);
|
|
|
|
useEffect(() => {
|
|
if (currentPage > totalPages) setCurrentPage(totalPages);
|
|
}, [currentPage, totalPages]);
|
|
|
|
const loadReports = async () => {
|
|
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, Trinidad y Tobago, Panamá, El Salvador y Honduras.
|
|
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',
|
|
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.');
|
|
}
|
|
|
|
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 {
|
|
if (isCurrentRequest()) {
|
|
setIsLoading(false);
|
|
if (activeRequestControllerRef.current === controller) {
|
|
activeRequestControllerRef.current = null;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
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) {
|
|
window.open(report.reportUrl, '_blank', 'noopener,noreferrer');
|
|
return;
|
|
}
|
|
|
|
onShowToast('Este reporte aún no tiene un Google Sheet disponible.', 'info');
|
|
};
|
|
|
|
const handleResolveReport = (report: HistoricalReport) => {
|
|
if (report.status === 'Resuelto') {
|
|
onShowToast('Este reporte ya fue marcado como resuelto.', 'info');
|
|
return;
|
|
}
|
|
|
|
setSelectedReport(report);
|
|
setResolutionComment('');
|
|
};
|
|
|
|
const handleConfirmResolve = async () => {
|
|
if (!selectedReport) return;
|
|
|
|
const trimmedComment = resolutionComment.trim();
|
|
if (trimmedComment.length < 10) {
|
|
onShowToast('Debes escribir un comentario de resolución más descriptivo.', 'error');
|
|
return;
|
|
}
|
|
|
|
const markResolvedUrl = getMarkResolvedUrl(country.code);
|
|
if (!markResolvedUrl) {
|
|
onShowToast(`La resolución de reportes para ${country.name} estará disponible próximamente.`, 'info');
|
|
return;
|
|
}
|
|
|
|
setIsSavingResolution(true);
|
|
|
|
try {
|
|
const response = await fetch(markResolvedUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
reportId: selectedReport.id,
|
|
comentarioResolucion: trimmedComment,
|
|
resueltoPorNombre: operatorName,
|
|
resueltoPorEmail: operatorEmail,
|
|
country: country.code,
|
|
countryName: country.name,
|
|
}),
|
|
});
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
if (!response.ok || payload?.ok === false) {
|
|
const message = Array.isArray(payload?.errors)
|
|
? payload.errors.join('\n')
|
|
: payload?.message || 'No se pudo marcar el reporte como resuelto.';
|
|
throw new Error(message);
|
|
}
|
|
|
|
const updated = payload.report ?? {};
|
|
const resolvedAt = formatDateTime(updated.resueltoEn ?? new Date().toISOString(), country.locale);
|
|
|
|
setReports((currentReports) =>
|
|
currentReports.map((item) =>
|
|
item.id === selectedReport.id
|
|
? {
|
|
...item,
|
|
status: 'Resuelto',
|
|
resolvedBy: String(updated.resueltoPorNombre ?? operatorName),
|
|
resolvedByEmail: String(updated.resueltoPorEmail ?? operatorEmail),
|
|
resolvedAt,
|
|
resolutionComment: String(updated.comentarioResolucion ?? trimmedComment),
|
|
}
|
|
: item
|
|
)
|
|
);
|
|
|
|
setSelectedReport(null);
|
|
setResolutionComment('');
|
|
onShowToast('Reporte marcado como resuelto correctamente.', 'success');
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'No se pudo marcar el reporte como resuelto.';
|
|
onShowToast(message, 'error');
|
|
} finally {
|
|
setIsSavingResolution(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col min-h-screen bg-neutral-50 text-neutral-800 font-sans">
|
|
<header className="bg-white border-b-4 border-[#6CC24A] flex justify-between items-center w-full px-4 sm:px-8 h-[84px] sticky top-0 z-40 shadow-sm gap-x-4">
|
|
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
|
<button
|
|
onClick={onOpenSidebar}
|
|
className="md:hidden p-2 -ml-2 text-neutral-600 hover:text-neutral-800 hover:bg-neutral-100 transition-colors flex-shrink-0"
|
|
aria-label="Abrir menú"
|
|
>
|
|
<Menu className="w-6 h-6" />
|
|
</button>
|
|
|
|
<h1 className="text-xs sm:text-sm md:text-lg lg:text-2xl font-black text-[#4F758B] tracking-tight uppercase flex items-center gap-1.5 sm:gap-2 min-w-0">
|
|
<span className="truncate">Reportes Históricos - {country.name}</span>
|
|
</h1>
|
|
</div>
|
|
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
|
<ReportIssueButton
|
|
country={country}
|
|
operatorName={operatorName}
|
|
operatorEmail={operatorEmail}
|
|
section="Reportes Históricos"
|
|
onShowToast={onShowToast}
|
|
/>
|
|
<div className="hidden sm:flex items-center border border-neutral-200 bg-white px-2.5 py-2 shadow-sm" aria-label={`Bandera de ${country.name}`}>
|
|
<img src={country.flag} alt={`Bandera de ${country.name}`} className="h-5 sm:h-6 w-auto object-contain" />
|
|
</div>
|
|
<button
|
|
onClick={loadReports}
|
|
disabled={isLoading}
|
|
className="hidden sm:inline-flex items-center gap-2 border border-[#4F758B]/20 text-[#4F758B] px-3 py-2 text-xs font-bold hover:border-[#4F758B]/50 disabled:opacity-50"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
Actualizar
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 py-8 flex flex-col gap-6">
|
|
<div className="bg-white border border-[#D0D0D0] p-4 flex flex-col lg:flex-row items-center justify-between gap-4 shadow-sm">
|
|
<div className="relative w-full lg:max-w-md">
|
|
<Search className="absolute left-3.5 top-1/2 transform -translate-y-1/2 text-neutral-400 w-4 h-4" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar por período, reporte, archivo o resolutor..."
|
|
value={searchTerm}
|
|
onChange={(event) => setSearchTerm(event.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 border border-[#D0D0D0] text-xs font-medium placeholder-neutral-400 focus:outline-none focus:border-[#4F758B] transition-colors"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 w-full lg:w-auto">
|
|
<div className="flex flex-wrap gap-2">
|
|
{[
|
|
['todos', 'Todos'],
|
|
['pendiente', 'Pendiente revisión'],
|
|
['resuelto', 'Resuelto'],
|
|
].map(([value, label]) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => setStatusFilter(value as typeof statusFilter)}
|
|
className={`px-3 py-1.5 text-[10px] uppercase tracking-wider font-black border transition-colors ${
|
|
statusFilter === value
|
|
? 'bg-[#4F758B] text-white border-[#4F758B]'
|
|
: 'bg-white text-neutral-500 border-neutral-300 hover:border-[#4F758B] hover:text-[#4F758B]'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</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: {totalFilteredReports} de {totalReports} reportes</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{loadError && (
|
|
<div className="border border-red-200 bg-red-50 text-[#ba1a1a] px-4 py-3 text-xs font-bold flex items-center gap-2">
|
|
<AlertTriangle className="w-4 h-4" />
|
|
{loadError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-white border border-[#D0D0D0] shadow-sm overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse min-w-[960px] xl:min-w-full">
|
|
<thead>
|
|
<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 / 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>
|
|
<th className="py-3 px-3 xl:px-4 text-center">Estado</th>
|
|
<th className="py-3 px-3 xl:px-4">Resuelto por</th>
|
|
<th className="py-3 px-3 xl:px-4 text-center">Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-neutral-200 text-xs">
|
|
{isLoading ? (
|
|
<tr>
|
|
<td colSpan={9} className="py-12 text-center text-neutral-400 font-medium">
|
|
<div className="inline-flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Cargando reportes históricos...
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : paginatedReports.length > 0 ? (
|
|
paginatedReports.map((report) => (
|
|
<motion.tr
|
|
key={report.id}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="hover:bg-neutral-50/70 transition-colors"
|
|
>
|
|
<td className="py-3 px-3 xl:px-4 font-medium text-neutral-700">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="w-4 h-4 text-neutral-400" />
|
|
<div>
|
|
<span>{report.date}</span>
|
|
{report.executionDate && <p className="text-[9px] text-neutral-400 mt-0.5">{report.executionDate}</p>}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-3 xl:px-4 font-bold text-[#4F758B]">{report.period}</td>
|
|
<td className="py-3 px-3 xl:px-4 font-mono text-neutral-500 truncate max-w-[260px]" title={report.payrollFile}>
|
|
<div className="flex items-center gap-1.5">
|
|
<FileSpreadsheet className="w-4 h-4 text-neutral-400 flex-shrink-0" />
|
|
<span className="truncate">{report.payrollFile || 'No especificado'}</span>
|
|
</div>
|
|
</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">
|
|
{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
|
|
className={`inline-block px-2.5 py-1 text-[10px] font-bold border ${
|
|
report.totalPending > 0
|
|
? 'bg-red-50 text-[#ba1a1a] border-red-200'
|
|
: 'bg-emerald-50 text-emerald-800 border-emerald-200'
|
|
}`}
|
|
>
|
|
{report.totalPending === 0
|
|
? 'Sin pendientes'
|
|
: `${report.totalPending} pendiente${report.totalPending > 1 ? 's' : ''}`}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-3 xl:px-4 text-center">
|
|
<span className={`inline-block px-2.5 py-1 text-[9px] uppercase font-black tracking-wider border ${statusClass(report.status)}`}>
|
|
{statusText(report.status)}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-3 xl:px-4 text-neutral-600 max-w-[220px]">
|
|
{report.resolvedBy ? (
|
|
<div title={report.resolutionComment || ''}>
|
|
<p className="font-bold text-neutral-700 truncate">{report.resolvedBy}</p>
|
|
<p className="text-[10px] text-neutral-400">{report.resolvedAt}</p>
|
|
</div>
|
|
) : (
|
|
<span className="text-neutral-400">Pendiente</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-3 xl:px-4 text-center">
|
|
<div className="flex justify-center gap-2">
|
|
<button
|
|
onClick={() => handleViewReport(report)}
|
|
className="inline-flex items-center gap-1.5 text-[#4F758B] hover:text-[#3d6378] font-bold text-xs border border-[#4F758B]/20 hover:border-[#4F758B]/50 px-3 py-1.5 bg-white hover:bg-neutral-50 transition-colors"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
<span>Ver</span>
|
|
</button>
|
|
<button
|
|
onClick={() => handleResolveReport(report)}
|
|
disabled={report.status === 'Resuelto'}
|
|
className="inline-flex items-center gap-1.5 text-emerald-700 hover:text-emerald-800 font-bold text-xs border border-emerald-200 hover:border-emerald-400 px-3 py-1.5 bg-emerald-50 hover:bg-emerald-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
<span>{report.status === 'Resuelto' ? 'Resuelto' : 'Marcar resuelto'}</span>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</motion.tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan={9} className="py-12 text-center text-neutral-400 font-medium">
|
|
No se encontraron reportes históricos.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<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 {totalFilteredReports} reporte(s).
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
|
disabled={currentPage === 1}
|
|
className="px-3 py-1.5 border border-neutral-300 bg-white disabled:opacity-40 disabled:cursor-not-allowed hover:border-[#4F758B] font-bold"
|
|
>
|
|
Anterior
|
|
</button>
|
|
<span className="font-bold text-neutral-700">Página {currentPage} de {totalPages}</span>
|
|
<button
|
|
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
|
disabled={currentPage === totalPages}
|
|
className="px-3 py-1.5 border border-neutral-300 bg-white disabled:opacity-40 disabled:cursor-not-allowed hover:border-[#4F758B] font-bold"
|
|
>
|
|
Siguiente
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</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
|
|
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"
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 12 }}
|
|
className="w-full max-w-lg bg-white border border-[#D0D0D0] shadow-xl"
|
|
>
|
|
<div className="border-b border-neutral-200 px-5 py-4 flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-base font-black text-[#4F758B] uppercase tracking-tight">Marcar reporte como resuelto</h2>
|
|
<p className="text-xs text-neutral-500 mt-1">{selectedReport.period}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setSelectedReport(null)}
|
|
disabled={isSavingResolution}
|
|
className="p-2 text-neutral-400 hover:text-neutral-700 disabled:opacity-50"
|
|
aria-label="Cerrar"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-5 space-y-4">
|
|
<div className="bg-amber-50 border border-amber-200 text-amber-800 p-3 text-xs leading-relaxed">
|
|
Antes de resolver, confirma que las diferencias fueron revisadas y explica cómo se cerró el caso.
|
|
</div>
|
|
|
|
<label className="block">
|
|
<span className="text-[10px] uppercase tracking-wider font-black text-neutral-500">Comentario de resolución</span>
|
|
<textarea
|
|
value={resolutionComment}
|
|
onChange={(event) => setResolutionComment(event.target.value)}
|
|
placeholder="Ejemplo: Se corrigió la cuenta bancaria en nómina y se validó contra el banco."
|
|
className="mt-2 w-full min-h-[130px] border border-[#D0D0D0] px-3 py-2 text-sm focus:outline-none focus:border-[#4F758B]"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="border-t border-neutral-200 px-5 py-4 flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setSelectedReport(null)}
|
|
disabled={isSavingResolution}
|
|
className="border border-neutral-300 bg-white px-4 py-2 text-xs font-bold text-neutral-600 hover:bg-neutral-50 disabled:opacity-50"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
onClick={handleConfirmResolve}
|
|
disabled={isSavingResolution}
|
|
className="bg-[#4F758B] hover:bg-[#41677b] text-white px-4 py-2 text-xs font-black uppercase tracking-wider disabled:opacity-50 inline-flex items-center gap-2"
|
|
>
|
|
{isSavingResolution && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
Confirmar resolución
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|