Update project files

This commit is contained in:
2026-07-13 16:10:01 -04:00
parent a6e66e6a89
commit e160a0901c
29 changed files with 3583 additions and 2898 deletions
+650
View File
@@ -0,0 +1,650 @@
import React, { useEffect, useMemo, 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 } from '../types';
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) || '';
}
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) || '';
}
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.fechaEjecucion ?? report.ejecutado_en ?? report.created_at;
return {
id: String(report.id ?? `reporte-${index + 1}`),
country: (rawCountry === 'TT' ? 'TT' : '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'),
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 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 filteredReports = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
return reports.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;
});
}, [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
);
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, statusFilter, country.code]);
useEffect(() => {
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [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;
}
setIsLoading(true);
setLoadError('');
try {
const url = new URL(historyUrl);
url.searchParams.set('country', country.code);
const response = await fetch(url.toString(), { method: 'GET' });
const payload = await response.json().catch(() => null);
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.');
onShowToast('No se pudieron cargar los reportes históricos.', 'error');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadReports();
}, [country.code]);
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-3 flex-shrink-0">
<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: {filteredReports.length} de {reports.length} 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</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">
<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>
</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 {filteredReports.length} 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>
{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>
);
}