Hoy vamos a unir dos cosas que ya necesitamos para un sistema real: mostrar menús según el usuario logueado y consultar datos desde JavaScript usando endpoints que responden JSON.
En las clases anteriores ya vimos login, sesiones, MVC, formularios y conexión con MySQL. Hoy vamos a conectar esas piezas para entender cómo se comporta un sistema con usuarios diferentes.
$_SESSION.fetch() para pedir datos al servidor.div con id.<div id="resultado"></div>.
Luego JavaScript consulta un endpoint, recibe JSON y pinta el HTML dentro de ese div.
En MVC no todo se mete en un solo archivo. Cada parte tiene una responsabilidad. Para este tema lo vamos a ver así:
| Parte | Qué hace | Ejemplo del tema |
|---|---|---|
| Vista | Muestra el layout, sidebar, botones, tabla vacía y contenedores. | views/dashboard.php |
| Controlador | Recibe una acción, valida sesión/permisos y decide qué responder. | controllers/BusquedaController.php |
| Modelo | Hace consultas SQL y devuelve datos. | models/BusquedaModel.php |
| JS auxiliar | Consume endpoints con fetch y renderiza HTML en la vista. | assets/js/busqueda.js |
Cuando un usuario inicia sesión, normalmente guardamos información en $_SESSION.
Por ejemplo:
$_SESSION['user_id'] = 1;
$_SESSION['user_nombre'] = 'Arturo';
$_SESSION['user_tipo'] = 'dueno_negocio';
$_SESSION['negocio_id'] = 3;
Pero para menús dinámicos necesitamos también saber qué permisos tiene. Por ejemplo:
$_SESSION['permisos'] = [
'ver_productos',
'crear_productos',
'vender',
'ver_reportes'
];
Para no estar escribiendo la misma validación en todas partes, conviene crear una función.
La podemos llamar hasPermission().
Qué parte es esto: es un helper, es decir, un archivo auxiliar. No es vista, no es modelo y no es controlador. Sirve para reutilizar funciones comunes.
<?php
// helpers/permission_helper.php
/**
* hasPermission()
*
* Revisa si el usuario actual tiene un permiso específico.
*
* Esta función lee el arreglo $_SESSION['permisos'].
* Si el permiso existe dentro del arreglo, regresa true.
* Si no existe, regresa false.
*
* Ejemplo:
* hasPermission('ver_productos')
*/
function hasPermission($permiso)
{
// Si la sesión no existe, no podemos revisar permisos.
// Regresamos false porque no hay usuario autenticado.
if (!isset($_SESSION['permisos'])) {
return false;
}
// in_array busca un valor dentro de un arreglo.
// Si el permiso está en la lista, el usuario sí puede usar esa sección.
return in_array($permiso, $_SESSION['permisos']);
}
/**
* requirePermission()
*
* Esta función sirve para proteger controladores o endpoints.
*
* Si el usuario no tiene el permiso requerido, se detiene el script.
* Para endpoints JSON, normalmente responderíamos JSON con error.
*/
function requirePermission($permiso)
{
if (!hasPermission($permiso)) {
http_response_code(403);
echo json_encode([
'ok' => false,
'msg' => 'No tienes permiso para realizar esta acción.'
]);
exit;
}
}
El sidebar es parte de la vista, porque es HTML que el usuario ve. Pero ese HTML puede tener condiciones en PHP para decidir qué opciones se muestran.
Esta vista usa:
$_SESSION['user_nombre'] para mostrar quién está logueado.hasPermission() para saber qué opciones enseñar.div id="contenidoDinamico" que después podrá llenar JavaScript.<?php
// views/dashboard.php
// La vista necesita sesión para leer los datos del usuario.
// También necesita el helper para revisar permisos.
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/../helpers/permission_helper.php';
// Este ejemplo asume que el login ya guardó datos en $_SESSION.
// Si no existe user_id, mandamos a login.
if (!isset($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
?>
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="d-flex">
<!-- SIDEBAR: esto es parte de la Vista -->
<aside class="bg-dark text-white p-3" style="width: 260px; min-height: 100vh;">
<h4 class="mb-1">Panel</h4>
<!-- Datos del usuario logueado -->
<div class="small text-white-50 mb-4">
Usuario: <?php echo $_SESSION['user_nombre']; ?><br>
Tipo: <?php echo $_SESSION['user_tipo']; ?>
</div>
<nav class="d-grid gap-2">
<!-- Esta opción solo aparece si tiene permiso ver_productos -->
<?php if (hasPermission('ver_productos')) { ?>
<a class="btn btn-outline-light text-start" href="#" data-section="productos">
Productos
</a>
<?php } ?>
<!-- Esta opción solo aparece si tiene permiso vender -->
<?php if (hasPermission('vender')) { ?>
<a class="btn btn-outline-light text-start" href="#" data-section="ventas">
Ventas
</a>
<?php } ?>
<!-- Esta opción solo aparece si tiene permiso ver_reportes -->
<?php if (hasPermission('ver_reportes')) { ?>
<a class="btn btn-outline-light text-start" href="#" data-section="reportes">
Reportes
</a>
<?php } ?>
<!-- Esta opción solo aparece si tiene permiso ver_usuarios -->
<?php if (hasPermission('ver_usuarios')) { ?>
<a class="btn btn-outline-light text-start" href="#" data-section="usuarios">
Usuarios
</a>
<?php } ?>
<a class="btn btn-danger text-start mt-3" href="/logout.php">
Cerrar sesión
</a>
</nav>
</aside>
<!-- ÁREA PRINCIPAL: también es Vista -->
<main class="flex-grow-1 p-4">
<h1 class="mb-2">Dashboard</h1>
<p class="text-muted">
Esta pantalla carga secciones usando JavaScript y endpoints JSON.
</p>
<!-- Buscador general -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<h5>Buscador general</h5>
<!-- Este input lo leerá JavaScript -->
<input
type="text"
id="txtBusqueda"
class="form-control"
placeholder="Escribe para buscar...">
<div class="form-text">
JavaScript tomará este texto, llamará un endpoint y pintará resultados abajo.
</div>
</div>
</div>
<!-- Este div se llena con JS -->
<div id="contenidoDinamico">
<div class="alert alert-info">
Selecciona una opción del menú o escribe en el buscador.
</div>
</div>
</main>
</div>
<!-- Este JS es auxiliar de la vista -->
<script src="/assets/js/dashboard.js"></script>
</body>
</html>
Un endpoint es una URL que recibe una petición y devuelve una respuesta. En este caso no vamos a devolver HTML completo, vamos a devolver JSON.
Ejemplo de URL:
/controllers/BusquedaController.php?action=buscar&q=coca
Esta URL puede servir para buscar productos, usuarios, categorías o lo que decidamos. Por eso lo explicamos como buscador general y luego se traduce a cada módulo.
<?php
// controllers/BusquedaController.php
/**
* Este archivo es un CONTROLADOR.
*
* No imprime una página completa.
* Recibe una acción por GET y responde JSON.
*
* Ejemplo:
* /controllers/BusquedaController.php?action=buscar&q=coca
*/
header('Content-Type: application/json; charset=utf-8');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Cargamos helper de permisos
require_once __DIR__ . '/../helpers/permission_helper.php';
// Cargamos el modelo que sabe consultar datos
require_once __DIR__ . '/../models/BusquedaModel.php';
// Si no hay sesión, el endpoint responde JSON con error.
// No hacemos header Location porque fetch espera JSON, no una redirección visual.
if (!isset($_SESSION['user_id'])) {
echo json_encode([
'ok' => false,
'msg' => 'Sesión no iniciada.'
]);
exit;
}
$action = $_GET['action'] ?? '';
$model = new BusquedaModel();
if ($action === 'buscar') {
// Para este ejemplo pedimos permiso de ver_productos.
// Después se puede cambiar según el módulo que se esté buscando.
requirePermission('ver_productos');
// q significa query o texto de búsqueda.
$q = trim($_GET['q'] ?? '');
if ($q === '') {
echo json_encode([
'ok' => true,
'data' => [],
'msg' => 'Sin texto de búsqueda.'
]);
exit;
}
// El negocio_id sale de sesión para que un negocio solo vea sus datos.
$negocioId = (int)($_SESSION['negocio_id'] ?? 0);
// El controlador pide los datos al modelo.
$resultado = $model->buscarGeneral($q, $negocioId);
if ($resultado['res'] !== 'ok') {
echo json_encode([
'ok' => false,
'msg' => $resultado['res']
]);
exit;
}
$data = [];
// Convertimos el resultado MySQL en arreglo normal para JSON.
while ($row = $resultado['query']->fetch_assoc()) {
$data[] = $row;
}
echo json_encode([
'ok' => true,
'data' => $data
]);
exit;
}
echo json_encode([
'ok' => false,
'msg' => 'Acción no válida.'
]);
fetch(), conviene responder JSON.
Si haces una redirección normal con header('Location'), JavaScript puede recibir HTML de login en lugar de JSON y se vuelve confuso.
El modelo es el que habla con MySQL. El controlador no debería tener consultas SQL largas metidas directamente.
Para hacerlo general, este ejemplo busca en productos, pero la idea aplica igual para usuarios, categorías, ventas o negocios.
<?php
// models/BusquedaModel.php
/**
* Este archivo es un MODELO.
*
* Su responsabilidad es hablar con la base de datos.
* No pinta HTML.
* No lee botones.
* No decide cómo se ve la pantalla.
*/
require_once __DIR__ . '/../lib/Conexion.php';
require_once __DIR__ . '/../lib/Functions.php';
class BusquedaModel
{
private $functions;
public function __construct()
{
// Reutilizamos la clase Functions que ya vimos en clase.
// Esta clase tiene el método execute($sql).
$this->functions = new Functions();
}
/**
* buscarGeneral()
*
* Recibe:
* - $q: texto que escribió el usuario
* - $negocioId: negocio actual según sesión
*
* Devuelve:
* - resultado de execute()
*
* En este ejemplo busca productos por:
* - nombre
* - código
* - descripción
*
* Luego se puede hacer otro método para buscar usuarios,
* categorías, ventas, etc.
*/
public function buscarGeneral($q, $negocioId)
{
// Para evitar errores simples con comillas,
// escapamos comillas simples de forma didáctica.
// Más adelante lo correcto sería prepared statements.
$q = str_replace("'", "''", $q);
$negocioId = (int)$negocioId;
$sql = "
SELECT
id,
codigo,
nombre,
descripcion,
precio,
existencia
FROM productos
WHERE activo = 1
AND negocio_id = $negocioId
AND (
nombre LIKE '%$q%'
OR codigo LIKE '%$q%'
OR descripcion LIKE '%$q%'
)
ORDER BY nombre ASC
LIMIT 20
";
return $this->functions->execute($sql);
}
}
Este archivo JS es auxiliar de la vista. No es el modelo, porque no consulta MySQL directamente. No es el controlador PHP, porque no está en el servidor. Su trabajo es:
fetch().contenidoDinamico.// assets/js/dashboard.js
/**
* Este archivo ayuda a la vista.
*
* La vista tiene:
* - un input con id txtBusqueda
* - un div con id contenidoDinamico
*
* JavaScript lee el input, llama al endpoint y pinta resultados.
*/
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("txtBusqueda");
const contenedor = document.getElementById("contenidoDinamico");
if (!input || !contenedor) {
return;
}
let timer = null;
input.addEventListener("keyup", () => {
clearTimeout(timer);
// Pequeña pausa para no llamar el endpoint en cada tecla inmediatamente.
timer = setTimeout(() => {
buscar(input.value);
}, 350);
});
async function buscar(texto) {
texto = texto.trim();
if (texto === "") {
contenedor.innerHTML = `
<div class="alert alert-info">
Escribe algo para buscar.
</div>
`;
return;
}
contenedor.innerHTML = `
<div class="alert alert-secondary">
Buscando...
</div>
`;
try {
// encodeURIComponent evita problemas con espacios, acentos o símbolos.
const url = "/controllers/BusquedaController.php?action=buscar&q=" + encodeURIComponent(texto);
const response = await fetch(url);
const json = await response.json();
if (!json.ok) {
contenedor.innerHTML = `
<div class="alert alert-danger">
${escapeHtml(json.msg)}
</div>
`;
return;
}
renderResultados(json.data);
} catch (error) {
contenedor.innerHTML = `
<div class="alert alert-danger">
Error al consultar el endpoint.
</div>
`;
}
}
function renderResultados(data) {
if (!data || data.length === 0) {
contenedor.innerHTML = `
<div class="alert alert-warning">
No se encontraron resultados.
</div>
`;
return;
}
let html = `
<div class="card shadow-sm">
<div class="card-body">
<h5>Resultados</h5>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Código</th>
<th>Nombre</th>
<th>Descripción</th>
<th>Precio</th>
<th>Existencia</th>
</tr>
</thead>
<tbody>
`;
data.forEach(item => {
html += `
<tr>
<td>${escapeHtml(item.codigo ?? "")}</td>
<td>${escapeHtml(item.nombre ?? "")}</td>
<td>${escapeHtml(item.descripcion ?? "")}</td>
<td>$${escapeHtml(item.precio ?? "0.00")}</td>
<td>${escapeHtml(item.existencia ?? "0")}</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
</div>
</div>
`;
contenedor.innerHTML = html;
}
/**
* escapeHtml()
*
* Evita que texto recibido del servidor se interprete como HTML peligroso.
* Es una protección básica cuando pintamos contenido usando innerHTML.
*/
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
});
Cuando el endpoint funciona bien, puede responder algo así:
{
"ok": true,
"data": [
{
"id": "1",
"codigo": "COCA600",
"nombre": "Coca Cola 600 ml",
"descripcion": "Refresco individual",
"precio": "18.00",
"existencia": "25.00"
},
{
"id": "2",
"codigo": "COCA2L",
"nombre": "Coca Cola 2 litros",
"descripcion": "Refresco familiar",
"precio": "39.00",
"existencia": "10.00"
}
]
}
Si algo falla, puede responder:
{
"ok": false,
"msg": "No tienes permiso para realizar esta acción."
}
Hay dos formas comunes de pintar información.
while que pinta una tabla.
En un sistema real no basta con ocultar opciones del menú. También hay que proteger el controlador.
Porque un usuario podría intentar entrar directo al endpoint escribiendo la URL. Por eso usamos validación doble:
// En la vista:
<?php if (hasPermission('ver_productos')) { ?>
<a href="#">Productos</a>
<?php } ?>
// En el controlador:
requirePermission('ver_productos');
fetch() en este ejemplo.