cambios para produccion
This commit is contained in:
4
.env
4
.env
@@ -14,3 +14,7 @@ VITE_FTP_HOST=ftp://134.122.126.66:21
|
|||||||
VITE_FTP_USER=mysql_inyect
|
VITE_FTP_USER=mysql_inyect
|
||||||
VITE_FTP_PASS=YTJjrRdnCPwjTSrN
|
VITE_FTP_PASS=YTJjrRdnCPwjTSrN
|
||||||
VITE_FTP_PATH=/carpeta_respaldos/
|
VITE_FTP_PATH=/carpeta_respaldos/
|
||||||
|
|
||||||
|
# URLs de Servicio de Reimpresión
|
||||||
|
VITE_REPRINT_URL_PERU=https://ws-posvirtual-peru-n.sial.cl/public/reimpresion_directa_asistenten/
|
||||||
|
VITE_REPRINT_URL_COLOMBIA=https://ws-posvirtual-colombia.sial.cl/public/reimpresion_directa_asistenten/
|
||||||
@@ -14,3 +14,7 @@ VITE_FTP_HOST=ftp.tuservidor.com
|
|||||||
VITE_FTP_USER=usuario_ftp
|
VITE_FTP_USER=usuario_ftp
|
||||||
VITE_FTP_PASS=clave_ftp
|
VITE_FTP_PASS=clave_ftp
|
||||||
VITE_FTP_PATH=/carpeta_respaldos/
|
VITE_FTP_PATH=/carpeta_respaldos/
|
||||||
|
|
||||||
|
# URLs de Servicio de Reimpresión
|
||||||
|
VITE_REPRINT_URL_PERU=https://ws-posvirtual-peru.sial.cl/public/reimpresion_directa_asistenten/
|
||||||
|
VITE_REPRINT_URL_COLOMBIA=https://ws-posvirtual-colombia.sial.cl/public/reimpresion_directa_asistenten/
|
||||||
|
|||||||
231
src/App.jsx
231
src/App.jsx
@@ -7,8 +7,12 @@ import CommandHistory from './components/CommandHistory';
|
|||||||
import DatabaseSelector from './components/DatabaseSelector';
|
import DatabaseSelector from './components/DatabaseSelector';
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
import BackupModal from './components/BackupModal';
|
import BackupModal from './components/BackupModal';
|
||||||
|
import FileRequestModal from './components/FileRequestModal';
|
||||||
|
import AutoReprintModal from './components/AutoReprintModal';
|
||||||
|
import ToolsMenu from './components/ToolsMenu';
|
||||||
import { commandService, authService } from './services/api';
|
import { commandService, authService } from './services/api';
|
||||||
import { injectDatabaseToSql } from './utils/sqlValidator';
|
import { injectDatabaseToSql } from './utils/sqlValidator';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [currentUser, setCurrentUser] = useState(null);
|
const [currentUser, setCurrentUser] = useState(null);
|
||||||
@@ -23,6 +27,8 @@ function App() {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [selectedClient, setSelectedClient] = useState('2'); // Peru is 2, Colombia is 3
|
const [selectedClient, setSelectedClient] = useState('2'); // Peru is 2, Colombia is 3
|
||||||
const [isBackupModalOpen, setIsBackupModalOpen] = useState(false);
|
const [isBackupModalOpen, setIsBackupModalOpen] = useState(false);
|
||||||
|
const [isFileModalOpen, setIsFileModalOpen] = useState(false);
|
||||||
|
const [isAutoReprintModalOpen, setIsAutoReprintModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if user is already logged in
|
// Check if user is already logged in
|
||||||
@@ -160,11 +166,6 @@ function App() {
|
|||||||
setPollingProgress(null);
|
setPollingProgress(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
|
||||||
const dateStr = now.toISOString().split('T')[0];
|
|
||||||
const timeStr = now.getTime().toString().slice(-3);
|
|
||||||
const commandId = `BK-${dateStr}-${timeStr}`;
|
|
||||||
|
|
||||||
const backupSql = `BACKUP ${JSON.stringify({
|
const backupSql = `BACKUP ${JSON.stringify({
|
||||||
database: backupData.database,
|
database: backupData.database,
|
||||||
ftp: {
|
ftp: {
|
||||||
@@ -175,14 +176,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
})}`;
|
})}`;
|
||||||
|
|
||||||
// Step 1: Create the command
|
// Step 1: Create the command - Let API generate numeric ID
|
||||||
const createResponse = await commandService.create({
|
const createResponse = await commandService.create({
|
||||||
store_id: parseInt(selectedStore.id, 10),
|
store_id: parseInt(selectedStore.id, 10),
|
||||||
sql: backupSql,
|
sql: backupSql,
|
||||||
privilegio: 'CRUD', // As per requirements
|
privilegio: 'CRUD',
|
||||||
id_user: currentUser.id,
|
id_user: currentUser.id,
|
||||||
id_cliente: parseInt(selectedClient, 10),
|
id_cliente: parseInt(selectedClient, 10),
|
||||||
command_id: commandId,
|
|
||||||
timeout: 600
|
timeout: 600
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,31 +192,34 @@ function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IMPORTANTE: Usar el command_id que devuelve la API
|
||||||
|
const actualCommandId = createResponse.command_id;
|
||||||
|
|
||||||
// Update status to polling
|
// Update status to polling
|
||||||
setExecutionStatus('polling');
|
setExecutionStatus('polling');
|
||||||
setCurrentResult({
|
setCurrentResult({
|
||||||
message: 'Solicitud de respaldo enviada. El agente está procesando...',
|
message: 'Solicitud de respaldo enviada. El agente está procesando...',
|
||||||
command_id: commandId,
|
command_id: actualCommandId,
|
||||||
stats: { info: `Backup #${commandId} iniciado. Esperando compresión y envío...` }
|
stats: { info: `Backup #${actualCommandId} iniciado. Esperando compresión y envío...` }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 2: Poll for results
|
// Step 2: Poll for results using numeric ID
|
||||||
const result = await commandService.pollForResult(commandId, {
|
const result = await commandService.pollForResult(actualCommandId, {
|
||||||
interval: 5000, // Poll every 5 seconds for backups
|
interval: 5000,
|
||||||
maxAttempts: 120, // Wait up to 10 minutes (600 seconds)
|
maxAttempts: 120,
|
||||||
onProgress: ({ attempts, maxAttempts, status }) => {
|
onProgress: ({ attempts, maxAttempts, status }) => {
|
||||||
setPollingProgress({ attempts, maxAttempts, status });
|
setPollingProgress({ attempts, maxAttempts, status });
|
||||||
setCurrentResult(prev => ({
|
setCurrentResult(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
message: `Procesando respaldo remoto... (${attempts}/${maxAttempts})`,
|
message: `Procesando respaldo remoto... (${attempts}/${maxAttempts})`,
|
||||||
stats: { info: `Backup #${commandId} - Intento ${attempts} de ${maxAttempts}` }
|
stats: { info: `Backup #${actualCommandId} - Intento ${attempts} de ${maxAttempts}` }
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setExecutionStatus('completed');
|
setExecutionStatus('completed');
|
||||||
setPollingProgress(null);
|
setPollingProgress(null);
|
||||||
setCurrentResult(normalizeApiResponse(result, commandId));
|
setCurrentResult(normalizeApiResponse(result, actualCommandId));
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -229,6 +232,169 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileRequest = async (fileData) => {
|
||||||
|
if (!selectedStore) return;
|
||||||
|
|
||||||
|
setIsFileModalOpen(false);
|
||||||
|
setExecutionStatus('running');
|
||||||
|
setCurrentResult(null);
|
||||||
|
setPollingProgress(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// El agente espera un objeto con type: file y opcionalmente path
|
||||||
|
const fileSql = `FILE ${JSON.stringify({
|
||||||
|
type: 'file',
|
||||||
|
path: fileData.path,
|
||||||
|
filename: fileData.filename
|
||||||
|
})}`;
|
||||||
|
|
||||||
|
// Step 1: Create the command - Let API generate numeric ID
|
||||||
|
const createResponse = await commandService.create({
|
||||||
|
store_id: parseInt(selectedStore.id, 10),
|
||||||
|
sql: fileSql,
|
||||||
|
privilegio: 'CRUD', // High privilege for file system access
|
||||||
|
id_user: currentUser.id,
|
||||||
|
id_cliente: parseInt(selectedClient, 10),
|
||||||
|
timeout: 300
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResponse.success) {
|
||||||
|
setExecutionStatus('error');
|
||||||
|
setCurrentResult({ error: createResponse.message || 'Error al solicitar archivo' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANTE: Usar el command_id que devuelve la API
|
||||||
|
const actualCommandId = createResponse.command_id;
|
||||||
|
|
||||||
|
// Update status to polling
|
||||||
|
setExecutionStatus('polling');
|
||||||
|
setCurrentResult({
|
||||||
|
message: 'Solicitud de archivo enviada. El agente está procesando...',
|
||||||
|
command_id: actualCommandId,
|
||||||
|
stats: { info: `Archivo #${actualCommandId} solicitado. Esperando agente...` }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Poll for results using numeric ID
|
||||||
|
const result = await commandService.pollForResult(actualCommandId, {
|
||||||
|
interval: 3000, // Poll every 3 seconds
|
||||||
|
maxAttempts: 100, // Wait up to 5 minutes
|
||||||
|
onProgress: ({ attempts, maxAttempts, status }) => {
|
||||||
|
setPollingProgress({ attempts, maxAttempts, status });
|
||||||
|
setCurrentResult(prev => ({
|
||||||
|
...prev,
|
||||||
|
message: `Buscando archivo en el equipo remoto... (${attempts}/${maxAttempts})`,
|
||||||
|
stats: { info: `Archivo #${actualCommandId} - Intento ${attempts} de ${maxAttempts}` }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setExecutionStatus('completed');
|
||||||
|
setPollingProgress(null);
|
||||||
|
const normalized = normalizeApiResponse(result, actualCommandId);
|
||||||
|
setCurrentResult(normalized);
|
||||||
|
|
||||||
|
// DESCARGA AUTOMÁTICA TXT
|
||||||
|
if (normalized && !normalized.error) {
|
||||||
|
const dRows = normalized.rows || normalized.data || [];
|
||||||
|
const dCols = normalized.columns || (dRows[0] ? Object.keys(dRows[0]) : []);
|
||||||
|
|
||||||
|
// Si tiene una sola fila y una sola columna, o si el comando es FILE
|
||||||
|
if (dRows.length === 1) {
|
||||||
|
const content = Array.isArray(dRows[0]) ? dRows[0][0] : (dCols[0] ? dRows[0][dCols[0]] : null);
|
||||||
|
if (content && typeof content === 'string' && content.length > 0) {
|
||||||
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `file_${actualCommandId}.txt`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setExecutionStatus('error');
|
||||||
|
setPollingProgress(null);
|
||||||
|
setCurrentResult({
|
||||||
|
error: error.message || "Error al procesar la petición de archivo",
|
||||||
|
stats: { info: 'Error durante la ejecución del comando FILE' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutoReprintRequest = async (reprintData) => {
|
||||||
|
if (!selectedStore) return;
|
||||||
|
|
||||||
|
setIsAutoReprintModalOpen(false);
|
||||||
|
setExecutionStatus('running');
|
||||||
|
setCurrentResult(null);
|
||||||
|
setPollingProgress(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine service URL based on country/client from environment variables
|
||||||
|
const serviceUrls = {
|
||||||
|
'2': import.meta.env.VITE_REPRINT_URL_PERU, // Peru
|
||||||
|
'3': import.meta.env.VITE_REPRINT_URL_COLOMBIA // Colombia
|
||||||
|
};
|
||||||
|
|
||||||
|
const serviceUrl = serviceUrls[selectedClient] || serviceUrls['2']; // Default to Peru
|
||||||
|
|
||||||
|
// Prepare data for direct HTTP POST call
|
||||||
|
const requestData = {
|
||||||
|
nlocal: selectedStore.id,
|
||||||
|
tdoc: reprintData.documentType,
|
||||||
|
fecha: reprintData.date,
|
||||||
|
ndoc: reprintData.documentNumber,
|
||||||
|
tipo: 'herramienta'
|
||||||
|
};
|
||||||
|
|
||||||
|
setCurrentResult({
|
||||||
|
message: 'Enviando solicitud de reimpresión al servicio externo...',
|
||||||
|
stats: { info: 'Conectando con el servicio de reimpresión...' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make direct HTTP POST call to external service
|
||||||
|
const response = await fetch(serviceUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error del servicio: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
setExecutionStatus('completed');
|
||||||
|
setPollingProgress(null);
|
||||||
|
setCurrentResult({
|
||||||
|
message: '✅ Solicitud ingresada con éxito. Por favor, constate con la tienda si el documento se imprimió correctamente.',
|
||||||
|
data: result,
|
||||||
|
stats: {
|
||||||
|
info: 'Solicitud procesada por el servicio externo',
|
||||||
|
service_url: serviceUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setExecutionStatus('error');
|
||||||
|
setPollingProgress(null);
|
||||||
|
setCurrentResult({
|
||||||
|
error: error.message || "Error al procesar la reimpresión automática",
|
||||||
|
stats: { info: 'Error al conectar con el servicio de reimpresión' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes different API response formats into a standard structure for ResultTable
|
* Normalizes different API response formats into a standard structure for ResultTable
|
||||||
@@ -257,12 +423,12 @@ function App() {
|
|||||||
// Check for download URL in dataContainer
|
// Check for download URL in dataContainer
|
||||||
if (dataContainer && (dataContainer.download_url || dataContainer.url || dataContainer.file_url)) {
|
if (dataContainer && (dataContainer.download_url || dataContainer.url || dataContainer.file_url)) {
|
||||||
normalizedResult.downloadUrl = dataContainer.download_url || dataContainer.url || dataContainer.file_url;
|
normalizedResult.downloadUrl = dataContainer.download_url || dataContainer.url || dataContainer.file_url;
|
||||||
normalizedResult.message = '¡Respaldo listo para descargar!';
|
normalizedResult.message = String(commandId).startsWith('FILE-') ? '¡Archivo listo para descargar!' : '¡Respaldo listo para descargar!';
|
||||||
}
|
}
|
||||||
// Fallback search in the whole result object
|
// Fallback search in the whole result object
|
||||||
else if (result.download_url || result.url || result.file_url) {
|
else if (result.download_url || result.url || result.file_url) {
|
||||||
normalizedResult.downloadUrl = result.download_url || result.url || result.file_url;
|
normalizedResult.downloadUrl = result.download_url || result.url || result.file_url;
|
||||||
normalizedResult.message = '¡Respaldo listo para descargar!';
|
normalizedResult.message = String(commandId).startsWith('FILE-') ? '¡Archivo listo para descargar!' : '¡Respaldo listo para descargar!';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataContainer) {
|
if (dataContainer) {
|
||||||
@@ -422,14 +588,12 @@ function App() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{selectedStore && (
|
{selectedStore && (
|
||||||
<button
|
<ToolsMenu
|
||||||
onClick={() => setIsBackupModalOpen(true)}
|
onFileRequest={() => setIsFileModalOpen(true)}
|
||||||
className="btn-primary"
|
onBackupRequest={() => setIsBackupModalOpen(true)}
|
||||||
style={{ padding: '0.375rem 0.75rem', fontSize: '0.75rem', backgroundColor: 'var(--warning)' }}
|
onAutoReprint={() => setIsAutoReprintModalOpen(true)}
|
||||||
title="Solicitar Respaldo de Base de Datos"
|
disabled={false}
|
||||||
>
|
/>
|
||||||
<Download size={14} /> Respaldo DB
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 pr-4" style={{ borderRight: '1px solid var(--border)' }}>
|
<div className="flex items-center gap-2 pr-4" style={{ borderRight: '1px solid var(--border)' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -475,6 +639,7 @@ function App() {
|
|||||||
result={currentResult}
|
result={currentResult}
|
||||||
loading={executionStatus === 'running' || executionStatus === 'polling'}
|
loading={executionStatus === 'running' || executionStatus === 'polling'}
|
||||||
pollingProgress={pollingProgress}
|
pollingProgress={pollingProgress}
|
||||||
|
onClear={() => setCurrentResult(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -488,6 +653,20 @@ function App() {
|
|||||||
onConfirm={handleBackupRequest}
|
onConfirm={handleBackupRequest}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FileRequestModal
|
||||||
|
isOpen={isFileModalOpen}
|
||||||
|
onClose={() => setIsFileModalOpen(false)}
|
||||||
|
store={selectedStore}
|
||||||
|
onConfirm={handleFileRequest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AutoReprintModal
|
||||||
|
isOpen={isAutoReprintModalOpen}
|
||||||
|
onClose={() => setIsAutoReprintModalOpen(false)}
|
||||||
|
store={selectedStore}
|
||||||
|
onConfirm={handleAutoReprintRequest}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.animate-spin {
|
.animate-spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
|
|||||||
263
src/components/AutoReprintModal.jsx
Normal file
263
src/components/AutoReprintModal.jsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Printer, Calendar, FileText, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AutoReprintModal({ isOpen, onClose, store, onConfirm }) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
documentType: 'FV',
|
||||||
|
date: '',
|
||||||
|
documentNumber: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onConfirm(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ar-modal-overlay">
|
||||||
|
<div className="ar-modal-content">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="ar-modal-header">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Printer size={20} style={{ color: 'var(--text-accent)' }} />
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1rem', color: '#fff', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Reimpresión Automática
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="ar-close-btn">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<form onSubmit={handleSubmit} className="ar-modal-body">
|
||||||
|
<div className="ar-info-banner">
|
||||||
|
<AlertCircle size={18} style={{ color: 'var(--info)', flexShrink: 0 }} />
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', lineHeight: '1.5' }}>
|
||||||
|
Se solicitará la reimpresión automática de documentos en <b style={{ color: '#fff' }}>{store?.name}</b>.
|
||||||
|
Configure los parámetros de búsqueda.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ar-form-grid">
|
||||||
|
{/* Document Type */}
|
||||||
|
<div className="ar-input-group">
|
||||||
|
<label><FileText size={12} /> Tipo de Documento</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={formData.documentType}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, documentType: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="FV">Factura (FV)</option>
|
||||||
|
<option value="BL">Boleta (BL)</option>
|
||||||
|
<option value="NC">Nota de Crédito (NC)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div className="ar-input-group">
|
||||||
|
<label><Calendar size={12} /> Fecha</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={formData.date}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Number */}
|
||||||
|
<div className="ar-input-group full-width">
|
||||||
|
<label><FileText size={12} /> Número de Documento</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.documentNumber}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, documentNumber: e.target.value }))}
|
||||||
|
placeholder="Ej: 12345"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ar-modal-footer">
|
||||||
|
<button type="button" onClick={onClose} className="ar-btn-secondary">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="ar-btn-primary">
|
||||||
|
<Printer size={18} /> Iniciar Reimpresión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.ar-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-modal-content {
|
||||||
|
background-color: var(--bg-panel);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: modalEntry 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalEntry {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-modal-header {
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-close-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-info-banner {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-input-group.full-width {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-input-group label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-input-group input,
|
||||||
|
.ar-input-group select {
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-input-group input:focus,
|
||||||
|
.ar-input-group select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-input-group select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-btn-secondary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-btn-secondary:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-btn-primary:hover {
|
||||||
|
background-color: var(--accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
src/components/FileRequestModal.jsx
Normal file
244
src/components/FileRequestModal.jsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, FileText, Folder, File, Shield, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function FileRequestModal({ isOpen, onClose, store, onConfirm }) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
path: '',
|
||||||
|
filename: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onConfirm(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bk-modal-overlay">
|
||||||
|
<div className="bk-modal-content">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bk-modal-header">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText size={20} style={{ color: 'var(--text-accent)' }} />
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1rem', color: '#fff', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Solicitar Archivo Remoto
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="bk-close-btn">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<form onSubmit={handleSubmit} className="bk-modal-body">
|
||||||
|
<div className="bk-info-banner">
|
||||||
|
<AlertCircle size={18} style={{ color: 'var(--warning)', flexShrink: 0 }} />
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', lineHeight: '1.5' }}>
|
||||||
|
Se enviará un comando <code style={{ color: 'var(--text-accent)', background: 'rgba(0,0,0,0.3)', padding: '2px 4px', borderRadius: '4px' }}>FILE</code> al agente remoto de <b style={{ color: '#fff' }}>{store?.name}</b>.
|
||||||
|
Debes especificar la ruta absoluta y el nombre del archivo.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bk-form-grid">
|
||||||
|
{/* File Path */}
|
||||||
|
<div className="bk-input-group full-width">
|
||||||
|
<label><Folder size={12} /> Ruta del Archivo</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.path}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, path: e.target.value }))}
|
||||||
|
placeholder="C:\Advicom\Logs\"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filename */}
|
||||||
|
<div className="bk-input-group full-width">
|
||||||
|
<label><File size={12} /> Nombre del Archivo</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.filename}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, filename: e.target.value }))}
|
||||||
|
placeholder="SP260206.log"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bk-modal-footer">
|
||||||
|
<button type="button" onClick={onClose} className="bk-btn-secondary">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="bk-btn-primary">
|
||||||
|
<Shield size={18} /> Solicitar Archivo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reusing styles from BackupModal via class names where possible,
|
||||||
|
but since BackupModal styles are scoped in a style tag,
|
||||||
|
I should probably ensure they are available or duplicate them if needed.
|
||||||
|
The user's CSS for BackupModal is quite comprehensive. */}
|
||||||
|
<style>{`
|
||||||
|
/* Reusing the same layout logic as BackupModal */
|
||||||
|
.bk-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-modal-content {
|
||||||
|
background-color: var(--bg-panel);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: modalEntry 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalEntry {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-modal-header {
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-close-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-info-banner {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-form-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-input-group label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-input-group input {
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-input-group input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-btn-secondary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-btn-secondary:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--accent);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-btn-primary:hover {
|
||||||
|
background-color: var(--accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,11 +22,15 @@ export default function Login({ onLoginSuccess }) {
|
|||||||
localStorage.setItem('user_data', JSON.stringify(response.user));
|
localStorage.setItem('user_data', JSON.stringify(response.user));
|
||||||
onLoginSuccess(response.user);
|
onLoginSuccess(response.user);
|
||||||
} else {
|
} else {
|
||||||
setError(response.message || 'Credenciales inválidas');
|
// Display error message from backend (error or mensaje field)
|
||||||
|
const errorMsg = response.mensaje || response.error || response.message || 'Credenciales inválidas';
|
||||||
|
setError(errorMsg);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login error:', err);
|
console.error('Login error:', err);
|
||||||
setError(err.message || 'Error al conectar con el servidor');
|
// Try to extract error message from response
|
||||||
|
const errorMsg = err.mensaje || err.error || err.message || 'Error al conectar con el servidor';
|
||||||
|
setError(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AlertCircle, Clock, Database, FileX, Loader2, CheckCircle2, Hash, Copy,
|
|||||||
|
|
||||||
const MAX_DISPLAY_ROWS = 1000;
|
const MAX_DISPLAY_ROWS = 1000;
|
||||||
|
|
||||||
export default function ResultTable({ result, loading, pollingProgress }) {
|
export default function ResultTable({ result, loading, pollingProgress, onClear }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// Normalize data
|
// Normalize data
|
||||||
@@ -40,36 +40,19 @@ export default function ResultTable({ result, loading, pollingProgress }) {
|
|||||||
return [header, ...csvRows].join('\n');
|
return [header, ...csvRows].join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Download as CSV
|
// Download file
|
||||||
const handleDownloadCSV = (cols, dataRows) => {
|
const handleDownload = (content, filename, type = 'text/csv') => {
|
||||||
const csv = generateCSV(cols, dataRows);
|
const blob = new Blob([content], { type: `${type};charset=utf-8;` });
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.setAttribute('href', url);
|
link.setAttribute('href', url);
|
||||||
link.setAttribute('download', `query_result_${Date.now()}.csv`);
|
link.setAttribute('download', filename);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-download if too many rows
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (rows.length > MAX_DISPLAY_ROWS) {
|
|
||||||
handleDownloadCSV(columns, rows);
|
|
||||||
}
|
|
||||||
}, [result]);
|
|
||||||
|
|
||||||
// Copy to clipboard
|
|
||||||
const handleCopy = (cols, dataRows) => {
|
|
||||||
const csv = generateCSV(cols, dataRows);
|
|
||||||
navigator.clipboard.writeText(csv).then(() => {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -246,7 +229,7 @@ export default function ResultTable({ result, loading, pollingProgress }) {
|
|||||||
Para mantener el rendimiento del navegador, los resultados que superan las {MAX_DISPLAY_ROWS} filas no se muestran en pantalla y se descargan automáticamente como CSV.
|
Para mantener el rendimiento del navegador, los resultados que superan las {MAX_DISPLAY_ROWS} filas no se muestran en pantalla y se descargan automáticamente como CSV.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownloadCSV(columns, rows)}
|
onClick={() => handleDownload(generateCSV(columns, rows), `query_result_${Date.now()}.csv`)}
|
||||||
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-all hover:scale-105"
|
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-all hover:scale-105"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--accent)',
|
background: 'var(--accent)',
|
||||||
@@ -268,7 +251,48 @@ export default function ResultTable({ result, loading, pollingProgress }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data table display - DARK THEME
|
// Final safety check for file content
|
||||||
|
const isFileContent = rows.length === 1 && columns.length === 1 && (
|
||||||
|
columns[0]?.toUpperCase() === 'CONTENT' ||
|
||||||
|
columns[0]?.toUpperCase() === 'TEXT' ||
|
||||||
|
columns[0]?.toUpperCase() === 'FILE' ||
|
||||||
|
(result?.command_id && String(result.command_id).startsWith('FILE-'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isFileContent) {
|
||||||
|
return (
|
||||||
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
|
||||||
|
<div style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', width: '80px', height: '80px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '1.5rem', border: '2px solid rgba(16, 185, 129, 0.3)' }}>
|
||||||
|
<CheckCircle2 size={40} style={{ color: 'var(--success)' }} />
|
||||||
|
</div>
|
||||||
|
<h2 style={{ color: 'var(--text-primary)', fontSize: '1.5rem', fontWeight: 600, marginBottom: '1rem' }}>¡Archivo procesado con éxito!</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', textAlign: 'center', marginBottom: '2rem', maxWidth: '450px' }}>
|
||||||
|
El contenido del archivo <strong>#{result.command_id}</strong> ha sido recibido y la descarga debería haber iniciado automáticamente.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const rawText = Array.isArray(rows[0]) ? rows[0][0] : rows[0][columns[0]];
|
||||||
|
handleDownload(rawText, `file_${result.command_id}.txt`, 'text/plain');
|
||||||
|
}}
|
||||||
|
className="px-6 py-2 rounded-lg font-medium transition-all"
|
||||||
|
style={{ background: 'var(--accent)', color: 'white', border: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 font-bold"><Download size={18} /> Descargar .txt de nuevo</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="px-6 py-2 rounded-lg font-medium transition-all"
|
||||||
|
style={{ background: 'var(--border)', color: 'var(--text-primary)', border: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Nueva consulta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data table display - STABLE standard view
|
||||||
return (
|
return (
|
||||||
<div className="result-container">
|
<div className="result-container">
|
||||||
{/* Stats Header */}
|
{/* Stats Header */}
|
||||||
@@ -293,9 +317,14 @@ export default function ResultTable({ result, loading, pollingProgress }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Copy Button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopy(columns, rows)}
|
onClick={() => {
|
||||||
|
const csv = generateCSV(columns, rows);
|
||||||
|
navigator.clipboard.writeText(csv).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}}
|
||||||
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition-colors"
|
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
style={{
|
style={{
|
||||||
background: copied ? 'var(--success)' : 'var(--border)',
|
background: copied ? 'var(--success)' : 'var(--border)',
|
||||||
@@ -303,34 +332,21 @@ export default function ResultTable({ result, loading, pollingProgress }) {
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
title="Copiar al portapapeles"
|
|
||||||
>
|
>
|
||||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
{copied ? 'Copiado!' : 'Copiar'}
|
{copied ? 'Copiado!' : 'Copiar'}
|
||||||
</button>
|
</button>
|
||||||
{/* Download CSV Button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownloadCSV(columns, rows)}
|
onClick={() => handleDownload(generateCSV(columns, rows), `query_result_${Date.now()}.csv`)}
|
||||||
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition-colors"
|
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
style={{
|
style={{ background: 'var(--accent)', color: 'white', border: 'none', cursor: 'pointer' }}
|
||||||
background: 'var(--accent)',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
title="Descargar CSV"
|
|
||||||
>
|
>
|
||||||
<Download size={14} />
|
<Download size={14} /> CSV
|
||||||
CSV
|
|
||||||
</button>
|
</button>
|
||||||
<span className="flex items-center gap-1 ml-2">
|
|
||||||
<CheckCircle2 size={14} style={{ color: 'var(--success)' }} />
|
|
||||||
<span className="text-xs" style={{ color: 'var(--success)' }}>Completado</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data Table */}
|
{/* Standard Table View */}
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -346,12 +362,7 @@ export default function ResultTable({ result, loading, pollingProgress }) {
|
|||||||
<tr key={rIdx}>
|
<tr key={rIdx}>
|
||||||
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.75rem' }}>{rIdx + 1}</td>
|
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.75rem' }}>{rIdx + 1}</td>
|
||||||
{columns.map((col, cIdx) => {
|
{columns.map((col, cIdx) => {
|
||||||
let val;
|
let val = Array.isArray(row) ? row[cIdx] : row[col];
|
||||||
if (Array.isArray(row)) {
|
|
||||||
val = row[cIdx];
|
|
||||||
} else {
|
|
||||||
val = row[col];
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<td key={cIdx}>
|
<td key={cIdx}>
|
||||||
{val === null ? (
|
{val === null ? (
|
||||||
@@ -359,7 +370,7 @@ export default function ResultTable({ result, loading, pollingProgress }) {
|
|||||||
) : typeof val === 'object' ? (
|
) : typeof val === 'object' ? (
|
||||||
<span style={{ color: 'var(--text-accent)' }}>{JSON.stringify(val)}</span>
|
<span style={{ color: 'var(--text-accent)' }}>{JSON.stringify(val)}</span>
|
||||||
) : (
|
) : (
|
||||||
String(val)
|
String(val).length > 200 ? String(val).substring(0, 200) + '...' : String(val)
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -372,3 +383,4 @@ export default function ResultTable({ result, loading, pollingProgress }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Store, Circle, Search, Globe } from 'lucide-react';
|
import { Store, Circle, Search, Globe } from 'lucide-react';
|
||||||
import { fetchClientStores } from '../services/storeClientService';
|
import { fetchClientStores } from '../services/storeClientService';
|
||||||
|
import { fetchStoreStatus } from '../services/storeStatusService';
|
||||||
|
|
||||||
export default function StoreSelector({ selectedStore, onSelect, onClientChange }) {
|
export default function StoreSelector({ selectedStore, onSelect, onClientChange }) {
|
||||||
const [stores, setStores] = useState([]);
|
const [stores, setStores] = useState([]);
|
||||||
@@ -23,17 +24,33 @@ export default function StoreSelector({ selectedStore, onSelect, onClientChange
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
// Function to load stores and their status
|
||||||
async function loadStores() {
|
const loadStoresWithStatus = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setStores([]); // Clear previous stores while loading
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchClientStores(selectedClient);
|
// Fetch both store list and status in parallel
|
||||||
setStores(data);
|
const [storeList, statusData] = await Promise.all([
|
||||||
if (data.length === 0 && selectedClient === '3') {
|
fetchClientStores(selectedClient),
|
||||||
// Optional user feedback for empty Colombia
|
fetchStoreStatus(selectedClient)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Merge store data with status information
|
||||||
|
const mergedStores = storeList.map(store => {
|
||||||
|
const statusInfo = statusData.find(s => s.store_id === parseInt(store.id));
|
||||||
|
return {
|
||||||
|
...store,
|
||||||
|
connectivity: statusInfo?.connectivity || 'OFFLINE',
|
||||||
|
last_ping: statusInfo?.last_ping,
|
||||||
|
seconds_since_ping: statusInfo?.seconds_since_ping,
|
||||||
|
status: statusInfo?.connectivity === 'ONLINE' ? 'online' : 'offline'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setStores(mergedStores);
|
||||||
|
|
||||||
|
if (mergedStores.length === 0 && selectedClient === '3') {
|
||||||
setError('No hay configuración de URL para Colombia aún.');
|
setError('No hay configuración de URL para Colombia aún.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -41,8 +58,20 @@ export default function StoreSelector({ selectedStore, onSelect, onClientChange
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
loadStores();
|
|
||||||
|
// Load stores when client changes
|
||||||
|
useEffect(() => {
|
||||||
|
loadStoresWithStatus();
|
||||||
|
}, [selectedClient]);
|
||||||
|
|
||||||
|
// Auto-refresh status every 30 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadStoresWithStatus();
|
||||||
|
}, 30000); // 30 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
}, [selectedClient]);
|
}, [selectedClient]);
|
||||||
|
|
||||||
const handleClientChange = (clientId) => {
|
const handleClientChange = (clientId) => {
|
||||||
@@ -111,7 +140,11 @@ export default function StoreSelector({ selectedStore, onSelect, onClientChange
|
|||||||
ID: {store.id} {store.extra?.turnorecojo && <span className="opacity-50">| {store.extra.turnorecojo}</span>}
|
ID: {store.id} {store.extra?.turnorecojo && <span className="opacity-50">| {store.extra.turnorecojo}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div title={store.status === 'online' ? 'Online' : 'Offline'}>
|
<div title={
|
||||||
|
store.connectivity === 'ONLINE'
|
||||||
|
? `Online - Última conexión: hace ${store.seconds_since_ping || 0} segundos`
|
||||||
|
: 'Fuera de línea'
|
||||||
|
}>
|
||||||
<Circle
|
<Circle
|
||||||
size={8}
|
size={8}
|
||||||
fill={store.status === 'online' ? '#10b981' : '#ef4444'}
|
fill={store.status === 'online' ? '#10b981' : '#ef4444'}
|
||||||
|
|||||||
164
src/components/ToolsMenu.jsx
Normal file
164
src/components/ToolsMenu.jsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Wrench, FileText, Download, Printer } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ToolsMenu({ onFileRequest, onBackupRequest, onAutoReprint, disabled }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleMenuItemClick = (action) => {
|
||||||
|
setIsOpen(false);
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tools-menu-container" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="btn-tools"
|
||||||
|
disabled={disabled}
|
||||||
|
title="Herramientas del Sistema"
|
||||||
|
>
|
||||||
|
<Wrench size={16} />
|
||||||
|
Herramientas
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="tools-dropdown">
|
||||||
|
<button
|
||||||
|
className="tools-menu-item"
|
||||||
|
onClick={() => handleMenuItemClick(onFileRequest)}
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
<span>Petición Archivo</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tools-menu-item"
|
||||||
|
onClick={() => handleMenuItemClick(onBackupRequest)}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
<span>Respaldo DB</span>
|
||||||
|
</button>
|
||||||
|
<div className="tools-menu-divider" />
|
||||||
|
<button
|
||||||
|
className="tools-menu-item"
|
||||||
|
onClick={() => handleMenuItemClick(onAutoReprint)}
|
||||||
|
>
|
||||||
|
<Printer size={16} />
|
||||||
|
<span>Reimpresión Automática</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.tools-menu-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-tools:hover:not(:disabled) {
|
||||||
|
background-color: var(--accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-tools:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
right: 0;
|
||||||
|
background-color: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
|
||||||
|
min-width: 220px;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: dropdownEntry 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropdownEntry {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-menu-item:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-menu-item svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-menu-item:hover svg {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--border);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/services/storeStatusService.js
Normal file
31
src/services/storeStatusService.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Service for fetching real-time store connectivity status
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch store status from the backend
|
||||||
|
* @param {string} clientId - Client ID (2 for Peru, 3 for Colombia)
|
||||||
|
* @returns {Promise<Array>} Array of store status objects
|
||||||
|
*/
|
||||||
|
export const fetchStoreStatus = async (clientId) => {
|
||||||
|
try {
|
||||||
|
const url = `${API_URL}/stores/status?filter=all&id_cliente=${clientId}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Return the stores array from the response
|
||||||
|
return data.stores || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching store status:', error);
|
||||||
|
// Return empty array on error to prevent breaking the UI
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user