Actualizar docker-compose.yml con sintaxis YAML corregida y puerto 80 para frontend
This commit is contained in:
97
frontend/src/components/IndicadorActualizacion.jsx
Normal file
97
frontend/src/components/IndicadorActualizacion.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
// src/components/IndicadorActualizacion.jsx
|
||||
// Indicador (arriba-derecha) con la hora de la última actualización + botón de
|
||||
// recarga. Al pulsar: re-lee las bases para la vista actual y recachea el resto.
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { getVistaActual, emitirRefresco } from "../lib/vistaActual";
|
||||
|
||||
function formatearHora(iso) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString("es-PE", {
|
||||
day: "2-digit", month: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
export default function IndicadorActualizacion() {
|
||||
const [hora, setHora] = useState(null);
|
||||
const [cargando, setCargando] = useState(false);
|
||||
|
||||
const consultar = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.ultimaActualizacion();
|
||||
setHora(r?.hora || null);
|
||||
} catch { /* silencioso */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
consultar();
|
||||
const id = setInterval(consultar, 60000);
|
||||
return () => clearInterval(id);
|
||||
}, [consultar]);
|
||||
|
||||
async function recargar() {
|
||||
if (cargando) return;
|
||||
setCargando(true);
|
||||
try {
|
||||
const r = await api.cacheRefreshManual(getVistaActual());
|
||||
setHora(r?.hora || null);
|
||||
emitirRefresco();
|
||||
} catch { /* silencioso */ }
|
||||
finally { setCargando(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes spin-actualiza { to { transform: rotate(360deg); } }
|
||||
.ind-actualiza-btn { transition: color .15s, transform .15s; }
|
||||
.ind-actualiza-btn:hover:not(:disabled) { color:#7dd3fc; transform: scale(1.15); }
|
||||
.ind-actualiza-spin { animation: spin-actualiza .9s linear infinite; }
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed", right: 16, top: 14, zIndex: 60,
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
background: "rgba(15,23,42,0.9)",
|
||||
color: cargando ? "#7dd3fc" : "#cbd5e1",
|
||||
border: "1px solid " + (cargando ? "#0ea5e9" : "#334155"),
|
||||
borderRadius: 20, padding: "6px 12px",
|
||||
fontSize: 12, lineHeight: 1, fontWeight: 500,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.28)",
|
||||
backdropFilter: "blur(4px)",
|
||||
userSelect: "none",
|
||||
}}
|
||||
title="Última actualización de las bases de datos"
|
||||
>
|
||||
<span style={{ opacity: cargando ? 1 : 0.85 }}>
|
||||
{cargando ? "Actualizando…" : formatearHora(hora)}
|
||||
</span>
|
||||
<button
|
||||
onClick={recargar}
|
||||
disabled={cargando}
|
||||
className={"ind-actualiza-btn" + (cargando ? " ind-actualiza-spin" : "")}
|
||||
title={cargando ? "Actualizando datos…" : "Actualizar ahora"}
|
||||
style={{
|
||||
background: "transparent", border: "none",
|
||||
cursor: cargando ? "default" : "pointer",
|
||||
color: "#38bdf8", fontSize: 15, padding: 0, lineHeight: 1,
|
||||
display: "flex", alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.4"
|
||||
strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
||||
<path d="M21 3v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/Modal.jsx
Normal file
34
frontend/src/components/Modal.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
// src/components/Modal.jsx
|
||||
export default function Modal({ title, onClose, children, width = 900 }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed", inset: 0, background: "rgba(15,23,42,0.55)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
zIndex: 1000, padding: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: "#fff", borderRadius: 16, width: "100%", maxWidth: width,
|
||||
maxHeight: "90vh", overflow: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "16px 22px", borderBottom: "1px solid #e2e8f0", position: "sticky",
|
||||
top: 0, background: "#fff", zIndex: 2,
|
||||
}}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 700, color: "#1e40af" }}>{title}</h3>
|
||||
<button onClick={onClose} style={{
|
||||
border: "none", background: "#f1f5f9", borderRadius: 8, width: 32, height: 32,
|
||||
cursor: "pointer", fontSize: 18, color: "#475569",
|
||||
}}>×</button>
|
||||
</div>
|
||||
<div style={{ padding: 22 }}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,26 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../lib/api";
|
||||
// src/components/Sidebar.jsx
|
||||
import { useAuth } from "../lib/auth";
|
||||
|
||||
const MENU = [
|
||||
{ id: "leads", ico: "🎯", label: "Leads" },
|
||||
{ id: "otros", ico: "📊", label: "Otros General" },
|
||||
{ id: "vendedores", ico: "🧑💼", label: "Vendedores" },
|
||||
{ id: "roas", ico: "📈", label: "ROAS" },
|
||||
{ id: "ocupabilidad", ico: "📊", label: "Ocupabilidad" },
|
||||
{ id: "ventas", ico: "💰", label: "Ventas" },
|
||||
{ id: "comisiones", ico: "🧮", label: "Comisiones" },
|
||||
{ id: "cobranza", ico: "📋", label: "Cobranza" },
|
||||
{ id: "rentabilidad", ico: "📈", label: "Rentabilidad" },
|
||||
{ id: "asesores", ico: "👥", label: "Asesores" },
|
||||
{ id: "usuarios", ico: "🔐", label: "Usuarios" },
|
||||
];
|
||||
|
||||
export default function Sidebar({ active, onChange, onConfig }) {
|
||||
const [menuUser, setMenuUser] = useState(false);
|
||||
const [ultima, setUltima] = useState("");
|
||||
const [actualizando, setActualizando] = useState(false);
|
||||
|
||||
const cargarHora = () => api.ultimaActualizacion()
|
||||
.then((r) => setUltima(r.hora || "")).catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
cargarHora();
|
||||
const id = setInterval(cargarHora, 60 * 1000); // refresca la hora cada minuto
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Actualiza TODO manualmente (como si pasaran los 15 min, pero al instante).
|
||||
async function actualizarTodo() {
|
||||
if (actualizando) return;
|
||||
setActualizando(true);
|
||||
try {
|
||||
const r = await api.refrescarTodo();
|
||||
if (r && r.hora) setUltima(r.hora); // hora del refresco manual recien hecho
|
||||
else await cargarHora();
|
||||
window.dispatchEvent(new Event("datos-actualizar")); // recarga el apartado abierto
|
||||
} catch (e) {
|
||||
// silencioso: no romper la UI si falla
|
||||
} finally {
|
||||
setActualizando(false);
|
||||
}
|
||||
}
|
||||
export default function Sidebar({ active, onChange }) {
|
||||
const { perfil, permisos, logout } = useAuth();
|
||||
const visibles = MENU.filter((m) => permisos.includes(m.id));
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-logo">Escuela <span>Refrigeración</span></div>
|
||||
|
||||
<nav className="nav" style={{ flex: 1 }}>
|
||||
{MENU.map((m) => (
|
||||
{visibles.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={`nav-item ${active === m.id ? "active" : ""}`}
|
||||
@@ -53,59 +31,22 @@ export default function Sidebar({ active, onChange, onConfig }) {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Botón de actualizar manual + hora de última actualización */}
|
||||
<div style={{ padding: "0 12px 8px" }}>
|
||||
<button onClick={actualizarTodo} disabled={actualizando}
|
||||
title="Actualizar todos los datos ahora (sin esperar los 15 min)"
|
||||
style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
|
||||
gap: 8, padding: "9px 12px", background: actualizando ? "#1e293b" : "#2563eb",
|
||||
border: "none", borderRadius: 9, color: "#fff", fontSize: 12.5, fontWeight: 600,
|
||||
cursor: actualizando ? "default" : "pointer", opacity: actualizando ? 0.75 : 1,
|
||||
transition: "all .15s ease" }}>
|
||||
<span style={{ display: "inline-block",
|
||||
animation: actualizando ? "spin 1s linear infinite" : "none" }}>🔄</span>
|
||||
{actualizando ? "Actualizando..." : "Actualizar ahora"}
|
||||
</button>
|
||||
{actualizando ? (
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: "#93c5fd", textAlign: "center" }}>
|
||||
Actualizando en segundo plano...
|
||||
</div>
|
||||
) : ultima ? (
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: "#64748b", textAlign: "center" }}>
|
||||
Actualizado: {ultima}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ position: "relative", borderTop: "1px solid rgba(255,255,255,0.08)", padding: 12 }}>
|
||||
{menuUser && (
|
||||
<div style={{ position: "absolute", bottom: 64, left: 12, right: 12, background: "#1e293b",
|
||||
border: "1px solid rgba(255,255,255,0.12)", borderRadius: 10, overflow: "hidden",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.4)" }}>
|
||||
<button onClick={() => { setMenuUser(false); onConfig && onConfig(); }}
|
||||
style={{ width: "100%", textAlign: "left", padding: "11px 14px", background: "transparent",
|
||||
border: "none", color: "#e2e8f0", cursor: "pointer", fontSize: 13,
|
||||
display: "flex", alignItems: "center", gap: 9 }}>
|
||||
⚙️ Configuración
|
||||
</button>
|
||||
<button onClick={() => setMenuUser(false)}
|
||||
style={{ width: "100%", textAlign: "left", padding: "11px 14px", background: "transparent",
|
||||
border: "none", borderTop: "1px solid rgba(255,255,255,0.08)", color: "#f87171",
|
||||
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center", gap: 9 }}>
|
||||
⎋ Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => setMenuUser((v) => !v)}
|
||||
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, background: "transparent",
|
||||
border: "none", cursor: "pointer", padding: "6px 4px", color: "#e2e8f0" }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: "50%", background: "#2563eb", color: "#fff",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 700,
|
||||
fontSize: 13, flexShrink: 0 }}>AS</div>
|
||||
<div style={{ flex: 1, textAlign: "left", lineHeight: 1.2 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Aron</div>
|
||||
<div style={{ fontSize: 11, color: "#94a3b8" }}>RP ERP</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "#94a3b8" }}>{menuUser ? "▲" : "▼"}</span>
|
||||
<div style={{ padding: 14, borderTop: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8", marginBottom: 2 }}>
|
||||
{perfil?.nombre || "Usuario"}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#60a5fa", fontWeight: 600, marginBottom: 10 }}>
|
||||
{perfil?.rol || ""}
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
style={{
|
||||
width: "100%", padding: "8px", border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: 8, background: "transparent", color: "#cbd5e1", fontSize: 12,
|
||||
cursor: "pointer", fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
🚪 Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/components/UI.jsx
|
||||
export function Loader({ text = "Cargando..." }) {
|
||||
return (
|
||||
<div className="loader-wrap">
|
||||
@@ -11,15 +12,34 @@ export function ErrorBox({ msg }) {
|
||||
return <div className="error-box">⚠️ {msg}</div>;
|
||||
}
|
||||
|
||||
export function colorSemaforo(pct) {
|
||||
if (pct >= 80) return { bg: "#d1fae5", fg: "#065f46", fill: "#10b981" };
|
||||
if (pct >= 60) return { bg: "#fef9c3", fg: "#854d0e", fill: "#f59e0b" };
|
||||
return { bg: "#fee2e2", fg: "#991b1b", fill: "#ef4444" };
|
||||
}
|
||||
|
||||
export function ProgressBar({ pct }) {
|
||||
const c = colorSemaforo(pct);
|
||||
const w = Math.min(Math.max(pct, 0), 100);
|
||||
return (
|
||||
<div className="pbar">
|
||||
<span className="pct" style={{ color: c.fg }}>{pct.toFixed(1)}%</span>
|
||||
<div className="track">
|
||||
<div className="fill" style={{ width: `${w}%`, background: c.fill }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Filters({ children }) {
|
||||
return <div className="filters">{children}</div>;
|
||||
}
|
||||
|
||||
export function Select({ label, value, options, onChange }) {
|
||||
export function Select({ label, value, options, onChange, width }) {
|
||||
return (
|
||||
<div className="filter-group">
|
||||
<div className="filter-group" style={width ? { width } : undefined}>
|
||||
{label && <label>{label}</label>}
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={width ? { width: "100%" } : undefined}>
|
||||
{options.map((o) => {
|
||||
const val = typeof o === "object" ? o.value : o;
|
||||
const txt = typeof o === "object" ? o.label : o;
|
||||
|
||||
Reference in New Issue
Block a user