- Layout.php inyecta --brand y --brand-dark desde doc_color de BD - Sidebar usa el mismo gradiente azul del login (brand-dark → brand) - Círculos decorativos animados igual que el login - Items activos: fondo blanco con texto brand (inverso al fondo) - Hover: overlay blanco sutil + translateX(3px) - Ancho unificado en --sidebar-width (270px) - Breakpoint responsive consistente en 991.98px Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
191 lines
6.8 KiB
PHP
191 lines
6.8 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>
|
|
|
|
<!-- 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
|
|
}
|
|
}
|