cambios para produccion

This commit is contained in:
beseira13
2026-02-09 16:41:30 -03:00
parent 39a369c403
commit 78af4360fc
10 changed files with 1035 additions and 97 deletions

View File

@@ -0,0 +1,263 @@
import React, { useState } from 'react';
import { X, Printer, Calendar, FileText, AlertCircle } from 'lucide-react';
export default function AutoReprintModal({ isOpen, onClose, store, onConfirm }) {
const [formData, setFormData] = useState({
documentType: 'FV',
date: '',
documentNumber: ''
});
if (!isOpen) return null;
const handleSubmit = (e) => {
e.preventDefault();
onConfirm(formData);
};
return (
<div className="ar-modal-overlay">
<div className="ar-modal-content">
{/* Header */}
<div className="ar-modal-header">
<div className="flex items-center gap-2">
<Printer size={20} style={{ color: 'var(--text-accent)' }} />
<h2 style={{ margin: 0, fontSize: '1rem', color: '#fff', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Reimpresión Automática
</h2>
</div>
<button onClick={onClose} className="ar-close-btn">
<X size={20} />
</button>
</div>
{/* Content */}
<form onSubmit={handleSubmit} className="ar-modal-body">
<div className="ar-info-banner">
<AlertCircle size={18} style={{ color: 'var(--info)', flexShrink: 0 }} />
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', lineHeight: '1.5' }}>
Se solicitará la reimpresión automática de documentos en <b style={{ color: '#fff' }}>{store?.name}</b>.
Configure los parámetros de búsqueda.
</div>
</div>
<div className="ar-form-grid">
{/* Document Type */}
<div className="ar-input-group">
<label><FileText size={12} /> Tipo de Documento</label>
<select
required
value={formData.documentType}
onChange={(e) => setFormData(prev => ({ ...prev, documentType: e.target.value }))}
>
<option value="FV">Factura (FV)</option>
<option value="BL">Boleta (BL)</option>
<option value="NC">Nota de Crédito (NC)</option>
</select>
</div>
{/* Date */}
<div className="ar-input-group">
<label><Calendar size={12} /> Fecha</label>
<input
type="date"
required
value={formData.date}
onChange={(e) => setFormData(prev => ({ ...prev, date: e.target.value }))}
/>
</div>
{/* Document Number */}
<div className="ar-input-group full-width">
<label><FileText size={12} /> Número de Documento</label>
<input
type="text"
required
value={formData.documentNumber}
onChange={(e) => setFormData(prev => ({ ...prev, documentNumber: e.target.value }))}
placeholder="Ej: 12345"
/>
</div>
</div>
<div className="ar-modal-footer">
<button type="button" onClick={onClose} className="ar-btn-secondary">
Cancelar
</button>
<button type="submit" className="ar-btn-primary">
<Printer size={18} /> Iniciar Reimpresión
</button>
</div>
</form>
</div>
<style>{`
.ar-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.ar-modal-content {
background-color: var(--bg-panel);
width: 100%;
max-width: 500px;
border-radius: 0.75rem;
border: 1px solid var(--border);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
overflow: hidden;
animation: modalEntry 0.3s ease-out;
}
@keyframes modalEntry {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.ar-modal-header {
background-color: var(--bg-input);
padding: 1rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.ar-close-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 4px;
}
.ar-close-btn:hover {
color: #fff;
background-color: rgba(255,255,255,0.1);
}
.ar-modal-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.ar-info-banner {
background-color: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.2);
padding: 0.75rem;
border-radius: 0.5rem;
display: flex;
gap: 0.75rem;
align-items: center;
}
.ar-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.ar-input-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.ar-input-group.full-width {
grid-column: span 2;
}
.ar-input-group label {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 0.5rem;
}
.ar-input-group input,
.ar-input-group select {
background-color: var(--bg-input);
border: 1px solid var(--border);
color: #fff;
padding: 0.625rem 0.875rem;
border-radius: 0.5rem;
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s;
}
.ar-input-group input:focus,
.ar-input-group select:focus {
border-color: var(--accent);
}
.ar-input-group select {
cursor: pointer;
}
.ar-modal-footer {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.ar-btn-secondary {
flex: 1;
padding: 0.75rem;
background: transparent;
border: 1px solid var(--border);
color: #fff;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.ar-btn-secondary:hover {
background-color: var(--bg-hover);
}
.ar-btn-primary {
flex: 1;
padding: 0.75rem;
background-color: var(--accent);
border: none;
color: #fff;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.ar-btn-primary:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import React, { useState } from 'react';
import { X, FileText, Folder, File, Shield, AlertCircle } from 'lucide-react';
export default function FileRequestModal({ isOpen, onClose, store, onConfirm }) {
const [formData, setFormData] = useState({
path: '',
filename: ''
});
if (!isOpen) return null;
const handleSubmit = (e) => {
e.preventDefault();
onConfirm(formData);
};
return (
<div className="bk-modal-overlay">
<div className="bk-modal-content">
{/* Header */}
<div className="bk-modal-header">
<div className="flex items-center gap-2">
<FileText size={20} style={{ color: 'var(--text-accent)' }} />
<h2 style={{ margin: 0, fontSize: '1rem', color: '#fff', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Solicitar Archivo Remoto
</h2>
</div>
<button onClick={onClose} className="bk-close-btn">
<X size={20} />
</button>
</div>
{/* Content */}
<form onSubmit={handleSubmit} className="bk-modal-body">
<div className="bk-info-banner">
<AlertCircle size={18} style={{ color: 'var(--warning)', flexShrink: 0 }} />
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', lineHeight: '1.5' }}>
Se enviará un comando <code style={{ color: 'var(--text-accent)', background: 'rgba(0,0,0,0.3)', padding: '2px 4px', borderRadius: '4px' }}>FILE</code> al agente remoto de <b style={{ color: '#fff' }}>{store?.name}</b>.
Debes especificar la ruta absoluta y el nombre del archivo.
</div>
</div>
<div className="bk-form-grid">
{/* File Path */}
<div className="bk-input-group full-width">
<label><Folder size={12} /> Ruta del Archivo</label>
<input
type="text"
required
value={formData.path}
onChange={(e) => setFormData(prev => ({ ...prev, path: e.target.value }))}
placeholder="C:\Advicom\Logs\"
/>
</div>
{/* Filename */}
<div className="bk-input-group full-width">
<label><File size={12} /> Nombre del Archivo</label>
<input
type="text"
required
value={formData.filename}
onChange={(e) => setFormData(prev => ({ ...prev, filename: e.target.value }))}
placeholder="SP260206.log"
/>
</div>
</div>
<div className="bk-modal-footer">
<button type="button" onClick={onClose} className="bk-btn-secondary">
Cancelar
</button>
<button type="submit" className="bk-btn-primary">
<Shield size={18} /> Solicitar Archivo
</button>
</div>
</form>
</div>
{/* Reusing styles from BackupModal via class names where possible,
but since BackupModal styles are scoped in a style tag,
I should probably ensure they are available or duplicate them if needed.
The user's CSS for BackupModal is quite comprehensive. */}
<style>{`
/* Reusing the same layout logic as BackupModal */
.bk-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.bk-modal-content {
background-color: var(--bg-panel);
width: 100%;
max-width: 450px;
border-radius: 0.75rem;
border: 1px solid var(--border);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
overflow: hidden;
animation: modalEntry 0.3s ease-out;
}
@keyframes modalEntry {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.bk-modal-header {
background-color: var(--bg-input);
padding: 1rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.bk-close-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 4px;
}
.bk-close-btn:hover {
color: #fff;
background-color: rgba(255,255,255,0.1);
}
.bk-modal-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.bk-info-banner {
background-color: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.2);
padding: 0.75rem;
border-radius: 0.5rem;
display: flex;
gap: 0.75rem;
align-items: center;
}
.bk-form-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.bk-input-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.bk-input-group label {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 0.5rem;
}
.bk-input-group input {
background-color: var(--bg-input);
border: 1px solid var(--border);
color: #fff;
padding: 0.625rem 0.875rem;
border-radius: 0.5rem;
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s;
}
.bk-input-group input:focus {
border-color: var(--accent);
}
.bk-modal-footer {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.bk-btn-secondary {
flex: 1;
padding: 0.75rem;
background: transparent;
border: 1px solid var(--border);
color: #fff;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.bk-btn-secondary:hover {
background-color: var(--bg-hover);
}
.bk-btn-primary {
flex: 1;
padding: 0.75rem;
background-color: var(--accent);
border: none;
color: #fff;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.bk-btn-primary:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
}
`}</style>
</div>
);
}

View File

@@ -22,11 +22,15 @@ export default function Login({ onLoginSuccess }) {
localStorage.setItem('user_data', JSON.stringify(response.user));
onLoginSuccess(response.user);
} else {
setError(response.message || 'Credenciales inválidas');
// Display error message from backend (error or mensaje field)
const errorMsg = response.mensaje || response.error || response.message || 'Credenciales inválidas';
setError(errorMsg);
}
} catch (err) {
console.error('Login error:', err);
setError(err.message || 'Error al conectar con el servidor');
// Try to extract error message from response
const errorMsg = err.mensaje || err.error || err.message || 'Error al conectar con el servidor';
setError(errorMsg);
} finally {
setLoading(false);
}

View File

@@ -3,7 +3,7 @@ import { AlertCircle, Clock, Database, FileX, Loader2, CheckCircle2, Hash, Copy,
const MAX_DISPLAY_ROWS = 1000;
export default function ResultTable({ result, loading, pollingProgress }) {
export default function ResultTable({ result, loading, pollingProgress, onClear }) {
const [copied, setCopied] = useState(false);
// Normalize data
@@ -40,36 +40,19 @@ export default function ResultTable({ result, loading, pollingProgress }) {
return [header, ...csvRows].join('\n');
};
// Download as CSV
const handleDownloadCSV = (cols, dataRows) => {
const csv = generateCSV(cols, dataRows);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
// Download file
const handleDownload = (content, filename, type = 'text/csv') => {
const blob = new Blob([content], { type: `${type};charset=utf-8;` });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `query_result_${Date.now()}.csv`);
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
// Auto-download if too many rows
React.useEffect(() => {
if (rows.length > MAX_DISPLAY_ROWS) {
handleDownloadCSV(columns, rows);
}
}, [result]);
// Copy to clipboard
const handleCopy = (cols, dataRows) => {
const csv = generateCSV(cols, dataRows);
navigator.clipboard.writeText(csv).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
// Loading state
if (loading) {
return (
@@ -246,7 +229,7 @@ export default function ResultTable({ result, loading, pollingProgress }) {
Para mantener el rendimiento del navegador, los resultados que superan las {MAX_DISPLAY_ROWS} filas no se muestran en pantalla y se descargan automáticamente como CSV.
</p>
<button
onClick={() => handleDownloadCSV(columns, rows)}
onClick={() => handleDownload(generateCSV(columns, rows), `query_result_${Date.now()}.csv`)}
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-all hover:scale-105"
style={{
background: 'var(--accent)',
@@ -268,7 +251,48 @@ export default function ResultTable({ result, loading, pollingProgress }) {
);
}
// Data table display - DARK THEME
// Final safety check for file content
const isFileContent = rows.length === 1 && columns.length === 1 && (
columns[0]?.toUpperCase() === 'CONTENT' ||
columns[0]?.toUpperCase() === 'TEXT' ||
columns[0]?.toUpperCase() === 'FILE' ||
(result?.command_id && String(result.command_id).startsWith('FILE-'))
);
if (isFileContent) {
return (
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
<div style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', width: '80px', height: '80px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '1.5rem', border: '2px solid rgba(16, 185, 129, 0.3)' }}>
<CheckCircle2 size={40} style={{ color: 'var(--success)' }} />
</div>
<h2 style={{ color: 'var(--text-primary)', fontSize: '1.5rem', fontWeight: 600, marginBottom: '1rem' }}>¡Archivo procesado con éxito!</h2>
<p style={{ color: 'var(--text-secondary)', textAlign: 'center', marginBottom: '2rem', maxWidth: '450px' }}>
El contenido del archivo <strong>#{result.command_id}</strong> ha sido recibido y la descarga debería haber iniciado automáticamente.
</p>
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={() => {
const rawText = Array.isArray(rows[0]) ? rows[0][0] : rows[0][columns[0]];
handleDownload(rawText, `file_${result.command_id}.txt`, 'text/plain');
}}
className="px-6 py-2 rounded-lg font-medium transition-all"
style={{ background: 'var(--accent)', color: 'white', border: 'none', cursor: 'pointer' }}
>
<div className="flex items-center gap-2 font-bold"><Download size={18} /> Descargar .txt de nuevo</div>
</button>
<button
onClick={onClear}
className="px-6 py-2 rounded-lg font-medium transition-all"
style={{ background: 'var(--border)', color: 'var(--text-primary)', border: 'none', cursor: 'pointer' }}
>
Nueva consulta
</button>
</div>
</div>
);
}
// Data table display - STABLE standard view
return (
<div className="result-container">
{/* Stats Header */}
@@ -293,9 +317,14 @@ export default function ResultTable({ result, loading, pollingProgress }) {
)}
</div>
<div className="flex items-center gap-2">
{/* Copy Button */}
<button
onClick={() => handleCopy(columns, rows)}
onClick={() => {
const csv = generateCSV(columns, rows);
navigator.clipboard.writeText(csv).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition-colors"
style={{
background: copied ? 'var(--success)' : 'var(--border)',
@@ -303,34 +332,21 @@ export default function ResultTable({ result, loading, pollingProgress }) {
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)}
onClick={() => handleDownload(generateCSV(columns, rows), `query_result_${Date.now()}.csv`)}
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"
style={{ background: 'var(--accent)', color: 'white', border: 'none', cursor: 'pointer' }}
>
<Download size={14} />
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 */}
{/* Standard Table View */}
<div className="table-wrapper">
<table>
<thead>
@@ -346,12 +362,7 @@ export default function ResultTable({ result, loading, pollingProgress }) {
<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];
}
let val = Array.isArray(row) ? row[cIdx] : row[col];
return (
<td key={cIdx}>
{val === null ? (
@@ -359,7 +370,7 @@ export default function ResultTable({ result, loading, pollingProgress }) {
) : typeof val === 'object' ? (
<span style={{ color: 'var(--text-accent)' }}>{JSON.stringify(val)}</span>
) : (
String(val)
String(val).length > 200 ? String(val).substring(0, 200) + '...' : String(val)
)}
</td>
);
@@ -372,3 +383,4 @@ export default function ResultTable({ result, loading, pollingProgress }) {
</div>
);
}

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Store, Circle, Search, Globe } from 'lucide-react';
import { fetchClientStores } from '../services/storeClientService';
import { fetchStoreStatus } from '../services/storeStatusService';
export default function StoreSelector({ selectedStore, onSelect, onClientChange }) {
const [stores, setStores] = useState([]);
@@ -23,26 +24,54 @@ export default function StoreSelector({ selectedStore, onSelect, onClientChange
}
}, []);
useEffect(() => {
async function loadStores() {
setLoading(true);
setError(null);
setStores([]); // Clear previous stores while loading
// Function to load stores and their status
const loadStoresWithStatus = async () => {
setLoading(true);
setError(null);
try {
const data = await fetchClientStores(selectedClient);
setStores(data);
if (data.length === 0 && selectedClient === '3') {
// Optional user feedback for empty Colombia
setError('No hay configuración de URL para Colombia aún.');
}
} catch (err) {
setError("Error cargando tiendas: " + err.message);
} finally {
setLoading(false);
try {
// Fetch both store list and status in parallel
const [storeList, statusData] = await Promise.all([
fetchClientStores(selectedClient),
fetchStoreStatus(selectedClient)
]);
// Merge store data with status information
const mergedStores = storeList.map(store => {
const statusInfo = statusData.find(s => s.store_id === parseInt(store.id));
return {
...store,
connectivity: statusInfo?.connectivity || 'OFFLINE',
last_ping: statusInfo?.last_ping,
seconds_since_ping: statusInfo?.seconds_since_ping,
status: statusInfo?.connectivity === 'ONLINE' ? 'online' : 'offline'
};
});
setStores(mergedStores);
if (mergedStores.length === 0 && selectedClient === '3') {
setError('No hay configuración de URL para Colombia aún.');
}
} catch (err) {
setError("Error cargando tiendas: " + err.message);
} finally {
setLoading(false);
}
loadStores();
};
// Load stores when client changes
useEffect(() => {
loadStoresWithStatus();
}, [selectedClient]);
// Auto-refresh status every 30 seconds
useEffect(() => {
const interval = setInterval(() => {
loadStoresWithStatus();
}, 30000); // 30 seconds
return () => clearInterval(interval);
}, [selectedClient]);
const handleClientChange = (clientId) => {
@@ -111,7 +140,11 @@ export default function StoreSelector({ selectedStore, onSelect, onClientChange
ID: {store.id} {store.extra?.turnorecojo && <span className="opacity-50">| {store.extra.turnorecojo}</span>}
</div>
</div>
<div title={store.status === 'online' ? 'Online' : 'Offline'}>
<div title={
store.connectivity === 'ONLINE'
? `Online - Última conexión: hace ${store.seconds_since_ping || 0} segundos`
: 'Fuera de línea'
}>
<Circle
size={8}
fill={store.status === 'online' ? '#10b981' : '#ef4444'}

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect, useRef } from 'react';
import { Wrench, FileText, Download, Printer } from 'lucide-react';
export default function ToolsMenu({ onFileRequest, onBackupRequest, onAutoReprint, disabled }) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef(null);
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleMenuItemClick = (action) => {
setIsOpen(false);
action();
};
return (
<div className="tools-menu-container" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="btn-tools"
disabled={disabled}
title="Herramientas del Sistema"
>
<Wrench size={16} />
Herramientas
</button>
{isOpen && (
<div className="tools-dropdown">
<button
className="tools-menu-item"
onClick={() => handleMenuItemClick(onFileRequest)}
>
<FileText size={16} />
<span>Petición Archivo</span>
</button>
<button
className="tools-menu-item"
onClick={() => handleMenuItemClick(onBackupRequest)}
>
<Download size={16} />
<span>Respaldo DB</span>
</button>
<div className="tools-menu-divider" />
<button
className="tools-menu-item"
onClick={() => handleMenuItemClick(onAutoReprint)}
>
<Printer size={16} />
<span>Reimpresión Automática</span>
</button>
</div>
)}
<style>{`
.tools-menu-container {
position: relative;
display: inline-block;
}
.btn-tools {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: var(--accent);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.btn-tools:hover:not(:disabled) {
background-color: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.btn-tools:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tools-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background-color: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
min-width: 220px;
z-index: 1000;
overflow: hidden;
animation: dropdownEntry 0.2s ease-out;
}
@keyframes dropdownEntry {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.tools-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
text-align: left;
}
.tools-menu-item:hover {
background-color: var(--bg-hover);
color: var(--accent);
}
.tools-menu-item svg {
flex-shrink: 0;
color: var(--text-muted);
transition: color 0.15s;
}
.tools-menu-item:hover svg {
color: var(--accent);
}
.tools-menu-divider {
height: 1px;
background-color: var(--border);
margin: 0.25rem 0;
}
`}</style>
</div>
);
}