This commit is contained in:
Lizandro Guarnizo
2026-04-16 22:23:33 -05:00
parent 50b7f7f8ca
commit ef822dd3c7
38 changed files with 2448 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* core/App.php
* Bootstrap y punto de entrada del ERP.
*
* Uso desde index.php (punto de entrada del módulo ERP):
*
* define('APP_ROOT', __DIR__);
* require_once __DIR__ . '/core/App.php';
* App::run();
*
* Los archivos lab_*.php legacy en raíz siguen funcionando directamente
* sin pasar por App — compatibilidad total garantizada.
*/
// ─── Constante de raíz del proyecto ─────────────────────────────────────────
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__));
}
// ─── Dependencias del core ───────────────────────────────────────────────────
require_once APP_ROOT . '/config/config.php';
require_once APP_ROOT . '/core/Helpers.php';
require_once APP_ROOT . '/core/Auth.php';
require_once APP_ROOT . '/core/Rbac.php';
require_once APP_ROOT . '/core/Router.php';
require_once APP_ROOT . '/core/Layout.php';
class App
{
// ─── Punto de entrada ────────────────────────────────────────────────────
public static function run(): void
{
self::boot();
$router = new Router();
// Rutas públicas: sin verificación de sesión
if (!$router->isPublic()) {
Auth::requireLogin();
// Enfermeros van a su portal propio, no al panel ERP
if (Auth::isEnfermero()) {
header('Location: ' . APP_ROOT . '/../enfermero_portal.php');
exit;
}
}
self::dispatch($router);
}
// ─── Bootstrap ──────────────────────────────────────────────────────────
/**
* Inicializa la sesión y la zona horaria.
* Se puede llamar también desde archivos legacy para obtener sesión lista.
*/
public static function boot(): void
{
if (session_status() === PHP_SESSION_NONE) {
// Cookies de sesión seguras
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
date_default_timezone_set(defined('TIMEZONE') ? TIMEZONE : 'America/Bogota');
}
// ─── Dispatch ───────────────────────────────────────────────────────────
private static function dispatch(Router $router): void
{
try {
$viewFile = $router->resolveFile();
} catch (RuntimeException $e) {
self::render404($router->getModule(), $router->getView());
return;
}
// Verificar permiso de módulo (solo en rutas protegidas)
if (!$router->isPublic()) {
$module = $router->getModule();
// Si el módulo está registrado en SYSTEM_MODULES, verificar acceso
if (defined('SYSTEM_MODULES') && array_key_exists($module, SYSTEM_MODULES)) {
if (!Rbac::hasModule($module)) {
self::render403($module);
return;
}
}
}
// Ejecutar la vista del módulo
include $viewFile;
}
// ─── Páginas de error ────────────────────────────────────────────────────
private static function render404(string $module, string $view): void
{
http_response_code(404);
Layout::open('Página no encontrada', 'fas fa-exclamation-triangle');
echo '<div class="container-fluid py-5 text-center">';
echo '<i class="fas fa-exclamation-triangle fa-4x text-warning mb-3"></i>';
echo '<h2>404 — Módulo no encontrado</h2>';
echo '<p class="text-muted">El módulo <code>' . esc($module) . '</code> / vista <code>' . esc($view) . '</code> no existe.</p>';
echo '<a href="' . esc(APP_URL ?? '/') . '" class="btn btn-primary"><i class="fas fa-home me-1"></i>Ir al inicio</a>';
echo '</div>';
Layout::close();
}
private static function render403(string $module): void
{
http_response_code(403);
Layout::open('Acceso denegado', 'fas fa-lock');
echo '<div class="container-fluid py-5 text-center">';
echo '<i class="fas fa-lock fa-4x text-danger mb-3"></i>';
echo '<h2>403 — Sin permiso</h2>';
echo '<p class="text-muted">Tu rol no tiene acceso al módulo <code>' . esc($module) . '</code>.</p>';
echo '<a href="' . esc(APP_URL ?? '/') . '" class="btn btn-secondary"><i class="fas fa-arrow-left me-1"></i>Volver</a>';
echo '</div>';
Layout::close();
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* core/Auth.php
* Gestión de sesión y autenticación de usuarios del panel.
* Extraído de config/config.php para separar responsabilidades.
*
* USO:
* require_once __DIR__ . '/../core/Auth.php';
* Auth::requireLogin(); // en cualquier vista protegida
* Auth::requireNotEnfermero(); // excluir enfermeros del panel admin
*/
class Auth
{
// ─── Verificación de sesión ─────────────────────────────────────────────
public static function isLoggedIn(): bool
{
return isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true;
}
public static function requireLogin(string $redirect = 'login.php'): void
{
if (!self::isLoggedIn()) {
header("Location: $redirect");
exit;
}
}
public static function requireNotEnfermero(string $redirect = 'enfermero_portal.php'): void
{
if (self::isEnfermero()) {
header("Location: $redirect");
exit;
}
}
// ─── Datos del usuario en sesión ────────────────────────────────────────
public static function id(): ?int
{
return isset($_SESSION['admin_user']['id'])
? (int) $_SESSION['admin_user']['id']
: null;
}
public static function role(): string
{
return $_SESSION['admin_user']['role'] ?? 'admin';
}
public static function fullName(): string
{
return $_SESSION['admin_user']['full_name']
?? $_SESSION['admin_user']['username']
?? 'Usuario';
}
public static function roleId(): ?int
{
$id = $_SESSION['admin_user']['role_id'] ?? null;
return $id ? (int) $id : null;
}
public static function isEnfermero(): bool
{
return self::role() === 'enfermero';
}
public static function isAdmin(): bool
{
return self::role() === 'admin';
}
public static function isSuperAdmin(): bool
{
return self::role() === 'superadmin';
}
// ─── Login / Logout ─────────────────────────────────────────────────────
/**
* Intenta autenticar usuario contra la BD.
* Devuelve array con datos del usuario o false si falla.
*/
public static function attempt(string $username, string $password): array|false
{
// Delegar en la función legacy de config.php mientras coexistan
// (authenticateUser está definida en config/config.php)
return authenticateUser($username, $password);
}
/**
* Establece la sesión tras un login exitoso.
*/
public static function login(array $userData): void
{
session_regenerate_id(true);
$_SESSION['admin_logged_in'] = true;
$_SESSION['admin_user'] = $userData;
$_SESSION['login_time'] = time();
}
public static function logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(
session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']
);
}
session_destroy();
}
// ─── Tiempo de sesión ───────────────────────────────────────────────────
public static function isSessionExpired(): bool
{
if (!isset($_SESSION['login_time'])) return false;
$timeout = defined('SESSION_TIMEOUT') ? SESSION_TIMEOUT : 1800;
return (time() - $_SESSION['login_time']) > $timeout;
}
public static function refreshSession(): void
{
$_SESSION['login_time'] = time();
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* core/Helpers.php
* Funciones de utilidad globales del ERP.
* Consolida helpers duplicados de api/lab/_helpers.php y config/config.php.
*
* REQUIERE: config/config.php ya cargado (para APP_ROOT si se usa).
*/
// ─── Seguridad / Output ─────────────────────────────────────────────────────
/**
* Escapa una cadena para salida HTML segura.
*/
if (!function_exists('esc')) {
function esc(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
// ─── Respuestas JSON ────────────────────────────────────────────────────────
/**
* Envía respuesta JSON de éxito y termina la ejecución.
*
* @param array $payload Datos adicionales a fusionar en la respuesta.
* @param string $message Mensaje descriptivo opcional.
*/
if (!function_exists('jsonOk')) {
function jsonOk(array $payload = [], string $message = ''): void
{
if (ob_get_level() > 0) {
ob_clean();
}
header('Content-Type: application/json; charset=utf-8');
$resp = ['ok' => true, 'success' => true];
if ($message !== '') {
$resp['message'] = $message;
}
echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE);
exit;
}
}
/**
* Envía respuesta JSON de error con código HTTP y termina la ejecución.
*
* @param string $message Descripción del error.
* @param int $code Código HTTP (400, 401, 403, 404, 500…).
*/
if (!function_exists('jsonError')) {
function jsonError(string $message, int $code = 400): void
{
if (ob_get_level() > 0) {
ob_clean();
}
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(
['ok' => false, 'success' => false, 'error' => $message, 'code' => $code],
JSON_UNESCAPED_UNICODE
);
exit;
}
}
// ─── Request ────────────────────────────────────────────────────────────────
/**
* Detiene la ejecución con 405 JSON si el método HTTP no coincide.
*
* @param string $method Método esperado: 'GET', 'POST', etc.
*/
if (!function_exists('requireMethod')) {
function requireMethod(string $method): void
{
if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) {
jsonError('Método no permitido', 405);
}
}
}
/**
* Lee y decodifica el body JSON del request actual.
* Se cachea en la misma petición.
*
* @return array Datos decodificados o array vacío si el body no es JSON válido.
*/
if (!function_exists('inputJson')) {
function inputJson(): array
{
static $parsed = null;
if ($parsed === null) {
$raw = file_get_contents('php://input');
$parsed = json_decode($raw ?: '{}', true) ?? [];
}
return $parsed;
}
}
// ─── Texto / Formato ────────────────────────────────────────────────────────
/**
* Convierte bytes a representación legible (KB, MB, GB).
*/
if (!function_exists('formatBytes')) {
function formatBytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = $bytes > 0 ? floor(log($bytes) / log(1024)) : 0;
$pow = min($pow, count($units) - 1);
return round($bytes / (1024 ** $pow), $precision) . ' ' . $units[$pow];
}
}
/**
* Formatea una fecha a formato legible en español.
*
* @param string|null $date Fecha en cualquier formato reconocible por strtotime.
* @param bool $time Si true incluye la hora.
*/
if (!function_exists('formatDate')) {
function formatDate(?string $date, bool $time = false): string
{
if (!$date) {
return '—';
}
$ts = strtotime($date);
$format = $time ? 'd/m/Y H:i' : 'd/m/Y';
return $ts ? date($format, $ts) : esc($date);
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
/**
* core/Layout.php
* Renderiza el HTML envolvente de cada vista del ERP.
*
* USO en una vista de módulo (modules/{m}/views/{v}.php):
*
* Layout::open('Dashboard Turnero', 'fas fa-ticket-alt');
* // ... contenido de la vista ...
* Layout::close();
*
* Layout::open() emite DOCTYPE, <head>, Bootstrap, sidebar y abre <main>.
* Layout::close() cierra </main>, agrega scripts y </body></html>.
*/
class Layout
{
// ─── Apertura ────────────────────────────────────────────────────────────
/**
* Emite el HTML inicial de la página (head + sidebar + apertura de main).
*
* @param string $title Título de la pestaña y del encabezado.
* @param string $icon Clase FontAwesome del ícono (ej. 'fas fa-ticket-alt').
*/
public static function open(string $title = 'Panel', string $icon = 'fas fa-th-large'): void
{
$appName = defined('APP_NAME') ? APP_NAME : 'ERP System';
$safeTitle = htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeIcon = htmlspecialchars($icon, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// Exponer variables para sidebar.php
$GLOBALS['_LAYOUT_TITLE'] = $title;
$GLOBALS['_LAYOUT_ICON'] = $icon;
// Detectar base URL (para assets relativos)
$base = defined('APP_URL') ? rtrim(APP_URL, '/') : '';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $safeTitle ?> — <?= htmlspecialchars($appName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></title>
<!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome 6 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- Estilos del sistema -->
<link href="<?= $base ?>/assets/css/styles.css?v=12" rel="stylesheet">
<style>
/* Variables del tema ERP */
:root {
--sidebar-width: 260px;
--header-height: 56px;
}
body {
overflow-x: hidden;
}
.main-content {
margin-left: var(--sidebar-width);
min-height: 100vh;
background: #f8f9fa;
}
@media (max-width: 768px) {
.main-content { margin-left: 0; }
}
/* Toast container */
#toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1090;
}
</style>
</head>
<body>
<!-- Toast container global -->
<div id="toast-container" aria-live="polite" aria-atomic="true"></div>
<?php
// Sidebar dinámico
$sidebarFile = APP_ROOT . '/shared/components/sidebar.php';
if (file_exists($sidebarFile)) {
$SIDEBAR_TITLE = $title;
$SIDEBAR_ICON = $icon;
include $sidebarFile;
}
?>
<!-- Contenido principal -->
<main class="main-content">
<!-- Barra de título de módulo -->
<div class="bg-white border-bottom px-4 py-3 d-flex align-items-center justify-content-between">
<h5 class="mb-0 fw-semibold">
<i class="<?= $safeIcon ?> me-2 text-primary"></i><?= $safeTitle ?>
</h5>
<small class="text-muted d-none d-sm-block">
<?= htmlspecialchars(Auth::fullName(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>
<span class="badge bg-secondary ms-1"><?= htmlspecialchars(Auth::role(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></span>
</small>
</div>
<div class="p-4">
<?php
}
// ─── Cierre ──────────────────────────────────────────────────────────────
/**
* Cierra la sección de contenido y emite los scripts finales.
*/
public static function close(): void
{
$base = defined('APP_URL') ? rtrim(APP_URL, '/') : '';
?>
</div><!-- /.p-4 -->
</main><!-- /.main-content -->
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Helper JS global: toasts y fetch wrapper -->
<script>
/**
* erp.toast(message, type)
* Muestra un Bootstrap Toast en la esquina superior derecha.
* type: 'success' | 'danger' | 'warning' | 'info'
*/
window.erp = window.erp || {};
erp.toast = function(message, type = 'success') {
const id = 'toast-' + Date.now();
const icons = { success: 'fa-check-circle', danger: 'fa-times-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle' };
const icon = icons[type] || icons.info;
const html = `
<div id="${id}" class="toast align-items-center text-bg-${type} border-0 mb-2" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="4000">
<div class="d-flex">
<div class="toast-body"><i class="fas ${icon} me-2"></i>${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Cerrar"></button>
</div>
</div>`;
document.getElementById('toast-container').insertAdjacentHTML('beforeend', html);
const el = document.getElementById(id);
const toast = new bootstrap.Toast(el);
toast.show();
el.addEventListener('hidden.bs.toast', () => el.remove());
};
/**
* erp.fetch(url, options)
* Wrapper de fetch que muestra toast en caso de error y devuelve el JSON parseado.
* Lanza un Error si ok === false.
*/
erp.fetch = async function(url, options = {}) {
try {
const res = await fetch(url, options);
const data = await res.json();
if (!data.ok) {
throw new Error(data.error || 'Error desconocido');
}
return data;
} catch (err) {
erp.toast(err.message, 'danger');
throw err;
}
};
</script>
</body>
</html>
<?php
}
}
+312
View File
@@ -0,0 +1,312 @@
<?php
/**
* core/ModuleRegistry.php
* Catálogo dinámico de módulos del ERP.
*
* Fuentes de datos (prioridad en orden):
* 1. Base de datos — tabla system_modules (si la migración 004 se ejecutó)
* 2. Archivos modules/{slug}/module.php (escaneo de la carpeta modules/)
* 3. Constante SYSTEM_MODULES de config.php (fallback mínimo)
*
* Los datos de BD prevalecen sobre los descriptores locales para los campos
* is_active y sort_order (el administrador puede cambiarlos via UI).
*
* CACHÉ: estática por request (no Redis necesario).
*/
class ModuleRegistry
{
/** @var array<string, array>|null Cache del registro completo */
private static ?array $registry = null;
/** Orden de visualización de categorías en el sidebar */
private const CATEGORY_ORDER = ['bot', 'lab', 'turnero', 'clinico', 'reportes', 'sistema'];
/** Etiquetas e íconos de cada categoría */
private const CATEGORIES = [
'bot' => ['label' => 'WhatsApp', 'icon' => 'fab fa-whatsapp'],
'lab' => ['label' => 'Laboratorio', 'icon' => 'fas fa-flask'],
'turnero' => ['label' => 'Turnero', 'icon' => 'fas fa-ticket-alt'],
'clinico' => ['label' => 'Clínico', 'icon' => 'fas fa-vials'],
'reportes'=> ['label' => 'Reportes', 'icon' => 'fas fa-chart-bar'],
'sistema' => ['label' => 'Sistema', 'icon' => 'fas fa-cog'],
];
// ─── API pública ────────────────────────────────────────────────────────
/**
* Retorna todos los módulos registrados (activos e inactivos).
*
* @return array<string, array> Indexado por slug.
*/
public static function getAll(): array
{
self::load();
return self::$registry;
}
/**
* Retorna solo los módulos ACTIVOS que el usuario actual puede ver.
* Si el usuario es admin sin role_id → todos los activos.
*
* @return array<string, array> Indexado por slug.
*/
public static function forCurrentUser(): array
{
self::load();
$result = [];
foreach (self::$registry as $slug => $mod) {
if (!$mod['is_active']) {
continue;
}
// hasModule() definida en config.php / core/Rbac.php (retrocompat)
if (function_exists('hasModule') && !hasModule($slug)) {
continue;
}
$result[$slug] = $mod;
}
return $result;
}
/**
* Retorna módulos activos agrupados por categoría para el sidebar.
* Solo incluye módulos accesibles por el usuario actual.
*
* @return array ['bot' => ['label'=>'WhatsApp','icon'=>'...','modules'=>[...]], ...]
*/
public static function forSidebar(): array
{
$userModules = self::forCurrentUser();
// Agrupar por categoría
$grouped = [];
foreach ($userModules as $slug => $mod) {
$cat = $mod['category'] ?? 'sistema';
if (!isset($grouped[$cat])) {
$catMeta = self::CATEGORIES[$cat] ?? ['label' => ucfirst($cat), 'icon' => 'fas fa-cube'];
$grouped[$cat] = [
'label' => $catMeta['label'],
'icon' => $catMeta['icon'],
'modules' => [],
];
}
$grouped[$cat]['modules'][$slug] = $mod;
}
// Ordenar grupos según CATEGORY_ORDER
$ordered = [];
foreach (self::CATEGORY_ORDER as $cat) {
if (isset($grouped[$cat])) {
// Ordenar módulos dentro de la categoría por sort_order
uasort($grouped[$cat]['modules'], fn($a, $b) => $a['sort_order'] <=> $b['sort_order']);
$ordered[$cat] = $grouped[$cat];
}
}
// Categorías extra no definidas en CATEGORY_ORDER al final
foreach ($grouped as $cat => $data) {
if (!isset($ordered[$cat])) {
$ordered[$cat] = $data;
}
}
return $ordered;
}
/**
* Retorna el descriptor de un módulo por slug, o null si no existe.
*/
public static function get(string $slug): ?array
{
self::load();
return self::$registry[$slug] ?? null;
}
/**
* Activa o desactiva un módulo en la base de datos.
*
* @throws RuntimeException Si no se puede conectar a la BD.
*/
public static function setActive(string $slug, bool $active): bool
{
try {
$db = Database::getInstance();
$pdo = $db->getConnection();
$stmt = $pdo->prepare(
"UPDATE system_modules SET is_active = ? WHERE slug = ?"
);
$ok = $stmt->execute([(int) $active, $slug]);
if ($ok) {
self::$registry = null; // invalidar caché
}
return $ok;
} catch (Exception $e) {
error_log("ModuleRegistry::setActive error: " . $e->getMessage());
return false;
}
}
/**
* Actualiza el sort_order de un módulo.
*/
public static function setSortOrder(string $slug, int $order): bool
{
try {
$db = Database::getInstance();
$pdo = $db->getConnection();
$stmt = $pdo->prepare(
"UPDATE system_modules SET sort_order = ? WHERE slug = ?"
);
$ok = $stmt->execute([$order, $slug]);
if ($ok) {
self::$registry = null;
}
return $ok;
} catch (Exception $e) {
error_log("ModuleRegistry::setSortOrder error: " . $e->getMessage());
return false;
}
}
/**
* Invalida la caché para forzar recarga en la próxima llamada.
* Útil después de cambios por el administrador.
*/
public static function flush(): void
{
self::$registry = null;
}
// ─── Carga interna ──────────────────────────────────────────────────────
private static function load(): void
{
if (self::$registry !== null) {
return;
}
self::$registry = [];
// 1. Escanear module.php locales para obtener metadatos base
self::loadFromFiles();
// 2. Sobreescribir is_active / sort_order desde la BD (si existe)
self::mergeFromDatabase();
// 3. Fallback: añadir slugs de SYSTEM_MODULES que no tengan module.php
self::fillFromConstant();
}
/**
* Escanea modules/{slug}/module.php y carga sus descriptores.
*/
private static function loadFromFiles(): void
{
if (!defined('APP_ROOT')) {
return;
}
$pattern = APP_ROOT . '/modules/*/module.php';
foreach (glob($pattern) ?: [] as $file) {
$data = @include $file;
if (is_array($data) && isset($data['slug'])) {
self::$registry[$data['slug']] = self::normalize($data);
}
}
}
/**
* Lee la tabla system_modules y sobreescribe campos controlados por el admin.
*/
private static function mergeFromDatabase(): void
{
try {
$db = Database::getInstance();
$pdo = $db->getConnection();
// Verificar si existe la tabla
$check = $pdo->query("SHOW TABLES LIKE 'system_modules'");
if (!$check || $check->rowCount() === 0) {
return;
}
$rows = $pdo->query(
"SELECT slug, name, icon, category, route, is_active, sort_order, oleada, description
FROM system_modules ORDER BY sort_order ASC"
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$slug = $row['slug'];
if (isset(self::$registry[$slug])) {
// Actualizar solo los campos que maneja el admin
self::$registry[$slug]['is_active'] = (bool)(int)$row['is_active'];
self::$registry[$slug]['sort_order'] = (int)$row['sort_order'];
// Si en BD hay datos más completos, usar los de BD
if (!empty($row['name'])) self::$registry[$slug]['name'] = $row['name'];
if (!empty($row['icon'])) self::$registry[$slug]['icon'] = $row['icon'];
if (!empty($row['category'])) self::$registry[$slug]['category'] = $row['category'];
if (!empty($row['route'])) self::$registry[$slug]['route'] = $row['route'];
if (!empty($row['description'])) self::$registry[$slug]['description'] = $row['description'];
} else {
// Módulo solo en BD (sin module.php local)
self::$registry[$slug] = self::normalize([
'slug' => $slug,
'name' => $row['name'],
'icon' => $row['icon'],
'category' => $row['category'],
'route' => $row['route'],
'is_active' => (bool)(int)$row['is_active'],
'sort_order' => (int)$row['sort_order'],
'oleada' => (int)$row['oleada'],
'description' => $row['description'],
]);
}
}
} catch (Exception $e) {
// BD no disponible → seguir con datos de archivos
error_log("ModuleRegistry DB merge error: " . $e->getMessage());
}
}
/**
* Agrega módulos del array SYSTEM_MODULES que no estén ya registrados.
* Garantiza compatibilidad si no se ejecutó la migración 004.
*/
private static function fillFromConstant(): void
{
if (!defined('SYSTEM_MODULES')) {
return;
}
foreach (SYSTEM_MODULES as $slug => $name) {
if (!isset(self::$registry[$slug])) {
self::$registry[$slug] = self::normalize([
'slug' => $slug,
'name' => $name,
'icon' => 'fas fa-cube',
'category' => 'sistema',
'route' => null,
'is_active' => true,
'sort_order' => 99,
'oleada' => 0,
]);
}
}
}
/**
* Normaliza un descriptor garantizando que todos los campos existen.
*/
private static function normalize(array $data): array
{
return [
'slug' => $data['slug'] ?? '',
'name' => $data['name'] ?? ucfirst($data['slug'] ?? ''),
'icon' => $data['icon'] ?? 'fas fa-cube',
'category' => $data['category'] ?? 'sistema',
'route' => $data['route'] ?? null,
'is_active' => (bool)($data['is_active'] ?? true),
'sort_order' => (int)($data['sort_order'] ?? 99),
'oleada' => (int)($data['oleada'] ?? 0),
'description' => $data['description'] ?? '',
'links' => $data['links'] ?? [],
];
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
/**
* core/Rbac.php
* Control de acceso basado en roles (RBAC) con granularidad de acciones.
* Consolida la lógica de hasModule() / requireAdmin() dispersa en _helpers.php
* y config.php con soporte para los nuevos campos can_* (Migración 001).
*
* USO EN VISTAS:
* Rbac::requireLogin();
* Rbac::requireModule('lab_domicilios');
* if (Rbac::can('lab_reportes', 'export')) { ... }
*
* USO EN API (reemplaza funciones sueltas de _helpers.php):
* Rbac::requireAdmin();
* Rbac::requireModule('turnero', 'create');
*/
class Rbac
{
// ─── Consulta básica de módulo ───────────────────────────────────────────
/**
* Comprueba si el usuario en sesión tiene acceso (cualquier nivel) al módulo.
* Retrocompatible: admin sin role_id tiene acceso total.
*/
public static function hasModule(string $slug): bool
{
$modules = $_SESSION['admin_user']['modules'] ?? null;
if ($modules === null) {
// Sesión legacy: solo admins pasan
return ($_SESSION['admin_user']['role'] ?? '') === 'admin'
|| ($_SESSION['admin_user']['role'] ?? '') === 'superadmin';
}
return in_array($slug, $modules, true);
}
/**
* Comprueba si el usuario tiene una acción específica sobre el módulo.
*
* $action: 'view' | 'create' | 'edit' | 'delete' | 'export'
*
* Los roles admin y superadmin tienen acceso total a todo.
*/
public static function can(string $slug, string $action = 'view'): bool
{
$role = $_SESSION['admin_user']['role'] ?? 'admin';
// Superadmin y admin tienen acceso total
if (in_array($role, ['superadmin', 'admin'], true)) {
return true;
}
// Primero verificar que tiene el módulo
if (!self::hasModule($slug)) {
return false;
}
// Leer permisos granulares desde sesión (cargados en authenticateUser)
$perms = $_SESSION['admin_user']['module_permissions'] ?? [];
// Formato anterior compatible: 'write' / 'read'
if (isset($perms[$slug]) && is_string($perms[$slug])) {
$legacy = $perms[$slug];
if ($action === 'view') return true; // read implica view
return $legacy === 'write';
}
// Formato nuevo: array con can_* (post Migración 001)
if (isset($perms[$slug]) && is_array($perms[$slug])) {
return (bool) ($perms[$slug]["can_{$action}"] ?? false);
}
// Sin permisos granulares → view por defecto
return $action === 'view';
}
// ─── Guardas / Requisitos ────────────────────────────────────────────────
/**
* Detiene con 403 JSON si el usuario no está autenticado.
* Para uso en vistas, redirige al login.
*/
public static function requireLogin(string $redirect = null): void
{
if (!isset($_SESSION['admin_logged_in']) || $_SESSION['admin_logged_in'] !== true) {
if ($redirect !== null) {
header("Location: $redirect");
exit;
}
self::jsonForbidden('No autenticado', 401);
}
}
/**
* Detiene con 403 si el usuario no es admin ni superadmin.
*/
public static function requireAdmin(): void
{
$role = $_SESSION['admin_user']['role'] ?? '';
if (!in_array($role, ['admin', 'superadmin'], true)) {
self::jsonForbidden('Acceso restringido a administradores', 403);
}
}
/**
* Detiene con 403 si el usuario no tiene acceso al módulo.
* Opcionalmente exige una acción específica.
*/
public static function requireModule(string $slug, string $action = 'view'): void
{
if (!self::can($slug, $action)) {
self::jsonForbidden("Sin permiso en módulo '{$slug}' ({$action})", 403);
}
}
/**
* Alias semántico: detiene si no tiene permiso de escritura en el módulo.
*/
public static function requireWrite(string $slug): void
{
self::requireModule($slug, 'edit');
}
// ─── Helpers internos ───────────────────────────────────────────────────
private static function jsonForbidden(string $msg, int $code = 403): void
{
// Si ya enviamos headers JSON (contexto API), responder JSON
if (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
// Contexto HTML: página de error simple
http_response_code($code);
echo "<h1>{$code}</h1><p>" . htmlspecialchars($msg) . "</p>";
exit;
}
// ─── Utilidades de sesión ────────────────────────────────────────────────
public static function currentRole(): string
{
return $_SESSION['admin_user']['role'] ?? 'admin';
}
public static function currentModules(): array
{
return $_SESSION['admin_user']['modules'] ?? [];
}
/**
* Carga en sesión los permisos granulares (can_*) desde la BD.
* Se llama desde authenticateUser() después de la Migración 001.
*/
public static function loadGranularPermissions(int $roleId): array
{
try {
$db = Database::getInstance();
$rows = $db->fetchAll(
"SELECT module_slug, can_view, can_create, can_edit, can_delete, can_export, extra_json
FROM role_modules
WHERE role_id = ?",
[$roleId]
);
$perms = [];
foreach ($rows as $row) {
$perms[$row['module_slug']] = [
'can_view' => (bool) $row['can_view'],
'can_create' => (bool) $row['can_create'],
'can_edit' => (bool) $row['can_edit'],
'can_delete' => (bool) $row['can_delete'],
'can_export' => (bool) $row['can_export'],
'extra' => $row['extra_json'] ? json_decode($row['extra_json'], true) : [],
];
}
return $perms;
} catch (Exception $e) {
error_log("[Rbac] Error cargando permisos: " . $e->getMessage());
return [];
}
}
}
// ─── Funciones globales de retrocompatibilidad ──────────────────────────────
// Las vistas y APIs legacy que llaman hasModule(), isEnfermero(), etc.
// siguen funcionando sin cambios apuntando a la clase Rbac.
if (!function_exists('hasModule')) {
function hasModule(string $slug): bool { return Rbac::hasModule($slug); }
}
if (!function_exists('canDo')) {
function canDo(string $slug, string $action): bool { return Rbac::can($slug, $action); }
}
+145
View File
@@ -0,0 +1,145 @@
<?php
/**
* core/Router.php
* Enrutador central del ERP.
*
* Mapea la URL entrante a modules/{m}/views/{v}.php.
* Soporta rutas limpias (/turnero/dashboard) y parámetros explícitos (?m=&v=).
*
* RUTAS PÚBLICAS (sin autenticación):
* turnero/display → pantalla TV para sala de espera
* turnero/kiosko → pantalla táctil para tomar turno
*/
class Router
{
/** Módulo por defecto cuando no se especifica ?m= */
private const DEFAULT_MODULE = 'dashboard';
/** Vista por defecto cuando no se especifica ?v= */
private const DEFAULT_VIEW = 'index';
/** Rutas que NO requieren sesión iniciada. Formato: 'modulo/vista' */
private const PUBLIC_ROUTES = [
'turnero/display',
'turnero/kiosko',
];
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
private const SLUG_PATTERN = '/^[a-zA-Z0-9_]{1,64}$/';
private string $module;
private string $view;
// ─── Constructor ────────────────────────────────────────────────────────
public function __construct()
{
[$this->module, $this->view] = $this->resolveRoute();
}
// ─── API pública ────────────────────────────────────────────────────────
public function getModule(): string { return $this->module; }
public function getView(): string { return $this->view; }
/**
* Indica si la ruta actual es pública (no requiere login).
*/
public function isPublic(): bool
{
return in_array($this->module . '/' . $this->view, self::PUBLIC_ROUTES, true);
}
/**
* Resuelve la ruta y devuelve el archivo de vista correspondiente.
* Lanza una excepción si el archivo no existe.
*
* @return string Ruta absoluta al archivo de vista.
* @throws RuntimeException Si el módulo/vista no se encuentran.
*/
public function resolveFile(): string
{
$file = APP_ROOT . "/modules/{$this->module}/views/{$this->view}.php";
if (!file_exists($file)) {
throw new RuntimeException(
"Vista no encontrada: {$this->module}/{$this->view}",
404
);
}
return $file;
}
// ─── Internos ───────────────────────────────────────────────────────────
/**
* Determina módulo y vista a partir de la URL.
* Prioridad: PATH_INFO limpio → parámetros GET ?m= ?v=
*
* @return array{string, string}
*/
private function resolveRoute(): array
{
// 1. Intentar ruta limpia desde PATH_INFO o REQUEST_URI
// Formato: /modulo/vista → e.g. /turnero/dashboard
$path = $this->cleanPath();
if ($path !== null) {
[$m, $v] = $path;
if ($this->isValidSlug($m) && $this->isValidSlug($v)) {
return [$m, $v];
}
}
// 2. Parámetros GET ?m=module&v=view
$m = $_GET['m'] ?? self::DEFAULT_MODULE;
$v = $_GET['v'] ?? self::DEFAULT_VIEW;
// Sanitizar: solo caracteres seguros
$m = preg_replace('/[^a-zA-Z0-9_]/', '', $m);
$v = preg_replace('/[^a-zA-Z0-9_]/', '', $v);
if (!$this->isValidSlug($m)) $m = self::DEFAULT_MODULE;
if (!$this->isValidSlug($v)) $v = self::DEFAULT_VIEW;
return [$m, $v];
}
/**
* Extrae módulo y vista de la REQUEST_URI limpia.
* Retorna null si la URI no corresponde al formato /m/v.
*
* @return array{string,string}|null
*/
private function cleanPath(): ?array
{
$uri = $_SERVER['REQUEST_URI'] ?? '';
// Eliminar query string
$pos = strpos($uri, '?');
if ($pos !== false) {
$uri = substr($uri, 0, $pos);
}
// Eliminar el subdirectorio base si el proyecto no está en la raíz
$scriptDir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/');
if ($scriptDir !== '' && str_starts_with($uri, $scriptDir)) {
$uri = substr($uri, strlen($scriptDir));
}
// Normalizar y dividir
$parts = array_values(array_filter(explode('/', trim($uri, '/'))));
if (count($parts) >= 2) {
return [$parts[0], $parts[1]];
}
return null;
}
private function isValidSlug(string $slug): bool
{
return (bool) preg_match(self::SLUG_PATTERN, $slug);
}
}