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

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