INICIO
This commit is contained in:
168
src/components/CommandHistory.jsx
Normal file
168
src/components/CommandHistory.jsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { History, CheckCircle, XCircle, Clock, Hash, Database, RefreshCw } from 'lucide-react';
|
||||
import { commandService } from '../services/api';
|
||||
|
||||
const USER_ID = import.meta.env.VITE_USER_ID || 1;
|
||||
|
||||
export default function CommandHistory({ filters, onSelect }) {
|
||||
const [commands, setCommands] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchHistory = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const userId = parseInt(USER_ID, 10);
|
||||
const response = await commandService.listByUser(userId, { limit: 20 });
|
||||
|
||||
// Backend returns an array directly based on example
|
||||
if (Array.isArray(response)) {
|
||||
setCommands(response);
|
||||
} else if (response && Array.isArray(response.data)) {
|
||||
setCommands(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching history:', err);
|
||||
setError('No se pudo cargar el historial');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, [JSON.stringify(filters)]);
|
||||
|
||||
const getStatusInfo = (estado) => {
|
||||
// Based on typical patterns: 1 = Success, 0/2 = Pending/Processing, 3 = Error
|
||||
// The example shows status "1" as success.
|
||||
const status = String(estado);
|
||||
switch (status) {
|
||||
case '1':
|
||||
return { icon: <CheckCircle size={14} style={{ color: 'var(--success)' }} />, label: 'Completado' };
|
||||
case '3':
|
||||
return { icon: <XCircle size={14} style={{ color: 'var(--error)' }} />, label: 'Error' };
|
||||
case '0':
|
||||
case '2':
|
||||
return { icon: <Clock size={14} style={{ color: 'var(--warning)' }} />, label: 'Pendiente' };
|
||||
default:
|
||||
return { icon: <Clock size={14} style={{ color: 'var(--text-muted)' }} />, label: 'Unknown' };
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString('es-ES', {
|
||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-panel">
|
||||
<div style={{ padding: '0.75rem 1rem', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 className="sidebar-header" style={{ padding: 0, background: 'transparent', fontSize: '0.75rem', height: 'auto', marginBottom: 0 }}>
|
||||
<History size={14} /> HISTORIAL POR USUARIO
|
||||
</h2>
|
||||
<button
|
||||
onClick={fetchHistory}
|
||||
className="icon-btn"
|
||||
style={{ padding: '4px' }}
|
||||
title="Actualizar historial"
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{loading && commands.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Loader2 size={24} className="animate-spin" style={{ margin: '0 auto', color: 'var(--text-muted)' }} />
|
||||
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '0.5rem' }}>Buscando comandos...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center text-xs text-error">{error}</div>
|
||||
) : commands.length === 0 ? (
|
||||
<div className="p-8 text-center text-xs text-muted">No hay comandos registrados.</div>
|
||||
) : (
|
||||
<div className="history-list">
|
||||
{commands.map(cmd => {
|
||||
const statusInfo = getStatusInfo(cmd.estado);
|
||||
return (
|
||||
<button
|
||||
key={cmd.command_id}
|
||||
onClick={() => onSelect(cmd)}
|
||||
className="history-item"
|
||||
style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}
|
||||
>
|
||||
<div className="history-meta" style={{ marginBottom: '0.5rem' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusInfo.icon}
|
||||
<span style={{ fontSize: '10px', color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||
{formatDate(cmd.fecha_solicitud)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1 font-mono text-[10px]" style={{ color: 'var(--text-accent)' }}>
|
||||
<Hash size={10} /> {cmd.command_id}
|
||||
</span>
|
||||
<span className="badge-outlined" style={{ fontSize: '10px', padding: '0 4px' }}>
|
||||
S:{cmd.store_id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="history-sql" style={{
|
||||
fontSize: '11px',
|
||||
lineHeight: '1.4',
|
||||
maxHeight: '3em',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
color: 'var(--text-primary)',
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
padding: '4px 6px',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
{cmd.sql}
|
||||
</div>
|
||||
{cmd.estado === '3' && cmd.json_response?.error && (
|
||||
<div style={{ fontSize: '10px', color: 'var(--error)', marginTop: '0.4rem', fontStyle: 'italic' }}>
|
||||
{cmd.json_response.error}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.animate-spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.history-list { display: flex; flex-direction: column; }
|
||||
.history-item {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.history-item:hover { background: rgba(255,255,255,0.05); }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-defining Loader2 since it wasn't imported
|
||||
const Loader2 = ({ size = 24, className = "" }) => (
|
||||
<RefreshCw size={size} className={className} />
|
||||
);
|
||||
171
src/components/DatabaseSelector.jsx
Normal file
171
src/components/DatabaseSelector.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Database, Loader2, RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { commandService } from '../services/api';
|
||||
|
||||
/**
|
||||
* DatabaseSelector Component
|
||||
* Fetches and displays available databases for a selected store
|
||||
*/
|
||||
export default function DatabaseSelector({ selectedStore, onDatabaseSelect, selectedDatabase, selectedClient }) {
|
||||
const [databases, setDatabases] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Fetch databases when store changes
|
||||
useEffect(() => {
|
||||
if (selectedStore) {
|
||||
fetchDatabases();
|
||||
} else {
|
||||
setDatabases([]);
|
||||
setError(null);
|
||||
}
|
||||
}, [selectedStore?.id]);
|
||||
|
||||
const fetchDatabases = async () => {
|
||||
if (!selectedStore) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setDatabases([]);
|
||||
|
||||
try {
|
||||
// Get user config
|
||||
const userId = import.meta.env.VITE_USER_ID ? parseInt(import.meta.env.VITE_USER_ID, 10) : undefined;
|
||||
const privilegio = import.meta.env.VITE_USER_PRIVILEGIO || 'READ';
|
||||
|
||||
// Create command to get databases
|
||||
const createResponse = await commandService.create({
|
||||
store_id: parseInt(selectedStore.id, 10),
|
||||
sql: 'SHOW DATABASES',
|
||||
privilegio: privilegio,
|
||||
id_user: userId,
|
||||
id_cliente: parseInt(selectedClient, 10)
|
||||
});
|
||||
|
||||
if (!createResponse.success) {
|
||||
setError('Error al enviar comando');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll for result
|
||||
const result = await commandService.pollForResult(createResponse.command_id, {
|
||||
interval: 1500,
|
||||
maxAttempts: 20
|
||||
});
|
||||
|
||||
// Parse result
|
||||
if (result.result && result.result.data) {
|
||||
// Extract database names from result
|
||||
const dbList = result.result.data.map(row => {
|
||||
// SHOW DATABASES returns rows with a single column (Database)
|
||||
const keys = Object.keys(row);
|
||||
return row[keys[0]]; // Get the first column value
|
||||
}).filter(db => db); // Remove empty values
|
||||
|
||||
setDatabases(dbList);
|
||||
|
||||
// Auto-select first database if none selected
|
||||
if (dbList.length > 0 && !selectedDatabase) {
|
||||
onDatabaseSelect(dbList[0]);
|
||||
}
|
||||
} else if (result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching databases:', err);
|
||||
setError(err.message || 'Error al obtener bases de datos');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedStore) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0 0.5rem'
|
||||
}}>
|
||||
<Database size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2" style={{ color: 'var(--text-muted)', fontSize: '0.75rem' }}>
|
||||
<Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
<span>Cargando DBs...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-2" style={{ color: 'var(--error)', fontSize: '0.75rem' }}>
|
||||
<AlertCircle size={14} />
|
||||
<span title={error}>Error</span>
|
||||
<button
|
||||
onClick={fetchDatabases}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
color: 'var(--text-muted)'
|
||||
}}
|
||||
title="Reintentar"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={selectedDatabase || ''}
|
||||
onChange={(e) => onDatabaseSelect(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
padding: '0.25rem 0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
minWidth: '150px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
disabled={databases.length === 0}
|
||||
>
|
||||
{databases.length === 0 ? (
|
||||
<option value="">Sin bases de datos</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="">Seleccionar BD...</option>
|
||||
{databases.map((db, idx) => (
|
||||
<option key={idx} value={db}>{db}</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchDatabases}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
color: 'var(--text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
title="Actualizar lista de bases de datos"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
326
src/components/Login.jsx
Normal file
326
src/components/Login.jsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import React, { useState } from 'react';
|
||||
import { User, Lock, LogIn, Database, ShieldCheck, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { authService } from '../services/api';
|
||||
|
||||
export default function Login({ onLoginSuccess }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await authService.login(username, password);
|
||||
|
||||
// Assuming the API returns { success: true, token: '...', user: { ... } }
|
||||
if (response.success) {
|
||||
localStorage.setItem('auth_token', response.token);
|
||||
localStorage.setItem('user_data', JSON.stringify(response.user));
|
||||
onLoginSuccess(response.user);
|
||||
} else {
|
||||
setError(response.message || 'Credenciales inválidas');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
setError(err.message || 'Error al conectar con el servidor');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
<Database size={32} className="logo-icon" />
|
||||
</div>
|
||||
<h1>ADVICOM SQL Manager</h1>
|
||||
<p>Bienvenido. Ingrese sus credenciales para continuar.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
{error && (
|
||||
<div className="error-alert">
|
||||
<AlertCircle size={18} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="input-group">
|
||||
<label htmlFor="username">Usuario</label>
|
||||
<div className="input-wrapper">
|
||||
<User className="input-icon" size={18} />
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="ej: admin"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label htmlFor="password">Contraseña</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock className="input-icon" size={18} />
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-btn"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
) : (
|
||||
<>
|
||||
<LogIn size={20} />
|
||||
<span>Iniciar Sesión</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
<div className="secure-badge">
|
||||
<ShieldCheck size={14} />
|
||||
<span>Conexión segura SSL</span>
|
||||
</div>
|
||||
<p>© 2026 Advicom Group • v1.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.login-page {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: radial-gradient(circle at top right, #1e293b, #0f172a);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
right: -10%;
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.1) 0%, transparent 70%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-page::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -10%;
|
||||
left: -10%;
|
||||
width: 30%;
|
||||
height: 30%;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.05) 0%, transparent 70%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1.5rem;
|
||||
padding: 2.5rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||
border-radius: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: white;
|
||||
margin: 0;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
color: #fca5a5;
|
||||
font-size: 0.8125rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: white;
|
||||
padding: 0.875rem 1rem 0.875rem 2.875rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.input-wrapper input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: rgba(2, 6, 23, 0.8);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
margin-top: 1rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.875rem;
|
||||
border-radius: 1rem;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 10px 15px -3px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 25px -5px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.login-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.secure-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--success);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-radius: 2rem;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
352
src/components/ResultTable.jsx
Normal file
352
src/components/ResultTable.jsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AlertCircle, Clock, Database, FileX, Loader2, CheckCircle2, Hash, Copy, Download, Check } from 'lucide-react';
|
||||
|
||||
const MAX_DISPLAY_ROWS = 1000;
|
||||
|
||||
export default function ResultTable({ result, loading, pollingProgress }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Normalize data
|
||||
let columns = [];
|
||||
let rows = [];
|
||||
|
||||
if (result && !result.error) {
|
||||
if (result.rows && result.columns) {
|
||||
columns = result.columns;
|
||||
rows = result.rows;
|
||||
} else if (Array.isArray(result.data) && result.data.length > 0) {
|
||||
columns = Object.keys(result.data[0]);
|
||||
rows = result.data;
|
||||
} else if (Array.isArray(result) && result.length > 0) {
|
||||
columns = Object.keys(result[0]);
|
||||
rows = result;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to convert data to CSV
|
||||
const generateCSV = (cols, dataRows) => {
|
||||
const header = cols.join(',');
|
||||
const csvRows = dataRows.map(row => {
|
||||
return cols.map(col => {
|
||||
let val = Array.isArray(row) ? row[cols.indexOf(col)] : row[col];
|
||||
if (val === null || val === undefined) val = '';
|
||||
val = String(val).replace(/"/g, '""');
|
||||
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
||||
val = `"${val}"`;
|
||||
}
|
||||
return val;
|
||||
}).join(',');
|
||||
});
|
||||
return [header, ...csvRows].join('\n');
|
||||
};
|
||||
|
||||
// Download as CSV
|
||||
const handleDownloadCSV = (cols, dataRows) => {
|
||||
const csv = generateCSV(cols, dataRows);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `query_result_${Date.now()}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Auto-download if too many rows
|
||||
React.useEffect(() => {
|
||||
if (rows.length > MAX_DISPLAY_ROWS) {
|
||||
handleDownloadCSV(columns, rows);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
// Copy to clipboard
|
||||
const handleCopy = (cols, dataRows) => {
|
||||
const csv = generateCSV(cols, dataRows);
|
||||
navigator.clipboard.writeText(csv).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{
|
||||
width: '5rem', height: '5rem',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '1.5rem',
|
||||
border: '2px solid rgba(59, 130, 246, 0.3)'
|
||||
}}>
|
||||
<Loader2 size={32} style={{ color: 'var(--info)', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-primary)', fontWeight: 500, marginBottom: '0.5rem' }}>
|
||||
{pollingProgress ? 'Esperando respuesta del agente remoto...' : 'Enviando comando...'}
|
||||
</p>
|
||||
{pollingProgress && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: '200px', height: '4px',
|
||||
backgroundColor: 'var(--border)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '0.5rem'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${(pollingProgress.attempts / pollingProgress.maxAttempts) * 100}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--info)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
<p className="text-xs font-mono" style={{ color: 'var(--text-muted)' }}>
|
||||
Polling: {pollingProgress.attempts} / {pollingProgress.maxAttempts}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No result yet
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ backgroundColor: 'var(--bg-panel)', padding: '2rem', borderRadius: '999px', marginBottom: '1rem', opacity: 0.5 }}>
|
||||
<Database size={32} />
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-muted)' }}>Los resultados aparecerán aquí</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (result.error) {
|
||||
return (
|
||||
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem' }}>
|
||||
<div style={{
|
||||
width: '4rem', height: '4rem',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '1rem'
|
||||
}}>
|
||||
<AlertCircle size={32} style={{ color: 'var(--error)' }} />
|
||||
</div>
|
||||
<h3 style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: '0.75rem' }}>Error en la ejecución</h3>
|
||||
<code style={{
|
||||
backgroundColor: 'rgba(69, 10, 10, 0.5)',
|
||||
color: '#fecaca',
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid rgba(127, 29, 29, 0.5)',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '0.875rem',
|
||||
display: 'block'
|
||||
}}>
|
||||
{result.error}
|
||||
</code>
|
||||
{result.command_id && (
|
||||
<p className="text-xs font-mono mt-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Command ID: #{result.command_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success message (no data rows)
|
||||
if (rows.length === 0 && !result.error) {
|
||||
if (result.command_id || result.message) {
|
||||
return (
|
||||
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{
|
||||
width: '4rem', height: '4rem',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '1rem',
|
||||
border: '2px solid rgba(16, 185, 129, 0.3)'
|
||||
}}>
|
||||
<CheckCircle2 size={28} style={{ color: 'var(--success)' }} />
|
||||
</div>
|
||||
<h3 style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: '0.5rem' }}>
|
||||
{result.message || 'Consulta ejecutada correctamente'}
|
||||
</h3>
|
||||
{result.command_id && (
|
||||
<p className="font-mono text-xs flex items-center gap-1" style={{ color: 'var(--text-accent)' }}>
|
||||
<Hash size={12} /> Command ID: {result.command_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<FileX size={48} className="mb-4" style={{ opacity: 0.5, color: 'var(--text-secondary)' }} />
|
||||
<p style={{ color: 'var(--text-secondary)' }}>La consulta no devolvió resultados.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Large dataset display
|
||||
if (rows.length > MAX_DISPLAY_ROWS) {
|
||||
return (
|
||||
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem' }}>
|
||||
<div style={{
|
||||
width: '5rem', height: '5rem',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '1.5rem',
|
||||
border: '2px solid rgba(59, 130, 246, 0.3)'
|
||||
}}>
|
||||
<Download size={32} style={{ color: 'var(--info)' }} />
|
||||
</div>
|
||||
<h3 style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: '0.5rem' }}>Resultado muy grande ({rows.length} filas)</h3>
|
||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', maxWidth: '500px', marginBottom: '1.5rem' }}>
|
||||
Para mantener el rendimiento del navegador, los resultados que superan las {MAX_DISPLAY_ROWS} filas no se muestran en pantalla y se descargan automáticamente como CSV.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleDownloadCSV(columns, rows)}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-all hover:scale-105"
|
||||
style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)'
|
||||
}}
|
||||
>
|
||||
<Download size={18} />
|
||||
Descargar CSV de nuevo
|
||||
</button>
|
||||
{result.command_id && (
|
||||
<p className="text-xs font-mono mt-6" style={{ color: 'var(--text-muted)' }}>
|
||||
Command ID: #{result.command_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Data table display - DARK THEME
|
||||
return (
|
||||
<div className="result-container">
|
||||
{/* Stats Header */}
|
||||
<div className="result-header">
|
||||
<div className="flex gap-4 items-center">
|
||||
{result.command_id && (
|
||||
<span className="flex items-center gap-1.5 font-mono" style={{ color: 'var(--text-accent)' }}>
|
||||
<Hash size={12} /> {result.command_id}
|
||||
</span>
|
||||
)}
|
||||
{result.stats?.row_count !== undefined && (
|
||||
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
<Database size={12} style={{ color: 'var(--success)' }} />
|
||||
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{result.stats.row_count}</span> filas
|
||||
</span>
|
||||
)}
|
||||
{result.stats?.duration && (
|
||||
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
<Clock size={12} style={{ color: 'var(--info)' }} />
|
||||
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{result.stats.duration}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
onClick={() => handleCopy(columns, rows)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition-colors"
|
||||
style={{
|
||||
background: copied ? 'var(--success)' : 'var(--border)',
|
||||
color: copied ? 'white' : 'var(--text-primary)',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
title="Copiar al portapapeles"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copied ? 'Copiado!' : 'Copiar'}
|
||||
</button>
|
||||
{/* Download CSV Button */}
|
||||
<button
|
||||
onClick={() => handleDownloadCSV(columns, rows)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition-colors"
|
||||
style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
title="Descargar CSV"
|
||||
>
|
||||
<Download size={14} />
|
||||
CSV
|
||||
</button>
|
||||
<span className="flex items-center gap-1 ml-2">
|
||||
<CheckCircle2 size={14} style={{ color: 'var(--success)' }} />
|
||||
<span className="text-xs" style={{ color: 'var(--success)' }}>Completado</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '50px', textAlign: 'center' }}>#</th>
|
||||
{columns.map((col, idx) => (
|
||||
<th key={idx}>{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rIdx) => (
|
||||
<tr key={rIdx}>
|
||||
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.75rem' }}>{rIdx + 1}</td>
|
||||
{columns.map((col, cIdx) => {
|
||||
let val;
|
||||
if (Array.isArray(row)) {
|
||||
val = row[cIdx];
|
||||
} else {
|
||||
val = row[col];
|
||||
}
|
||||
return (
|
||||
<td key={cIdx}>
|
||||
{val === null ? (
|
||||
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>NULL</span>
|
||||
) : typeof val === 'object' ? (
|
||||
<span style={{ color: 'var(--text-accent)' }}>{JSON.stringify(val)}</span>
|
||||
) : (
|
||||
String(val)
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
src/components/SqlEditor.jsx
Normal file
186
src/components/SqlEditor.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play, AlertTriangle, Eraser, CheckCircle, XCircle, Shield, Database } from 'lucide-react';
|
||||
import { validateSql, getQuickWarning, getAllowedCommands } from '../utils/sqlValidator';
|
||||
|
||||
export default function SqlEditor({
|
||||
initialSql = '',
|
||||
onExecute,
|
||||
executionStatus,
|
||||
selectedDatabase,
|
||||
userPrivilegio = 'READ'
|
||||
}) {
|
||||
const [sql, setSql] = useState(initialSql);
|
||||
const [validationResult, setValidationResult] = useState({ valid: true });
|
||||
const [quickWarning, setQuickWarning] = useState(null);
|
||||
|
||||
// Get allowed commands based on current privilege
|
||||
const allowedCommands = getAllowedCommands(userPrivilegio);
|
||||
|
||||
// Update sql when initialSql changes (e.g., from history selection)
|
||||
useEffect(() => {
|
||||
if (initialSql) {
|
||||
setSql(initialSql);
|
||||
}
|
||||
}, [initialSql]);
|
||||
|
||||
// Real-time validation on SQL change
|
||||
useEffect(() => {
|
||||
if (sql.trim()) {
|
||||
const warning = getQuickWarning(sql, userPrivilegio);
|
||||
setQuickWarning(warning);
|
||||
// Also run full validation to update status icon
|
||||
const result = validateSql(sql, userPrivilegio);
|
||||
setValidationResult(result);
|
||||
} else {
|
||||
setQuickWarning(null);
|
||||
setValidationResult({ valid: true });
|
||||
}
|
||||
}, [sql]);
|
||||
|
||||
const handleExecute = () => {
|
||||
if (!sql.trim()) return;
|
||||
|
||||
// Full validation before sending
|
||||
const result = validateSql(sql, userPrivilegio);
|
||||
setValidationResult(result);
|
||||
|
||||
if (!result.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation passed, execute
|
||||
onExecute(sql);
|
||||
};
|
||||
|
||||
const isInvalid = !validationResult.valid;
|
||||
|
||||
return (
|
||||
<div className="editor-container">
|
||||
<div className="editor-toolbar">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-mono text-xs tracking-wide text-primary" style={{ color: 'white' }}>SQL EDITOR</h3>
|
||||
{/* Privilege Badge */}
|
||||
<span
|
||||
className="badge-outlined text-[10px] flex items-center gap-1"
|
||||
style={{
|
||||
color: (userPrivilegio === 'WRITE' || userPrivilegio === 'CRUD') ? '#f59e0b' : '#60a5fa',
|
||||
borderColor: (userPrivilegio === 'WRITE' || userPrivilegio === 'CRUD') ? '#f59e0b' : '#60a5fa'
|
||||
}}
|
||||
title={`Privilegio actual: ${userPrivilegio}`}
|
||||
>
|
||||
<Shield size={10} />
|
||||
{userPrivilegio}
|
||||
</span>
|
||||
{/* Selected Database Badge */}
|
||||
{selectedDatabase && (
|
||||
<span
|
||||
className="badge-outlined text-[10px] flex items-center gap-1 cursor-pointer hover:bg-[rgba(16,185,129,0.2)] transition-colors"
|
||||
style={{
|
||||
color: '#10b981',
|
||||
borderColor: '#10b981',
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
title="Click para copiar el nombre de la DB"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(selectedDatabase);
|
||||
// Visual feedback handled by CSS or alert if needed, but let's keep it simple
|
||||
const originalText = selectedDatabase;
|
||||
// We could use a temporary state here, but since this is a small change:
|
||||
console.log(`Copiado: ${selectedDatabase}`);
|
||||
}}
|
||||
>
|
||||
<Database size={10} />
|
||||
{selectedDatabase}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Validation status indicator */}
|
||||
{sql.trim() && (
|
||||
<div title={isInvalid ? validationResult.error : 'Sintaxis válida'}>
|
||||
{isInvalid ? (
|
||||
<XCircle size={16} style={{ color: 'var(--error)' }} />
|
||||
) : (
|
||||
<CheckCircle size={16} style={{ color: 'var(--success)' }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setSql(''); setValidationResult({ valid: true }); setQuickWarning(null); }}
|
||||
className="icon-btn"
|
||||
title="Limpiar Editor"
|
||||
>
|
||||
<Eraser size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', flex: 1, backgroundColor: 'var(--bg-input)' }}>
|
||||
<textarea
|
||||
value={sql}
|
||||
onChange={(e) => setSql(e.target.value)}
|
||||
className="editor-textarea"
|
||||
placeholder="-- Escribe tu consulta SQL aquí... SELECT * FROM products WHERE stock < 5;"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{/* Quick warning for restricted keywords */}
|
||||
{quickWarning && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '1rem', right: '1rem',
|
||||
backgroundColor: 'rgba(127, 29, 29, 0.9)', color: '#fecaca',
|
||||
padding: '0.5rem 0.75rem', borderRadius: '0.25rem', fontSize: '0.75rem',
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid rgba(185, 28, 28, 0.5)',
|
||||
maxWidth: '400px'
|
||||
}}>
|
||||
<AlertTriangle size={14} style={{ flexShrink: 0 }} />
|
||||
<span>{quickWarning}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation error message bar */}
|
||||
{isInvalid && (
|
||||
<div style={{
|
||||
padding: '0.75rem 1rem',
|
||||
backgroundColor: 'rgba(127, 29, 29, 0.3)',
|
||||
borderTop: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
color: '#fca5a5',
|
||||
fontSize: '0.8125rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem'
|
||||
}}>
|
||||
<XCircle size={16} style={{ flexShrink: 0 }} />
|
||||
<span>{validationResult.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="editor-footer">
|
||||
<div className="text-xs text-secondary">
|
||||
<p>Permitido: <span style={{ color: 'var(--success)' }}>{allowedCommands.join(', ')}</span></p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={!sql.trim() || executionStatus === 'running' || isInvalid}
|
||||
className="btn-primary"
|
||||
style={quickWarning || isInvalid ? { backgroundColor: 'var(--error)', opacity: isInvalid ? 0.6 : 1 } : {}}
|
||||
>
|
||||
{executionStatus === 'running' ? (
|
||||
<>
|
||||
<span style={{ width: '1rem', height: '1rem', border: '2px solid white', borderTopColor: 'transparent', borderRadius: '50%', display: 'inline-block', animation: 'spin 1s linear infinite' }}></span>
|
||||
Ejecutando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} fill="currentColor" /> Ejecutar Consulta
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/StoreSelector.jsx
Normal file
126
src/components/StoreSelector.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Store, Circle, Search, Globe } from 'lucide-react';
|
||||
import { fetchClientStores } from '../services/storeClientService';
|
||||
|
||||
export default function StoreSelector({ selectedStore, onSelect, onClientChange }) {
|
||||
const [stores, setStores] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// New State for Client Selection
|
||||
const [selectedClient, setSelectedClient] = useState('2'); // Default to Peru (2)
|
||||
|
||||
const clients = [
|
||||
{ id: '2', name: 'FORUS PERU' },
|
||||
{ id: '3', name: 'FORUS COLOMBIA' }
|
||||
];
|
||||
|
||||
// Initialize parent with default client
|
||||
useEffect(() => {
|
||||
if (onClientChange) {
|
||||
onClientChange(selectedClient);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadStores() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStores([]); // Clear previous stores while loading
|
||||
|
||||
try {
|
||||
const data = await fetchClientStores(selectedClient);
|
||||
setStores(data);
|
||||
if (data.length === 0 && selectedClient === '3') {
|
||||
// Optional user feedback for empty Colombia
|
||||
setError('No hay configuración de URL para Colombia aún.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Error cargando tiendas: " + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadStores();
|
||||
}, [selectedClient]);
|
||||
|
||||
const handleClientChange = (clientId) => {
|
||||
setSelectedClient(clientId);
|
||||
if (onClientChange) {
|
||||
onClientChange(clientId);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredStores = stores.filter(s =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.id.includes(search)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Client Selector Area */}
|
||||
<div className="p-4 pb-2 border-b border-slate-800/50 bg-[var(--bg-input)]">
|
||||
<div className="flex items-center gap-2 mb-2 text-xs font-bold text-secondary uppercase tracking-wider">
|
||||
<Globe size={12} /> Cliente
|
||||
</div>
|
||||
<select
|
||||
value={selectedClient}
|
||||
onChange={(e) => handleClientChange(e.target.value)}
|
||||
className="w-full bg-[#1e293b] text-white text-xs border border-slate-700 rounded p-2 outline-none focus:border-blue-500 transition-colors"
|
||||
>
|
||||
{clients.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="search-box pt-2">
|
||||
<h2 className="sidebar-header" style={{ padding: 0, background: 'transparent', fontSize: '0.75rem', height: 'auto', marginBottom: '0.75rem' }}>
|
||||
<Store size={14} /> SELECCIONAR TIENDA
|
||||
</h2>
|
||||
<div className="search-input-wrapper">
|
||||
<Search size={14} className="absolute left-3" style={{ color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar ID o Nombre..."
|
||||
className="search-input"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="store-list">
|
||||
{loading && <div className="p-4 text-center text-secondary text-xs animate-pulse">Cargando tiendas remotas...</div>}
|
||||
{error && <div className="p-2 text-red-400 text-xs border border-red-900/30 bg-red-900/10 rounded m-2">{error}</div>}
|
||||
|
||||
{!loading && !error && filteredStores.length === 0 && (
|
||||
<div className="p-4 text-center text-muted text-xs">No se encontraron tiendas.</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredStores.map(store => (
|
||||
<button
|
||||
key={store.id}
|
||||
onClick={() => onSelect(store)}
|
||||
className={`store-item ${selectedStore?.id === store.id ? 'selected' : ''}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-medium text-sm mb-0.5 truncate" title={store.name}>{store.name}</div>
|
||||
<div className="sub-text">
|
||||
ID: {store.id} {store.extra?.turnorecojo && <span className="opacity-50">| {store.extra.turnorecojo}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div title={store.status === 'online' ? 'Online' : 'Offline'}>
|
||||
<Circle
|
||||
size={8}
|
||||
fill={store.status === 'online' ? '#10b981' : '#ef4444'}
|
||||
color={store.status === 'online' ? '#10b981' : '#ef4444'}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user