- chat.php: recuperado y re-aplicado panel-switch móvil, viewport iOS, safe-area, sticky topbar, link ← Menú, botón volver - lugar.php: panel-switch tablet (≤860px), botón ← Cola, JS mobile - recepcion.php: panel-switch tablet (≤900px), botón ← Cola, JS mobile - sidebar.php: soporte atributo target en links de módulo Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
516 lines
20 KiB
PHP
516 lines
20 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
|
|
// ── Paleta de colores por categoría ────────────────────────────────────────
|
|
$_catPalette = [
|
|
'lab' => '#60a5fa',
|
|
'bot' => '#4ade80',
|
|
'turnero' => '#fbbf24',
|
|
'clinico' => '#c084fc',
|
|
'formularios' => '#22d3ee',
|
|
'sistema' => '#94a3b8',
|
|
];
|
|
|
|
// ── 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');
|
|
$_catColor = $_catPalette[$catKey] ?? '#94a3b8';
|
|
?>
|
|
<li class="sidebar-section<?= $_catHasActive ? ' has-active' : '' ?>"
|
|
data-cat="<?= $catId ?>"
|
|
style="--cat-color:<?= $_catColor ?>"
|
|
onclick="sbToggleCat('<?= $catId ?>',this,event)">
|
|
<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 ?>" style="--cat-color:<?= $_catColor ?>">
|
|
<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 ?>"
|
|
<?= !empty($lnk['target']) ? 'target="' . htmlspecialchars($lnk['target'], ENT_QUOTES, 'UTF-8') . '"' : '' ?>>
|
|
<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 {
|
|
position: relative;
|
|
display: block;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
padding: 8px 30px 6px;
|
|
margin-top: 6px;
|
|
border-radius: 8px 8px 0 0;
|
|
transition: background .15s, border-color .15s;
|
|
border-left: 3px solid transparent;
|
|
font-size: .72rem;
|
|
font-weight: 700;
|
|
letter-spacing: .05em;
|
|
text-transform: uppercase;
|
|
color: rgba(255,255,255,.65);
|
|
}
|
|
.sidebar-section:hover { background: rgba(255,255,255,.08); color: rgba(255,255,255,.9); }
|
|
.sidebar-section.has-active {
|
|
border-left-color: rgba(255,255,255,.7);
|
|
background: rgba(255,255,255,.1);
|
|
color: #fff;
|
|
}
|
|
.sidebar-section .sb-cat-label { display:inline-flex; align-items:center; gap:5px; }
|
|
.sb-chevron {
|
|
position: absolute;
|
|
right: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
font-size: .6rem;
|
|
opacity: .6;
|
|
transition: transform .25s ease;
|
|
}
|
|
.sidebar-section.collapsed .sb-chevron { transform: translateY(-50%) 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, ev) {
|
|
if (ev) { ev.preventDefault(); ev.stopPropagation(); }
|
|
var nav = document.getElementById('erp-sidebar');
|
|
var savedScroll = nav ? nav.scrollTop : 0;
|
|
|
|
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);
|
|
|
|
// Restaurar scroll del sidebar para que no salte al top
|
|
if (nav) { requestAnimationFrame(function(){ nav.scrollTop = savedScroll; }); }
|
|
};
|
|
|
|
// ── 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();
|
|
});
|
|
|
|
// Restaurar posición de scroll del sidebar entre páginas
|
|
var nav = document.getElementById('erp-sidebar');
|
|
if (nav) {
|
|
var savedScroll = parseInt(localStorage.getItem('sb_scroll') || '0', 10);
|
|
if (savedScroll) nav.scrollTop = savedScroll;
|
|
|
|
// Guardar posición al navegar
|
|
document.querySelectorAll('#erp-sidebar .nav-link').forEach(function(link) {
|
|
link.addEventListener('click', function() {
|
|
localStorage.setItem('sb_scroll', nav.scrollTop);
|
|
});
|
|
});
|
|
}
|
|
});
|
|
})();
|
|
</script>
|