Se agrego datastage
This commit is contained in:
4
.env
4
.env
@@ -1,4 +1,4 @@
|
||||
VITE_DEBUG_MODE=false
|
||||
|
||||
VITE_EFC_API_URL=http://192.168.1.195:8000/api/v1
|
||||
VITE_EFC_MICROSERVICE_URL=http://192.168.1.195:8001/api/v1
|
||||
VITE_EFC_API_URL=https://api.efc-aduanasoft.com/api/v1
|
||||
VITE_EFC_MICROSERVICE_URL=https://api.efc-aduanasoft.com/microservicio/api/v1
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Documents from './pages/Documents';
|
||||
import Datastage from './pages/Datastage';
|
||||
import Agenda from './pages/Agenda';
|
||||
import Vucem from './pages/Vucem';
|
||||
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||
@@ -97,6 +98,12 @@ function AppContent() {
|
||||
<Procesos />
|
||||
</RequireAuth>
|
||||
} />
|
||||
{/* Ruta para Datastage */}
|
||||
<Route path="/datastage" element={
|
||||
<RequireAuth>
|
||||
<Datastage />
|
||||
</RequireAuth>
|
||||
} />
|
||||
{/* Ruta para agenda */}
|
||||
<Route path="/agenda" element={
|
||||
<RequireAuth>
|
||||
|
||||
39
src/api/datastage.js
Normal file
39
src/api/datastage.js
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
import.meta.env;
|
||||
import { getWithAuth, postWithAuth, patchWithAuth, deleteWithAuth } from '../fetchWithAuth';
|
||||
const API_BASE = `${import.meta.env.VITE_EFC_API_URL}/datastage/datastages/`;
|
||||
|
||||
export async function fetchDatastages() {
|
||||
const res = await getWithAuth(API_BASE);
|
||||
if (!res.ok) throw new Error('Error al obtener datastages');
|
||||
const data = await res.json();
|
||||
// Si la respuesta es paginada, devolver results
|
||||
if (data && Array.isArray(data.results)) return data.results;
|
||||
// Si la respuesta no es un array, devolver array vacío
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchDatastageDetail(id) {
|
||||
const res = await getWithAuth(`${API_BASE}${id}/`);
|
||||
if (!res.ok) throw new Error('Error al obtener detalle');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createDatastage(data) {
|
||||
const res = await postWithAuth(API_BASE, data);
|
||||
if (!res.ok) throw new Error('Error al crear datastage');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateDatastage(id, data) {
|
||||
const res = await patchWithAuth(`${API_BASE}${id}/`, data);
|
||||
if (!res.ok) throw new Error('Error al actualizar datastage');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteDatastage(id) {
|
||||
const res = await deleteWithAuth(`${API_BASE}${id}/`);
|
||||
if (!res.ok) throw new Error('Error al eliminar datastage');
|
||||
return true;
|
||||
}
|
||||
33
src/components/ConfirmModal.jsx
Normal file
33
src/components/ConfirmModal.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ConfirmModal({ open, onClose, onConfirm, message = '¿Estás seguro?', confirmText = 'Confirmar', cancelText = 'Cancelar' }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<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 items-center animate-fade-in">
|
||||
<svg className="h-12 w-12 text-blue-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01" />
|
||||
</svg>
|
||||
<h2 className="text-xl font-bold text-blue-700 mb-2 text-center">{message}</h2>
|
||||
<div className="flex gap-4 mt-4">
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg font-semibold shadow hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-gray-300 text-gray-800 rounded-lg font-semibold shadow hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes fade-in { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
|
||||
.animate-fade-in { animation: fade-in 0.3s ease; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,6 +140,18 @@ export default function Sidebar({ isMobileOpen, onMobileClose }) {
|
||||
<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>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'Datastage',
|
||||
path: '/datastage',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<ellipse cx="12" cy="7" rx="8" ry="3" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M4 7v10c0 1.657 3.582 3 8 3s8-1.343 8-3V7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M4 17c0 1.657 3.582 3 8 3s8-1.343 8-3" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigate('/datastage')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -86,14 +86,17 @@ const refreshToken = async () => {
|
||||
|
||||
// Función principal para hacer peticiones con manejo automático de tokens
|
||||
export const fetchWithAuth = async (url, options = {}) => {
|
||||
|
||||
// Obtener el token actual
|
||||
let token = localStorage.getItem('access');
|
||||
|
||||
|
||||
// Configurar headers por defecto
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` })
|
||||
};
|
||||
let defaultHeaders = {};
|
||||
if (token) defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
// Solo poner Content-Type si no es GET o si hay body
|
||||
if ((options.method && options.method.toUpperCase() !== 'GET') || options.body) {
|
||||
defaultHeaders['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
// Combinar headers
|
||||
const finalOptions = {
|
||||
|
||||
476
src/pages/Datastage.jsx
Normal file
476
src/pages/Datastage.jsx
Normal file
@@ -0,0 +1,476 @@
|
||||
|
||||
|
||||
import React, { useEffect, useState, useLayoutEffect, useRef } from 'react';
|
||||
import SuccessModal from '../components/SuccessModal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import {
|
||||
fetchDatastages,
|
||||
fetchDatastageDetail,
|
||||
createDatastage,
|
||||
updateDatastage,
|
||||
deleteDatastage
|
||||
} from '../api/datastage';
|
||||
import { postFormDataWithAuth, patchFormDataWithAuth } from '../fetchWithAuth';
|
||||
// PATCH para actualizar solo el campo procesado
|
||||
async function patchProcesadoTrue(item) {
|
||||
const url = `${import.meta.env.VITE_EFC_API_URL}/datastage/datastages/${item.id}/`;
|
||||
const body = { procesado: true };
|
||||
return fetchWithAuth(url, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
import { fetchWithAuth } from '../fetchWithAuth';
|
||||
|
||||
// Modal para mostrar registros cargados
|
||||
function RegistrosCargadosModal({ open, onClose, registros }) {
|
||||
if (!open || !registros) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-lg font-bold mb-4">Registros cargados</h2>
|
||||
<div className="overflow-x-auto max-h-96">
|
||||
<table className="min-w-full border border-gray-300 text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="px-2 py-1 border">Registro</th>
|
||||
<th className="px-2 py-1 border">Cantidad</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(registros).map(([registro, cantidad]) => (
|
||||
<tr key={registro}>
|
||||
<td className="px-2 py-1 border">{registro}</td>
|
||||
<td className="px-2 py-1 border text-right">{cantidad}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Procesar datastage (adaptado para mostrar registros cargados)
|
||||
async function procesarDatastage(item, setDatastages, setSuccess, setError, setRegistrosCargados, setShowRegistrosModal) {
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_EFC_API_URL}/datastage/datastages/${item.id}/procesar/`;
|
||||
const body = {
|
||||
organizacion: item.organizacion,
|
||||
contribuyente: item.contribuyente,
|
||||
procesado: item.procesado
|
||||
};
|
||||
const res = await fetchWithAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.status === 200) {
|
||||
// PATCH para marcar como procesado en backend
|
||||
await patchProcesadoTrue(item);
|
||||
setDatastages(prev => prev.map(d => d.id === item.id ? { ...d, procesado: true } : d));
|
||||
setSuccess('Procesado correctamente');
|
||||
const data = await res.json();
|
||||
if (data && data.registros_cargados) {
|
||||
setRegistrosCargados(data.registros_cargados);
|
||||
setShowRegistrosModal(true);
|
||||
}
|
||||
} else {
|
||||
setError('No se pudo procesar el datastage');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('No se pudo procesar el datastage');
|
||||
}
|
||||
}
|
||||
// Descarga autenticada de archivos datastage
|
||||
function downloadDatastageFile(id, filename) {
|
||||
const url = `${import.meta.env.VITE_EFC_API_URL}/datastage/datastages/${id}/download-datastage/`;
|
||||
fetchWithAuth(url, { method: 'GET' })
|
||||
.then(async res => {
|
||||
if (!res.ok) throw new Error('Error al descargar archivo');
|
||||
const blob = await res.blob();
|
||||
const link = document.createElement('a');
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = filename || `datastage_${id}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
})
|
||||
.catch(() => alert('No se pudo descargar el archivo.'));
|
||||
}
|
||||
|
||||
export default function Datastage() {
|
||||
const focusKeeperRef = useRef(null);
|
||||
const [datastages, setDatastages] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [form, setForm] = useState({ archivo: null, contribuyente: '' });
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
// Estado para mostrar registros cargados
|
||||
const [showRegistrosModal, setShowRegistrosModal] = useState(false);
|
||||
const [registrosCargados, setRegistrosCargados] = useState(null);
|
||||
const [deleteId, setDeleteId] = useState(null);
|
||||
// Animación header
|
||||
const [showAnimation, setShowAnimation] = useState(false);
|
||||
const [hasAnimated, setHasAnimated] = useState(false);
|
||||
useLayoutEffect(() => { setShowAnimation(true); }, []);
|
||||
useEffect(() => { if (showAnimation && !hasAnimated) setTimeout(() => setHasAnimated(true), 800); }, [showAnimation, hasAnimated]);
|
||||
|
||||
// Cargar lista
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchDatastages();
|
||||
setDatastages(data);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
// Ver detalle
|
||||
const handleSelect = async (id) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const detail = await fetchDatastageDetail(id);
|
||||
setSelected(detail);
|
||||
setShowDetailModal(true);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Crear
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('contribuyente', form.contribuyente);
|
||||
if (form.archivo) fd.append('archivo', form.archivo);
|
||||
await postFormDataWithAuth(`${import.meta.env.VITE_EFC_API_URL}/datastage/datastages/`, fd);
|
||||
setForm({ archivo: null, contribuyente: '' });
|
||||
setShowCreateModal(false);
|
||||
setSuccess('Datastage creado exitosamente');
|
||||
setShowSuccessModal(true);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Editar
|
||||
const handleEdit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('contribuyente', form.contribuyente);
|
||||
if (form.archivo) fd.append('archivo', form.archivo);
|
||||
await patchFormDataWithAuth(`${import.meta.env.VITE_EFC_API_URL}/datastage/datastages/${editingId}/`, fd);
|
||||
setForm({ archivo: null, contribuyente: '' });
|
||||
setEditingId(null);
|
||||
setShowEditModal(false);
|
||||
setSuccess('Datastage actualizado exitosamente');
|
||||
setShowSuccessModal(true);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Eliminar
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteDatastage(deleteId);
|
||||
if (selected && selected.id === deleteId) setSelected(null);
|
||||
setShowDeleteModal(false);
|
||||
setSuccess('Datastage eliminado exitosamente');
|
||||
setShowSuccessModal(true);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Abrir modal de edición
|
||||
const openEditModal = (item) => {
|
||||
setForm({ archivo: null, contribuyente: item.contribuyente });
|
||||
setEditingId(item.id);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Abrir modal de creación
|
||||
const openCreateModal = () => {
|
||||
setForm({ archivo: null, contribuyente: '' });
|
||||
setEditingId(null);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
// Abrir modal de eliminación
|
||||
const openDeleteModal = (id) => {
|
||||
setDeleteId(id);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// Cancelar edición
|
||||
const handleCancelEdit = () => {
|
||||
setForm({ archivo: null, contribuyente: '' });
|
||||
setEditingId(null);
|
||||
setShowEditModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 sm:p-6 bg-gradient-to-br from-gray-50 to-blue-50">
|
||||
<div ref={focusKeeperRef} tabIndex={-1} style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden', outline: 'none' }} aria-hidden="true"></div>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header decorativo */}
|
||||
<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" +
|
||||
(showAnimation && !hasAnimated ? ' animate-fadein-slideup opacity-0' : '')
|
||||
}
|
||||
style={showAnimation && !hasAnimated ? { animation: 'fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards' } : undefined}>
|
||||
<div className="flex-shrink-0 bg-white/20 backdrop-blur-sm rounded-full p-3 sm:p-4 shadow-lg animate-bounce-slow">
|
||||
<svg className="h-8 w-8 sm:h-10 sm:w-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<ellipse cx="12" cy="7" rx="8" ry="3" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M4 7v10c0 1.657 3.582 3 8 3s8-1.343 8-3V7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M4 17c0 1.657 3.582 3 8 3s8-1.343 8-3" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-extrabold text-white tracking-tight mb-1 flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
Datastage
|
||||
<span className="inline-block bg-white/20 backdrop-blur-sm text-white text-xs font-semibold px-3 py-1 rounded-full animate-fade-in">{datastages.length}</span>
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base lg:text-lg text-blue-100 font-medium">Gestiona y visualiza la información de Datastage.</p>
|
||||
</div>
|
||||
{/* Efecto decorativo de fondo */}
|
||||
<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="url(#grad1)" />
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#1e40af" stopOpacity="0.15" />
|
||||
<stop offset="1" stopColor="#1e3a8a" stopOpacity="0.10" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{/* Animación personalizada para el icono y contador */}
|
||||
<style>{`
|
||||
@keyframes bounce-slow {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
.animate-bounce-slow {
|
||||
animation: bounce-slow 2.2s infinite;
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.7s ease;
|
||||
}
|
||||
@keyframes fadein-slideup { 0% { opacity: 0; transform: translateY(40px); } 100% { opacity: 1; transform: translateY(0); } }
|
||||
.animate-fadein-slideup { animation: fadein-slideup 0.7s cubic-bezier(0.22,1,0.36,1) 0.05s forwards; }
|
||||
`}</style>
|
||||
|
||||
{/* Botón flotante para crear */}
|
||||
<div className="flex justify-end mb-4">
|
||||
<button onClick={openCreateModal} className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg font-semibold shadow hover:from-blue-700 hover:to-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-400 transition-all">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nuevo Datastage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lista */}
|
||||
<div className="bg-white shadow-lg rounded-xl border border-gray-200">
|
||||
<div className="px-4 sm:px-6 lg:px-8 pt-6 sm:pt-8 pb-2 border-b border-gray-200">
|
||||
<h2 className="text-xl sm:text-2xl font-extrabold text-blue-800 tracking-tight mb-1">Lista de Datastages</h2>
|
||||
<div className="h-1 w-10 bg-blue-400 rounded mb-2"></div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 rounded-lg overflow-hidden text-xs bg-white">
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="border px-2 py-2 whitespace-nowrap">ID</th>
|
||||
<th className="border px-2 py-2 whitespace-nowrap">Archivo</th>
|
||||
<th className="border px-2 py-2 whitespace-nowrap">Contribuyente</th>
|
||||
<th className="border px-2 py-2 whitespace-nowrap">Procesado</th>
|
||||
<th className="border px-2 py-2 whitespace-nowrap">Creado</th>
|
||||
<th className="border px-2 py-2 whitespace-nowrap">Actualizado</th>
|
||||
<th className="border px-2 py-2 whitespace-nowrap">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{datastages.map(item => (
|
||||
<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 ? (
|
||||
<>
|
||||
<span className="block text-xs text-gray-700 truncate font-mono">
|
||||
{(() => {
|
||||
try {
|
||||
const url = new URL(item.archivo);
|
||||
return decodeURIComponent(url.pathname.split('/').pop() || '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-blue-600 underline break-all hover:text-blue-800"
|
||||
onClick={() => downloadDatastageFile(
|
||||
item.id,
|
||||
(() => {
|
||||
try {
|
||||
const url = new URL(item.archivo);
|
||||
return decodeURIComponent(url.pathname.split('/').pop() || '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})()
|
||||
)}
|
||||
>
|
||||
Descargar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400">Sin archivo</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="border px-2 py-2">{item.contribuyente}</td>
|
||||
<td className="border px-2 py-2 text-center">
|
||||
<span className={item.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'}>
|
||||
{item.procesado ? 'Sí' : 'No'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="border px-2 py-2 whitespace-nowrap">{item.created_at ? new Date(item.created_at).toLocaleString() : ''}</td>
|
||||
<td className="border px-2 py-2 whitespace-nowrap">{item.updated_at ? new Date(item.updated_at).toLocaleString() : ''}</td>
|
||||
<td className="border px-2 py-2 space-x-2 text-center">
|
||||
<button onClick={() => handleSelect(item.id)} className="inline-flex items-center gap-1 text-blue-600 underline hover:text-blue-800">
|
||||
<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>
|
||||
<button onClick={() => openEditModal(item)} className="inline-flex items-center gap-1 text-yellow-600 underline hover:text-yellow-800">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15.232 5.232l3.536 3.536M9 13l6-6 3.536 3.536a2 2 0 010 2.828l-7.072 7.072a2 2 0 01-2.828 0l-3.536-3.536a2 2 0 010-2.828l7.072-7.072z" /></svg>
|
||||
</button>
|
||||
<button onClick={() => openDeleteModal(item.id)} className="inline-flex items-center gap-1 text-red-600 underline hover:text-red-800">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => procesarDatastage(item, setDatastages, setSuccess, setError, setRegistrosCargados, setShowRegistrosModal)}
|
||||
className="inline-flex items-center gap-1 text-green-700 underline hover:text-green-900"
|
||||
title="Procesar"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" /></svg>
|
||||
Procesar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modales */}
|
||||
{/* Modal de creación */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
|
||||
<form onSubmit={handleCreate} className="bg-white rounded-xl shadow-2xl border border-blue-200 p-8 max-w-sm w-full flex flex-col animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-blue-700 mb-4">Nuevo Datastage</h2>
|
||||
{error && <div className="text-red-500 mb-2">{error}</div>}
|
||||
<label className="block text-xs font-semibold text-gray-700 mb-1">Archivo (.zip)</label>
|
||||
<input type="file" accept=".zip" className="mb-3 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50 transition-all" onChange={e => setForm(f => ({ ...f, archivo: e.target.files[0] }))} required />
|
||||
<label className="block text-xs font-semibold text-gray-700 mb-1">Contribuyente</label>
|
||||
<input className="mb-3 w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50 transition-all" value={form.contribuyente} onChange={e => setForm(f => ({ ...f, contribuyente: e.target.value }))} required />
|
||||
<div className="flex gap-2 mt-2 justify-end">
|
||||
<button type="button" onClick={() => setShowCreateModal(false)} className="px-4 py-2 bg-gray-300 text-gray-800 rounded-lg font-semibold shadow hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400">Cancelar</button>
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg font-semibold shadow hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400">Crear</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de edición */}
|
||||
{showEditModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
|
||||
<form onSubmit={handleEdit} className="bg-white rounded-xl shadow-2xl border border-yellow-200 p-8 max-w-sm w-full flex flex-col animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-yellow-700 mb-4">Editar Datastage</h2>
|
||||
{error && <div className="text-red-500 mb-2">{error}</div>}
|
||||
<label className="block text-xs font-semibold text-gray-700 mb-1">Archivo (.zip)</label>
|
||||
<input type="file" accept=".zip" className="mb-3 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-gray-50 transition-all" onChange={e => setForm(f => ({ ...f, archivo: e.target.files[0] }))} />
|
||||
<label className="block text-xs font-semibold text-gray-700 mb-1">Contribuyente</label>
|
||||
<input className="mb-3 w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-gray-50 transition-all" value={form.contribuyente} onChange={e => setForm(f => ({ ...f, contribuyente: e.target.value }))} required />
|
||||
<div className="flex gap-2 mt-2 justify-end">
|
||||
<button type="button" onClick={handleCancelEdit} className="px-4 py-2 bg-gray-300 text-gray-800 rounded-lg font-semibold shadow hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400">Cancelar</button>
|
||||
<button type="submit" className="px-4 py-2 bg-yellow-600 text-white rounded-lg font-semibold shadow hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-yellow-400">Actualizar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de confirmación para eliminar */}
|
||||
<ConfirmModal open={showDeleteModal} onClose={() => setShowDeleteModal(false)} onConfirm={handleDelete} message="¿Seguro que deseas eliminar este datastage?" confirmText="Eliminar" cancelText="Cancelar" />
|
||||
|
||||
{/* Modal de detalle */}
|
||||
{showDetailModal && selected && (
|
||||
<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>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>
|
||||
<div className="mb-1"><b>Creado:</b> {selected.created_at ? new Date(selected.created_at).toLocaleString() : ''}</div>
|
||||
<div className="mb-1"><b>Actualizado:</b> {selected.updated_at ? new Date(selected.updated_at).toLocaleString() : ''}</div>
|
||||
<button onClick={() => setShowDetailModal(false)} className="mt-2 bg-gray-300 px-3 py-1 rounded hover:bg-gray-400 transition">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Modal de registros cargados */}
|
||||
<RegistrosCargadosModal open={showRegistrosModal} onClose={() => setShowRegistrosModal(false)} registros={registrosCargados} />
|
||||
|
||||
{/* Modal de éxito */}
|
||||
<SuccessModal open={showSuccessModal} onClose={() => setShowSuccessModal(false)} message={success} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -364,7 +364,6 @@ export default function Documents() {
|
||||
<option value="5">Pedimento EDocument</option>
|
||||
<option value="6">Estado Pedimento</option>
|
||||
<option value="7">Acuse Cove</option>
|
||||
<option value="8">Cove</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
@@ -474,12 +473,12 @@ export default function Documents() {
|
||||
switch (String(doc.document_type)) {
|
||||
case '1': return 'Pedimento Partida';
|
||||
case '2': return 'Pedimento Completo';
|
||||
case '3': return 'Pedimento Remesas';
|
||||
case '3': return 'Pedimento Remesas';
|
||||
case '4': return 'Pedimento Acuse';
|
||||
case '5': return 'Pedimento EDocument';
|
||||
case '6': return 'Estado Pedimento';
|
||||
case '7': return 'Acuse Cove';
|
||||
case '8': return 'Cove';
|
||||
|
||||
default: return doc.document_type || '';
|
||||
}
|
||||
})()
|
||||
|
||||
Reference in New Issue
Block a user