Compare commits

..

7 Commits

6 changed files with 1734 additions and 188 deletions

View File

@@ -14,6 +14,7 @@ import LandingAnimated from './pages/LandingAnimated';
import Expedientes from './pages/Expedientes'; import Expedientes from './pages/Expedientes';
import Organization from './pages/Organization'; import Organization from './pages/Organization';
import Users from './pages/Users'; import Users from './pages/Users';
import UserForm from './pages/UserForm';
import Reports from './pages/Reports'; import Reports from './pages/Reports';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import Importers from './pages/Importers'; import Importers from './pages/Importers';
@@ -76,6 +77,16 @@ function AppContent() {
<Users /> <Users />
</RequireAuth> </RequireAuth>
} /> } />
<Route path="/users/new" element={
<RequireAuth>
<UserForm />
</RequireAuth>
} />
<Route path="/users/:id/edit" element={
<RequireAuth>
<UserForm />
</RequireAuth>
} />
<Route path="/reports" element={ <Route path="/reports" element={
<RequireAuth> <RequireAuth>
<Reports /> <Reports />

File diff suppressed because it is too large Load Diff

View File

@@ -334,6 +334,8 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
const [processingAcuseCove, setProcessingAcuseCove] = useState(null); const [processingAcuseCove, setProcessingAcuseCove] = useState(null);
const [processingEdoc, setProcessingEdoc] = useState(null); const [processingEdoc, setProcessingEdoc] = useState(null);
const [processingAcuseEdoc, setProcessingAcuseEdoc] = useState(null); const [processingAcuseEdoc, setProcessingAcuseEdoc] = useState(null);
// Modal de advertencia por documentos con errores en EDocs
const [edocErrorModal, setEdocErrorModal] = useState({ open: false, edoc: null, tipo: null });
// Agregar estado para el modal de documentos // Agregar estado para el modal de documentos
const [showDocumentsModal, setShowDocumentsModal] = useState(false); const [showDocumentsModal, setShowDocumentsModal] = useState(false);
@@ -412,6 +414,7 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
// La API devuelve un array directamente, no un objeto con results // La API devuelve un array directamente, no un objeto con results
const credenciales = Array.isArray(data) ? data : (data.results || []); const credenciales = Array.isArray(data) ? data : (data.results || []);
console.log('credenciales >>>> ', credenciales);
return credenciales; return credenciales;
} }
return []; return [];
@@ -459,8 +462,10 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
password: credencial.password, password: credencial.password,
efirma: credencial.efirma, efirma: credencial.efirma,
// Convertir URLs completas a rutas relativas // Convertir URLs completas a rutas relativas
key: credencial.key ? credencial.key.split('/').slice(-2).join('/') : '', key: credencial.key_download_url,
cer: credencial.cer ? credencial.cer.split('/').slice(-2).join('/') : '', cer: credencial.cer_download_url,
// key: credencial.key ? credencial.key.split('/').slice(-2).join('/') : '',
// cer: credencial.cer ? credencial.cer.split('/').slice(-2).join('/') : '',
is_active: credencial.is_active, is_active: credencial.is_active,
organizacion: credencial.organizacion organizacion: credencial.organizacion
} }
@@ -565,6 +570,7 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
const [previewDoc, setPreviewDoc] = useState(null); const [previewDoc, setPreviewDoc] = useState(null);
const [previewContent, setPreviewContent] = useState(''); const [previewContent, setPreviewContent] = useState('');
const [imageZoom, setImageZoom] = useState(1); const [imageZoom, setImageZoom] = useState(1);
const [previewIframeLoaded, setPreviewIframeLoaded] = useState(false);
// Refs // Refs
const focusKeeperRef = useRef(null); const focusKeeperRef = useRef(null);
@@ -647,6 +653,7 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
setPreviewDoc(doc); setPreviewDoc(doc);
setImageZoom(1); setImageZoom(1);
setPreviewContent(''); setPreviewContent('');
setPreviewIframeLoaded(false);
setPreviewOpen(true); setPreviewOpen(true);
try { try {
const res = await fetchWithAuth(`${API_URL}/record/documents/descargar/${doc.id}/`); const res = await fetchWithAuth(`${API_URL}/record/documents/descargar/${doc.id}/`);
@@ -686,12 +693,17 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
setPreviewLoading(false); setPreviewLoading(false);
} else { } else {
const blob = await res.blob(); const blob = await res.blob();
if (blob.size === 0) {
setPreviewError('El archivo está vacío o no está disponible en el servidor.');
setPreviewLoading(false);
return;
}
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
setPreviewUrl(url); setPreviewUrl(url);
setPreviewLoading(false); setPreviewLoading(false);
} }
} catch (err) { } catch (err) {
console.error('Error in preview:', err); console.error('Error en vista previa (VU):', err);
if (err.message === 'SESSION_EXPIRED') { if (err.message === 'SESSION_EXPIRED') {
setPreviewError('Tu sesión ha expirado, por favor inicia sesión de nuevo.'); setPreviewError('Tu sesión ha expirado, por favor inicia sesión de nuevo.');
} else { } else {
@@ -711,6 +723,7 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
setPreviewDoc(doc); setPreviewDoc(doc);
setImageZoom(1); setImageZoom(1);
setPreviewContent(''); setPreviewContent('');
setPreviewIframeLoaded(false);
setPreviewOpen(true); setPreviewOpen(true);
try { try {
const res = await fetchWithAuth(`${API_URL}/record/documents/descargar/${doc.id}/`); const res = await fetchWithAuth(`${API_URL}/record/documents/descargar/${doc.id}/`);
@@ -750,12 +763,17 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
setPreviewLoading(false); setPreviewLoading(false);
} else { } else {
const blob = await res.blob(); const blob = await res.blob();
if (blob.size === 0) {
setPreviewError('El archivo está vacío o no está disponible en el servidor.');
setPreviewLoading(false);
return;
}
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
setPreviewUrl(url); setPreviewUrl(url);
setPreviewLoading(false); setPreviewLoading(false);
} }
} catch (err) { } catch (err) {
console.error('Error in preview:', err); console.error('Error en vista previa:', err);
if (err.message === 'SESSION_EXPIRED') { if (err.message === 'SESSION_EXPIRED') {
setPreviewError('Tu sesión ha expirado, por favor inicia sesión de nuevo.'); setPreviewError('Tu sesión ha expirado, por favor inicia sesión de nuevo.');
} else { } else {
@@ -777,6 +795,7 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
setPreviewDoc(null); setPreviewDoc(null);
setPreviewContent(''); setPreviewContent('');
setImageZoom(1); setImageZoom(1);
setPreviewIframeLoaded(false);
}; };
// Funciones para el nuevo diseño de documentos // Funciones para el nuevo diseño de documentos
@@ -2005,8 +2024,10 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
user: credencial.usuario, user: credencial.usuario,
password: credencial.password, password: credencial.password,
efirma: credencial.efirma, efirma: credencial.efirma,
key: credencial.key, key: credencial.key_download_url,
cer: credencial.cer, cer: credencial.cer_download_url,
// key: credencial.key,
// cer: credencial.cer,
is_active: credencial.is_active, is_active: credencial.is_active,
organizacion: credencial.organizacion organizacion: credencial.organizacion
} }
@@ -2078,8 +2099,10 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
user: credencial.usuario, user: credencial.usuario,
password: credencial.password, password: credencial.password,
efirma: credencial.efirma, efirma: credencial.efirma,
key: credencial.key, key: credencial.key_download_url,
cer: credencial.cer, cer: credencial.cer_download_url,
// key: credencial.key,
// cer: credencial.cer,
is_active: credencial.is_active, is_active: credencial.is_active,
organizacion: credencial.organizacion organizacion: credencial.organizacion
} }
@@ -2152,8 +2175,10 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
user: credencial.usuario, user: credencial.usuario,
password: credencial.password, password: credencial.password,
efirma: credencial.efirma, efirma: credencial.efirma,
key: credencial.key, key: credencial.key_download_url,
cer: credencial.cer, cer: credencial.cer_download_url,
// key: credencial.key,
// cer: credencial.cer,
is_active: credencial.is_active, is_active: credencial.is_active,
organizacion: credencial.organizacion organizacion: credencial.organizacion
} }
@@ -2226,8 +2251,10 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
user: credencial.usuario, user: credencial.usuario,
password: credencial.password, password: credencial.password,
efirma: credencial.efirma, efirma: credencial.efirma,
key: credencial.key, key: credencial.key_download_url,
cer: credencial.cer, cer: credencial.cer_download_url,
// key: credencial.key,
// cer: credencial.cer,
is_active: credencial.is_active, is_active: credencial.is_active,
organizacion: credencial.organizacion organizacion: credencial.organizacion
} }
@@ -5411,7 +5438,7 @@ useEffect(() => {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{/* Tabla de EDocs */} {/* Tabla de EDocs */}
<div className="overflow-hidden rounded-lg shadow ring-1 ring-black ring-opacity-5"> <div className="overflow-x-auto rounded-lg shadow ring-1 ring-black ring-opacity-5">
<table className="min-w-full divide-y divide-gray-300"> <table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
@@ -5502,20 +5529,29 @@ useEffect(() => {
{edoc.acuse_descargado ? 'Descargado' : 'Pendiente'} {edoc.acuse_descargado ? 'Descargado' : 'Pendiente'}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-sm font-medium text-right whitespace-nowrap"> <td className="px-6 py-4 text-sm font-medium text-right whitespace-nowrap" style={{minWidth: '140px'}}>
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2 flex-nowrap">
{/* Botón EDoc */} {/* Botón EDoc */}
<button <button
onClick={() => handleEdocProcess(edoc)} onClick={() => {
const tieneError = !edoc.edocument_descargado && edoc.documentos?.some(d => d.document_type === 22);
if (tieneError) {
setEdocErrorModal({ open: true, edoc, tipo: 'edoc' });
} else {
handleEdocProcess(edoc);
}
}}
disabled={edoc.edocument_descargado || processingEdoc === edoc.id} disabled={edoc.edocument_descargado || processingEdoc === edoc.id}
className={`p-1 rounded transition-colors ${ className={`p-1 rounded transition-colors ${
edoc.edocument_descargado edoc.edocument_descargado
? 'text-gray-400 cursor-not-allowed' ? 'text-gray-400 cursor-not-allowed'
: processingEdoc === edoc.id : processingEdoc === edoc.id
? 'text-blue-400 cursor-not-allowed' ? 'text-blue-400 cursor-not-allowed'
: !edoc.edocument_descargado && edoc.documentos?.some(d => d.document_type === 22)
? 'text-yellow-500 hover:text-yellow-700 opacity-60'
: 'text-blue-600 hover:text-blue-900' : 'text-blue-600 hover:text-blue-900'
}`} }`}
title={edoc.edocument_descargado ? 'EDoc ya descargado' : processingEdoc === edoc.id ? 'Procesando EDoc...' : 'Procesar EDoc'} title={edoc.edocument_descargado ? 'EDoc ya descargado' : processingEdoc === edoc.id ? 'Procesando EDoc...' : !edoc.edocument_descargado && edoc.documentos?.some(d => d.document_type === 22) ? 'EDoc con errores — haz clic para más información' : 'Procesar EDoc'}
> >
{processingEdoc === edoc.id ? ( {processingEdoc === edoc.id ? (
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -5530,16 +5566,25 @@ useEffect(() => {
{/* Botón Acuse de EDoc */} {/* Botón Acuse de EDoc */}
<button <button
onClick={() => handleAcuseEdocProcess(edoc)} onClick={() => {
const tieneError = !edoc.acuse_descargado && edoc.documentos?.some(d => d.document_type === 26);
if (tieneError) {
setEdocErrorModal({ open: true, edoc, tipo: 'acuse' });
} else {
handleAcuseEdocProcess(edoc);
}
}}
disabled={edoc.acuse_descargado || processingAcuseEdoc === edoc.id} disabled={edoc.acuse_descargado || processingAcuseEdoc === edoc.id}
className={`p-1 rounded transition-colors ${ className={`p-1 rounded transition-colors ${
edoc.acuse_descargado edoc.acuse_descargado
? 'text-gray-400 cursor-not-allowed' ? 'text-gray-400 cursor-not-allowed'
: processingAcuseEdoc === edoc.id : processingAcuseEdoc === edoc.id
? 'text-green-400 cursor-not-allowed' ? 'text-green-400 cursor-not-allowed'
: !edoc.acuse_descargado && edoc.documentos?.some(d => d.document_type === 26)
? 'text-yellow-500 hover:text-yellow-700 opacity-60'
: 'text-green-600 hover:text-green-900' : 'text-green-600 hover:text-green-900'
}`} }`}
title={edoc.acuse_descargado ? 'Acuse de EDoc ya descargado' : processingAcuseEdoc === edoc.id ? 'Procesando Acuse de EDoc...' : 'Procesar Acuse de EDoc'} title={edoc.acuse_descargado ? 'Acuse de EDoc ya descargado' : processingAcuseEdoc === edoc.id ? 'Procesando Acuse de EDoc...' : !edoc.acuse_descargado && edoc.documentos?.some(d => d.document_type === 26) ? 'Acuse con errores — haz clic para más información' : 'Procesar Acuse de EDoc'}
> >
{processingAcuseEdoc === edoc.id ? ( {processingAcuseEdoc === edoc.id ? (
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -6683,20 +6728,42 @@ useEffect(() => {
<h4 className="mb-2 text-lg font-semibold">Error al cargar documento</h4> <h4 className="mb-2 text-lg font-semibold">Error al cargar documento</h4>
<p className="text-sm text-gray-600">{previewError}</p> <p className="text-sm text-gray-600">{previewError}</p>
<button <button
onClick={() => setPreviewError(null)} onClick={() => { setPreviewError(null); if (previewDoc) handlePreview(previewDoc); }}
className="px-4 py-2 mt-4 text-red-800 transition-colors bg-red-100 rounded-lg hover:bg-red-200" className="px-4 py-2 mt-4 text-red-800 transition-colors bg-red-100 rounded-lg hover:bg-red-200"
> >
Reintentar Volver a cargar
</button> </button>
</div> </div>
</div> </div>
) : previewType === 'pdf' ? ( ) : previewType === 'pdf' ? (
<div className="flex-1 bg-white"> <div className="relative flex-1 bg-white">
{/* Spinner hasta que el iframe confirme carga */}
{!previewIframeLoaded && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white">
<div className="text-center">
<div className="inline-flex items-center space-x-3 text-blue-600">
<svg className="w-10 h-10 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>
<span className="text-lg font-medium">Cargando documento...</span>
</div>
</div>
</div>
)}
<iframe <iframe
src={previewUrl} src={previewUrl}
title="PDF Preview" title="PDF Preview"
className="w-full h-full border-0" className="w-full h-full border-0"
style={{ minHeight: '500px' }} style={{ minHeight: '500px' }}
onLoad={() => {
setPreviewIframeLoaded(true);
setPreviewLoading(false);
}}
onError={() => {
setPreviewError('No se pudo cargar el documento PDF.');
setPreviewLoading(false);
}}
/> />
</div> </div>
) : previewType === 'img' ? ( ) : previewType === 'img' ? (
@@ -7217,6 +7284,91 @@ useEffect(() => {
</div> </div>
)} )}
{/* Modal de advertencia: EDoc/Acuse con documentos de error */}
{edocErrorModal.open && edocErrorModal.edoc && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 animate-fade-in">
<div className="w-full max-w-lg p-6 bg-white rounded-lg shadow-xl animate-modal-slide-up">
<div className="flex items-center mb-4 space-x-3">
<div className="flex items-center justify-center w-10 h-10 bg-yellow-100 rounded-full shrink-0">
<svg className="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">
{edocErrorModal.tipo === 'acuse' ? 'Acuse de EDoc con errores' : 'EDoc con errores'}
</h3>
</div>
<p className="mb-1 text-sm text-gray-700">
Este EDocument (<span className="font-medium">{edocErrorModal.edoc.numero_edocument}</span>) cuenta con errores en la respuesta recibida.
Revisa el documento de error antes de volver a intentarlo.
</p>
<p className="mb-4 text-sm text-gray-500">
Puedes continuar con el flujo de trabajo, pero se recomienda corregir el error primero.
</p>
{/* Lista de documentos de error */}
{(() => {
const tipoFiltro = edocErrorModal.tipo === 'acuse' ? 26 : 22;
const docsError = edocErrorModal.edoc.documentos?.filter(d => d.document_type === tipoFiltro) || [];
return docsError.length > 0 ? (
<div className="mb-4">
<p className="mb-2 text-xs font-medium tracking-wide text-gray-500 uppercase">Documentos de error</p>
<ul className="space-y-2">
{docsError.map(doc => {
const nombreArchivo = (doc.archivo || '').split('/').pop() || 'Documento';
return (
<li key={doc.id} className="flex items-center justify-between p-2 rounded-md bg-gray-50">
<span className="text-sm text-gray-700 truncate max-w-xs" title={nombreArchivo}>
{nombreArchivo}
</span>
<button
onClick={() => {
setEdocErrorModal({ open: false, edoc: null, tipo: null });
handlePreviewVU(doc);
}}
className="p-1 ml-2 text-blue-600 rounded shrink-0 hover:text-blue-900 hover:bg-blue-50"
title="Ver documento"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</li>
);
})}
</ul>
</div>
) : null;
})()}
<div className="flex justify-end space-x-2">
<button
onClick={() => setEdocErrorModal({ open: false, edoc: null, tipo: null })}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancelar
</button>
<button
onClick={() => {
const { edoc, tipo } = edocErrorModal;
setEdocErrorModal({ open: false, edoc: null, tipo: null });
if (tipo === 'acuse') {
handleAcuseEdocProcess(edoc);
} else {
handleEdocProcess(edoc);
}
}}
className="px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-md hover:bg-yellow-700"
>
Continuar de todas formas
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -198,6 +198,7 @@ export default function Reports() {
const [organizaciones, setOrganizaciones] = useState([]); const [organizaciones, setOrganizaciones] = useState([]);
const [importadores, setImportadores] = useState([]); const [importadores, setImportadores] = useState([]);
const [rfcOptions, setRfcOptions] = useState([]);
useEffect(() => { useEffect(() => {
const fetchOrganizaciones = async () => { const fetchOrganizaciones = async () => {
@@ -241,6 +242,27 @@ export default function Reports() {
pedimento: '' pedimento: ''
}); });
// Cargar RFCs cuando cambia la organización seleccionada en filtros globales
useEffect(() => {
const fetchRfcs = async () => {
if (!globalFilters.organizacion) {
setRfcOptions([]);
return;
}
try {
const url = `${import.meta.env.VITE_EFC_API_URL}/reports/exportmodel/datastage/?organizacion=${globalFilters.organizacion}`;
const res = await fetchWithAuth(url);
if (!res.ok) throw new Error('Error al obtener RFCs');
const data = await res.json();
setRfcOptions(data.rfcs || []);
} catch (err) {
console.error('Error fetching RFCs:', err);
setRfcOptions([]);
}
};
fetchRfcs();
}, [globalFilters.organizacion]);
const renderGlobalFilters = () => ( const renderGlobalFilters = () => (
<div className="mb-6"> <div className="mb-6">
<div className="bg-white rounded-xl shadow-lg overflow-hidden border border-blue-100"> <div className="bg-white rounded-xl shadow-lg overflow-hidden border border-blue-100">
@@ -269,16 +291,17 @@ export default function Reports() {
value={globalFilters.organizacion || ''} value={globalFilters.organizacion || ''}
onChange={(e) => setGlobalFilters(prev => ({ onChange={(e) => setGlobalFilters(prev => ({
...prev, ...prev,
organizacion: e.target.value organizacion: e.target.value,
rfc: ''
}))} }))}
className="block w-full rounded-lg border-gray-300 pl-3 pr-10 py-2.5 text-gray-900 placeholder-gray-500 className="block w-full rounded-lg border-gray-300 pl-3 pr-10 py-2.5 text-gray-900 placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm
transition-all duration-200 bg-white appearance-none" transition-all duration-200 bg-white appearance-none"
> >
<option value="">Todas las organizaciones</option> <option value="" disabled>Selecciona una organización</option>
{organizaciones.results && organizaciones.results.map(org => ( {organizaciones.results && organizaciones.results.map(org => (
<option key={org.id} value={org.id}> <option key={org.id} value={org.id}>
{org.nombre} {/* Usar el campo 'nombre' que sí existe */} {org.nombre}
</option> </option>
))} ))}
</select> </select>
@@ -318,13 +341,11 @@ export default function Reports() {
}))} }))}
className="w-full px-3 py-2 border border-green-300 rounded-md shadow-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white text-slate-900 text-sm font-mono uppercase" className="w-full px-3 py-2 border border-green-300 rounded-md shadow-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white text-slate-900 text-sm font-mono uppercase"
style={{ textTransform: 'uppercase' }} style={{ textTransform: 'uppercase' }}
disabled={!globalFilters.organizacion}
> >
<option value="" >Selecciona un RFC</option> <option value="">Todos los RFC</option>
{importadores.filter(imp => { {rfcOptions.map(rfc => (
if (!globalFilters.organizacion) return true; <option key={rfc} value={rfc}>{rfc}</option>
return imp.organizacion === globalFilters.organizacion;
}).map(imp => (
<option key={imp.rfc} value={imp.rfc}>{imp.rfc}</option>
))} ))}
</select> </select>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"> <div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
@@ -807,7 +828,12 @@ export default function Reports() {
.map(([modelo]) => modelo); .map(([modelo]) => modelo);
if (modelosConCampos.length === 0) { if (modelosConCampos.length === 0) {
alert('Por favor selecciona al menos un campo en algún modelo'); showMessage('Por favor selecciona al menos un campo en algún modelo', 'error');
return;
}
if (!globalFilters.organizacion) {
showMessage('Debes seleccionar una organización antes de generar el reporte', 'error');
return; return;
} }

770
src/pages/UserForm.jsx Normal file
View File

@@ -0,0 +1,770 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { createUser, updateUser } from '../api/users.ts';
import { useNotification } from '../context/NotificationContext';
const initialForm = {
username: '',
email: '',
first_name: '',
last_name: '',
password: '',
confirmPassword: '',
rfc: [],
userType: 'agente', // 'agente' | 'importador'
groups: [],
is_active: true,
};
// Perfiles disponibles en el sistema
const AVAILABLE_GROUPS = [
{ id: 1, label: 'Admin', description: 'Administrador del sistema' },
{ id: 2, label: 'Developer', description: 'Desarrollador' },
{ id: 3, label: 'User', description: 'Acceso base (requerido)' },
{ id: 4, label: 'Agente Aduanal', description: 'Agente aduanal' },
{ id: 5, label: 'Importador', description: 'Importador general' }
];
export default function UserForm() {
const { id } = useParams(); // presente si es edición
const isEditing = Boolean(id);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { showMessage } = useNotification();
// Preseleccionar tipo desde query param (?type=agente|importador)
const initialType = searchParams.get('type') === 'importador' ? 'importador' : 'agente';
const [form, setForm] = useState({
...initialForm,
userType: initialType,
groups: initialType === 'importador' ? [3, 5] : [4, 3],
});
const [importadores, setImportadores] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [loadingUser, setLoadingUser] = useState(isEditing);
// Validación de contraseña
const [passwordValidation, setPasswordValidation] = useState({
length: false, uppercase: false, lowercase: false, number: false, special: false,
});
const [showPasswordValidation, setShowPasswordValidation] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [passwordsMatch, setPasswordsMatch] = useState(true);
const [showPasswordMatchValidation, setShowPasswordMatchValidation] = useState(false);
// Inyectar animaciones
useEffect(() => {
if (typeof window !== 'undefined' && !document.getElementById('users-animations')) {
const style = document.createElement('style');
style.id = 'users-animations';
style.innerHTML = `
@keyframes fadeInUpUsers {
0% { opacity: 0; transform: translateY(32px); }
100% { opacity: 1; transform: translateY(0); }
}
.fade-in-up-users { animation: fadeInUpUsers 0.7s cubic-bezier(0.22, 1, 0.36, 1) both; }
@keyframes bounce-slow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.animate-bounce-slow { animation: bounce-slow 2.2s infinite; }
`;
document.head.appendChild(style);
}
}, []);
// Cargar importadores
useEffect(() => {
const access = localStorage.getItem('access');
if (!access) { window.location.href = '/login'; return; }
fetch(`${import.meta.env.VITE_EFC_API_URL}/customs/importadores/`, {
headers: { Authorization: `Bearer ${access}` },
})
.then(r => r.json())
.then(data => setImportadores(Array.isArray(data) ? data : []))
.catch(() => setImportadores([]));
}, []);
// Cargar datos del usuario si es edición
useEffect(() => {
if (!isEditing) return;
const access = localStorage.getItem('access');
fetch(`${import.meta.env.VITE_EFC_API_URL}/user/users/${id}/`, {
headers: { Authorization: `Bearer ${access}` },
})
.then(r => r.json())
.then(data => {
const isImportador = data.is_importador === true ||
(Array.isArray(data.groups) && data.groups.includes(5));
setForm({
username: data.username || '',
email: data.email || '',
first_name: data.first_name || '',
last_name: data.last_name || '',
password: '',
confirmPassword: '',
// rfc es M2M: viene como array de PKs (strings de RFC)
rfc: Array.isArray(data.rfc) ? data.rfc : (data.rfc ? [data.rfc] : []),
userType: isImportador ? 'importador' : 'agente',
groups: Array.isArray(data.groups) ? data.groups : [],
is_active: data.is_active !== false,
});
setLoadingUser(false);
})
.catch(() => {
showMessage('Error al cargar datos del usuario', 'error');
setLoadingUser(false);
});
}, [id, isEditing, showMessage]);
const validatePassword = (password) => {
const v = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /\d/.test(password),
special: /[!@#$%^&*(),.?":{}|<>]/.test(password),
};
setPasswordValidation(v);
setShowPasswordValidation(password.length > 0);
};
const validatePasswordMatch = (password, confirm) => {
setPasswordsMatch(password === confirm);
setShowPasswordMatchValidation(confirm.length > 0);
};
const isPasswordValid = () => Object.values(passwordValidation).every(Boolean);
const isFormValid = () => {
if (!isEditing) {
return isPasswordValid() && passwordsMatch &&
form.password.length > 0 && form.confirmPassword.length > 0;
}
// En edición la contraseña es opcional
if (form.password.length > 0) {
return isPasswordValid() && passwordsMatch && form.confirmPassword.length > 0;
}
return true;
};
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
if (name === 'password') {
validatePassword(value);
if (form.confirmPassword) validatePasswordMatch(value, form.confirmPassword);
}
if (name === 'confirmPassword') validatePasswordMatch(form.password, value);
};
const handleUserTypeChange = (type) => {
setForm(prev => ({
...prev,
userType: type,
// Limpiar RFCs si cambia a agente
rfc: type === 'agente' ? [] : prev.rfc,
// Preseleccionar perfiles por defecto según tipo
groups: type === 'importador' ? [3, 5] : [4, 3],
}));
};
const handleGroupToggle = (groupId) => {
setForm(prev => {
const groups = prev.groups.includes(groupId)
? prev.groups.filter(g => g !== groupId)
: [...prev.groups, groupId];
return { ...prev, groups };
});
};
const handleRfcToggle = (rfc) => {
setForm(prev => {
const current = Array.isArray(prev.rfc) ? prev.rfc : [];
const next = current.includes(rfc)
? current.filter(r => r !== rfc)
: [...current, rfc];
return { ...prev, rfc: next };
});
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!isFormValid()) return;
setSubmitting(true);
try {
const payload = {
username: form.username,
email: form.email,
first_name: form.first_name,
last_name: form.last_name,
groups: form.groups,
is_importador: form.userType === 'importador',
is_active: form.is_active,
};
if (form.userType === 'importador') {
payload.rfc = Array.isArray(form.rfc) ? form.rfc : [];
}
if (form.password) payload.password = form.password;
if (isEditing) {
await updateUser(id, payload);
showMessage('Usuario actualizado exitosamente', 'success');
} else {
await createUser(payload);
showMessage('Usuario creado exitosamente', 'success');
}
navigate('/users');
} catch (err) {
showMessage(err.message, 'error');
} finally {
setSubmitting(false);
}
};
const isImportador = form.userType === 'importador';
if (loadingUser) {
return (
<div className="min-h-screen p-4 sm:p-6 bg-gradient-to-br from-gray-50 to-blue-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="min-h-screen p-4 sm:p-6 bg-gradient-to-br from-gray-50 to-blue-50">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="mb-8 relative overflow-hidden rounded-2xl shadow bg-gradient-to-r from-blue-600 via-blue-700 to-blue-800 border border-blue-200 p-6 sm:p-8 flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div className="flex-1">
<h1 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight mb-1">
{isEditing ? 'Editar Usuario' : 'Nuevo Usuario'}
</h1>
<p className="text-sm sm:text-base text-white/80 font-medium">
{isEditing ? 'Modifica los datos del usuario seleccionado' : 'Registro en el Sistema de Gestión de Usuarios'}
</p>
</div>
{/* Botón regresar */}
<button
type="button"
onClick={() => navigate('/users')}
className="flex-shrink-0 inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white text-sm font-medium rounded-lg border border-white/30 transition-all duration-200"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Regresar
</button>
<div className="absolute -top-10 -right-10 opacity-20 pointer-events-none select-none">
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="white" fillOpacity="0.15" />
</svg>
</div>
</div>
{/* Formulario */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Tipo de usuario — solo en creación */}
{!isEditing && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 fade-in-up-users">
<div className="flex items-center mb-4 pb-3 border-b border-gray-200">
<div className="bg-purple-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Tipo de Usuario</h4>
<p className="text-xs text-slate-500">Define el rol principal del nuevo usuario</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<button
type="button"
onClick={() => handleUserTypeChange('agente')}
className={`relative flex flex-col items-start p-4 rounded-xl border-2 transition-all duration-200 ${
!isImportador
? 'border-blue-500 bg-blue-50 shadow-md'
: 'border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-50/50'
}`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center mb-3 ${!isImportador ? 'bg-blue-600' : 'bg-gray-200'}`}>
<svg className={`w-5 h-5 ${!isImportador ? 'text-white' : 'text-gray-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<span className={`text-sm font-semibold ${!isImportador ? 'text-blue-800' : 'text-gray-700'}`}>Agente Aduanal</span>
<span className="text-xs text-gray-500 mt-1">Gestión de trámites aduaneros</span>
{!isImportador && (
<div className="absolute top-3 right-3 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</button>
<button
type="button"
onClick={() => handleUserTypeChange('importador')}
className={`relative flex flex-col items-start p-4 rounded-xl border-2 transition-all duration-200 ${
isImportador
? 'border-blue-500 bg-blue-50 shadow-md'
: 'border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-50/50'
}`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center mb-3 ${isImportador ? 'bg-blue-600' : 'bg-gray-200'}`}>
<svg className={`w-5 h-5 ${isImportador ? 'text-white' : 'text-gray-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<span className={`text-sm font-semibold ${isImportador ? 'text-blue-800' : 'text-gray-700'}`}>Importador</span>
<span className="text-xs text-gray-500 mt-1">Empresa con RFCs asociados</span>
{isImportador && (
<div className="absolute top-3 right-3 w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</button>
</div>
</div>
)}
{/* Información Personal */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 fade-in-up-users" style={{ animationDelay: '0.05s' }}>
<div className="flex items-center mb-4 pb-3 border-b border-gray-200">
<div className="bg-blue-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Información Personal</h4>
<p className="text-xs text-slate-500">Datos de identificación del usuario</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">
Nombre de usuario <span className="text-red-600">*</span>
</label>
<input
type="text"
name="username"
value={form.username}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm"
placeholder="nombre_usuario"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">
Correo electrónico <span className="text-red-600">*</span>
</label>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm"
placeholder="usuario@ejemplo.com"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">Nombre</label>
<input
type="text"
name="first_name"
value={form.first_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm"
placeholder="Nombre del usuario"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">Apellido</label>
<input
type="text"
name="last_name"
value={form.last_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm"
placeholder="Apellido del usuario"
/>
</div>
</div>
</div>
{/* RFC — solo para importadores */}
{isImportador && (
<div className="bg-white rounded-2xl shadow-lg border border-blue-100 p-6 fade-in-up-users" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center mb-4 pb-3 border-b border-blue-200">
<div className="bg-blue-700 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 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>
<h4 className="text-sm font-semibold text-slate-800">Información Fiscal</h4>
<p className="text-xs text-slate-500">RFCs de importadores asociados al usuario (puede ser más de uno)</p>
</div>
</div>
{importadores.length === 0 ? (
<p className="text-sm text-gray-500 italic">No hay importadores disponibles en el catálogo.</p>
) : (
<div className="flex flex-col sm:flex-row gap-3 items-stretch">
{/* Columna izquierda — disponibles */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-slate-600 uppercase tracking-wide">Disponibles</span>
<span className="text-xs text-slate-400 font-mono">
{importadores.filter(imp => !(Array.isArray(form.rfc) && form.rfc.includes(imp.rfc))).length}
</span>
</div>
<div className="border border-slate-200 rounded-xl bg-slate-50 overflow-hidden flex flex-col" style={{ minHeight: '200px', maxHeight: '280px' }}>
<div className="overflow-y-auto flex-1">
{importadores.filter(imp => !(Array.isArray(form.rfc) && form.rfc.includes(imp.rfc))).length === 0 ? (
<div className="flex items-center justify-center h-full py-8 text-xs text-slate-400 italic">
Todos los RFC han sido asignados
</div>
) : (
importadores
.filter(imp => !(Array.isArray(form.rfc) && form.rfc.includes(imp.rfc)))
.map(imp => (
<div
key={imp.rfc}
onDoubleClick={() => handleRfcToggle(imp.rfc)}
className="flex items-center justify-between px-3 py-2 border-b border-slate-100 last:border-b-0 text-xs font-mono text-slate-700 hover:bg-blue-50 hover:text-blue-800 cursor-pointer select-none group transition-colors duration-100"
title="Doble clic para agregar"
>
<span className="uppercase truncate">{imp.rfc}</span>
<svg className="w-3.5 h-3.5 text-slate-300 group-hover:text-blue-400 flex-shrink-0 ml-2 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</div>
))
)}
</div>
</div>
</div>
{/* Botones centrales */}
<div className="flex sm:flex-col items-center justify-center gap-2 py-2 sm:py-0">
<button
type="button"
title="Agregar todos"
onClick={() => setForm(prev => ({ ...prev, rfc: importadores.map(i => i.rfc) }))}
className="w-8 h-8 rounded-lg border border-slate-300 bg-white hover:bg-blue-50 hover:border-blue-400 flex items-center justify-center transition-colors shadow-sm"
>
<svg className="w-4 h-4 text-slate-500 hover:text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
<button
type="button"
title="Quitar todos"
onClick={() => setForm(prev => ({ ...prev, rfc: [] }))}
className="w-8 h-8 rounded-lg border border-slate-300 bg-white hover:bg-red-50 hover:border-red-400 flex items-center justify-center transition-colors shadow-sm"
>
<svg className="w-4 h-4 text-slate-500 hover:text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
</div>
{/* Columna derecha — seleccionados */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-blue-700 uppercase tracking-wide">Asignados</span>
<span className="text-xs text-blue-500 font-mono font-semibold">
{Array.isArray(form.rfc) ? form.rfc.length : 0}
</span>
</div>
<div className="border border-blue-200 rounded-xl bg-blue-50/40 overflow-hidden flex flex-col" style={{ minHeight: '200px', maxHeight: '280px' }}>
<div className="overflow-y-auto flex-1">
{!Array.isArray(form.rfc) || form.rfc.length === 0 ? (
<div className="flex items-center justify-center h-full py-8 text-xs text-slate-400 italic">
Sin RFC asignados
</div>
) : (
form.rfc.map(r => (
<div
key={r}
onDoubleClick={() => handleRfcToggle(r)}
className="flex items-center justify-between px-3 py-2 border-b border-blue-100 last:border-b-0 text-xs font-mono text-blue-800 hover:bg-red-50 hover:text-red-700 cursor-pointer select-none group transition-colors duration-100"
title="Doble clic para quitar"
>
<svg className="w-3.5 h-3.5 text-blue-300 group-hover:text-red-400 flex-shrink-0 mr-2 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 19l-7-7 7-7" />
</svg>
<span className="uppercase truncate flex-1">{r}</span>
</div>
))
)}
</div>
</div>
</div>
</div>
)}
</div>
)}
{/* Perfiles */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 fade-in-up-users" style={{ animationDelay: '0.15s' }}>
<div className="flex items-center mb-4 pb-3 border-b border-gray-200">
<div className="bg-blue-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Perfiles</h4>
<p className="text-xs text-slate-500">Asigna los perfiles a los que pertenecerá el usuario</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{AVAILABLE_GROUPS.map(group => {
const active = form.groups.includes(group.id);
return (
<button
key={group.id}
type="button"
onClick={() => handleGroupToggle(group.id)}
className={`relative flex flex-col items-start p-3 rounded-xl border-2 text-left transition-all duration-200 ${
active
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'
}`}
>
<span className={`text-xs font-semibold ${active ? 'text-blue-800' : 'text-gray-700'}`}>
{group.label}
</span>
<span className="text-xs text-gray-400 mt-0.5">{group.description}</span>
{active && (
<div className="absolute top-2 right-2 w-4 h-4 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</button>
);
})}
</div>
</div>
{/* Credenciales */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 fade-in-up-users" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center mb-4 pb-3 border-b border-gray-200">
<div className="bg-red-600 rounded-lg p-2 mr-3 shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h4 className="text-sm font-semibold text-slate-800">Credenciales de Acceso</h4>
<p className="text-xs text-slate-500">
{isEditing ? 'Deja en blanco para mantener la contraseña actual' : 'Configura la contraseña de acceso'}
</p>
</div>
</div>
<div className="space-y-4">
{/* Estado del usuario */}
<div className="flex items-center justify-between p-3 rounded-xl border border-slate-200 bg-slate-50">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${form.is_active ? 'bg-blue-100' : 'bg-slate-200'}`}>
<svg className={`w-4 h-4 ${form.is_active ? 'text-blue-600' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={form.is_active ? 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' : 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636'} />
</svg>
</div>
<div>
<p className="text-xs font-semibold text-slate-700">Estado de la cuenta</p>
<p className={`text-xs ${form.is_active ? 'text-blue-600' : 'text-slate-400'}`}>
{form.is_active ? 'Activo — el usuario puede iniciar sesión' : 'Inactivo — acceso bloqueado'}
</p>
</div>
</div>
<button
type="button"
onClick={() => setForm(prev => ({ ...prev, is_active: !prev.is_active }))}
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${form.is_active ? 'bg-blue-600' : 'bg-slate-300'}`}
role="switch"
aria-checked={form.is_active}
>
<span className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform duration-200 ${form.is_active ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
{/* Contraseña */}
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">
Contraseña {!isEditing && <span className="text-red-600">*</span>}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
name="password"
value={form.password}
onChange={handleChange}
required={!isEditing}
className={`w-full px-3 py-2 pr-10 border rounded-md shadow-sm focus:ring-2 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm ${
showPasswordValidation && isPasswordValid()
? 'border-green-300 focus:ring-green-500 focus:border-green-500'
: showPasswordValidation
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-slate-300 focus:ring-red-500 focus:border-red-500'
}`}
placeholder={isEditing ? 'Dejar vacío para mantener actual' : 'Contraseña segura del usuario'}
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-slate-600"
>
{showPassword ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464m1.414 1.414L21.536 21.536" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
{/* Indicadores de validación */}
{showPasswordValidation && (
<div className="mt-3 p-3 bg-slate-100 rounded-lg border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-700">Requisitos de contraseña:</span>
{isPasswordValid() && (
<div className="flex items-center text-green-600">
<svg className="w-4 h-4 mr-1" 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" />
</svg>
<span className="text-xs font-medium">Válida</span>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[
{ key: 'length', label: 'Mínimo 8 caracteres' },
{ key: 'uppercase', label: 'Una letra mayúscula' },
{ key: 'lowercase', label: 'Una letra minúscula' },
{ key: 'number', label: 'Un número' },
{ key: 'special', label: 'Un carácter especial (!@#$%^&*)', span: true },
].map(({ key, label, span }) => (
<div key={key} className={`flex items-center text-xs ${passwordValidation[key] ? 'text-green-600' : 'text-red-500'} ${span ? 'sm:col-span-2' : ''}`}>
<svg className="w-3 h-3 mr-1.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={passwordValidation[key] ? 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' : 'M6 18L18 6M6 6l12 12'} />
</svg>
{label}
</div>
))}
</div>
</div>
)}
</div>
{/* Confirmar contraseña */}
<div className="space-y-1">
<label className="block text-xs font-semibold text-slate-700">
Confirmar Contraseña {!isEditing && <span className="text-red-600">*</span>}
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
name="confirmPassword"
value={form.confirmPassword}
onChange={handleChange}
required={!isEditing}
className={`w-full px-3 py-2 pr-10 border rounded-md shadow-sm focus:ring-2 transition-all duration-200 bg-white text-slate-900 placeholder-slate-400 text-sm ${
showPasswordMatchValidation && passwordsMatch && form.confirmPassword.length > 0
? 'border-green-300 focus:ring-green-500 focus:border-green-500'
: showPasswordMatchValidation && !passwordsMatch
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-slate-300 focus:ring-red-500 focus:border-red-500'
}`}
placeholder="Confirme la contraseña"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(v => !v)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-slate-600"
>
{showConfirmPassword ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464m1.414 1.414L21.536 21.536" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
{showPasswordMatchValidation && (
<div className={`mt-2 flex items-center text-xs ${passwordsMatch ? 'text-green-600' : 'text-red-500'}`}>
<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={passwordsMatch ? 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' : 'M6 18L18 6M6 6l12 12'} />
</svg>
<span className="font-medium">
{passwordsMatch ? 'Las contraseñas coinciden' : 'Las contraseñas no coinciden'}
</span>
</div>
)}
</div>
</div>
</div>
{/* Botones de acción */}
<div className="flex flex-col sm:flex-row justify-end gap-3 pb-8 fade-in-up-users" style={{ animationDelay: '0.25s' }}>
<button
type="button"
onClick={() => navigate('/users')}
disabled={submitting}
className="w-full sm:w-auto px-6 py-2.5 border border-slate-300 rounded-lg shadow-sm text-sm font-semibold text-slate-700 bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 transition-all duration-200 disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting || (showPasswordValidation && !isFormValid())}
className="w-full sm:w-auto px-6 py-2.5 border border-transparent rounded-lg shadow-lg text-sm font-semibold text-white bg-gradient-to-r from-blue-700 to-blue-900 hover:from-blue-800 hover:to-blue-950 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={isEditing ? 'M5 13l4 4L19 7' : 'M12 6v6m0 0v6m0-6h6m-6 0H6'} />
</svg>
<span>
{submitting
? (isEditing ? 'Actualizando...' : 'Creando...')
: (isEditing ? 'Actualizar Usuario' : 'Crear Usuario')}
</span>
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { fetchUsers, createUser, updateUser, deleteUser, getCurrentUser } from '../api/users.ts'; import { fetchUsers, createUser, updateUser, deleteUser, getCurrentUser } from '../api/users.ts';
import { useNotification } from '../context/NotificationContext'; import { useNotification } from '../context/NotificationContext';
@@ -47,6 +48,7 @@ export default function Users() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const { showMessage } = useNotification(); const { showMessage } = useNotification();
const navigate = useNavigate();
// Estados para validación de contraseña // Estados para validación de contraseña
const [passwordValidation, setPasswordValidation] = useState({ const [passwordValidation, setPasswordValidation] = useState({
@@ -552,44 +554,17 @@ export default function Users() {
</div> </div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3"> <div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<button <button
onClick={() => { setShowCreateModal(true); setCreateType('agente'); }} onClick={() => navigate('/users/new')}
type="button" type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium 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" className="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium 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"
> >
<svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg> </svg>
<span className="hidden sm:inline">Nuevo Agente</span> <span className="hidden sm:inline">Nuevo Usuario</span>
<span className="sm:hidden">Agente</span> <span className="sm:hidden">Usuario</span>
</button>
<button
onClick={async () => {
setCreateType('importador');
// Fetch importadores RFC
try {
const res = await fetch(`${import.meta.env.VITE_EFC_API_URL}/customs/importadores/`, { method: 'GET', headers: { 'Authorization': `Bearer ${localStorage.getItem('access')}` } });
const data = await res.json();
if (Array.isArray(data)) {
setImportadores(data);
} else {
setImportadores([]);
}
} catch {
setImportadores([]);
}
setShowCreateModal(true);
}}
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 transform hover:scale-105"
>
<svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="hidden sm:inline">Nuevo Importador</span>
<span className="sm:hidden">Importador</span>
</button> </button>
</div> </div>
{/* Modal para crear usuario (agente o importador) eliminado */}
</div> </div>
{/* Filtros avanzados */} {/* Filtros avanzados */}
@@ -777,6 +752,16 @@ export default function Users() {
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-center"> <td className="px-6 py-4 whitespace-nowrap text-center">
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
<button
onClick={() => navigate(`/users/${user.id}/edit`)}
className="inline-flex items-center px-3 py-1.5 border border-blue-300 shadow-sm text-xs font-medium rounded-lg text-blue-700 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 transform hover:scale-105"
title="Editar usuario"
>
<svg className="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Editar
</button>
<button <button
onClick={() => { setShowDeleteModal(true); setUserToDelete(user); }} onClick={() => { setShowDeleteModal(true); setUserToDelete(user); }}
disabled={user.username === localStorage.getItem('username')} disabled={user.username === localStorage.getItem('username')}
@@ -874,16 +859,27 @@ export default function Users() {
<div className="text-xs text-gray-500 mt-1">ID: {user.id}</div> <div className="text-xs text-gray-500 mt-1">ID: {user.id}</div>
</div> </div>
</div> </div>
<button <div className="flex items-center gap-2">
onClick={() => { setShowDeleteModal(true); setUserToDelete(user); }} <button
disabled={user.username === localStorage.getItem('username')} onClick={() => navigate(`/users/${user.id}/edit`)}
className={`inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-xs font-medium rounded-lg text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200 ${user.username === localStorage.getItem('username') ? 'opacity-50 cursor-not-allowed' : ''}`} className="inline-flex items-center px-3 py-2 border border-blue-300 shadow-sm text-xs font-medium rounded-lg text-blue-700 bg-white hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"
title={user.username === localStorage.getItem('username') ? 'No puedes eliminar tu propia cuenta' : 'Eliminar usuario'} title="Editar usuario"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg> </svg>
</button> </button>
<button
onClick={() => { setShowDeleteModal(true); setUserToDelete(user); }}
disabled={user.username === localStorage.getItem('username')}
className={`inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-xs font-medium rounded-lg text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200 ${user.username === localStorage.getItem('username') ? 'opacity-50 cursor-not-allowed' : ''}`}
title={user.username === localStorage.getItem('username') ? 'No puedes eliminar tu propia cuenta' : 'Eliminar usuario'}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-4 text-xs"> <div className="grid grid-cols-2 gap-4 text-xs">
<div> <div>