Files
whatsapp/shared/components/sidebar.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 97a3dba249 feat: sidebar con categorías colapsables (acordeón)
- Cada sección del menú se puede abrir/cerrar con clic
- Estado persiste en localStorage por categoría
- La sección con el link activo siempre se auto-expande
- Chevron rota al colapsar con transición suave
- CSS en styles.css actualizado para que sidebar-section sea clickeable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 23:05:43 -05:00

308 lines
12 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 desde DB (si no fue inyectado ya por Layout.php) ─────────────
if (!defined('_SIDEBAR_BRAND_INJECTED')) {
define('_SIDEBAR_BRAND_INJECTED', true);
$_sb_brand = '#1565c0';
$_sb_brand_dark = '#0d47a1';
try {
if (class_exists('Database', false)) {
$_sb_row = Database::getInstance()->fetch("SELECT valor FROM lab_config WHERE clave = 'doc_color'");
if ($_sb_row && preg_match('/^#[0-9a-fA-F]{3,8}$/', $_sb_row['valor'])) {
$_sb_brand = $_sb_row['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' : '';
};
// Título del sidebar: prioridad al nombre definido por el módulo, luego el genérico
$_sidebar_title = $SIDEBAR_TITLE ?? $GLOBALS['_LAYOUT_TITLE'] ?? 'Panel ERP';
$_sidebar_icon = $SIDEBAR_ICON ?? $GLOBALS['_LAYOUT_ICON'] ?? 'fas fa-th-large';
// 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">
<h4><i class="<?= htmlspecialchars($_sidebar_icon, ENT_QUOTES, 'UTF-8') ?>"></i>
<?= htmlspecialchars($_sidebar_title, ENT_QUOTES, 'UTF-8') ?></h4>
</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>
/* ── 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);
};
// Inicializar al cargar
document.addEventListener('DOMContentLoaded', function () {
var collapsed = getCollapsed();
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;
// Medir altura real antes de colapsar
group.style.maxHeight = group.scrollHeight + 'px';
group.style.opacity = '1';
applyState(group, section, isCollapsed, false);
});
});
})();
</script>