694 lines
42 KiB
JavaScript
694 lines
42 KiB
JavaScript
import React, { useEffect, useState, useLayoutEffect, useRef } from 'react';
|
||
import { fetchWithAuth, postWithAuth } from '../fetchWithAuth';
|
||
// Animación fade-in/slide-up para bloques
|
||
const fadeInSlideUp = `@keyframes fadein-slideup { 0% { opacity: 0; transform: translateY(40px); } 100% { opacity: 1; transform: translateY(0); } }`;
|
||
if (typeof document !== 'undefined' && !document.getElementById('fadein-slideup-documents')) {
|
||
const style = document.createElement('style');
|
||
style.id = 'fadein-slideup-documents';
|
||
style.innerHTML = fadeInSlideUp;
|
||
document.head.appendChild(style);
|
||
}
|
||
import { fetchDocuments } from '../api/expedientes.ts';
|
||
import { useNotification } from '../context/NotificationContext';
|
||
import { usePolling } from '../hooks/usePolling';
|
||
import { Link } from 'react-router-dom';
|
||
|
||
const API_URL = import.meta.env.VITE_EFC_API_URL;
|
||
|
||
const downloadFile = async (id, filename = 'archivo', setSuccess, setError, showMessage) => {
|
||
try {
|
||
const res = await fetchWithAuth(`${API_URL}/record/documents/descargar/${id}/`);
|
||
|
||
if (!res.ok) {
|
||
alert('No autorizado o error en la descarga');
|
||
return;
|
||
}
|
||
|
||
const blob = await res.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
window.URL.revokeObjectURL(url);
|
||
if (setSuccess) setSuccess('Descarga exitosa');
|
||
} catch (error) {
|
||
console.error('Error downloading file:', error);
|
||
showMessage('Error al descargar el archivo', 'error');
|
||
}
|
||
};
|
||
|
||
export default function Documents() {
|
||
const focusKeeperRef = useRef(null);
|
||
const [success, setSuccess] = useState('');
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||
const [alertaFilter, setAlertaFilter] = useState('all'); // all, true, false
|
||
const [expedienteFilter, setExpedienteFilter] = useState('all'); // all, true, false
|
||
const [contribuyenteFilter, setContribuyenteFilter] = useState('');
|
||
const [contribuyenteInput, setContribuyenteInput] = useState('');
|
||
const [fechaPagoFilter, setFechaPagoFilter] = useState('');
|
||
const [pedimentoFilter, setPedimentoFilter] = useState('');
|
||
const [searchFilter, setSearchFilter] = useState('');
|
||
const [curpApoderadoFilter, setCurpApoderadoFilter] = useState('');
|
||
const [patenteFilter, setPatenteFilter] = useState('');
|
||
const [aduanaFilter, setAduanaFilter] = useState('');
|
||
const [tipoOperacionFilter, setTipoOperacionFilter] = useState('');
|
||
const [clavePedimentoFilter, setClavePedimentoFilter] = useState('');
|
||
const { showMessage } = useNotification();
|
||
// Estado para controlar la animación de entrada
|
||
const [showAnimation, setShowAnimation] = useState(false);
|
||
const [hasAnimated, setHasAnimated] = useState(false);
|
||
useLayoutEffect(() => {
|
||
// Forzar un render antes de activar la animación
|
||
setShowAnimation(true);
|
||
}, []);
|
||
useEffect(() => {
|
||
if (showAnimation && !hasAnimated) {
|
||
const timeout = setTimeout(() => {
|
||
setHasAnimated(true);
|
||
setShowAnimation(false);
|
||
}, 700); // Duración igual a la animación
|
||
return () => clearTimeout(timeout);
|
||
}
|
||
}, [showAnimation, hasAnimated]);
|
||
|
||
// Fetching usando la función tipada de TypeScript
|
||
const fetchPedimentosData = async (page = currentPage, pageSize = itemsPerPage) => {
|
||
// Construir objeto de filtros
|
||
const filters = {
|
||
search: searchFilter || undefined,
|
||
pedimento: pedimentoFilter || undefined,
|
||
existe_expediente: expedienteFilter === 'all' ? undefined : expedienteFilter,
|
||
alerta: alertaFilter === 'all' ? undefined : alertaFilter,
|
||
contribuyente: contribuyenteFilter || undefined,
|
||
curp_apoderado: curpApoderadoFilter || undefined,
|
||
fecha_pago: fechaPagoFilter || undefined,
|
||
patente: patenteFilter || undefined,
|
||
aduana: aduanaFilter || undefined,
|
||
tipo_operacion: tipoOperacionFilter || undefined,
|
||
clave_pedimento: clavePedimentoFilter || undefined,
|
||
};
|
||
return await fetchDocuments(page, pageSize, filters);
|
||
};
|
||
|
||
// Hook de polling que se ejecuta cada 30 segundos
|
||
const { data: pedimentos, loading, error, refetch } = usePolling(
|
||
() => fetchPedimentosData(currentPage, itemsPerPage),
|
||
30000, // 30 segundos
|
||
[currentPage, itemsPerPage, searchFilter, pedimentoFilter, expedienteFilter, alertaFilter, contribuyenteFilter, curpApoderadoFilter, fechaPagoFilter, patenteFilter, aduanaFilter, tipoOperacionFilter, clavePedimentoFilter]
|
||
);
|
||
|
||
// Manejo de errores de sesión
|
||
useEffect(() => {
|
||
if (error && error.message === 'SESSION_EXPIRED') {
|
||
localStorage.removeItem('access');
|
||
localStorage.removeItem('refresh');
|
||
showMessage('Tu sesión ha expirado, por favor inicia sesión de nuevo.', 'error');
|
||
setTimeout(() => {
|
||
window.location.href = '/login';
|
||
}, 2000);
|
||
} else if (error) {
|
||
showMessage(error.message, 'error');
|
||
}
|
||
}, [error, showMessage]);
|
||
|
||
// Cálculos de paginación usando la estructura tipada
|
||
const documentsArray = pedimentos && pedimentos.results ? pedimentos.results : [];
|
||
const totalDocuments = pedimentos && typeof pedimentos.count === 'number' ? pedimentos.count : 0;
|
||
const totalPages = totalDocuments > 0 ? Math.ceil(totalDocuments / itemsPerPage) : 1;
|
||
const currentDocuments = documentsArray;
|
||
|
||
// Obtener lista única de contribuyentes para el combobox (de la página actual)
|
||
const contribuyentes = Array.from(new Set(currentDocuments.map(d => d.contribuyente).filter(Boolean)));
|
||
|
||
|
||
// Refuerza la paginación SPA: nunca recarga la página, solo cambia el estado local
|
||
const handlePageChange = (newPage, e) => {
|
||
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
||
if (e && typeof e.stopPropagation === 'function') e.stopPropagation();
|
||
if (newPage < 1 || newPage > totalPages || newPage === currentPage) return;
|
||
setCurrentPage(newPage);
|
||
// Quitar el foco del botón activo para evitar salto de scroll
|
||
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
|
||
document.activeElement.blur();
|
||
}
|
||
};
|
||
|
||
// Forzar foco al div invisible para evitar saltos por enfoque automático
|
||
useLayoutEffect(() => {
|
||
if (focusKeeperRef.current) {
|
||
focusKeeperRef.current.focus();
|
||
}
|
||
}, [currentPage]);
|
||
|
||
const handleItemsPerPageChange = (newItemsPerPage) => {
|
||
setItemsPerPage(newItemsPerPage);
|
||
setCurrentPage(1); // Reset a la primera página
|
||
};
|
||
|
||
|
||
// El layout principal y la tabla siempre se renderizan, loader/error/empty solo dentro del área de la tabla
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 p-4 sm:p-6 lg:p-8">
|
||
<div ref={focusKeeperRef} tabIndex={-1} style={{position:'absolute',width:0,height:0,overflow:'hidden',outline:'none'}} aria-hidden="true"></div>
|
||
<div className="max-w-7xl mx-auto">
|
||
{/* Header mejorado y decorativo */}
|
||
<div className={
|
||
"mb-6 sm:mb-8 relative overflow-hidden rounded-3xl shadow-2xl bg-gradient-to-r from-blue-600 via-blue-700 to-blue-800 p-6 sm:p-8 flex items-center gap-4 sm:gap-6"+
|
||
(showAnimation && !hasAnimated ? ' animate-fadein-slideup opacity-0' : '')
|
||
}
|
||
style={showAnimation && !hasAnimated ? { animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards' } : undefined}>
|
||
<div className="flex-shrink-0 bg-white/20 backdrop-blur-sm rounded-full p-3 sm:p-4 shadow-lg animate-bounce-slow">
|
||
<svg className="h-8 w-8 sm:h-10 sm:w-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-extrabold text-white tracking-tight mb-1 flex flex-col sm:flex-row sm:items-center gap-2">
|
||
<span>Expedientes</span>
|
||
{totalDocuments > 0 && (
|
||
<span className="inline-block bg-white/20 backdrop-blur-sm text-white text-xs sm:text-sm font-semibold px-3 py-1 rounded-full shadow-lg animate-fade-in">
|
||
{totalDocuments} registros
|
||
</span>
|
||
)}
|
||
</h1>
|
||
<p className="text-sm sm:text-lg text-blue-100 font-medium leading-relaxed">Gestiona y descarga los documentos de tus pedimentos</p>
|
||
</div>
|
||
{/* Efectos decorativos de fondo modernos */}
|
||
<div className="absolute -top-10 -right-10 opacity-20 pointer-events-none select-none">
|
||
<div className="w-32 h-32 bg-white/10 rounded-full blur-xl"></div>
|
||
</div>
|
||
<div className="absolute -bottom-6 -left-6 opacity-15 pointer-events-none select-none">
|
||
<div className="w-24 h-24 bg-white/10 rounded-full blur-lg"></div>
|
||
</div>
|
||
{/* Partículas flotantes */}
|
||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||
<div className="absolute top-1/4 left-1/4 w-2 h-2 bg-white/30 rounded-full animate-ping"></div>
|
||
<div className="absolute top-3/4 right-1/3 w-1 h-1 bg-white/40 rounded-full animate-pulse"></div>
|
||
<div className="absolute top-1/2 right-1/4 w-3 h-3 bg-white/20 rounded-full animate-bounce"></div>
|
||
</div>
|
||
</div>
|
||
{/* Animación personalizada para el icono y contador */}
|
||
<style>{`
|
||
@keyframes bounce-slow {
|
||
0%, 100% { transform: translateY(0) scale(1); }
|
||
50% { transform: translateY(-8px) scale(1.05); }
|
||
}
|
||
.animate-bounce-slow {
|
||
animation: bounce-slow 3s infinite;
|
||
}
|
||
@keyframes fade-in {
|
||
from { opacity: 0; transform: scale(0.9) translateY(10px); }
|
||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||
}
|
||
.animate-fade-in {
|
||
animation: fade-in 0.8s ease-out;
|
||
}
|
||
`}</style>
|
||
|
||
<div className={
|
||
"bg-white shadow-2xl rounded-3xl border border-gray-100 overflow-hidden"+
|
||
(showAnimation && !hasAnimated ? ' animate-fadein-slideup opacity-0' : '')
|
||
}
|
||
style={showAnimation && !hasAnimated ? { animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.15s forwards' } : undefined}>
|
||
<div className="px-4 sm:px-6 py-4 sm:py-6 border-b border-gray-200 bg-gradient-to-r from-gray-50 to-blue-50/30">
|
||
{/* Filtros avanzados */}
|
||
<div className="mb-4 sm:mb-6">
|
||
<h3 className="text-sm font-semibold text-gray-800 mb-3 flex items-center">
|
||
<svg className="w-4 h-4 mr-2 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.414A1 1 0 013 6.707V4z" />
|
||
</svg>
|
||
Filtros de búsqueda
|
||
</h3>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
|
||
{/* Search global */}
|
||
<div className="flex flex-col">
|
||
<label className="text-xs font-semibold text-gray-700 mb-1.5">Buscar</label>
|
||
<input
|
||
type="text"
|
||
value={searchFilter}
|
||
onChange={e => setSearchFilter(e.target.value)}
|
||
placeholder="Buscar pedimento, contribuyente..."
|
||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||
/>
|
||
</div>
|
||
{/* Pedimento */}
|
||
<div className="flex flex-col">
|
||
<label className="text-xs font-semibold text-gray-700 mb-1.5">Pedimento</label>
|
||
<input
|
||
type="text"
|
||
value={pedimentoFilter}
|
||
onChange={e => setPedimentoFilter(e.target.value)}
|
||
placeholder="Número de pedimento..."
|
||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||
/>
|
||
</div>
|
||
{/* Expediente */}
|
||
<div className="flex flex-col">
|
||
<label className="text-xs font-semibold text-gray-700 mb-1.5">Expediente</label>
|
||
<select value={expedienteFilter} onChange={e => setExpedienteFilter(e.target.value)}
|
||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md">
|
||
<option value="all">Todos</option>
|
||
<option value="true">Con expediente</option>
|
||
<option value="false">Sin expediente</option>
|
||
</select>
|
||
</div>
|
||
{/* Contribuyente combobox */}
|
||
<div className="flex flex-col relative">
|
||
<label className="text-xs font-semibold text-gray-700 mb-1.5">Contribuyente</label>
|
||
<input
|
||
type="text"
|
||
value={contribuyenteInput}
|
||
onChange={e => {
|
||
setContribuyenteInput(e.target.value);
|
||
setContribuyenteFilter('');
|
||
}}
|
||
placeholder="Buscar o escribir..."
|
||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||
autoComplete="off"
|
||
/>
|
||
{/* Dropdown de sugerencias */}
|
||
{contribuyenteInput && (
|
||
<div className="absolute top-16 left-0 right-0 bg-white border border-gray-200 rounded-xl shadow-2xl z-50 max-h-40 overflow-auto">
|
||
{contribuyentes.filter(c => c.toLowerCase().includes(contribuyenteInput.toLowerCase())).length === 0 ? (
|
||
<div className="px-3 py-2 text-sm text-gray-500">Sin coincidencias</div>
|
||
) : (
|
||
contribuyentes.filter(c => c.toLowerCase().includes(contribuyenteInput.toLowerCase())).map(c => (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
className="w-full text-left px-3 py-2 hover:bg-blue-50 text-sm transition-colors duration-200 first:rounded-t-xl last:rounded-b-xl"
|
||
onClick={() => {
|
||
setContribuyenteFilter(c);
|
||
setContribuyenteInput('');
|
||
}}
|
||
>
|
||
{c}
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* CURP Apoderado */}
|
||
<div className="flex flex-col">
|
||
<label className="text-xs font-semibold text-gray-700 mb-1.5">CURP Apoderado</label>
|
||
<input
|
||
type="text"
|
||
value={curpApoderadoFilter}
|
||
onChange={e => setCurpApoderadoFilter(e.target.value)}
|
||
placeholder="CURP del apoderado..."
|
||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||
/>
|
||
</div>
|
||
{/* Fecha de pago */}
|
||
<div className="flex flex-col">
|
||
<label className="text-xs font-semibold text-gray-700 mb-1.5">Fecha de pago</label>
|
||
<input type="date" value={fechaPagoFilter} onChange={e => setFechaPagoFilter(e.target.value)}
|
||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md" />
|
||
</div>
|
||
{/* Patente */}
|
||
<div className="flex flex-col">
|
||
<label className="text-xs font-semibold text-gray-700 mb-1.5">Patente</label>
|
||
<input
|
||
type="text"
|
||
value={patenteFilter}
|
||
onChange={e => setPatenteFilter(e.target.value)}
|
||
placeholder="Patente..."
|
||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||
/>
|
||
</div>
|
||
{/* Aduana */}
|
||
<div className="flex flex-col">
|
||
<label className="text-xs font-semibold text-gray-700 mb-1.5">Aduana</label>
|
||
<input
|
||
type="text"
|
||
value={aduanaFilter}
|
||
onChange={e => setAduanaFilter(e.target.value)}
|
||
placeholder="Aduana..."
|
||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||
/>
|
||
</div>
|
||
{/* Tipo de operación */}
|
||
<div className="flex flex-col">
|
||
<label className="text-xs font-semibold text-gray-700 mb-1.5">Tipo de operación</label>
|
||
<input
|
||
type="text"
|
||
value={tipoOperacionFilter}
|
||
onChange={e => setTipoOperacionFilter(e.target.value)}
|
||
placeholder="ID tipo operación..."
|
||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||
/>
|
||
</div>
|
||
{/* Clave pedimento */}
|
||
<div className="flex flex-col">
|
||
<label className="text-xs font-semibold text-gray-700 mb-1.5">Clave pedimento</label>
|
||
<input
|
||
type="text"
|
||
value={clavePedimentoFilter}
|
||
onChange={e => setClavePedimentoFilter(e.target.value)}
|
||
placeholder="Clave pedimento..."
|
||
className="w-full border border-gray-300 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||
<div className="flex items-center gap-3">
|
||
<span className="inline-flex items-center text-xs text-blue-600 bg-blue-50 px-3 py-2 rounded-full font-medium">
|
||
<svg className="w-4 h-4 mr-2 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
Actualización automática cada 30s
|
||
</span>
|
||
{loading && (
|
||
<span className="inline-flex items-center text-xs text-orange-600 bg-orange-50 px-3 py-2 rounded-full font-medium">
|
||
<svg className="w-4 h-4 mr-2 animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
Actualizando...
|
||
</span>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={refetch}
|
||
disabled={loading}
|
||
className="inline-flex items-center px-4 py-2.5 border border-transparent text-sm font-medium rounded-xl text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:scale-105 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||
>
|
||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
Actualizar Ahora
|
||
</button>
|
||
</div>
|
||
{success && (
|
||
<div className="mt-4 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 shadow-sm">
|
||
<div className="flex">
|
||
<div className="flex-shrink-0">
|
||
<svg className="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||
</svg>
|
||
</div>
|
||
<div className="ml-3">
|
||
<p className="text-sm font-medium text-green-800">{success}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="overflow-hidden">
|
||
{/* Vista de tabla para pantallas grandes */}
|
||
<div className="hidden lg:block">
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-200">
|
||
<thead className="bg-gradient-to-r from-gray-50 to-blue-50">
|
||
<tr>
|
||
<th className="px-4 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Pedimento</th>
|
||
<th className="px-4 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Fecha de pago</th>
|
||
<th className="px-4 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Contribuyente</th>
|
||
<th className="px-4 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">CURP Apoderado</th>
|
||
<th className="px-4 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Importe total</th>
|
||
<th className="px-4 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Saldo disponible</th>
|
||
<th className="px-4 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Importe pedimento</th>
|
||
<th className="px-4 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider border-b border-gray-200">Expediente</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-100">
|
||
{loading ? (
|
||
<tr>
|
||
<td colSpan={8} className="px-6 py-12 text-center">
|
||
<div className="flex flex-col items-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
||
<span className="text-gray-500 text-lg font-medium">Cargando expedientes...</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : error ? (
|
||
<tr>
|
||
<td colSpan={8} className="px-6 py-12 text-center">
|
||
<div className="flex flex-col items-center">
|
||
<div className="bg-red-100 rounded-full p-3 mb-4">
|
||
<svg className="h-8 w-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||
</svg>
|
||
</div>
|
||
<span className="text-red-600 text-lg font-medium">Error: {error.message || 'Error al cargar expedientes'}</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : currentDocuments.length > 0 ? (
|
||
currentDocuments.map(ped => (
|
||
<tr key={ped.id} className="hover:bg-blue-50 transition-all duration-300 group">
|
||
<td className="px-4 py-4 whitespace-nowrap">
|
||
<Link
|
||
to={`/expedientes/pedimento/${ped.id}`}
|
||
className="text-blue-600 hover:text-blue-800 font-semibold transition-colors duration-200 group-hover:underline"
|
||
>
|
||
{ped.pedimento}
|
||
</Link>
|
||
</td>
|
||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-700">{ped.fechapago}</td>
|
||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-700 max-w-xs truncate" title={ped.contribuyente}>{ped.contribuyente}</td>
|
||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-700">{ped.curp_apoderado}</td>
|
||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">${ped.importe_total}</td>
|
||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">${ped.saldo_disponible}</td>
|
||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">${ped.importe_pedimento}</td>
|
||
<td className="px-4 py-4 whitespace-nowrap">
|
||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${
|
||
ped.existe_expediente
|
||
? 'bg-green-100 text-green-800 border border-green-200'
|
||
: 'bg-gray-100 text-gray-600 border border-gray-200'
|
||
}`}>
|
||
{ped.existe_expediente ? (
|
||
<>
|
||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||
</svg>
|
||
Sí
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||
</svg>
|
||
No
|
||
</>
|
||
)}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))
|
||
) : (
|
||
<tr>
|
||
<td colSpan={8} className="px-6 py-12 text-center">
|
||
<div className="flex flex-col items-center">
|
||
<div className="bg-gray-100 rounded-full p-4 mb-4">
|
||
<svg className="h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No hay expedientes</h3>
|
||
<p className="text-gray-500">No se encontraron expedientes con los filtros aplicados.</p>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Vista de tarjetas para pantallas pequeñas y medianas */}
|
||
<div className="lg:hidden space-y-4 p-4">
|
||
{loading ? (
|
||
<div className="flex flex-col items-center py-12">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
||
<span className="text-gray-500 text-lg font-medium">Cargando expedientes...</span>
|
||
</div>
|
||
) : error ? (
|
||
<div className="flex flex-col items-center py-12">
|
||
<div className="bg-red-100 rounded-full p-3 mb-4">
|
||
<svg className="h-8 w-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||
</svg>
|
||
</div>
|
||
<span className="text-red-600 text-lg font-medium">Error: {error.message || 'Error al cargar expedientes'}</span>
|
||
</div>
|
||
) : currentDocuments.length > 0 ? (
|
||
currentDocuments.map(ped => (
|
||
<div key={ped.id} className="bg-white rounded-2xl shadow-lg border border-gray-200 p-4 hover:shadow-xl transition-all duration-300 relative">
|
||
<div className="flex items-start justify-between mb-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="bg-blue-100 rounded-xl p-2 flex-shrink-0">
|
||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<Link
|
||
to={`/expedientes/pedimento/${ped.id}`}
|
||
className="text-lg font-semibold text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
||
>
|
||
{ped.pedimento}
|
||
</Link>
|
||
<p className="text-sm text-gray-500">{ped.fechapago}</p>
|
||
</div>
|
||
</div>
|
||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||
ped.existe_expediente
|
||
? 'bg-green-100 text-green-800 border border-green-200'
|
||
: 'bg-gray-100 text-gray-600 border border-gray-200'
|
||
}`}>
|
||
{ped.existe_expediente ? 'Con expediente' : 'Sin expediente'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-3 mb-4">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium text-gray-600">Contribuyente:</span>
|
||
<span className="text-sm text-gray-900 text-right max-w-[60%] truncate" title={ped.contribuyente}>
|
||
{ped.contribuyente}
|
||
</span>
|
||
</div>
|
||
{ped.curp_apoderado && (
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium text-gray-600">CURP Apoderado:</span>
|
||
<span className="text-sm text-gray-900">{ped.curp_apoderado}</span>
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-1 gap-2">
|
||
<div className="flex items-center justify-between bg-green-50 rounded-lg p-2">
|
||
<span className="text-sm font-medium text-green-700">Importe total:</span>
|
||
<span className="text-sm font-bold text-green-800">${ped.importe_total}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between bg-blue-50 rounded-lg p-2">
|
||
<span className="text-sm font-medium text-blue-700">Saldo disponible:</span>
|
||
<span className="text-sm font-bold text-blue-800">${ped.saldo_disponible}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-2">
|
||
<span className="text-sm font-medium text-gray-700">Importe pedimento:</span>
|
||
<span className="text-sm font-bold text-gray-800">${ped.importe_pedimento}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="bg-gray-50 rounded-2xl p-8 text-center">
|
||
<div className="bg-gray-100 rounded-full p-4 w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
</div>
|
||
<p className="text-gray-500 font-medium">No hay expedientes disponibles</p>
|
||
<p className="text-gray-400 text-sm mt-1">Intenta ajustar los filtros de búsqueda</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Paginación moderna y responsiva */}
|
||
{totalDocuments > 0 && (
|
||
<div className="bg-gradient-to-r from-gray-50 to-blue-50/30 px-4 sm:px-6 py-4 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200">
|
||
{(() => {
|
||
const totalPages = Math.max(1, Math.ceil(totalDocuments / itemsPerPage));
|
||
const maxPagesToShow = 5;
|
||
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
|
||
let endPage = startPage + maxPagesToShow - 1;
|
||
if (endPage > totalPages) {
|
||
endPage = totalPages;
|
||
startPage = Math.max(1, endPage - maxPagesToShow + 1);
|
||
}
|
||
const pageNumbers = [];
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
pageNumbers.push(i);
|
||
}
|
||
return (
|
||
<div className="flex flex-col sm:flex-row sm:items-center w-full gap-4">
|
||
<div className="flex items-center gap-3">
|
||
<label htmlFor="itemsPerPage" className="text-xs font-semibold text-gray-700">Registros por página:</label>
|
||
<select
|
||
id="itemsPerPage"
|
||
value={itemsPerPage}
|
||
onChange={e => handleItemsPerPageChange(Number(e.target.value))}
|
||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm"
|
||
>
|
||
{[5, 10, 20, 50, 100, 200, 400, 800, 1200, 2400, 10000].map(size => (
|
||
<option key={size} value={size}>{size}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="flex items-center justify-center sm:justify-end flex-1 gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={e => handlePageChange(1, e)}
|
||
disabled={currentPage === 1}
|
||
className={`px-3 py-2 rounded-lg border text-sm font-semibold transition-all duration-200 ${currentPage === 1 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900 shadow-sm hover:shadow-md'}`}
|
||
>
|
||
«
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={e => handlePageChange(currentPage - 1, e)}
|
||
disabled={currentPage === 1}
|
||
className={`px-3 py-2 rounded-lg border text-sm font-semibold transition-all duration-200 ${currentPage === 1 ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900 shadow-sm hover:shadow-md'}`}
|
||
>
|
||
‹
|
||
</button>
|
||
<div className="hidden sm:flex items-center gap-1">
|
||
{pageNumbers.map(num => (
|
||
<button
|
||
type="button"
|
||
key={num}
|
||
onClick={e => handlePageChange(num, e)}
|
||
className={`px-3 py-2 rounded-lg border text-sm font-semibold transition-all duration-200 ${num === currentPage ? 'bg-blue-600 text-white border-blue-700 shadow-md cursor-default' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900 shadow-sm hover:shadow-md'}`}
|
||
disabled={num === currentPage}
|
||
>
|
||
{num}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="sm:hidden flex items-center px-3 py-2 bg-white border border-gray-200 rounded-lg shadow-sm">
|
||
<span className="text-sm font-semibold text-gray-700">
|
||
{currentPage} / {totalPages}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={e => handlePageChange(currentPage + 1, e)}
|
||
disabled={currentPage >= totalPages}
|
||
className={`px-3 py-2 rounded-lg border text-sm font-semibold transition-all duration-200 ${(currentPage >= totalPages) ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900 shadow-sm hover:shadow-md'}`}
|
||
>
|
||
›
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={e => handlePageChange(totalPages, e)}
|
||
disabled={currentPage >= totalPages}
|
||
className={`px-3 py-2 rounded-lg border text-sm font-semibold transition-all duration-200 ${(currentPage >= totalPages) ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' : 'bg-white text-blue-700 border-blue-200 hover:bg-blue-50 hover:text-blue-900 shadow-sm hover:shadow-md'}`}
|
||
>
|
||
»
|
||
</button>
|
||
</div>
|
||
<div className="text-center sm:text-right">
|
||
<span className="text-xs text-gray-600 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm">
|
||
Mostrando <span className="font-bold text-blue-600">{((currentPage - 1) * itemsPerPage) + 1}</span> a <span className="font-bold text-blue-600">{Math.min(currentPage * itemsPerPage, totalDocuments)}</span> de <span className="font-bold text-blue-600">{totalDocuments}</span> registros
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|