diff --git a/.env b/.env index 5519a30..8cb02ad 100644 --- a/.env +++ b/.env @@ -13,4 +13,8 @@ VITE_USER_PRIVILEGIO=READ VITE_FTP_HOST=ftp://134.122.126.66:21 VITE_FTP_USER=mysql_inyect VITE_FTP_PASS=YTJjrRdnCPwjTSrN -VITE_FTP_PATH=/carpeta_respaldos/ \ No newline at end of file +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/ \ No newline at end of file diff --git a/.env.example b/.env.example index 85dd1c8..01932ce 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,7 @@ VITE_FTP_HOST=ftp.tuservidor.com VITE_FTP_USER=usuario_ftp VITE_FTP_PASS=clave_ftp 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/ diff --git a/src/App.jsx b/src/App.jsx index b65e5c7..5a25e4d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,8 +7,12 @@ import CommandHistory from './components/CommandHistory'; import DatabaseSelector from './components/DatabaseSelector'; import Login from './components/Login'; 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 { injectDatabaseToSql } from './utils/sqlValidator'; +import { FileText } from 'lucide-react'; function App() { const [currentUser, setCurrentUser] = useState(null); @@ -23,6 +27,8 @@ function App() { const [sidebarOpen, setSidebarOpen] = useState(true); const [selectedClient, setSelectedClient] = useState('2'); // Peru is 2, Colombia is 3 const [isBackupModalOpen, setIsBackupModalOpen] = useState(false); + const [isFileModalOpen, setIsFileModalOpen] = useState(false); + const [isAutoReprintModalOpen, setIsAutoReprintModalOpen] = useState(false); useEffect(() => { // Check if user is already logged in @@ -160,11 +166,6 @@ function App() { setPollingProgress(null); 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({ database: backupData.database, 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({ store_id: parseInt(selectedStore.id, 10), sql: backupSql, - privilegio: 'CRUD', // As per requirements + privilegio: 'CRUD', id_user: currentUser.id, id_cliente: parseInt(selectedClient, 10), - command_id: commandId, timeout: 600 }); @@ -192,31 +192,34 @@ function App() { 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 respaldo enviada. El agente está procesando...', - command_id: commandId, - stats: { info: `Backup #${commandId} iniciado. Esperando compresión y envío...` } + command_id: actualCommandId, + stats: { info: `Backup #${actualCommandId} iniciado. Esperando compresión y envío...` } }); - // Step 2: Poll for results - const result = await commandService.pollForResult(commandId, { - interval: 5000, // Poll every 5 seconds for backups - maxAttempts: 120, // Wait up to 10 minutes (600 seconds) + // Step 2: Poll for results using numeric ID + const result = await commandService.pollForResult(actualCommandId, { + interval: 5000, + maxAttempts: 120, onProgress: ({ attempts, maxAttempts, status }) => { setPollingProgress({ attempts, maxAttempts, status }); setCurrentResult(prev => ({ ...prev, message: `Procesando respaldo remoto... (${attempts}/${maxAttempts})`, - stats: { info: `Backup #${commandId} - Intento ${attempts} de ${maxAttempts}` } + stats: { info: `Backup #${actualCommandId} - Intento ${attempts} de ${maxAttempts}` } })); } }); setExecutionStatus('completed'); setPollingProgress(null); - setCurrentResult(normalizeApiResponse(result, commandId)); + setCurrentResult(normalizeApiResponse(result, actualCommandId)); } catch (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 @@ -257,12 +423,12 @@ function App() { // Check for download URL in dataContainer if (dataContainer && (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 else if (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) { @@ -422,14 +588,12 @@ function App() {
{selectedStore && ( - + setIsFileModalOpen(true)} + onBackupRequest={() => setIsBackupModalOpen(true)} + onAutoReprint={() => setIsAutoReprintModalOpen(true)} + disabled={false} + /> )}
setCurrentResult(null)} />
@@ -488,6 +653,20 @@ function App() { onConfirm={handleBackupRequest} /> + setIsFileModalOpen(false)} + store={selectedStore} + onConfirm={handleFileRequest} + /> + + setIsAutoReprintModalOpen(false)} + store={selectedStore} + onConfirm={handleAutoReprintRequest} + /> + +
+ ); +} diff --git a/src/components/FileRequestModal.jsx b/src/components/FileRequestModal.jsx new file mode 100644 index 0000000..c0e8581 --- /dev/null +++ b/src/components/FileRequestModal.jsx @@ -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 ( +
+
+ {/* Header */} +
+
+ +

+ Solicitar Archivo Remoto +

+
+ +
+ + {/* Content */} +
+
+ +
+ Se enviará un comando FILE al agente remoto de {store?.name}. + Debes especificar la ruta absoluta y el nombre del archivo. +
+
+ +
+ {/* File Path */} +
+ + setFormData(prev => ({ ...prev, path: e.target.value }))} + placeholder="C:\Advicom\Logs\" + /> +
+ + {/* Filename */} +
+ + setFormData(prev => ({ ...prev, filename: e.target.value }))} + placeholder="SP260206.log" + /> +
+
+ +
+ + +
+
+
+ + {/* 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. */} + +
+ ); +} diff --git a/src/components/Login.jsx b/src/components/Login.jsx index ff08a6e..a427199 100644 --- a/src/components/Login.jsx +++ b/src/components/Login.jsx @@ -22,11 +22,15 @@ export default function Login({ onLoginSuccess }) { localStorage.setItem('user_data', JSON.stringify(response.user)); onLoginSuccess(response.user); } 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) { 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 { setLoading(false); } diff --git a/src/components/ResultTable.jsx b/src/components/ResultTable.jsx index 284f0c7..cfaf7db 100644 --- a/src/components/ResultTable.jsx +++ b/src/components/ResultTable.jsx @@ -3,7 +3,7 @@ import { AlertCircle, Clock, Database, FileX, Loader2, CheckCircle2, Hash, Copy, 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); // Normalize data @@ -40,36 +40,19 @@ export default function ResultTable({ result, loading, pollingProgress }) { return [header, ...csvRows].join('\n'); }; - // Download as CSV - const handleDownloadCSV = (cols, dataRows) => { - const csv = generateCSV(cols, dataRows); - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + // Download file + const handleDownload = (content, filename, type = 'text/csv') => { + const blob = new Blob([content], { type: `${type};charset=utf-8;` }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.setAttribute('href', url); - link.setAttribute('download', `query_result_${Date.now()}.csv`); + link.setAttribute('download', filename); document.body.appendChild(link); link.click(); document.body.removeChild(link); 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 if (loading) { 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.

+ + + + ); + } + + // Data table display - STABLE standard view return (
{/* Stats Header */} @@ -293,9 +317,14 @@ export default function ResultTable({ result, loading, pollingProgress }) { )}
- {/* Copy Button */} - {/* Download CSV Button */} - - - Completado -
- {/* Data Table */} + {/* Standard Table View */}
@@ -346,12 +362,7 @@ export default function ResultTable({ result, loading, pollingProgress }) { {columns.map((col, cIdx) => { - let val; - if (Array.isArray(row)) { - val = row[cIdx]; - } else { - val = row[col]; - } + let val = Array.isArray(row) ? row[cIdx] : row[col]; return ( ); @@ -372,3 +383,4 @@ export default function ResultTable({ result, loading, pollingProgress }) { ); } + diff --git a/src/components/StoreSelector.jsx b/src/components/StoreSelector.jsx index f4876a6..be5fab4 100644 --- a/src/components/StoreSelector.jsx +++ b/src/components/StoreSelector.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { Store, Circle, Search, Globe } from 'lucide-react'; import { fetchClientStores } from '../services/storeClientService'; +import { fetchStoreStatus } from '../services/storeStatusService'; export default function StoreSelector({ selectedStore, onSelect, onClientChange }) { const [stores, setStores] = useState([]); @@ -23,26 +24,54 @@ export default function StoreSelector({ selectedStore, onSelect, onClientChange } }, []); - useEffect(() => { - async function loadStores() { - setLoading(true); - setError(null); - setStores([]); // Clear previous stores while loading + // Function to load stores and their status + const loadStoresWithStatus = async () => { + setLoading(true); + setError(null); - try { - const data = await fetchClientStores(selectedClient); - setStores(data); - if (data.length === 0 && selectedClient === '3') { - // Optional user feedback for empty Colombia - setError('No hay configuración de URL para Colombia aún.'); - } - } catch (err) { - setError("Error cargando tiendas: " + err.message); - } finally { - setLoading(false); + try { + // Fetch both store list and status in parallel + const [storeList, statusData] = await Promise.all([ + fetchClientStores(selectedClient), + 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.'); } + } catch (err) { + setError("Error cargando tiendas: " + err.message); + } finally { + 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]); const handleClientChange = (clientId) => { @@ -111,7 +140,11 @@ export default function StoreSelector({ selectedStore, onSelect, onClientChange ID: {store.id} {store.extra?.turnorecojo && | {store.extra.turnorecojo}} -
+
{ + 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 ( +
+ + + {isOpen && ( +
+ + +
+ +
+ )} + + +
+ ); +} diff --git a/src/services/storeStatusService.js b/src/services/storeStatusService.js new file mode 100644 index 0000000..2a078a8 --- /dev/null +++ b/src/services/storeStatusService.js @@ -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 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 []; + } +};
{rIdx + 1} {val === null ? ( @@ -359,7 +370,7 @@ export default function ResultTable({ result, loading, pollingProgress }) { ) : typeof val === 'object' ? ( {JSON.stringify(val)} ) : ( - String(val) + String(val).length > 200 ? String(val).substring(0, 200) + '...' : String(val) )}