This commit is contained in:
beseira13
2026-01-19 12:03:44 -03:00
parent 7fb80533c9
commit b85c365412
27 changed files with 5888 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
.git
.env
.env.local
.vscode
*.log
README.md

10
.env Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View 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
View 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
View 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
View 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
View 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

View 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} />
);

View 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
View 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>
);
}

View 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>
);
}

View 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í...&#10;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>
);
}

View 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
View 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
View 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
View 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;

View 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
View 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
View 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/, ''),
}
}
}
})