Files
whatsapp/shared/components/sidebar.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 2fc33d22b2 fix: logo visible en sidebar — reemplaza filtro invert por fondo blanco
El filtro brightness(0) invert(1) hacía invisible logos con fondo claro.
Ahora el logo se muestra sobre una píldora blanca semitransparente,
visible independientemente de los colores del logo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 00:34:26 -05:00

468 lines
18 KiB
PHP

<?php
/**
* shared/components/sidebar.php
* Menú lateral dinámico del ERP generado desde ModuleRegistry.
*
* DEPENDENCIAS:
* - config/config.php (sesión iniciada, SYSTEM_MODULES disponible)
* - core/Rbac.php (hasModule())
* - core/ModuleRegistry.php (forSidebar())
*
* Carga ModuleRegistry si aún no está disponible (compatibilidad con
* archivos lab_*.php que incluyen este sidebar directamente).
*/
// Cargar ModuleRegistry si no está ya (cuando el sidebar se incluye desde legacy files)
if (!class_exists('ModuleRegistry', false)) {
// Asegurar APP_ROOT para que loadFromFiles() funcione
if (!defined('APP_ROOT')) {
define('APP_ROOT', realpath(__DIR__ . '/../../'));
}
$__mrFile = defined('APP_ROOT')
? APP_ROOT . '/core/ModuleRegistry.php'
: __DIR__ . '/../../core/ModuleRegistry.php';
if (file_exists($__mrFile)) {
require_once $__mrFile;
}
}
// ── Brand color + logo desde DB ──────────────────────────────────────────────
if (!defined('_SIDEBAR_BRAND_INJECTED')) {
define('_SIDEBAR_BRAND_INJECTED', true);
$_sb_brand = '#1565c0';
$_sb_brand_dark = '#0d47a1';
$_sb_logo = '';
$_sb_lab_nombre = '';
try {
if (class_exists('Database', false)) {
$__cfgs = Database::getInstance()->fetchAll(
"SELECT clave, valor FROM lab_config WHERE clave IN ('doc_color','doc_logo_base64','empresa_nombre')"
);
foreach ($__cfgs as $__r) {
if ($__r['clave'] === 'doc_color' && preg_match('/^#[0-9a-fA-F]{3,8}$/', $__r['valor'])) {
$_sb_brand = $__r['valor'];
} elseif ($__r['clave'] === 'doc_logo_base64' && !empty($__r['valor'])) {
$_sb_logo = $__r['valor'];
} elseif ($__r['clave'] === 'empresa_nombre') {
$_sb_lab_nombre = $__r['valor'];
}
}
$_sb_hex = ltrim($_sb_brand, '#');
$_sb_brand_dark = sprintf('#%02x%02x%02x',
max(0, hexdec(substr($_sb_hex, 0, 2)) - 40),
max(0, hexdec(substr($_sb_hex, 2, 2)) - 40),
max(0, hexdec(substr($_sb_hex, 4, 2)) - 40)
);
}
} catch (\Throwable $_) {}
echo "<style>:root{--brand:{$_sb_brand};--brand-dark:{$_sb_brand_dark}}</style>\n";
}
// Ruta del script que está siendo cargado actualmente
$_current_script = basename($_SERVER['PHP_SELF']);
/**
* Retorna 'active' si la URL o script actual coincide con la ruta dada.
*/
$_isActive = function (string $route) use ($_current_script): string {
if ($route === '') return '';
$parsed = parse_url($route);
$routeBase = basename($parsed['path'] ?? '');
// Rutas ERP con query params: comparar script Y cada param de la ruta
if (!empty($parsed['query'])) {
$uriParsed = parse_url($_SERVER['REQUEST_URI'] ?? '');
if ($routeBase !== basename($uriParsed['path'] ?? '')) return '';
parse_str($parsed['query'], $routeParams);
parse_str($uriParsed['query'] ?? '', $uriParams);
foreach ($routeParams as $k => $v) {
if (($uriParams[$k] ?? null) !== $v) return '';
}
return ' active';
}
// Rutas directas como /lab_domicilios.php
return $routeBase === $_current_script ? ' active' : '';
};
$_sb_display_name = $_sb_lab_nombre ?: 'Panel ERP';
// Nombre del usuario para el pie del sidebar
$_user_name = $_SESSION['admin_user']['full_name']
?? $_SESSION['admin_user']['username']
?? 'Usuario';
$_user_role = $_SESSION['admin_user']['role'] ?? 'admin';
// Mapa de roles a etiqueta legible
$_role_label = [
'superadmin' => 'Super Admin',
'admin' => 'Administrador',
'recepcionista' => 'Recepcionista',
'bacteriologo' => 'Bacteriólogo',
'enfermero' => 'Enfermero',
'supervisor' => 'Supervisor',
'operador_bot' => 'Operador Bot',
'readonly' => 'Solo Lectura',
][$_user_role] ?? ucfirst($_user_role);
// Base URL para rutas relativas
$_base = defined('APP_URL') ? rtrim(APP_URL, '/') : '';
?>
<nav class="sidebar" id="erp-sidebar">
<div class="sidebar-header">
<?php if ($_sb_logo): ?>
<img src="<?= htmlspecialchars($_sb_logo, ENT_QUOTES, 'UTF-8') ?>"
alt="Logo" class="sb-logo-img">
<?php else: ?>
<div class="sb-logo-fallback">
<i class="fas fa-flask"></i>
</div>
<?php endif; ?>
<div class="sb-brand-name"><?= htmlspecialchars($_sb_display_name, ENT_QUOTES, 'UTF-8') ?></div>
<a href="<?= $_base ?>/lab_configuracion.php" class="sb-config-btn" title="Configuración">
<i class="fas fa-gear"></i>
</a>
</div>
<!-- Buscador de menú -->
<div class="sb-search-wrap">
<i class="fas fa-search sb-search-icon"></i>
<input type="text" id="sbSearch" class="sb-search" placeholder="Buscar módulo…"
oninput="sbFilter(this.value)" autocomplete="off">
</div>
<ul class="sidebar-menu" id="sb-menu">
<?php
// ── Renderizado dinámico desde ModuleRegistry ───────────────────────────────
if (class_exists('ModuleRegistry', false)) {
$sidebarGroups = ModuleRegistry::forSidebar();
foreach ($sidebarGroups as $catKey => $catData):
if (empty($catData['modules'])) continue;
// Detectar si algún link de esta categoría está activo (para auto-expandir)
$_catHasActive = false;
foreach ($catData['modules'] as $__mod) {
$__links = $__mod['links'] ?? [];
if (!empty($__links)) {
foreach ($__links as $__lnk) {
if ($_isActive($__lnk['route']) !== '') { $_catHasActive = true; break 2; }
}
} else {
$__route = $__mod['route'] ?? '';
if ($__route && $_isActive($__route) !== '') { $_catHasActive = true; break; }
}
}
$catId = 'sb-cat-' . htmlspecialchars($catKey, ENT_QUOTES, 'UTF-8');
?>
<li class="sidebar-section<?= $_catHasActive ? ' has-active' : '' ?>"
data-cat="<?= $catId ?>"
onclick="sbToggleCat('<?= $catId ?>', this)">
<span class="sb-cat-label">
<i class="<?= htmlspecialchars($catData['icon'], ENT_QUOTES, 'UTF-8') ?> me-1"></i>
<?= htmlspecialchars($catData['label'], ENT_QUOTES, 'UTF-8') ?>
</span>
<i class="fas fa-chevron-down sb-chevron"></i>
</li>
<li class="sb-group" id="<?= $catId ?>">
<ul>
<?php foreach ($catData['modules'] as $slug => $mod):
$links = $mod['links'] ?? [];
if (!empty($links)):
foreach ($links as $lnk):
$href = $_base . $lnk['route'];
$active = $_isActive($lnk['route']);
?>
<li><a href="<?= htmlspecialchars($href, ENT_QUOTES, 'UTF-8') ?>"
class="nav-link<?= $active ?>">
<i class="<?= htmlspecialchars($lnk['icon'], ENT_QUOTES, 'UTF-8') ?>"></i>
<?= htmlspecialchars($lnk['name'], ENT_QUOTES, 'UTF-8') ?></a></li>
<?php endforeach;
else:
$route = $mod['route'] ?? '';
$href = $route ? $_base . $route : '#';
$active = $route ? $_isActive($route) : '';
?>
<li><a href="<?= htmlspecialchars($href, ENT_QUOTES, 'UTF-8') ?>"
class="nav-link<?= $active ?>">
<i class="<?= htmlspecialchars($mod['icon'], ENT_QUOTES, 'UTF-8') ?>"></i>
<?= htmlspecialchars($mod['name'], ENT_QUOTES, 'UTF-8') ?></a></li>
<?php endif;
endforeach; ?>
</ul>
</li>
<?php endforeach;
} else {
// Fallback estático
?>
<li class="sidebar-section has-active" data-cat="sb-cat-lab" onclick="sbToggleCat('sb-cat-lab',this)">
<span class="sb-cat-label"><i class="fas fa-house-medical me-1"></i> Laboratorio</span>
<i class="fas fa-chevron-down sb-chevron"></i>
</li>
<li class="sb-group" id="sb-cat-lab"><ul>
<?php if (function_exists('hasModule') && hasModule('lab_dashboard')): ?>
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
<?php endif; ?>
<?php if (function_exists('hasModule') && hasModule('lab_domicilios')): ?>
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
<?php endif; ?>
<?php if (function_exists('hasModule') && hasModule('lab_pacientes')): ?>
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
<?php endif; ?>
</ul></li>
<?php } ?>
<!-- Pie -->
<li class="sidebar-divider"></li>
<li><a href="<?= $_base ?>/logout.php" class="nav-link logout-link"
onclick="return confirm('¿Cerrar sesión?')">
<i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
</ul>
<!-- Información del usuario -->
<div class="sidebar-user">
<div class="sidebar-user-name">
<i class="fas fa-user-circle me-1"></i>
<?= htmlspecialchars($_user_name, ENT_QUOTES, 'UTF-8') ?>
</div>
<div class="sidebar-user-role"><?= htmlspecialchars($_role_label, ENT_QUOTES, 'UTF-8') ?></div>
</div>
</nav>
<style>
/* ── Header con logo ────────────────────────────────── */
.sidebar-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 18px 16px 14px;
position: relative;
}
.sb-logo-img {
max-width: 130px;
max-height: 64px;
object-fit: contain;
background: rgba(255,255,255,.92);
border-radius: 10px;
padding: 6px 10px;
}
.sb-logo-fallback {
width: 52px; height: 52px;
border-radius: 14px;
background: rgba(255,255,255,.18);
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; color: #fff;
}
.sb-brand-name {
font-size: .72rem;
font-weight: 700;
letter-spacing: .04em;
color: rgba(255,255,255,.80);
text-align: center;
line-height: 1.3;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* config en esquina superior derecha */
.sb-config-btn {
position: absolute;
top: 10px; right: 10px;
color: rgba(255,255,255,.55);
font-size: .9rem;
padding: 5px 7px;
border-radius: 6px;
transition: color .15s, background .15s;
line-height: 1;
}
.sb-config-btn:hover { color:#fff; background:rgba(255,255,255,.15); }
/* ── Buscador de menú ───────────────────────────────── */
.sb-search-wrap {
position: relative;
padding: 10px 14px 6px;
flex-shrink: 0;
}
.sb-search-icon {
position: absolute;
left: 24px;
top: 50%;
transform: translateY(-30%);
color: rgba(255,255,255,.45);
font-size: .75rem;
pointer-events: none;
}
.sb-search {
width: 100%;
background: rgba(255,255,255,.12);
border: 1px solid rgba(255,255,255,.18);
border-radius: 8px;
color: #fff;
padding: 6px 10px 6px 28px;
font-size: .8rem;
outline: none;
transition: background .2s, border-color .2s;
}
.sb-search::placeholder { color: rgba(255,255,255,.45); }
.sb-search:focus { background: rgba(255,255,255,.2); border-color: rgba(255,255,255,.4); }
/* ── Acordeón de categorías ─────────────────────────── */
.sidebar-section {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
padding: 8px 16px 6px;
margin-top: 6px;
border-radius: 8px 8px 0 0;
transition: background .15s;
}
.sidebar-section:hover { background: rgba(255,255,255,.08); }
.sidebar-section .sb-cat-label { display:flex; align-items:center; gap:4px; }
.sb-chevron {
font-size: .65rem;
opacity: .7;
transition: transform .25s ease;
flex-shrink: 0;
}
.sidebar-section.collapsed .sb-chevron { transform: rotate(-90deg); }
/* Grupo colapsable */
.sb-group { overflow: hidden; transition: max-height .28s ease, opacity .2s ease; }
.sb-group > ul { list-style: none; padding: 0 0 4px; margin: 0; }
.sb-group.collapsed { max-height: 0 !important; opacity: 0; pointer-events: none; }
</style>
<script>
(function () {
var STORE_KEY = 'sb_collapsed_cats';
function getCollapsed() {
try { return JSON.parse(localStorage.getItem(STORE_KEY) || '[]'); } catch(_) { return []; }
}
function setCollapsed(arr) {
try { localStorage.setItem(STORE_KEY, JSON.stringify(arr)); } catch(_) {}
}
function applyState(groupEl, sectionEl, collapsed, animate) {
if (collapsed) {
if (!animate) { groupEl.style.maxHeight = '0'; groupEl.style.opacity = '0'; }
groupEl.classList.add('collapsed');
sectionEl.classList.add('collapsed');
} else {
groupEl.classList.remove('collapsed');
sectionEl.classList.remove('collapsed');
groupEl.style.maxHeight = groupEl.scrollHeight + 'px';
groupEl.style.opacity = '1';
}
}
window.sbToggleCat = function(catId, sectionEl) {
var group = document.getElementById(catId);
if (!group) return;
var isCollapsed = group.classList.contains('collapsed');
var arr = getCollapsed();
if (isCollapsed) {
arr = arr.filter(function(id){ return id !== catId; });
applyState(group, sectionEl, false, true);
} else {
if (arr.indexOf(catId) === -1) arr.push(catId);
group.style.maxHeight = group.scrollHeight + 'px';
group.style.opacity = '1';
requestAnimationFrame(function(){
group.classList.add('collapsed');
sectionEl.classList.add('collapsed');
group.style.maxHeight = '0';
group.style.opacity = '0';
});
}
setCollapsed(arr);
};
// ── Buscador de menú ─────────────────────────────────────────
window.sbFilter = function(q) {
q = q.trim().toLowerCase();
var menu = document.getElementById('sb-menu');
if (!menu) return;
if (!q) {
// Restaurar estado guardado
menu.querySelectorAll('.sb-group').forEach(function(g) {
g.style.display = '';
});
menu.querySelectorAll('.sidebar-section').forEach(function(s) {
s.style.display = '';
});
// Re-aplicar estado colapsado
var collapsed = getCollapsed();
menu.querySelectorAll('.sb-group').forEach(function(group) {
var section = document.querySelector('[data-cat="' + group.id + '"]');
var hasActive = !!group.querySelector('.nav-link.active');
var isCol = !hasActive && collapsed.indexOf(group.id) !== -1;
if (section) applyState(group, section, isCol, false);
});
return;
}
// Mostrar todos expandidos y filtrar por texto
menu.querySelectorAll('.sb-group').forEach(function(g) {
var links = g.querySelectorAll('.nav-link');
var anyMatch = false;
links.forEach(function(a) {
var txt = a.textContent.toLowerCase();
var show = txt.includes(q);
a.parentElement.style.display = show ? '' : 'none';
if (show) anyMatch = true;
});
// Mostrar/ocultar grupo completo
g.style.display = anyMatch ? '' : 'none';
g.style.maxHeight = '2000px';
g.style.opacity = '1';
g.classList.remove('collapsed');
var section = document.querySelector('[data-cat="' + g.id + '"]');
if (section) {
section.style.display = anyMatch ? '' : 'none';
section.classList.remove('collapsed');
}
});
};
// Inicializar al cargar — sin animación para evitar flash
document.addEventListener('DOMContentLoaded', function () {
var collapsed = getCollapsed();
// Deshabilitar transición durante init
var style = document.createElement('style');
style.id = '_sb_notrans';
style.textContent = '.sb-group { transition: none !important; }';
document.head.appendChild(style);
document.querySelectorAll('.sb-group').forEach(function(group) {
var catId = group.id;
var section = document.querySelector('[data-cat="' + catId + '"]');
if (!section) return;
var hasActive = !!group.querySelector('.nav-link.active');
var isCollapsed = !hasActive && collapsed.indexOf(catId) !== -1;
// Altura real (funciona incluso con overflow:hidden)
group.style.maxHeight = group.scrollHeight + 'px';
group.style.opacity = '1';
applyState(group, section, isCollapsed, false);
});
// Reactivar transición en el próximo frame
requestAnimationFrame(function() {
var el = document.getElementById('_sb_notrans');
if (el) el.remove();
});
});
})();
</script>