Compare commits
9 Commits
fix/proces
...
546a411df8
| Author | SHA1 | Date | |
|---|---|---|---|
| 546a411df8 | |||
|
|
75885dc3a9 | ||
|
|
078297cd61 | ||
|
|
06f2485336 | ||
| b262c7098f | |||
|
|
1374dc22a3 | ||
| 7e1d6ff05b | |||
|
|
0fc3b66090 | ||
| f8a81a4ef6 |
11
src/App.jsx
11
src/App.jsx
@@ -14,6 +14,7 @@ import LandingAnimated from './pages/LandingAnimated';
|
||||
import Expedientes from './pages/Expedientes';
|
||||
import Organization from './pages/Organization';
|
||||
import Users from './pages/Users';
|
||||
import UserForm from './pages/UserForm';
|
||||
import Reports from './pages/Reports';
|
||||
import Settings from './pages/Settings';
|
||||
import Importers from './pages/Importers';
|
||||
@@ -76,6 +77,16 @@ function AppContent() {
|
||||
<Users />
|
||||
</RequireAuth>
|
||||
} />
|
||||
<Route path="/users/new" element={
|
||||
<RequireAuth>
|
||||
<UserForm />
|
||||
</RequireAuth>
|
||||
} />
|
||||
<Route path="/users/:id/edit" element={
|
||||
<RequireAuth>
|
||||
<UserForm />
|
||||
</RequireAuth>
|
||||
} />
|
||||
<Route path="/reports" element={
|
||||
<RequireAuth>
|
||||
<Reports />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -381,11 +381,11 @@ export default function Datastage() {
|
||||
<tr key={item.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="border px-2 py-2 text-center">{item.id}</td>
|
||||
<td className="border px-2 py-2 max-w-xs truncate">
|
||||
{item.archivo ? (
|
||||
{item.download_url ? (
|
||||
<span className="flex items-center gap-1 text-xs text-gray-700 truncate font-mono">
|
||||
{(() => {
|
||||
try {
|
||||
const url = new URL(item.archivo);
|
||||
const url = new URL(item.download_url);
|
||||
return decodeURIComponent(url.pathname.split('/').pop() || '');
|
||||
} catch {
|
||||
return '';
|
||||
@@ -399,7 +399,7 @@ export default function Datastage() {
|
||||
item.id,
|
||||
(() => {
|
||||
try {
|
||||
const url = new URL(item.archivo);
|
||||
const url = new URL(item.download_url);
|
||||
return decodeURIComponent(url.pathname.split('/').pop() || '');
|
||||
} catch {
|
||||
return '';
|
||||
@@ -507,16 +507,16 @@ export default function Datastage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-700 break-all font-mono mb-1">
|
||||
{item.archivo ? (
|
||||
{item.download_url ? (
|
||||
<span className="flex items-center gap-1">
|
||||
{(() => { try { const url = new URL(item.archivo); return decodeURIComponent(url.pathname.split('/').pop() || ''); } catch { return ''; } })()}
|
||||
{(() => { try { const url = new URL(item.download_url); return decodeURIComponent(url.pathname.split('/').pop() || ''); } catch { return ''; } })()}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-6 h-6 rounded bg-blue-100 border border-blue-200 text-blue-700 hover:bg-blue-200 hover:border-blue-300 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-400 ml-1"
|
||||
title="Descargar archivo"
|
||||
onClick={() => downloadDatastageFile(
|
||||
item.id,
|
||||
(() => { try { const url = new URL(item.archivo); return decodeURIComponent(url.pathname.split('/').pop() || ''); } catch { return ''; } })()
|
||||
(() => { try { const url = new URL(item.download_url); return decodeURIComponent(url.pathname.split('/').pop() || ''); } catch { return ''; } })()
|
||||
)}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -803,7 +803,7 @@ export default function Datastage() {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
|
||||
<div className="bg-white rounded-xl shadow-2xl border border-blue-200 p-8 max-w-sm w-full flex flex-col animate-fade-in">
|
||||
<h3 className="text-lg font-bold mb-2 text-blue-900">Detalle de Datastage #{selected.id}</h3>
|
||||
<div className="mb-1"><b>Archivo:</b> {selected.archivo ? <a href={selected.archivo} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline break-all">Descargar</a> : <span className="text-gray-400">Sin archivo</span>}</div>
|
||||
{/* <div className="mb-1"><b>Archivo:</b> {selected.download_url ? <a href={selected.download_url} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline break-all">Descargar</a> : <span className="text-gray-400">Sin archivo</span>}</div> */}
|
||||
<div className="mb-1"><b>Contribuyente:</b> {selected.contribuyente}</div>
|
||||
<div className="mb-1"><b>Procesado:</b> <span className={selected.procesado ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs' : 'bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs'}>{selected.procesado ? 'Sí' : 'No'}</span></div>
|
||||
<div className="mb-1"><b>Organización:</b> {selected.organizacion}</div>
|
||||
|
||||
@@ -334,6 +334,8 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
const [processingAcuseCove, setProcessingAcuseCove] = useState(null);
|
||||
const [processingEdoc, setProcessingEdoc] = 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
|
||||
const [showDocumentsModal, setShowDocumentsModal] = useState(false);
|
||||
@@ -412,6 +414,7 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
// La API devuelve un array directamente, no un objeto con results
|
||||
const credenciales = Array.isArray(data) ? data : (data.results || []);
|
||||
|
||||
console.log('credenciales >>>> ', credenciales);
|
||||
return credenciales;
|
||||
}
|
||||
return [];
|
||||
@@ -459,8 +462,10 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
password: credencial.password,
|
||||
efirma: credencial.efirma,
|
||||
// Convertir URLs completas a rutas relativas
|
||||
key: credencial.key ? credencial.key.split('/').slice(-2).join('/') : '',
|
||||
cer: credencial.cer ? credencial.cer.split('/').slice(-2).join('/') : '',
|
||||
key: credencial.key_download_url,
|
||||
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,
|
||||
organizacion: credencial.organizacion
|
||||
}
|
||||
@@ -565,6 +570,7 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
const [previewDoc, setPreviewDoc] = useState(null);
|
||||
const [previewContent, setPreviewContent] = useState('');
|
||||
const [imageZoom, setImageZoom] = useState(1);
|
||||
const [previewIframeLoaded, setPreviewIframeLoaded] = useState(false);
|
||||
|
||||
// Refs
|
||||
const focusKeeperRef = useRef(null);
|
||||
@@ -647,6 +653,7 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
setPreviewDoc(doc);
|
||||
setImageZoom(1);
|
||||
setPreviewContent('');
|
||||
setPreviewIframeLoaded(false);
|
||||
setPreviewOpen(true);
|
||||
try {
|
||||
const res = await fetchWithAuth(`${API_URL}/record/documents/descargar/${doc.id}/`);
|
||||
@@ -686,12 +693,17 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
setPreviewLoading(false);
|
||||
} else {
|
||||
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);
|
||||
setPreviewUrl(url);
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in preview:', err);
|
||||
console.error('Error en vista previa (VU):', err);
|
||||
if (err.message === 'SESSION_EXPIRED') {
|
||||
setPreviewError('Tu sesión ha expirado, por favor inicia sesión de nuevo.');
|
||||
} else {
|
||||
@@ -711,6 +723,7 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
setPreviewDoc(doc);
|
||||
setImageZoom(1);
|
||||
setPreviewContent('');
|
||||
setPreviewIframeLoaded(false);
|
||||
setPreviewOpen(true);
|
||||
try {
|
||||
const res = await fetchWithAuth(`${API_URL}/record/documents/descargar/${doc.id}/`);
|
||||
@@ -750,12 +763,17 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
setPreviewLoading(false);
|
||||
} else {
|
||||
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);
|
||||
setPreviewUrl(url);
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in preview:', err);
|
||||
console.error('Error en vista previa:', err);
|
||||
if (err.message === 'SESSION_EXPIRED') {
|
||||
setPreviewError('Tu sesión ha expirado, por favor inicia sesión de nuevo.');
|
||||
} else {
|
||||
@@ -777,6 +795,7 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
setPreviewDoc(null);
|
||||
setPreviewContent('');
|
||||
setImageZoom(1);
|
||||
setPreviewIframeLoaded(false);
|
||||
};
|
||||
|
||||
// Funciones para el nuevo diseño de documentos
|
||||
@@ -2005,8 +2024,10 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
user: credencial.usuario,
|
||||
password: credencial.password,
|
||||
efirma: credencial.efirma,
|
||||
key: credencial.key,
|
||||
cer: credencial.cer,
|
||||
key: credencial.key_download_url,
|
||||
cer: credencial.cer_download_url,
|
||||
// key: credencial.key,
|
||||
// cer: credencial.cer,
|
||||
is_active: credencial.is_active,
|
||||
organizacion: credencial.organizacion
|
||||
}
|
||||
@@ -2078,8 +2099,10 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
user: credencial.usuario,
|
||||
password: credencial.password,
|
||||
efirma: credencial.efirma,
|
||||
key: credencial.key,
|
||||
cer: credencial.cer,
|
||||
key: credencial.key_download_url,
|
||||
cer: credencial.cer_download_url,
|
||||
// key: credencial.key,
|
||||
// cer: credencial.cer,
|
||||
is_active: credencial.is_active,
|
||||
organizacion: credencial.organizacion
|
||||
}
|
||||
@@ -2152,8 +2175,10 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
user: credencial.usuario,
|
||||
password: credencial.password,
|
||||
efirma: credencial.efirma,
|
||||
key: credencial.key,
|
||||
cer: credencial.cer,
|
||||
key: credencial.key_download_url,
|
||||
cer: credencial.cer_download_url,
|
||||
// key: credencial.key,
|
||||
// cer: credencial.cer,
|
||||
is_active: credencial.is_active,
|
||||
organizacion: credencial.organizacion
|
||||
}
|
||||
@@ -2226,8 +2251,10 @@ const handleDeleteSelectedPedimentoDocuments = async () => {
|
||||
user: credencial.usuario,
|
||||
password: credencial.password,
|
||||
efirma: credencial.efirma,
|
||||
key: credencial.key,
|
||||
cer: credencial.cer,
|
||||
key: credencial.key_download_url,
|
||||
cer: credencial.cer_download_url,
|
||||
// key: credencial.key,
|
||||
// cer: credencial.cer,
|
||||
is_active: credencial.is_active,
|
||||
organizacion: credencial.organizacion
|
||||
}
|
||||
@@ -5411,7 +5438,7 @@ useEffect(() => {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 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">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
@@ -5502,20 +5529,29 @@ useEffect(() => {
|
||||
{edoc.acuse_descargado ? 'Descargado' : 'Pendiente'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<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 flex-nowrap">
|
||||
{/* Botón EDoc */}
|
||||
<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}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
edoc.edocument_descargado
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: processingEdoc === edoc.id
|
||||
? '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'
|
||||
}`}
|
||||
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 ? (
|
||||
<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 */}
|
||||
<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}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
edoc.acuse_descargado
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: processingAcuseEdoc === edoc.id
|
||||
? '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'
|
||||
}`}
|
||||
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 ? (
|
||||
<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>
|
||||
<p className="text-sm text-gray-600">{previewError}</p>
|
||||
<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"
|
||||
>
|
||||
Reintentar
|
||||
Volver a cargar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : previewType === 'pdf' ? (
|
||||
<div className="flex-1 bg-white">
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
title="PDF Preview"
|
||||
<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
|
||||
src={previewUrl}
|
||||
title="PDF Preview"
|
||||
className="w-full h-full border-0"
|
||||
style={{ minHeight: '500px' }}
|
||||
onLoad={() => {
|
||||
setPreviewIframeLoaded(true);
|
||||
setPreviewLoading(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setPreviewError('No se pudo cargar el documento PDF.');
|
||||
setPreviewLoading(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : previewType === 'img' ? (
|
||||
@@ -7217,6 +7284,91 @@ useEffect(() => {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -198,6 +198,7 @@ export default function Reports() {
|
||||
|
||||
const [organizaciones, setOrganizaciones] = useState([]);
|
||||
const [importadores, setImportadores] = useState([]);
|
||||
const [rfcOptions, setRfcOptions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrganizaciones = async () => {
|
||||
@@ -241,6 +242,27 @@ export default function Reports() {
|
||||
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 = () => (
|
||||
<div className="mb-6">
|
||||
<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 || ''}
|
||||
onChange={(e) => setGlobalFilters(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
|
||||
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 => (
|
||||
<option key={org.id} value={org.id}>
|
||||
{org.nombre} {/* Usar el campo 'nombre' que sí existe */}
|
||||
{org.nombre}
|
||||
</option>
|
||||
))}
|
||||
</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"
|
||||
style={{ textTransform: 'uppercase' }}
|
||||
disabled={!globalFilters.organizacion}
|
||||
>
|
||||
<option value="" >Selecciona un RFC</option>
|
||||
{importadores.filter(imp => {
|
||||
if (!globalFilters.organizacion) return true;
|
||||
return imp.organizacion === globalFilters.organizacion;
|
||||
}).map(imp => (
|
||||
<option key={imp.rfc} value={imp.rfc}>{imp.rfc}</option>
|
||||
<option value="">Todos los RFC</option>
|
||||
{rfcOptions.map(rfc => (
|
||||
<option key={rfc} value={rfc}>{rfc}</option>
|
||||
))}
|
||||
</select>
|
||||
<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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
770
src/pages/UserForm.jsx
Normal file
770
src/pages/UserForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { fetchUsers, createUser, updateUser, deleteUser, getCurrentUser } from '../api/users.ts';
|
||||
import { useNotification } from '../context/NotificationContext';
|
||||
|
||||
@@ -47,6 +48,7 @@ export default function Users() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const { showMessage } = useNotification();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Estados para validación de contraseña
|
||||
const [passwordValidation, setPasswordValidation] = useState({
|
||||
@@ -552,44 +554,17 @@ export default function Users() {
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
|
||||
<button
|
||||
onClick={() => { setShowCreateModal(true); setCreateType('agente'); }}
|
||||
onClick={() => navigate('/users/new')}
|
||||
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"
|
||||
>
|
||||
<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 Agente</span>
|
||||
<span className="sm:hidden">Agente</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>
|
||||
<span className="hidden sm:inline">Nuevo Usuario</span>
|
||||
<span className="sm:hidden">Usuario</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* Modal para crear usuario (agente o importador) eliminado */}
|
||||
</div>
|
||||
|
||||
{/* Filtros avanzados */}
|
||||
@@ -777,6 +752,16 @@ export default function Users() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<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
|
||||
onClick={() => { setShowDeleteModal(true); setUserToDelete(user); }}
|
||||
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>
|
||||
</div>
|
||||
<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 className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/users/${user.id}/edit`)}
|
||||
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="Editar usuario"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
</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 className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user