Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/jpbarbatic/webapp/llms.txt

Use this file to discover all available pages before exploring further.

El módulo de Productos es el núcleo funcional del panel de administración. Permite gestionar el catálogo completo: crear, editar y eliminar registros, filtrar y paginar el listado, asociar imágenes a cada producto mediante un proceso con transacciones de base de datos, y exportar el catálogo filtrado a un documento PDF generado con la librería mPDF.

Modelo de datos

La tabla productos almacena todos los campos del catálogo. La clave foránea id_categoria se define con ON DELETE SET NULL, de modo que al borrar una categoría los productos asociados quedan sin categoría en lugar de eliminarse.
ColumnaTipoNotas
idint AUTO_INCREMENTClave primaria
nombrevarchar(30)Requerido
descripcionvarchar(500)Requerido
preciodecimal(10,2)Puede ser NULL
stockintPuede ser NULL
id_categoriaintFK → categorias.id · ON DELETE SET NULL
num_fotosintPor defecto 0; se actualiza con transacción
CREATE TABLE `productos` (
  `id`           int(11)        NOT NULL AUTO_INCREMENT,
  `nombre`       varchar(30)    NOT NULL,
  `descripcion`  varchar(500)   NOT NULL,
  `precio`       decimal(10,2)  DEFAULT NULL,
  `stock`        int(11)        DEFAULT NULL,
  `id_categoria` int(11)        DEFAULT NULL,
  `num_fotos`    int(11)        DEFAULT 0,
  PRIMARY KEY (`id`),
  KEY `id_categoria` (`id_categoria`),
  CONSTRAINT `productos_ibfk_1`
    FOREIGN KEY (`id_categoria`) REFERENCES `categorias` (`id`)
    ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Rutas del módulo

URLMétodoArchivoDescripción
productos/GETpublic/productos/index.phpListado con filtros, paginación y ordenación
productos/nuevo.phpGETpublic/productos/nuevo.phpFormulario de alta de un nuevo producto
productos/guardar.phpPOSTpublic/productos/guardar.phpCrea o actualiza el registro (insert/update)
productos/editar.php?id=NGETpublic/productos/editar.phpCarga el formulario con los datos del producto
productos/borrar.php?id=NGETpublic/productos/borrar.phpElimina el registro y redirige al listado
productos/subir_fotos.phpPOSTpublic/productos/subir_fotos.phpSube imágenes con transacción atómica
productos/exportar.phpGETpublic/productos/exportar.phpGenera y descarga el catálogo en PDF

Listado con filtros

El controlador public/productos/index.php construye dinámicamente la consulta SQL en función de los parámetros de filtro recibidos por GET. Primero invoca paginacion() (de includes/utilidades.php) para obtener la página actual, el $offset, el campo de ordenación y su dirección, y después añade cláusulas WHERE opcionales:
<?php
$db = require_once('../../includes/backend.php');
require_once('../../includes/utilidades.php');

extract(paginacion());

$params = [];
$sql = 'SELECT * FROM productos WHERE TRUE';

// Filtro por nombre o por ID exacto con la sintaxis id:N
if (isset($_GET['filtro']['nombre'])) {
    if (preg_match('/id:?(\d+)/', $_GET['filtro']['nombre'], $coincidencias)) {
        $sql .= ' AND id = ?';
        $params[] = $coincidencias[1];
    } else {
        $sql .= ' AND nombre LIKE ?';
        $params[] = '%' . $_GET['filtro']['nombre'] . '%';
    }
}

// Filtro por categoría
if (isset($_GET['filtro']['categoria']) and !empty($_GET['filtro']['categoria'])) {
    $sql .= ' AND id_categoria=?';
    $params[] = $_GET['filtro']['categoria'];
}

$res = db_select($db, $sql, $params, $items_pagina, $offset, $orden, $orden_dir);
extract($res);

$categorias    = db_query($db, 'SELECT * FROM categorias ORDER BY nombre');
$categoriasPorId = array_column($categorias, 'nombre', 'id');

$titulo = 'Productos';
$vista  = 'productos/listado';
require('../../html/plantilla.html.php');
La función db_select recibe la consulta base, los parámetros vinculados, el tamaño de página, el desplazamiento y los criterios de ordenación. Devuelve un array asociativo con las claves datos (registros de la página actual) y total (total de filas sin paginar), que se extraen con extract($res). La vista de filtro (html/productos/filtro.html.php) se muestra como un panel colapsable de Bootstrap. Cuando el usuario ya ha aplicado un filtro ($_GET['filtro'] existe), el panel aparece expandido automáticamente:
<div class="collapse<?= isset($_GET['filtro']) ? ' show' : '' ?> mt-3" id="collapseExample">
    <form action="">
        <input class="form-control form-control-sm"
               name="filtro[nombre]"
               value="<?= isset($_GET['filtro']['nombre']) ? $_GET['filtro']['nombre'] : '' ?>">
        <select class="form-select form-select-sm" name="filtro[categoria]">
            <option value=''>--Elige una categoría--</option>
            <?= html_opciones($categorias,
                isset($_GET['filtro']['categoria']) ? $_GET['filtro']['categoria'] : '',
                'id', 'nombre') ?>
        </select>
        <input class="btn btn-primary btn-sm" type="submit" value="Buscar">
    </form>
</div>
La paginación se renderiza con html/paginacion.html.php, que calcula el número total de páginas con ceil($total / $items_pagina) y genera los enlaces de navegación utilizando la variable $vista para construir las URLs correctas.
Atajo de búsqueda por ID: si escribes id:42 (o id42) en el campo “Nombre/Id” del filtro, la expresión regular /id:?(\d+)/ lo detecta y transforma la consulta a WHERE id = 42 en lugar de hacer una búsqueda LIKE. Esto permite localizar un producto concreto de forma inmediata sin escribir su nombre exacto. Este atajo sólo está disponible en el listado; el exportador a PDF usa únicamente el filtro por LIKE sobre el nombre.

Crear / Editar producto

El flujo de alta y edición comparte el mismo formulario HTML y el mismo controlador de guardado.
1

Abrir el formulario de alta

nuevo.php carga las categorías disponibles con db_query y pasa $titulo = 'Nuevo producto' y $vista = 'productos/nuevo' a la plantilla. Como $producto no está definido, el formulario aparece vacío.
$categorias = db_query($db, 'SELECT * FROM categorias');
$titulo = 'Nuevo producto';
$vista  = 'productos/nuevo';
require('../../html/plantilla.html.php');
2

Renderizar el formulario compartido

La vista html/productos/formulario.html.php se usa tanto para alta como para edición. Los campos leen $producto si existe, o muestran valores por defecto en caso contrario. El <select> de categoría usa html_opciones() para pre-seleccionar la categoría actual:
<form action="productos/guardar.php" method="post">
    <input readonly name="id"
           value="<?= isset($producto) ? $producto['id'] : '' ?>">
    <input required name="nombre"
           value="<?= isset($producto) ? $producto['nombre'] : '' ?>">
    <textarea name="descripcion">
        <?= isset($producto) ? $producto['descripcion'] : '' ?>
    </textarea>
    <select name="id_categoria">
        <option value="">-- Elija una categoría --</option>
        <?= html_opciones($categorias, $producto['id_categoria'], 'id', 'nombre') ?>
    </select>
    <input name="precio" value="<?= isset($producto) ? $producto['precio'] : '0.00' ?>">
    <input name="stock"  value="<?= isset($producto) ? $producto['stock']  : 0 ?>">
    <input type="submit" value="Guardar">
</form>
3

Abrir el formulario de edición

editar.php recupera el registro existente con db_get_by_id y lo pasa a la misma plantilla de formulario:
$id      = $_REQUEST['id'];
$producto  = db_get_by_id($db, 'productos', $id);
$categorias = db_query($db, 'SELECT * FROM categorias');
$titulo = 'Editar producto';
$vista  = 'productos/editar';
require('../../html/plantilla.html.php');
4

Persistir los datos con guardar.php

guardar.php decide entre insertar o actualizar según la presencia del campo id en la petición. Tras guardar, redirige siempre a la vista de edición del registro resultante:
if (empty($_REQUEST['id'])) {
    // Alta: devuelve el nuevo id autoincremental
    $id = db_insert($db, 'productos', $_REQUEST);
    $_SESSION['mensaje']['ok'] = 'Registro guardado correctamente';
} else {
    // Edición
    $id = $_REQUEST['id'];
    db_update($db, 'productos', $_REQUEST);
    $_SESSION['mensaje']['ok'] = 'Registro guardado correctamente';
}

header('Location: editar.php?id=' . $id);
El mensaje de confirmación se almacena en sesión y la plantilla lo muestra en la siguiente petición.

Subida de fotos

El endpoint public/productos/subir_fotos.php acepta múltiples archivos a través del campo fotos[]. Cada imagen se procesa de forma atómica: primero se abre una transacción de base de datos, se incrementa num_fotos en el registro del producto y solo si la copia física del archivo a disco tiene éxito se confirma la transacción; en caso contrario se hace rollback.
$id = $_POST['id'];

for ($i = 0; $i < count($_FILES['fotos']['name']); $i++) {
    if (!$_FILES['fotos']['error'][$i]) {

        db_begin($db);
        $producto = db_get_by_id($db, 'productos', $id);
        $producto['num_fotos']++;
        db_update($db, 'productos', $producto);

        $path = __DIR__ . '/../imagenes/productos/' . $id;
        if (!is_dir($path)) {
            mkdir($path);
        }

        if (copy($_FILES['fotos']['tmp_name'][$i], $path . '/' . $producto['num_fotos'] . '.jpg')) {
            db_commit($db);
        } else {
            db_rollback($db);
        }
    }
}
Los puntos clave de este proceso son:
  • Directorio por producto: las imágenes se guardan en public/imagenes/productos/{id}/, creando el directorio si no existe.
  • Nombre de archivo secuencial: cada foto recibe como nombre el valor actualizado de num_fotos (p. ej. 1.jpg, 2.jpg, …).
  • Transacción atómica: el contador num_fotos y el archivo en disco se actualizan de forma consistente. Si la copia falla, el contador no queda incrementado en la base de datos.
Solo se procesan los archivos cuyo campo error valga 0 (es decir, carga sin errores según PHP). Los archivos con cualquier otro código de error (UPLOAD_ERR_SIZE, UPLOAD_ERR_NO_FILE, etc.) se omiten silenciosamente. En la versión actual del código la redirección al finalizar está comentada; el script termina sin redirigir al navegador.

Exportación PDF

El controlador public/productos/exportar.php construye la misma consulta SQL que el listado (con los mismos filtros de nombre por LIKE y categoría recibidos por GET) y la pasa directamente a la función generar_pdf_db() de includes/pdf.php, sin realizar paginación. A diferencia del listado, el exportador no implementa el atajo id:N; el filtro de nombre siempre aplica LIKE.
require(__DIR__ . '/../../includes/pdf.php');

$params = [];
$sql = 'SELECT * FROM productos WHERE TRUE';

if (isset($_GET['filtro']['nombre'])) {
    $sql .= ' AND nombre LIKE ?';
    $params[] = '%' . $_GET['filtro']['nombre'] . '%';
}

if (isset($_GET['filtro']['categoria']) and !empty($_GET['filtro']['categoria'])) {
    $sql .= ' AND id_categoria=?';
    $params[] = $_GET['filtro']['categoria'];
}

$orden     = isset($_GET['orden'])     ? $_GET['orden']     : 'id';
$orden_dir = isset($_GET['orden_dir']) ? $_GET['orden_dir'] : 'asc';

generar_pdf_db(
    $db,
    $sql,
    $params,
    $orden,
    $orden_dir,
    __DIR__ . '/../../html/productos/listado.pdf.php'
);
La función generar_pdf_db de includes/pdf.php instancia mPDF en modo UTF-8, evalúa la plantilla listado.pdf.php capturando su salida con ob_start()/ob_get_clean() y entrega el PDF al navegador con $mpdf->Output():
function generar_pdf_db($db, $sql, $params, $orden, $orden_dir, $plantilla)
{
    require(__DIR__ . '/../vendor/autoload.php');

    $mpdf = new \Mpdf\Mpdf([
        'mode'   => 'utf-8',
        'debug'  => true,
        'logDir' => '/tmp/mpdf_logs',
    ]);

    ob_start();
    include $plantilla;
    $html = ob_get_clean();

    $mpdf->WriteHTML($html);
    $mpdf->Output();
    $mpdf->cleanup();
}
La plantilla html/productos/listado.pdf.php itera los productos en lotes de 1000 registros usando llamadas sucesivas a db_select hasta agotar el total, generando una tabla HTML con estilos inline. El pie de página con numeración se define mediante la regla CSS @page { @bottom-center { content: "Página " counter(page) " de " counter(pages); } }, compatible con mPDF:
<?php $num_registros = 0; $max_registros = 1000;
$res = db_select($db, $sql, $params, $max_registros, 0, $orden, $orden_dir);
extract($res);
while (true): $productos = $datos; ?>
    <?php foreach ($productos as $producto): ?>
        <tr>
            <td class="id"><?= $producto['id'] ?></td>
            <td><?= $producto['nombre'] ?></td>
            <td class="numero"><?= $producto['stock'] ?></td>
            <td class="numero"><?= $producto['precio'] ?></td>
        </tr>
    <?php endforeach; ?>
    <?php
    $num_registros += count($productos);
    if ($num_registros < $total) {
        $res = db_select($db, $sql, $params, $max_registros, $num_registros, $orden, $orden_dir);
        extract($res);
    } else {
        break;
    }
    ?>
<?php endwhile; ?>
El botón PDF del listado HTML pasa el QUERY_STRING actual al exportador, de modo que el PDF respeta los filtros activos en ese momento:
<a href="productos/exportar.php<?= $_SERVER['QUERY_STRING'] ?? '' ?>">PDF</a>

Borrado

El controlador public/productos/borrar.php recibe el id por GET, llama a db_delete_by_id y redirige al listado con un mensaje de confirmación en sesión:
if (db_delete_by_id($db, 'productos', $_REQUEST['id'])) {
    $_SESSION['mensaje']['ok'] = 'Registro borrado correctamente';
}
header('Location: .');
El botón de eliminar en el listado no envía la petición directamente. En su lugar, abre el modal de confirmación de Bootstrap (#deleteModal) pasando el id del registro como atributo data-id:
<button type="button" class="btn btn-danger btn-sm"
        data-bs-toggle="modal"
        data-bs-target="#deleteModal"
        data-id="<?= (int)$producto['id'] ?>">
    <i class="bi bi-trash"></i> Eliminar
</button>
El modal (html/confirmacion.borrado.html.php) contiene un formulario POST hacia productos/borrar.php con campos ocultos para el id y un campo csrf_token cuyo valor se lee de $_SESSION['csrf_token']. El servidor (borrar.php) no valida ese token en la versión actual del código; la protección CSRF es sólo del lado cliente (el campo se incluye en el formulario pero no se comprueba en el controlador).
<div class="modal fade" id="deleteModal">
  <form id="deleteForm" method="POST" action="productos/borrar.php">
    <input type="hidden" name="id" id="modalRecordId">
    <input type="hidden" name="csrf_token"
           value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
    <button type="submit" id="btnConfirmDelete">Sí, eliminar</button>
  </form>
</div>
deleteModal.addEventListener('show.bs.modal', (event) => {
    const button = event.relatedTarget;
    modalRecordId.value = button.getAttribute('data-id');
});

deleteForm.addEventListener('submit', () => {
    btnConfirm.disabled = true;
    btnSpinner.classList.remove('d-none');
});

Build docs developers (and LLMs) love