Files
whatsapp/core/Layout.php
T

194 lines
6.9 KiB
PHP

<?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;
// Color de marca desde BD
$brandColor = '#1565c0';
try {
$row = Database::getInstance()->fetch("SELECT valor FROM lab_config WHERE clave = 'doc_color'");
if ($row && preg_match('/^#[0-9a-fA-F]{3,8}$/', $row['valor'])) {
$brandColor = $row['valor'];
}
} catch (\Throwable $_) {}
// Derivar color oscuro para gradiente
$hex = ltrim($brandColor, '#');
$brandDark = sprintf('#%02x%02x%02x',
max(0, hexdec(substr($hex,0,2)) - 40),
max(0, hexdec(substr($hex,2,2)) - 40),
max(0, hexdec(substr($hex,4,2)) - 40)
);
// 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=13" rel="stylesheet">
<style>
:root {
--brand: <?= htmlspecialchars($brandColor, ENT_QUOTES, 'UTF-8') ?>;
--brand-dark: <?= htmlspecialchars($brandDark, ENT_QUOTES, 'UTF-8') ?>;
--sidebar-width: 270px;
}
body { overflow-x: hidden; }
.main-content {
margin-left: var(--sidebar-width);
min-height: 100vh;
background: #f8f9fa;
}
@media (max-width: 991.98px) {
.main-content { margin-left: 0; }
}
#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>
<!-- Sidebar toggle responsivo (móvil/tablet) -->
<script src="<?= $base ?>/assets/js/lab-sidebar.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
}
}