INICIO
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
README.md
|
||||||
10
.env
Normal file
10
.env
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# URL del Backend (API REST)
|
||||||
|
VITE_API_URL=http://localhost:3120/v1
|
||||||
|
|
||||||
|
# Título de la aplicación
|
||||||
|
VITE_APP_TITLE=ADVICOM SQL Manager
|
||||||
|
|
||||||
|
# Configuración de Usuario
|
||||||
|
VITE_USER_ID=1
|
||||||
|
# Privilegios: READ (solo lectura) | WRITE (permite INSERT, UPDATE, DELETE)
|
||||||
|
VITE_USER_PRIVILEGIO=READ
|
||||||
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# URL del Backend (API REST)
|
||||||
|
VITE_API_URL=http://localhost:3000/api
|
||||||
|
|
||||||
|
# Título de la aplicación
|
||||||
|
VITE_APP_TITLE=Remote SQL Admin
|
||||||
|
|
||||||
|
# Configuración de Usuario
|
||||||
|
VITE_USER_ID=1
|
||||||
|
# Privilegios: READ (solo lectura) | WRITE (permite INSERT, UPDATE, DELETE)
|
||||||
|
VITE_USER_PRIVILEGIO=READ
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
# We use build args to inject environment variables if needed during build time
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ARG VITE_APP_TITLE
|
||||||
|
ARG VITE_USER_ID
|
||||||
|
ARG VITE_USER_PRIVILEGIO
|
||||||
|
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_APP_TITLE=$VITE_APP_TITLE
|
||||||
|
ENV VITE_USER_ID=$VITE_USER_ID
|
||||||
|
ENV VITE_USER_PRIVILEGIO=$VITE_USER_PRIVILEGIO
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:stable-alpine
|
||||||
|
|
||||||
|
# Copy build artifacts from build stage
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy custom nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
remote-sql-admin:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- VITE_API_URL=http://tu-api-prod.com/v1
|
||||||
|
- VITE_APP_TITLE=ADVICOM SQL Manager
|
||||||
|
- VITE_USER_ID=1
|
||||||
|
- VITE_USER_PRIVILEGIO=READ
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
restart: always
|
||||||
|
container_name: advicom-sql-frontend
|
||||||
29
eslint.config.js
Normal file
29
eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ADVICOM SQL Manager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
nginx.conf
Normal file
34
nginx.conf
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy for Peru API to avoid CORS
|
||||||
|
location /api-peru/ {
|
||||||
|
proxy_pass https://ws-posvirtual-peru.sial.cl/;
|
||||||
|
proxy_set_header Host ws-posvirtual-peru.sial.cl;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# SSL settings
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_ssl_session_reuse off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
3082
package-lock.json
generated
Normal file
3082
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "remote-sql-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
src/App.css
Normal file
42
src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
395
src/App.jsx
Normal file
395
src/App.jsx
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Database, Settings, Menu, Loader2, LogOut, User as UserIcon } from 'lucide-react';
|
||||||
|
import StoreSelector from './components/StoreSelector';
|
||||||
|
import SqlEditor from './components/SqlEditor';
|
||||||
|
import ResultTable from './components/ResultTable';
|
||||||
|
import CommandHistory from './components/CommandHistory';
|
||||||
|
import DatabaseSelector from './components/DatabaseSelector';
|
||||||
|
import Login from './components/Login';
|
||||||
|
import { commandService, authService } from './services/api';
|
||||||
|
import { injectDatabaseToSql } from './utils/sqlValidator';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [currentUser, setCurrentUser] = useState(null);
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false);
|
||||||
|
const [selectedStore, setSelectedStore] = useState(null);
|
||||||
|
const [sidebarTab, setSidebarTab] = useState('stores');
|
||||||
|
const [executionStatus, setExecutionStatus] = useState('idle'); // 'idle' | 'running' | 'polling' | 'completed' | 'error'
|
||||||
|
const [currentResult, setCurrentResult] = useState(null);
|
||||||
|
const [editorSql, setEditorSql] = useState('');
|
||||||
|
const [pollingProgress, setPollingProgress] = useState(null);
|
||||||
|
const [selectedDatabase, setSelectedDatabase] = useState(null);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [selectedClient, setSelectedClient] = useState('2'); // Peru is 2, Colombia is 3
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user is already logged in
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const userData = localStorage.getItem('user_data');
|
||||||
|
if (token && userData) {
|
||||||
|
setCurrentUser(JSON.parse(userData));
|
||||||
|
}
|
||||||
|
setSessionChecked(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoginSuccess = (user) => {
|
||||||
|
setCurrentUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
authService.logout();
|
||||||
|
setCurrentUser(null);
|
||||||
|
setSelectedStore(null);
|
||||||
|
setCurrentResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sessionChecked) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-app">
|
||||||
|
<Loader2 size={40} className="animate-spin text-accent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return <Login onLoginSuccess={handleLoginSuccess} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStoreSelect = (store) => {
|
||||||
|
setSelectedStore(store);
|
||||||
|
setSelectedDatabase(null); // Reset database when store changes
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClientChange = (clientId) => {
|
||||||
|
setSelectedClient(clientId);
|
||||||
|
// Optionally reset store when client changes if they are not compatible
|
||||||
|
// setSelectedStore(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecute = async (sql) => {
|
||||||
|
if (!selectedStore) {
|
||||||
|
alert("Por favor selecciona una tienda primero.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExecutionStatus('running');
|
||||||
|
setCurrentResult(null);
|
||||||
|
setPollingProgress(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use logged in user info
|
||||||
|
const userId = currentUser.id;
|
||||||
|
const privilegio = currentUser.privilegio || 'READ';
|
||||||
|
|
||||||
|
// Prepend database name to tables if a database is selected
|
||||||
|
// Ej: "SELECT * FROM users" -> "SELECT * FROM dbname.users"
|
||||||
|
const finalSql = selectedDatabase ? injectDatabaseToSql(sql, selectedDatabase) : sql;
|
||||||
|
|
||||||
|
console.log('SQL Original:', sql);
|
||||||
|
console.log('SQL con DB:', finalSql);
|
||||||
|
|
||||||
|
// Step 1: Create the command
|
||||||
|
const createResponse = await commandService.create({
|
||||||
|
store_id: parseInt(selectedStore.id, 10),
|
||||||
|
sql: finalSql,
|
||||||
|
privilegio: privilegio,
|
||||||
|
id_user: userId,
|
||||||
|
id_cliente: parseInt(selectedClient, 10)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResponse.success) {
|
||||||
|
setExecutionStatus('error');
|
||||||
|
setCurrentResult({ error: createResponse.message || 'Error al crear comando' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandId = createResponse.command_id;
|
||||||
|
|
||||||
|
// Update status to polling
|
||||||
|
setExecutionStatus('polling');
|
||||||
|
setCurrentResult({
|
||||||
|
message: 'Esperando respuesta del agente remoto...',
|
||||||
|
command_id: commandId,
|
||||||
|
stats: { info: `Comando #${commandId} enviado. Polling en progreso...` }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Poll for results
|
||||||
|
const result = await commandService.pollForResult(commandId, {
|
||||||
|
interval: 2000, // Poll every 2 seconds
|
||||||
|
maxAttempts: 60, // Wait up to 2 minutes
|
||||||
|
onProgress: ({ attempts, maxAttempts, status }) => {
|
||||||
|
setPollingProgress({ attempts, maxAttempts, status });
|
||||||
|
setCurrentResult(prev => ({
|
||||||
|
...prev,
|
||||||
|
message: `Esperando respuesta del agente remoto... (${attempts}/${maxAttempts})`,
|
||||||
|
stats: { info: `Comando #${commandId} - Polling intento ${attempts} de ${maxAttempts}` }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Process and display results
|
||||||
|
setExecutionStatus('completed');
|
||||||
|
setPollingProgress(null);
|
||||||
|
|
||||||
|
console.log('Raw API Response:', result); // Debug log
|
||||||
|
|
||||||
|
const normalizedResult = normalizeApiResponse(result, commandId);
|
||||||
|
|
||||||
|
console.log('Normalized Result:', normalizedResult); // Debug log
|
||||||
|
|
||||||
|
setCurrentResult(normalizedResult);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setExecutionStatus('error');
|
||||||
|
setPollingProgress(null);
|
||||||
|
setCurrentResult({
|
||||||
|
error: error.message || "Error desconocido al ejecutar comando",
|
||||||
|
stats: { info: 'Error durante la ejecución o polling' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes different API response formats into a standard structure for ResultTable
|
||||||
|
*/
|
||||||
|
const normalizeApiResponse = (result, commandId) => {
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
let normalizedResult = {
|
||||||
|
command_id: result.command_id || commandId,
|
||||||
|
processed_at: result.processed_at || result.fecha_proceso,
|
||||||
|
stats: {
|
||||||
|
row_count: 0,
|
||||||
|
duration: 'N/A'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if there's an error at any level
|
||||||
|
if (result.status === 'error' || result.estado === '3' || result.error) {
|
||||||
|
normalizedResult.error = result.error || result.message || (result.json_response && result.json_response.error) || 'Error en la ejecución';
|
||||||
|
return normalizedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for nested result object (main format) or json_response (history format)
|
||||||
|
const dataContainer = result.result || result.json_response;
|
||||||
|
|
||||||
|
if (dataContainer) {
|
||||||
|
// Get execution time
|
||||||
|
if (dataContainer.execution_time_ms) {
|
||||||
|
normalizedResult.stats.duration = `${dataContainer.execution_time_ms}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for error in inner result
|
||||||
|
if (dataContainer.error) {
|
||||||
|
normalizedResult.error = dataContainer.error;
|
||||||
|
}
|
||||||
|
// Check for data array
|
||||||
|
else if (dataContainer.data && Array.isArray(dataContainer.data)) {
|
||||||
|
if (dataContainer.data.length > 0) {
|
||||||
|
normalizedResult.columns = Object.keys(dataContainer.data[0]);
|
||||||
|
normalizedResult.rows = dataContainer.data;
|
||||||
|
normalizedResult.stats.row_count = dataContainer.data.length;
|
||||||
|
} else {
|
||||||
|
normalizedResult.message = 'La consulta no devolvió resultados';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If dataContainer itself is an array
|
||||||
|
else if (Array.isArray(dataContainer)) {
|
||||||
|
if (dataContainer.length > 0) {
|
||||||
|
normalizedResult.columns = Object.keys(dataContainer[0]);
|
||||||
|
normalizedResult.rows = dataContainer;
|
||||||
|
normalizedResult.stats.row_count = dataContainer.length;
|
||||||
|
} else {
|
||||||
|
normalizedResult.message = 'La consulta no devolvió resultados';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Direct data array
|
||||||
|
else if (result.data && Array.isArray(result.data)) {
|
||||||
|
if (result.data.length > 0) {
|
||||||
|
normalizedResult.columns = Object.keys(result.data[0]);
|
||||||
|
normalizedResult.rows = result.data;
|
||||||
|
normalizedResult.stats.row_count = result.data.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Direct rows array
|
||||||
|
else if (result.rows && Array.isArray(result.rows)) {
|
||||||
|
normalizedResult.columns = result.columns || (result.rows[0] ? Object.keys(result.rows[0]) : []);
|
||||||
|
normalizedResult.rows = result.rows;
|
||||||
|
normalizedResult.stats.row_count = result.rows.length;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
normalizedResult.message = 'Consulta ejecutada correctamente';
|
||||||
|
normalizedResult.rawResponse = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHistorySelect = (cmd) => {
|
||||||
|
setEditorSql(cmd.sql || '');
|
||||||
|
|
||||||
|
// If we have the result in the history item, show it
|
||||||
|
if (cmd.estado === '1' && (cmd.json_response || cmd.result)) {
|
||||||
|
const normalized = normalizeApiResponse(cmd, cmd.command_id);
|
||||||
|
setCurrentResult(normalized);
|
||||||
|
setExecutionStatus('completed');
|
||||||
|
} else if (cmd.estado === '3') {
|
||||||
|
const normalized = normalizeApiResponse(cmd, cmd.command_id);
|
||||||
|
setCurrentResult(normalized);
|
||||||
|
setExecutionStatus('error');
|
||||||
|
} else {
|
||||||
|
setCurrentResult(null);
|
||||||
|
setExecutionStatus('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get status text for display
|
||||||
|
const getStatusDisplay = () => {
|
||||||
|
if (executionStatus === 'polling' && pollingProgress) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1" style={{ color: 'var(--warning)' }}>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Polling {pollingProgress.attempts}/{pollingProgress.maxAttempts}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span style={{ color: 'var(--success)' }}>Active</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-container">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className={`sidebar ${sidebarOpen ? '' : 'closed'}`}>
|
||||||
|
<div className="sidebar-header" style={{ justifyContent: 'center', background: 'var(--bg-input)' }}>
|
||||||
|
<Database size={20} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span>ADVICOM SQL Manager</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarTab('stores')}
|
||||||
|
className={`tab-btn ${sidebarTab === 'stores' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
Tiendas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarTab('history')}
|
||||||
|
className={`tab-btn ${sidebarTab === 'history' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
Historial
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||||
|
{sidebarTab === 'stores' ? (
|
||||||
|
<StoreSelector
|
||||||
|
selectedStore={selectedStore}
|
||||||
|
onSelect={handleStoreSelect}
|
||||||
|
onClientChange={handleClientChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CommandHistory
|
||||||
|
filters={{ store_id: selectedStore?.id }}
|
||||||
|
onSelect={handleHistorySelect}
|
||||||
|
key={currentUser?.id} // Force re-render if user changes
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '1rem', borderTop: '1px solid var(--border)', fontSize: '0.75rem', color: 'var(--text-muted)', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>Advicom Group • v1.0.0</span>
|
||||||
|
<span>Status: {getStatusDisplay()}</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="main-content">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="header-bar">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="icon-btn">
|
||||||
|
<Menu size={18} />
|
||||||
|
</button>
|
||||||
|
{selectedStore ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span style={{ fontWeight: 600, color: 'white' }}>{selectedStore.name}</span>
|
||||||
|
<span className="badge-outlined" style={{ fontSize: '0.75rem' }}>{selectedStore.id}</span>
|
||||||
|
<div style={{ height: '20px', width: '1px', background: 'var(--border)' }} />
|
||||||
|
<DatabaseSelector
|
||||||
|
selectedStore={selectedStore}
|
||||||
|
selectedClient={selectedClient}
|
||||||
|
selectedDatabase={selectedDatabase}
|
||||||
|
onDatabaseSelect={setSelectedDatabase}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontStyle: 'italic', color: 'var(--text-muted)' }}>Selecciona una tienda para comenzar...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 pr-4" style={{ borderRight: '1px solid var(--border)' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '32px', height: '32px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: '0.75rem', fontWeight: 600, color: 'white'
|
||||||
|
}}>
|
||||||
|
{currentUser.username?.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span style={{ fontSize: '0.8125rem', fontWeight: 600 }}>{currentUser.username}</span>
|
||||||
|
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{currentUser.privilegio}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={handleLogout} className="icon-btn" title="Cerrar sesión" style={{ color: 'var(--error)' }}>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-btn">
|
||||||
|
<Settings size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content Grid */}
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', padding: '1rem', display: 'grid', gridTemplateRows: 'minmax(200px, 1fr) 1.5fr', gap: '1rem' }}>
|
||||||
|
{/* Top: SQL Editor */}
|
||||||
|
<div style={{ minHeight: '200px' }}>
|
||||||
|
<SqlEditor
|
||||||
|
initialSql={editorSql}
|
||||||
|
onExecute={handleExecute}
|
||||||
|
executionStatus={executionStatus}
|
||||||
|
selectedDatabase={selectedDatabase}
|
||||||
|
userPrivilegio={currentUser?.privilegio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom: Results */}
|
||||||
|
<div style={{ minHeight: '200px', overflow: 'hidden' }}>
|
||||||
|
<ResultTable
|
||||||
|
result={currentResult}
|
||||||
|
loading={executionStatus === 'running' || executionStatus === 'polling'}
|
||||||
|
pollingProgress={pollingProgress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
168
src/components/CommandHistory.jsx
Normal file
168
src/components/CommandHistory.jsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { History, CheckCircle, XCircle, Clock, Hash, Database, RefreshCw } from 'lucide-react';
|
||||||
|
import { commandService } from '../services/api';
|
||||||
|
|
||||||
|
const USER_ID = import.meta.env.VITE_USER_ID || 1;
|
||||||
|
|
||||||
|
export default function CommandHistory({ filters, onSelect }) {
|
||||||
|
const [commands, setCommands] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const userId = parseInt(USER_ID, 10);
|
||||||
|
const response = await commandService.listByUser(userId, { limit: 20 });
|
||||||
|
|
||||||
|
// Backend returns an array directly based on example
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
setCommands(response);
|
||||||
|
} else if (response && Array.isArray(response.data)) {
|
||||||
|
setCommands(response.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching history:', err);
|
||||||
|
setError('No se pudo cargar el historial');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHistory();
|
||||||
|
}, [JSON.stringify(filters)]);
|
||||||
|
|
||||||
|
const getStatusInfo = (estado) => {
|
||||||
|
// Based on typical patterns: 1 = Success, 0/2 = Pending/Processing, 3 = Error
|
||||||
|
// The example shows status "1" as success.
|
||||||
|
const status = String(estado);
|
||||||
|
switch (status) {
|
||||||
|
case '1':
|
||||||
|
return { icon: <CheckCircle size={14} style={{ color: 'var(--success)' }} />, label: 'Completado' };
|
||||||
|
case '3':
|
||||||
|
return { icon: <XCircle size={14} style={{ color: 'var(--error)' }} />, label: 'Error' };
|
||||||
|
case '0':
|
||||||
|
case '2':
|
||||||
|
return { icon: <Clock size={14} style={{ color: 'var(--warning)' }} />, label: 'Pendiente' };
|
||||||
|
default:
|
||||||
|
return { icon: <Clock size={14} style={{ color: 'var(--text-muted)' }} />, label: 'Unknown' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleString('es-ES', {
|
||||||
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-panel">
|
||||||
|
<div style={{ padding: '0.75rem 1rem', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2 className="sidebar-header" style={{ padding: 0, background: 'transparent', fontSize: '0.75rem', height: 'auto', marginBottom: 0 }}>
|
||||||
|
<History size={14} /> HISTORIAL POR USUARIO
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={fetchHistory}
|
||||||
|
className="icon-btn"
|
||||||
|
style={{ padding: '4px' }}
|
||||||
|
title="Actualizar historial"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{loading && commands.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Loader2 size={24} className="animate-spin" style={{ margin: '0 auto', color: 'var(--text-muted)' }} />
|
||||||
|
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '0.5rem' }}>Buscando comandos...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-4 text-center text-xs text-error">{error}</div>
|
||||||
|
) : commands.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-xs text-muted">No hay comandos registrados.</div>
|
||||||
|
) : (
|
||||||
|
<div className="history-list">
|
||||||
|
{commands.map(cmd => {
|
||||||
|
const statusInfo = getStatusInfo(cmd.estado);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cmd.command_id}
|
||||||
|
onClick={() => onSelect(cmd)}
|
||||||
|
className="history-item"
|
||||||
|
style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}
|
||||||
|
>
|
||||||
|
<div className="history-meta" style={{ marginBottom: '0.5rem' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusInfo.icon}
|
||||||
|
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||||
|
{formatDate(cmd.fecha_solicitud)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex items-center gap-1 font-mono text-[10px]" style={{ color: 'var(--text-accent)' }}>
|
||||||
|
<Hash size={10} /> {cmd.command_id}
|
||||||
|
</span>
|
||||||
|
<span className="badge-outlined" style={{ fontSize: '10px', padding: '0 4px' }}>
|
||||||
|
S:{cmd.store_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="history-sql" style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
maxHeight: '3em',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
background: 'rgba(0,0,0,0.2)',
|
||||||
|
padding: '4px 6px',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
{cmd.sql}
|
||||||
|
</div>
|
||||||
|
{cmd.estado === '3' && cmd.json_response?.error && (
|
||||||
|
<div style={{ fontSize: '10px', color: 'var(--error)', marginTop: '0.4rem', fontStyle: 'italic' }}>
|
||||||
|
{cmd.json_response.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.animate-spin { animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
.history-list { display: flex; flex-direction: column; }
|
||||||
|
.history-item {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.history-item:hover { background: rgba(255,255,255,0.05); }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-defining Loader2 since it wasn't imported
|
||||||
|
const Loader2 = ({ size = 24, className = "" }) => (
|
||||||
|
<RefreshCw size={size} className={className} />
|
||||||
|
);
|
||||||
171
src/components/DatabaseSelector.jsx
Normal file
171
src/components/DatabaseSelector.jsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Database, Loader2, RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
|
import { commandService } from '../services/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DatabaseSelector Component
|
||||||
|
* Fetches and displays available databases for a selected store
|
||||||
|
*/
|
||||||
|
export default function DatabaseSelector({ selectedStore, onDatabaseSelect, selectedDatabase, selectedClient }) {
|
||||||
|
const [databases, setDatabases] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Fetch databases when store changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedStore) {
|
||||||
|
fetchDatabases();
|
||||||
|
} else {
|
||||||
|
setDatabases([]);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [selectedStore?.id]);
|
||||||
|
|
||||||
|
const fetchDatabases = async () => {
|
||||||
|
if (!selectedStore) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setDatabases([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get user config
|
||||||
|
const userId = import.meta.env.VITE_USER_ID ? parseInt(import.meta.env.VITE_USER_ID, 10) : undefined;
|
||||||
|
const privilegio = import.meta.env.VITE_USER_PRIVILEGIO || 'READ';
|
||||||
|
|
||||||
|
// Create command to get databases
|
||||||
|
const createResponse = await commandService.create({
|
||||||
|
store_id: parseInt(selectedStore.id, 10),
|
||||||
|
sql: 'SHOW DATABASES',
|
||||||
|
privilegio: privilegio,
|
||||||
|
id_user: userId,
|
||||||
|
id_cliente: parseInt(selectedClient, 10)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResponse.success) {
|
||||||
|
setError('Error al enviar comando');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for result
|
||||||
|
const result = await commandService.pollForResult(createResponse.command_id, {
|
||||||
|
interval: 1500,
|
||||||
|
maxAttempts: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse result
|
||||||
|
if (result.result && result.result.data) {
|
||||||
|
// Extract database names from result
|
||||||
|
const dbList = result.result.data.map(row => {
|
||||||
|
// SHOW DATABASES returns rows with a single column (Database)
|
||||||
|
const keys = Object.keys(row);
|
||||||
|
return row[keys[0]]; // Get the first column value
|
||||||
|
}).filter(db => db); // Remove empty values
|
||||||
|
|
||||||
|
setDatabases(dbList);
|
||||||
|
|
||||||
|
// Auto-select first database if none selected
|
||||||
|
if (dbList.length > 0 && !selectedDatabase) {
|
||||||
|
onDatabaseSelect(dbList[0]);
|
||||||
|
}
|
||||||
|
} else if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching databases:', err);
|
||||||
|
setError(err.message || 'Error al obtener bases de datos');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedStore) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0 0.5rem'
|
||||||
|
}}>
|
||||||
|
<Database size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2" style={{ color: 'var(--text-muted)', fontSize: '0.75rem' }}>
|
||||||
|
<Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
<span>Cargando DBs...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center gap-2" style={{ color: 'var(--error)', fontSize: '0.75rem' }}>
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span title={error}>Error</span>
|
||||||
|
<button
|
||||||
|
onClick={fetchDatabases}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px',
|
||||||
|
color: 'var(--text-muted)'
|
||||||
|
}}
|
||||||
|
title="Reintentar"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={selectedDatabase || ''}
|
||||||
|
onChange={(e) => onDatabaseSelect(e.target.value)}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-input)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
minWidth: '150px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
disabled={databases.length === 0}
|
||||||
|
>
|
||||||
|
{databases.length === 0 ? (
|
||||||
|
<option value="">Sin bases de datos</option>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<option value="">Seleccionar BD...</option>
|
||||||
|
{databases.map((db, idx) => (
|
||||||
|
<option key={idx} value={db}>{db}</option>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={fetchDatabases}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
title="Actualizar lista de bases de datos"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
src/components/Login.jsx
Normal file
326
src/components/Login.jsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { User, Lock, LogIn, Database, ShieldCheck, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
import { authService } from '../services/api';
|
||||||
|
|
||||||
|
export default function Login({ onLoginSuccess }) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authService.login(username, password);
|
||||||
|
|
||||||
|
// Assuming the API returns { success: true, token: '...', user: { ... } }
|
||||||
|
if (response.success) {
|
||||||
|
localStorage.setItem('auth_token', response.token);
|
||||||
|
localStorage.setItem('user_data', JSON.stringify(response.user));
|
||||||
|
onLoginSuccess(response.user);
|
||||||
|
} else {
|
||||||
|
setError(response.message || 'Credenciales inválidas');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login error:', err);
|
||||||
|
setError(err.message || 'Error al conectar con el servidor');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-header">
|
||||||
|
<div className="login-logo">
|
||||||
|
<Database size={32} className="logo-icon" />
|
||||||
|
</div>
|
||||||
|
<h1>ADVICOM SQL Manager</h1>
|
||||||
|
<p>Bienvenido. Ingrese sus credenciales para continuar.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="login-form">
|
||||||
|
{error && (
|
||||||
|
<div className="error-alert">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="username">Usuario</label>
|
||||||
|
<div className="input-wrapper">
|
||||||
|
<User className="input-icon" size={18} />
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="ej: admin"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="password">Contraseña</label>
|
||||||
|
<div className="input-wrapper">
|
||||||
|
<Lock className="input-icon" size={18} />
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="login-btn"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="animate-spin" size={20} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn size={20} />
|
||||||
|
<span>Iniciar Sesión</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="login-footer">
|
||||||
|
<div className="secure-badge">
|
||||||
|
<ShieldCheck size={14} />
|
||||||
|
<span>Conexión segura SSL</span>
|
||||||
|
</div>
|
||||||
|
<p>© 2026 Advicom Group • v1.0.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.login-page {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
background: radial-gradient(circle at top right, #1e293b, #0f172a);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -10%;
|
||||||
|
right: -10%;
|
||||||
|
width: 40%;
|
||||||
|
height: 40%;
|
||||||
|
background: radial-gradient(circle, rgba(59, 130, 246, 0.1) 0%, transparent 70%);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -10%;
|
||||||
|
left: -10%;
|
||||||
|
width: 30%;
|
||||||
|
height: 30%;
|
||||||
|
background: radial-gradient(circle, rgba(59, 130, 246, 0.05) 0%, transparent 70%);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: rgba(30, 41, 59, 0.7);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
padding: 2.5rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1;
|
||||||
|
animation: slideUp 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-alert {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: white;
|
||||||
|
padding: 0.875rem 1rem 0.875rem 2.875rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(2, 6, 23, 0.8);
|
||||||
|
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
margin-top: 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.875rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secure-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border-radius: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
src/components/ResultTable.jsx
Normal file
352
src/components/ResultTable.jsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { AlertCircle, Clock, Database, FileX, Loader2, CheckCircle2, Hash, Copy, Download, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
const MAX_DISPLAY_ROWS = 1000;
|
||||||
|
|
||||||
|
export default function ResultTable({ result, loading, pollingProgress }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Normalize data
|
||||||
|
let columns = [];
|
||||||
|
let rows = [];
|
||||||
|
|
||||||
|
if (result && !result.error) {
|
||||||
|
if (result.rows && result.columns) {
|
||||||
|
columns = result.columns;
|
||||||
|
rows = result.rows;
|
||||||
|
} else if (Array.isArray(result.data) && result.data.length > 0) {
|
||||||
|
columns = Object.keys(result.data[0]);
|
||||||
|
rows = result.data;
|
||||||
|
} else if (Array.isArray(result) && result.length > 0) {
|
||||||
|
columns = Object.keys(result[0]);
|
||||||
|
rows = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to convert data to CSV
|
||||||
|
const generateCSV = (cols, dataRows) => {
|
||||||
|
const header = cols.join(',');
|
||||||
|
const csvRows = dataRows.map(row => {
|
||||||
|
return cols.map(col => {
|
||||||
|
let val = Array.isArray(row) ? row[cols.indexOf(col)] : row[col];
|
||||||
|
if (val === null || val === undefined) val = '';
|
||||||
|
val = String(val).replace(/"/g, '""');
|
||||||
|
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
||||||
|
val = `"${val}"`;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}).join(',');
|
||||||
|
});
|
||||||
|
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;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `query_result_${Date.now()}.csv`);
|
||||||
|
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 (
|
||||||
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '5rem', height: '5rem',
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
border: '2px solid rgba(59, 130, 246, 0.3)'
|
||||||
|
}}>
|
||||||
|
<Loader2 size={32} style={{ color: 'var(--info)', animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--text-primary)', fontWeight: 500, marginBottom: '0.5rem' }}>
|
||||||
|
{pollingProgress ? 'Esperando respuesta del agente remoto...' : 'Enviando comando...'}
|
||||||
|
</p>
|
||||||
|
{pollingProgress && (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '200px', height: '4px',
|
||||||
|
backgroundColor: 'var(--border)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: `${(pollingProgress.attempts / pollingProgress.maxAttempts) * 100}%`,
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'var(--info)',
|
||||||
|
transition: 'width 0.3s ease'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-mono" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Polling: {pollingProgress.attempts} / {pollingProgress.maxAttempts}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No result yet
|
||||||
|
if (!result) {
|
||||||
|
return (
|
||||||
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{ backgroundColor: 'var(--bg-panel)', padding: '2rem', borderRadius: '999px', marginBottom: '1rem', opacity: 0.5 }}>
|
||||||
|
<Database size={32} />
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>Los resultados aparecerán aquí</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (result.error) {
|
||||||
|
return (
|
||||||
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '4rem', height: '4rem',
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '1rem'
|
||||||
|
}}>
|
||||||
|
<AlertCircle size={32} style={{ color: 'var(--error)' }} />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: '0.75rem' }}>Error en la ejecución</h3>
|
||||||
|
<code style={{
|
||||||
|
backgroundColor: 'rgba(69, 10, 10, 0.5)',
|
||||||
|
color: '#fecaca',
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid rgba(127, 29, 29, 0.5)',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '600px',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
display: 'block'
|
||||||
|
}}>
|
||||||
|
{result.error}
|
||||||
|
</code>
|
||||||
|
{result.command_id && (
|
||||||
|
<p className="text-xs font-mono mt-3" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Command ID: #{result.command_id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success message (no data rows)
|
||||||
|
if (rows.length === 0 && !result.error) {
|
||||||
|
if (result.command_id || result.message) {
|
||||||
|
return (
|
||||||
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '4rem', height: '4rem',
|
||||||
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
border: '2px solid rgba(16, 185, 129, 0.3)'
|
||||||
|
}}>
|
||||||
|
<CheckCircle2 size={28} style={{ color: 'var(--success)' }} />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: '0.5rem' }}>
|
||||||
|
{result.message || 'Consulta ejecutada correctamente'}
|
||||||
|
</h3>
|
||||||
|
{result.command_id && (
|
||||||
|
<p className="font-mono text-xs flex items-center gap-1" style={{ color: 'var(--text-accent)' }}>
|
||||||
|
<Hash size={12} /> Command ID: {result.command_id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<FileX size={48} className="mb-4" style={{ opacity: 0.5, color: 'var(--text-secondary)' }} />
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>La consulta no devolvió resultados.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large dataset display
|
||||||
|
if (rows.length > MAX_DISPLAY_ROWS) {
|
||||||
|
return (
|
||||||
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '5rem', height: '5rem',
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
border: '2px solid rgba(59, 130, 246, 0.3)'
|
||||||
|
}}>
|
||||||
|
<Download size={32} style={{ color: 'var(--info)' }} />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: '0.5rem' }}>Resultado muy grande ({rows.length} filas)</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', maxWidth: '500px', marginBottom: '1.5rem' }}>
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadCSV(columns, rows)}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-all hover:scale-105"
|
||||||
|
style={{
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
Descargar CSV de nuevo
|
||||||
|
</button>
|
||||||
|
{result.command_id && (
|
||||||
|
<p className="text-xs font-mono mt-6" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Command ID: #{result.command_id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data table display - DARK THEME
|
||||||
|
return (
|
||||||
|
<div className="result-container">
|
||||||
|
{/* Stats Header */}
|
||||||
|
<div className="result-header">
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
{result.command_id && (
|
||||||
|
<span className="flex items-center gap-1.5 font-mono" style={{ color: 'var(--text-accent)' }}>
|
||||||
|
<Hash size={12} /> {result.command_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{result.stats?.row_count !== undefined && (
|
||||||
|
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<Database size={12} style={{ color: 'var(--success)' }} />
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{result.stats.row_count}</span> filas
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{result.stats?.duration && (
|
||||||
|
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<Clock size={12} style={{ color: 'var(--info)' }} />
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{result.stats.duration}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Copy Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(columns, rows)}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
background: copied ? 'var(--success)' : 'var(--border)',
|
||||||
|
color: copied ? 'white' : 'var(--text-primary)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
title="Copiar al portapapeles"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copied ? 'Copiado!' : 'Copiar'}
|
||||||
|
</button>
|
||||||
|
{/* Download CSV Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadCSV(columns, rows)}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
title="Descargar CSV"
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
CSV
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '50px', textAlign: 'center' }}>#</th>
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<th key={idx}>{col}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, rIdx) => (
|
||||||
|
<tr key={rIdx}>
|
||||||
|
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.75rem' }}>{rIdx + 1}</td>
|
||||||
|
{columns.map((col, cIdx) => {
|
||||||
|
let val;
|
||||||
|
if (Array.isArray(row)) {
|
||||||
|
val = row[cIdx];
|
||||||
|
} else {
|
||||||
|
val = row[col];
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<td key={cIdx}>
|
||||||
|
{val === null ? (
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>NULL</span>
|
||||||
|
) : typeof val === 'object' ? (
|
||||||
|
<span style={{ color: 'var(--text-accent)' }}>{JSON.stringify(val)}</span>
|
||||||
|
) : (
|
||||||
|
String(val)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
src/components/SqlEditor.jsx
Normal file
186
src/components/SqlEditor.jsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Play, AlertTriangle, Eraser, CheckCircle, XCircle, Shield, Database } from 'lucide-react';
|
||||||
|
import { validateSql, getQuickWarning, getAllowedCommands } from '../utils/sqlValidator';
|
||||||
|
|
||||||
|
export default function SqlEditor({
|
||||||
|
initialSql = '',
|
||||||
|
onExecute,
|
||||||
|
executionStatus,
|
||||||
|
selectedDatabase,
|
||||||
|
userPrivilegio = 'READ'
|
||||||
|
}) {
|
||||||
|
const [sql, setSql] = useState(initialSql);
|
||||||
|
const [validationResult, setValidationResult] = useState({ valid: true });
|
||||||
|
const [quickWarning, setQuickWarning] = useState(null);
|
||||||
|
|
||||||
|
// Get allowed commands based on current privilege
|
||||||
|
const allowedCommands = getAllowedCommands(userPrivilegio);
|
||||||
|
|
||||||
|
// Update sql when initialSql changes (e.g., from history selection)
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialSql) {
|
||||||
|
setSql(initialSql);
|
||||||
|
}
|
||||||
|
}, [initialSql]);
|
||||||
|
|
||||||
|
// Real-time validation on SQL change
|
||||||
|
useEffect(() => {
|
||||||
|
if (sql.trim()) {
|
||||||
|
const warning = getQuickWarning(sql, userPrivilegio);
|
||||||
|
setQuickWarning(warning);
|
||||||
|
// Also run full validation to update status icon
|
||||||
|
const result = validateSql(sql, userPrivilegio);
|
||||||
|
setValidationResult(result);
|
||||||
|
} else {
|
||||||
|
setQuickWarning(null);
|
||||||
|
setValidationResult({ valid: true });
|
||||||
|
}
|
||||||
|
}, [sql]);
|
||||||
|
|
||||||
|
const handleExecute = () => {
|
||||||
|
if (!sql.trim()) return;
|
||||||
|
|
||||||
|
// Full validation before sending
|
||||||
|
const result = validateSql(sql, userPrivilegio);
|
||||||
|
setValidationResult(result);
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation passed, execute
|
||||||
|
onExecute(sql);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInvalid = !validationResult.valid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-container">
|
||||||
|
<div className="editor-toolbar">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-mono text-xs tracking-wide text-primary" style={{ color: 'white' }}>SQL EDITOR</h3>
|
||||||
|
{/* Privilege Badge */}
|
||||||
|
<span
|
||||||
|
className="badge-outlined text-[10px] flex items-center gap-1"
|
||||||
|
style={{
|
||||||
|
color: (userPrivilegio === 'WRITE' || userPrivilegio === 'CRUD') ? '#f59e0b' : '#60a5fa',
|
||||||
|
borderColor: (userPrivilegio === 'WRITE' || userPrivilegio === 'CRUD') ? '#f59e0b' : '#60a5fa'
|
||||||
|
}}
|
||||||
|
title={`Privilegio actual: ${userPrivilegio}`}
|
||||||
|
>
|
||||||
|
<Shield size={10} />
|
||||||
|
{userPrivilegio}
|
||||||
|
</span>
|
||||||
|
{/* Selected Database Badge */}
|
||||||
|
{selectedDatabase && (
|
||||||
|
<span
|
||||||
|
className="badge-outlined text-[10px] flex items-center gap-1 cursor-pointer hover:bg-[rgba(16,185,129,0.2)] transition-colors"
|
||||||
|
style={{
|
||||||
|
color: '#10b981',
|
||||||
|
borderColor: '#10b981',
|
||||||
|
background: 'rgba(16, 185, 129, 0.1)',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
title="Click para copiar el nombre de la DB"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(selectedDatabase);
|
||||||
|
// Visual feedback handled by CSS or alert if needed, but let's keep it simple
|
||||||
|
const originalText = selectedDatabase;
|
||||||
|
// We could use a temporary state here, but since this is a small change:
|
||||||
|
console.log(`Copiado: ${selectedDatabase}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Database size={10} />
|
||||||
|
{selectedDatabase}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Validation status indicator */}
|
||||||
|
{sql.trim() && (
|
||||||
|
<div title={isInvalid ? validationResult.error : 'Sintaxis válida'}>
|
||||||
|
{isInvalid ? (
|
||||||
|
<XCircle size={16} style={{ color: 'var(--error)' }} />
|
||||||
|
) : (
|
||||||
|
<CheckCircle size={16} style={{ color: 'var(--success)' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { setSql(''); setValidationResult({ valid: true }); setQuickWarning(null); }}
|
||||||
|
className="icon-btn"
|
||||||
|
title="Limpiar Editor"
|
||||||
|
>
|
||||||
|
<Eraser size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', flex: 1, backgroundColor: 'var(--bg-input)' }}>
|
||||||
|
<textarea
|
||||||
|
value={sql}
|
||||||
|
onChange={(e) => setSql(e.target.value)}
|
||||||
|
className="editor-textarea"
|
||||||
|
placeholder="-- Escribe tu consulta SQL aquí... SELECT * FROM products WHERE stock < 5;"
|
||||||
|
spellCheck="false"
|
||||||
|
/>
|
||||||
|
{/* Quick warning for restricted keywords */}
|
||||||
|
{quickWarning && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: '1rem', right: '1rem',
|
||||||
|
backgroundColor: 'rgba(127, 29, 29, 0.9)', color: '#fecaca',
|
||||||
|
padding: '0.5rem 0.75rem', borderRadius: '0.25rem', fontSize: '0.75rem',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid rgba(185, 28, 28, 0.5)',
|
||||||
|
maxWidth: '400px'
|
||||||
|
}}>
|
||||||
|
<AlertTriangle size={14} style={{ flexShrink: 0 }} />
|
||||||
|
<span>{quickWarning}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation error message bar */}
|
||||||
|
{isInvalid && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
backgroundColor: 'rgba(127, 29, 29, 0.3)',
|
||||||
|
borderTop: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
|
color: '#fca5a5',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<XCircle size={16} style={{ flexShrink: 0 }} />
|
||||||
|
<span>{validationResult.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="editor-footer">
|
||||||
|
<div className="text-xs text-secondary">
|
||||||
|
<p>Permitido: <span style={{ color: 'var(--success)' }}>{allowedCommands.join(', ')}</span></p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleExecute}
|
||||||
|
disabled={!sql.trim() || executionStatus === 'running' || isInvalid}
|
||||||
|
className="btn-primary"
|
||||||
|
style={quickWarning || isInvalid ? { backgroundColor: 'var(--error)', opacity: isInvalid ? 0.6 : 1 } : {}}
|
||||||
|
>
|
||||||
|
{executionStatus === 'running' ? (
|
||||||
|
<>
|
||||||
|
<span style={{ width: '1rem', height: '1rem', border: '2px solid white', borderTopColor: 'transparent', borderRadius: '50%', display: 'inline-block', animation: 'spin 1s linear infinite' }}></span>
|
||||||
|
Ejecutando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play size={16} fill="currentColor" /> Ejecutar Consulta
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/StoreSelector.jsx
Normal file
126
src/components/StoreSelector.jsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Store, Circle, Search, Globe } from 'lucide-react';
|
||||||
|
import { fetchClientStores } from '../services/storeClientService';
|
||||||
|
|
||||||
|
export default function StoreSelector({ selectedStore, onSelect, onClientChange }) {
|
||||||
|
const [stores, setStores] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// New State for Client Selection
|
||||||
|
const [selectedClient, setSelectedClient] = useState('2'); // Default to Peru (2)
|
||||||
|
|
||||||
|
const clients = [
|
||||||
|
{ id: '2', name: 'FORUS PERU' },
|
||||||
|
{ id: '3', name: 'FORUS COLOMBIA' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Initialize parent with default client
|
||||||
|
useEffect(() => {
|
||||||
|
if (onClientChange) {
|
||||||
|
onClientChange(selectedClient);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadStores() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setStores([]); // Clear previous stores while loading
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadStores();
|
||||||
|
}, [selectedClient]);
|
||||||
|
|
||||||
|
const handleClientChange = (clientId) => {
|
||||||
|
setSelectedClient(clientId);
|
||||||
|
if (onClientChange) {
|
||||||
|
onClientChange(clientId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredStores = stores.filter(s =>
|
||||||
|
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
s.id.includes(search)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Client Selector Area */}
|
||||||
|
<div className="p-4 pb-2 border-b border-slate-800/50 bg-[var(--bg-input)]">
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-xs font-bold text-secondary uppercase tracking-wider">
|
||||||
|
<Globe size={12} /> Cliente
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={selectedClient}
|
||||||
|
onChange={(e) => handleClientChange(e.target.value)}
|
||||||
|
className="w-full bg-[#1e293b] text-white text-xs border border-slate-700 rounded p-2 outline-none focus:border-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
{clients.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="search-box pt-2">
|
||||||
|
<h2 className="sidebar-header" style={{ padding: 0, background: 'transparent', fontSize: '0.75rem', height: 'auto', marginBottom: '0.75rem' }}>
|
||||||
|
<Store size={14} /> SELECCIONAR TIENDA
|
||||||
|
</h2>
|
||||||
|
<div className="search-input-wrapper">
|
||||||
|
<Search size={14} className="absolute left-3" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar ID o Nombre..."
|
||||||
|
className="search-input"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="store-list">
|
||||||
|
{loading && <div className="p-4 text-center text-secondary text-xs animate-pulse">Cargando tiendas remotas...</div>}
|
||||||
|
{error && <div className="p-2 text-red-400 text-xs border border-red-900/30 bg-red-900/10 rounded m-2">{error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && filteredStores.length === 0 && (
|
||||||
|
<div className="p-4 text-center text-muted text-xs">No se encontraron tiendas.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && filteredStores.map(store => (
|
||||||
|
<button
|
||||||
|
key={store.id}
|
||||||
|
onClick={() => onSelect(store)}
|
||||||
|
className={`store-item ${selectedStore?.id === store.id ? 'selected' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="font-medium text-sm mb-0.5 truncate" title={store.name}>{store.name}</div>
|
||||||
|
<div className="sub-text">
|
||||||
|
ID: {store.id} {store.extra?.turnorecojo && <span className="opacity-50">| {store.extra.turnorecojo}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div title={store.status === 'online' ? 'Online' : 'Offline'}>
|
||||||
|
<Circle
|
||||||
|
size={8}
|
||||||
|
fill={store.status === 'online' ? '#10b981' : '#ef4444'}
|
||||||
|
color={store.status === 'online' ? '#10b981' : '#ef4444'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
427
src/index.css
Normal file
427
src/index.css
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
:root {
|
||||||
|
--bg-app: #0f172a;
|
||||||
|
--bg-panel: #1e293b;
|
||||||
|
--bg-sidebar: #1e293b;
|
||||||
|
--bg-header: rgba(30, 41, 59, 0.8);
|
||||||
|
--bg-input: #020617;
|
||||||
|
--bg-hover: rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
--text-primary: #f8fafc;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-accent: #60a5fa;
|
||||||
|
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--border: #334155;
|
||||||
|
--border-dark: #1e293b;
|
||||||
|
|
||||||
|
--success: #10b981;
|
||||||
|
--error: #ef4444;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--info: #3b82f6;
|
||||||
|
|
||||||
|
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-color: var(--bg-app);
|
||||||
|
color: var(--text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow: hidden;
|
||||||
|
/* App handles scrolling */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
background-color: var(--bg-sidebar);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.closed {
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--bg-app);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header-bar {
|
||||||
|
height: 3.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background-color: var(--bg-header);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
height: 3.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--text-accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
background-color: rgba(30, 41, 59, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Common Components */
|
||||||
|
.icon-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility */
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-2 {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-4 {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-full {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-xs {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Component: Store Selector */
|
||||||
|
.search-box {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-item:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-item.selected {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-item.selected .sub-text {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History */
|
||||||
|
.history-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-sql {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-outlined {
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor */
|
||||||
|
.editor-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
background-color: var(--bg-app);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-textarea {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-footer {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--bg-panel);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Result Table */
|
||||||
|
.result-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: var(--bg-panel);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: var(--bg-app);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-right: 1px solid rgba(51, 65, 85, 0.3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
167
src/services/api.js
Normal file
167
src/services/api.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interceptor for better error handling
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
// Handle 204 No Content (empty response)
|
||||||
|
if (response.status === 204) {
|
||||||
|
return { noContent: true };
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('user_data');
|
||||||
|
// window.location.reload(); // Optional, App.jsx will handle state
|
||||||
|
}
|
||||||
|
const errorMsg = error.response?.data?.message || error.message || 'Unknown Error';
|
||||||
|
return Promise.reject({
|
||||||
|
message: errorMsg,
|
||||||
|
status: error.response?.status,
|
||||||
|
originalError: error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
login: async (username, password) => {
|
||||||
|
return api.post('/autentificacion', { username, password });
|
||||||
|
},
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('user_data');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeService = {
|
||||||
|
list: () => api.get('/stores'),
|
||||||
|
get: (id) => api.get(`/stores/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command Service
|
||||||
|
* Handles SQL command creation, retrieval, and polling
|
||||||
|
*/
|
||||||
|
export const commandService = {
|
||||||
|
/**
|
||||||
|
* Create a new SQL command to be executed by a remote store agent
|
||||||
|
* @param {Object} params - Command parameters
|
||||||
|
* @param {Number} params.store_id - Target store ID (Required)
|
||||||
|
* @param {String} params.sql - SQL statement to execute (Required)
|
||||||
|
* @param {String} [params.privilegio='READ'] - Permission level (Optional)
|
||||||
|
* @param {Number} [params.id_user] - User ID requesting the command (Optional)
|
||||||
|
* @returns {Promise<{success: boolean, message: string, command_id: number}>}
|
||||||
|
*/
|
||||||
|
create: ({ store_id, sql, privilegio = 'READ', id_user, id_cliente }) => {
|
||||||
|
const payload = { store_id, sql, privilegio };
|
||||||
|
if (id_user !== undefined) {
|
||||||
|
payload.id_user = id_user;
|
||||||
|
}
|
||||||
|
if (id_cliente !== undefined) {
|
||||||
|
payload.id_cliente = id_cliente;
|
||||||
|
}
|
||||||
|
return api.post('/commands', payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending commands for a store (Polling endpoint)
|
||||||
|
* @param {Number} storeId - Store ID
|
||||||
|
* @returns {Promise<{command_id: number, sql: string, privilegio: string} | {noContent: true}>}
|
||||||
|
*/
|
||||||
|
getPending: (storeId) => api.get('/commands', { params: { store_id: storeId } }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single command by ID (includes results when completed)
|
||||||
|
* @param {Number} id - Command ID
|
||||||
|
* @returns {Promise<Object>} Command object with status and results
|
||||||
|
*/
|
||||||
|
get: (id) => api.get(`/commands/${id}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll for command results until completed or timeout
|
||||||
|
* @param {Number} commandId - Command ID to poll
|
||||||
|
* @param {Object} options - Polling options
|
||||||
|
* @param {Number} [options.interval=2000] - Polling interval in ms
|
||||||
|
* @param {Number} [options.maxAttempts=30] - Max polling attempts (default: 60 seconds)
|
||||||
|
* @param {Function} [options.onProgress] - Callback for status updates
|
||||||
|
* @returns {Promise<Object>} Final command result
|
||||||
|
*/
|
||||||
|
pollForResult: async (commandId, options = {}) => {
|
||||||
|
const {
|
||||||
|
interval = 2000,
|
||||||
|
maxAttempts = 30,
|
||||||
|
onProgress = () => { }
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
attempts++;
|
||||||
|
onProgress({ attempts, maxAttempts, status: 'polling' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.get(`/commands/${commandId}`);
|
||||||
|
|
||||||
|
// Check if command has completed
|
||||||
|
if (result.status === 'completed' || result.estado === 'completed') {
|
||||||
|
onProgress({ attempts, maxAttempts, status: 'completed' });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if command has error
|
||||||
|
if (result.status === 'error' || result.estado === 'error') {
|
||||||
|
onProgress({ attempts, maxAttempts, status: 'error' });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if result contains data (some APIs return data directly)
|
||||||
|
if (result.data || result.rows || result.result) {
|
||||||
|
onProgress({ attempts, maxAttempts, status: 'completed' });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// If 404, command might still be processing - continue polling
|
||||||
|
if (error.status === 404) {
|
||||||
|
console.log(`Command ${commandId} not found yet, retrying...`);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before next attempt
|
||||||
|
await new Promise(resolve => setTimeout(resolve, interval));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout reached
|
||||||
|
throw { message: `Timeout: No se recibió respuesta después de ${maxAttempts * interval / 1000} segundos.` };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List commands for a specific user
|
||||||
|
* @param {Number} userId - User ID
|
||||||
|
* @param {Object} params - Query parameters (limit)
|
||||||
|
*/
|
||||||
|
listByUser: (userId, params = { limit: 50 }) => api.get(`/commands/user/${userId}`, { params }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
33
src/services/storeClientService.js
Normal file
33
src/services/storeClientService.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export const fetchClientStores = async (clientId) => {
|
||||||
|
try {
|
||||||
|
let url;
|
||||||
|
if (clientId === '2') { // Peru
|
||||||
|
// Use local proxy to avoid CORS in dev
|
||||||
|
url = '/api-peru/public/locales_ecommer/';
|
||||||
|
} else if (clientId === '3') { // Colombia
|
||||||
|
// Placeholder: User didn't provide Colombia URL yet.
|
||||||
|
console.warn("Colombia URL not configured");
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
if (json.status === 'OK' && Array.isArray(json.data)) {
|
||||||
|
return json.data.map(item => ({
|
||||||
|
id: item.numbodega || item.numlocal, // Use numbodega as ID if available, else numlocal
|
||||||
|
name: item.nombrebodega,
|
||||||
|
status: item.estado === '1' ? 'online' : 'offline',
|
||||||
|
extra: { turnorecojo: item.turnorecojo } // Keep extra info just in case
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching client stores:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
173
src/utils/sqlValidator.js
Normal file
173
src/utils/sqlValidator.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* SQL Validation Utility
|
||||||
|
* Basic frontend validation for SQL queries before sending to backend.
|
||||||
|
* Respects user privileges for write operations.
|
||||||
|
* NOTE: Critical validation should still be done on the backend.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Base allowed SQL commands (always allowed)
|
||||||
|
const BASE_ALLOWED_COMMANDS = ['SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN'];
|
||||||
|
|
||||||
|
// Write commands (only allowed with WRITE privilege)
|
||||||
|
const WRITE_COMMANDS = ['DELETE', 'UPDATE', 'INSERT'];
|
||||||
|
|
||||||
|
// Always forbidden keywords (dangerous operations)
|
||||||
|
const ALWAYS_FORBIDDEN = [
|
||||||
|
'DROP', 'ALTER', 'TRUNCATE', 'CREATE', 'REPLACE',
|
||||||
|
'RENAME', 'GRANT', 'REVOKE', 'LOCK', 'UNLOCK'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getAllowedCommands(privilegio = 'READ') {
|
||||||
|
if (privilegio === 'WRITE' || privilegio === 'CRUD') {
|
||||||
|
return [...BASE_ALLOWED_COMMANDS, ...WRITE_COMMANDS];
|
||||||
|
}
|
||||||
|
return BASE_ALLOWED_COMMANDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get forbidden commands based on user privilege
|
||||||
|
* @param {string} privilegio - User privilege level
|
||||||
|
* @returns {string[]} Array of forbidden keywords
|
||||||
|
*/
|
||||||
|
export function getForbiddenKeywords(privilegio = 'READ') {
|
||||||
|
if (privilegio === 'WRITE' || privilegio === 'CRUD') {
|
||||||
|
// WRITE/CRUD privilege: only block dangerous operations
|
||||||
|
return ALWAYS_FORBIDDEN;
|
||||||
|
}
|
||||||
|
// READ privilege: block write commands + dangerous operations
|
||||||
|
return [...WRITE_COMMANDS, ...ALWAYS_FORBIDDEN];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a SQL query string based on user privilege
|
||||||
|
* @param {string} sql - The SQL query to validate
|
||||||
|
* @param {string} privilegio - User privilege level ('READ' or 'WRITE')
|
||||||
|
* @returns {{ valid: boolean, error?: string, warning?: string }}
|
||||||
|
*/
|
||||||
|
export function validateSql(sql, privilegio = 'READ') {
|
||||||
|
// Trim and normalize whitespace
|
||||||
|
const trimmedSql = sql.trim();
|
||||||
|
|
||||||
|
// Check if empty
|
||||||
|
if (!trimmedSql) {
|
||||||
|
return { valid: false, error: 'La consulta SQL no puede estar vacía.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check minimum length
|
||||||
|
if (trimmedSql.length < 6) {
|
||||||
|
return { valid: false, error: 'La consulta SQL es demasiado corta.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize to uppercase for keyword checking
|
||||||
|
const upperSql = trimmedSql.toUpperCase();
|
||||||
|
|
||||||
|
// Get forbidden keywords based on privilege
|
||||||
|
const forbiddenKeywords = getForbiddenKeywords(privilegio);
|
||||||
|
|
||||||
|
// Check for forbidden keywords
|
||||||
|
for (const keyword of forbiddenKeywords) {
|
||||||
|
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
|
||||||
|
if (regex.test(upperSql)) {
|
||||||
|
const isWriteCommand = WRITE_COMMANDS.includes(keyword);
|
||||||
|
const errorMsg = isWriteCommand
|
||||||
|
? `Comando "${keyword}" no permitido. Tu privilegio actual es: ${privilegio}. Requiere: WRITE.`
|
||||||
|
: `Comando no permitido: "${keyword}". Esta operación está bloqueada por seguridad.`;
|
||||||
|
return { valid: false, error: errorMsg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get allowed commands based on privilege
|
||||||
|
const allowedCommands = getAllowedCommands(privilegio);
|
||||||
|
|
||||||
|
// Check if starts with an allowed command
|
||||||
|
const firstWord = upperSql.split(/\s+/)[0];
|
||||||
|
if (!allowedCommands.includes(firstWord)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Comando no reconocido: "${firstWord}". Comandos permitidos: ${allowedCommands.join(', ')}.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic syntax checks
|
||||||
|
const openParens = (trimmedSql.match(/\(/g) || []).length;
|
||||||
|
const closeParens = (trimmedSql.match(/\)/g) || []).length;
|
||||||
|
if (openParens !== closeParens) {
|
||||||
|
return { valid: false, error: 'Paréntesis desbalanceados en la consulta.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleQuotes = (trimmedSql.match(/'/g) || []).length;
|
||||||
|
if (singleQuotes % 2 !== 0) {
|
||||||
|
return { valid: false, error: 'Comillas simples desbalanceadas en la consulta.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dangerous patterns (always blocked)
|
||||||
|
const suspiciousPatterns = [
|
||||||
|
/;\s*(DROP|ALTER|TRUNCATE|CREATE)/i,
|
||||||
|
/\/\*.*\*\//s, // Block comments that could hide malicious code
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of suspiciousPatterns) {
|
||||||
|
if (pattern.test(trimmedSql)) {
|
||||||
|
return { valid: false, error: 'La consulta contiene patrones no permitidos por seguridad.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All checks passed
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automáticamente antepone el nombre de la base de datos a las tablas en el SQL
|
||||||
|
* si estas no tienen ya un prefijo de base de datos.
|
||||||
|
* Ej: "SELECT * FROM users" -> "SELECT * FROM db_name.users"
|
||||||
|
*/
|
||||||
|
export function injectDatabaseToSql(sql, dbName) {
|
||||||
|
if (!dbName || !sql) return sql;
|
||||||
|
|
||||||
|
// Regex para encontrar tablas después de palabras clave comunes
|
||||||
|
// Busca: FROM table, JOIN table, UPDATE table, INTO table, DESCRIBE table, etc.
|
||||||
|
// Solo actúa si el nombre de la tabla NO contiene ya un punto (.)
|
||||||
|
const tableKeywords = ['FROM', 'JOIN', 'UPDATE', 'INTO', 'DESCRIBE', 'DESC', 'TABLE'];
|
||||||
|
let transformedSql = sql;
|
||||||
|
|
||||||
|
tableKeywords.forEach(keyword => {
|
||||||
|
// Expresión regular que busca la palabra clave seguida de un nombre de tabla sin punto
|
||||||
|
// \bkeyword\b: busca la palabra exacta
|
||||||
|
// \s+: uno o más espacios
|
||||||
|
// ([a-zA-Z0-9_]+): captura el nombre de la tabla (letras, números, guiones bajos)
|
||||||
|
// (?!\.): lookahead negativo para asegurar que no le siga un punto (que ya tenga DB)
|
||||||
|
const regex = new RegExp(`(\\b${keyword}\\s+)([a-zA-Z0-9_]+)(?!\\.)`, 'gi');
|
||||||
|
|
||||||
|
transformedSql = transformedSql.replace(regex, `$1${dbName}.$2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return transformedSql;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick check if SQL contains restricted keywords for warning display
|
||||||
|
* @param {string} sql
|
||||||
|
* @param {string} privilegio
|
||||||
|
* @returns {string|null} Warning message or null if OK
|
||||||
|
*/
|
||||||
|
export function getQuickWarning(sql, privilegio = 'READ') {
|
||||||
|
if (!sql || !sql.trim()) return null;
|
||||||
|
|
||||||
|
const upperSql = sql.toUpperCase();
|
||||||
|
const forbiddenKeywords = getForbiddenKeywords(privilegio);
|
||||||
|
|
||||||
|
for (const keyword of forbiddenKeywords) {
|
||||||
|
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
|
||||||
|
if (regex.test(upperSql)) {
|
||||||
|
const isWriteCommand = WRITE_COMMANDS.includes(keyword);
|
||||||
|
if (isWriteCommand) {
|
||||||
|
return `Advertencia: "${keyword}" requiere privilegio WRITE. Tu privilegio: ${privilegio}.`;
|
||||||
|
}
|
||||||
|
return `Advertencia: Comando "${keyword}" bloqueado por seguridad.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export constants for use in UI
|
||||||
|
export { BASE_ALLOWED_COMMANDS, WRITE_COMMANDS, ALWAYS_FORBIDDEN };
|
||||||
17
vite.config.js
Normal file
17
vite.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api-peru': {
|
||||||
|
target: 'https://ws-posvirtual-peru.sial.cl',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
rewrite: (path) => path.replace(/^\/api-peru/, ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user