Añadir páginas en el portal de cliente

Cómo añadir una página propia al Portal de Cliente desde tu plugin

Esta guía explica, paso a paso, cómo un plugin externo (por ejemplo MiPlugin) puede añadir una página nueva al portal de cliente (plugin PortalCliente) sin modificar su código fuente, usando dos mecanismos complementarios del core:

  1. Controladores anidados (Controller/PortalCliente/) — para añadir una página/ficha

propia, con su propia URL, que solo se despliega si PortalCliente está activo.

  1. Extensiones del controlador PortalCliente — para añadir una pestaña dentro del propio

panel principal del portal (https://tudominio.com/PortalCliente).

Ambos mecanismos se pueden combinar. Es exactamente el patrón que usaron los plugins Servicios y Proyectos antes de integrarse directamente en el core de PortalCliente.

Requiere FacturaScripts 2026.65 o superior (commit a4f412e1b), que es cuando Core/Internal/PluginsDeploy.php empezó a desplegar controladores anidados por carpeta.


1. El mecanismo de controladores anidados

Cuando se hace el deploy de los plugins (Core/Internal/PluginsDeploy.php), dentro de la carpeta Controller/ de cada plugin no se despliegan subcarpetas normales (se ignoran), con una única excepción: si el nombre de la subcarpeta coincide con el nombre de otro plugin activado, su contenido PHP (un único nivel, no más subcarpetas) se aplana dentro de Dinamic/Controller/, igual que si fuera un controlador normal del plugin.

Es decir:

Plugins/MiPlugin/Controller/PortalCliente/MiControlador.php

Se aplana en tiempo de deploy a:

Dinamic/Controller/MiControlador.php

solo si el plugin PortalCliente está activo. Si PortalCliente está desactivado, ese archivo se ignora por completo y el controlador no existe.

Esto significa que tu plugin puede depender opcionalmente de PortalCliente sin necesidad de declararlo como dependencia obligatoria: si el usuario no tiene instalado el portal, tu controlador simplemente no se genera y no se producen errores.

1.1. Crea el controlador anidado

Crea el archivo con esta ruta exacta dentro de tu propio plugin:

Plugins/MiPlugin/Controller/PortalCliente/PortalMiSeccion.php

<?php
/**
 * Copyright (C) 2026 Tu Nombre <tu@email.com>
 */

namespace FacturaScripts\Plugins\MiPlugin\Controller\PortalCliente;

use FacturaScripts\Core\Tools;
use FacturaScripts\Dinamic\Lib\PortalViewController;

/**
 * Ficha de MiModelo en el portal del cliente.
 *
 * Se accede por la url amigable PortalMiSeccion/{código} y solo se despliega si el plugin
 * PortalCliente está activo (ver Init.php).
 *
 * @author Tu Nombre <tu@email.com>
 */
class PortalMiSeccion extends PortalViewController
{
    public function getModelClassName(): string
    {
        return 'MiModelo';
    }

    public function getPageData(): array
    {
        $data = parent::getPageData();
        $data['menu'] = 'PortalCliente';
        $data['title'] = 'mi-seccion';
        $data['icon'] = 'fa-solid fa-star';
        return $data;
    }

    public function portalMenuActive(): string
    {
        return 'PortalMiSeccion';
    }

    protected function createViews()
    {
        $model = $this->preloadModel();
        if (false === $model->exists()) {
            $this->error404();
            return;
        }

        // aquí comprueba los permisos del contacto sobre $model,
        // igual que hace PortalServicio::setContactPermissions()

        parent::createViews();

        $this->addHtmlView('info', 'Tab/PortalInfoMiSeccion', 'MiModelo', 'info', 'fa-solid fa-info-circle');
    }

    protected function getComposeUrlColumn(): string
    {
        // columna por la que se busca el registro en la url amigable
        // (normalmente un identificador público tipo uuid, no la clave primaria)
        return 'pc_uuid';
    }
}

Puntos clave:

El namespace debe ser FacturaScripts\Plugins\MiPlugin\Controller\PortalCliente (tu plugin real + Controller\PortalCliente), nunca Plugins\PortalCliente\.... La clase debe extender una de las clases base que ofrece PortalCliente: FacturaScripts\Dinamic\Lib\PortalViewController — para una ficha de un único registro (equivalente en el portal a un EditController). Es la que usan PortalFactura, PortalAlbaran, PortalServicio, PortalProyecto. FacturaScripts\Dinamic\Lib\PortalPanelController — para una página con varias pestañas propias. FacturaScripts\Dinamic\Lib\PortalController — para una página sin modelo (por ejemplo un formulario o una landing propia dentro del portal). Solo se admite un nivel de subcarpeta (Controller/PortalCliente/Archivo.php); no metas más subcarpetas dentro, se ignorarían. Usa siempre el namespace Dinamic\... para las clases del core (PortalViewController, Tools, modelos, etc.), nunca el namespace fijo del plugin de origen — es la convención habitual de FacturaScripts para que las extensiones de terceros también apliquen. 1.2. Registra la ruta amigable, el menú y el buscador en tu Init.php Todo debe quedar condicionado a que PortalCliente esté activo, con FacturaScripts\Core\Plugins::isEnabled('PortalCliente'):

<?php

namespace FacturaScripts\Plugins\MiPlugin;

use FacturaScripts\Core\Kernel;
use FacturaScripts\Core\Plugins;
use FacturaScripts\Core\Template\InitClass;
use FacturaScripts\Dinamic\Lib\PortalMenu;
use FacturaScripts\Dinamic\Lib\PortalSearch;
use FacturaScripts\Dinamic\Model\Contacto;

class Init extends InitClass
{
    public function init(): void
    {
        if (Plugins::isEnabled('PortalCliente')) {
            // ruta amigable: PortalMiSeccion/{uuid}
            Kernel::addRoutes(function () {
                Kernel::addRoute('/PortalMiSeccion/*', 'PortalMiSeccion');
            });

            // entrada en el menú lateral del portal
            PortalMenu::add(
                'PortalMiSeccion',              // nombre: el mismo que portalMenuActive() y, si
                                                 // añades una ListView con este id, la pestaña
                'mi-seccion',                   // clave de traducción del título (o callable)
                'fa-solid fa-star',              // icono FontAwesome
                150,                             // orden dentro del menú
                function (Contacto $contact) {
                    // condición para que el contacto vea esta sección
                    return (bool)$contact->pc_allow_show_mi_seccion;
                }
            );

            // sección del buscador global del portal (opcional)
            PortalSearch::add('PortalMiSeccion', [
                'model' => 'MiModelo',
                'fields' => ['nombre', 'descripcion'],
                'order_by' => ['fecha' => 'DESC'],
                'filter' => function (Contacto $contact) {
                    return empty($contact->codcliente) ? [] : [
                        \FacturaScripts\Core\Where::eq('codcliente', $contact->codcliente),
                    ];
                },
                'map' => function ($registro) {
                    return [
                        'title' => $registro->nombre,
                        'subtitle' => \FacturaScripts\Core\Tools::date($registro->fecha),
                        'url' => $registro->url('public'),
                    ];
                },
            ]);
        }

        // resto de tu init()...
    }

    public function uninstall(): void {}

    public function update(): void
    {
        // fuerza a que se regeneren los controladores anidados y sus páginas
        \FacturaScripts\Core\Plugins::deploy(true, true);
        \FacturaScripts\Core\Cache::clear();
    }
}

Puntos clave:

PortalMenu::add() y PortalSearch::add() viven en Plugins/PortalCliente/Lib/, así que solo existen (namespace Dinamic\Lib\...) cuando PortalCliente está activo — de ahí que todo el bloque vaya dentro del if (Plugins::isEnabled('PortalCliente')). El nombre que le pases a PortalMenu::add($name, ...) debe coincidir con lo que devuelva portalMenuActive() en tu controlador, para que el menú marque la sección activa. Tras instalar o actualizar el plugin, update() debe forzar Plugins::deploy(true, true) seguido de Cache::clear(); si no, el controlador anidado no se aplanará hasta el siguiente deploy. Si tu plugin puede activarse/desactivarse independientemente de PortalCliente, recuerda que desactivar tu plugin (o PortalCliente) y volver a desplegar retira automáticamente el controlador, la entrada de menú y la ruta — no hace falta limpieza manual. 1.3. Colisión de nombres Si dos plugins distintos declaran un archivo con el mismo nombre dentro de Controller/PortalCliente/ (por ejemplo ambos PortalFicha.php), el deploy solo aplana el primero que encuentra y descarta el resto en silencio. Usa siempre un nombre de controlador prefijado con el nombre de tu plugin o de tu modelo (PortalMiSeccion, no PortalFicha) para evitar colisiones con otros plugins de terceros.

  1. Añadir una pestaña dentro del panel principal (PortalCliente)

Si en lugar de una página/ficha aparte quieres que tu contenido aparezca como una pestaña más dentro del panel principal (/PortalCliente, junto a "Facturas", "Pedidos", etc.), necesitas extender el controlador PortalCliente con una extensión, ya que no puedes tocar su código fuente.

El controlador PortalCliente (Plugins/PortalCliente/Controller/PortalCliente.php) es un PortalPanelController, y su ciclo de vida (commonCore()) dispara los siguientes ganchos pipe(), que cualquier extensión puede engancharse:

createViews — para añadir tu pestaña con $this->addListView(...) / addHtmlView(...) / addEditView(...). loadData($viewName, $view) — para cargar los datos de tu pestaña. execPreviousAction($action) / execAfterAction($action) — para atender acciones propias. 2.1. Crea la extensión

Plugins/MiPlugin/Extension/Controller/PortalCliente.php

<?php
/**
 * Copyright (C) 2026 Tu Nombre <tu@email.com>
 */

namespace FacturaScripts\Plugins\MiPlugin\Extension\Controller;

use Closure;

/**
 * Extensión del controlador PortalCliente que añade la pestaña "Mi sección".
 *
 * @author Tu Nombre <tu@email.com>
 */
class PortalCliente
{
    public function createViews(): Closure
    {
        return function () {
            $this->addListView('ListMiSeccion', 'MiModelo', 'mi-seccion', 'fa-solid fa-star');
        };
    }

    public function loadData(): Closure
    {
        return function ($viewName, $view) {
            if ($viewName !== 'ListMiSeccion') {
                return;
            }

            $view->loadData('', [
                \FacturaScripts\Core\Where::eq('codcliente', $this->contact->codcliente),
            ]);
        };
    }
}

Puntos clave:

Cada método debe devolver un Closure, nunca ejecutar la lógica directamente: el core invoca el closure más adelante, enlazado (bindTo) al propio controlador PortalCliente, así que dentro del closure $this es la instancia real del controlador y tienes acceso a $this->contact, $this->addListView(), $this->views, etc. — incluso a métodos protected. El nombre del método (createViews, loadData, ...) debe coincidir exactamente con el nombre del gancho pipe() que quieras interceptar. Puedes registrar tantos métodos como ganchos quieras enganchar en la misma clase de extensión. 2.2. Regístrala en tu Init.php

public function init(): void
{
    if (\FacturaScripts\Core\Plugins::isEnabled('PortalCliente')) {
        $this->loadExtension(new Extension\Controller\PortalCliente());
    }

    // resto de tu init()...
}

loadExtension() (heredado de InitClass) deduce la clase objetivo a partir del namespace de la extensión: MiPlugin\Extension\Controller\PortalCliente se aplica sobre FacturaScripts\Dinamic\Controller\PortalCliente. Como en el punto 1, todo debe quedar condicionado a que PortalCliente esté activo.

2.3. Que la pestaña aparezca también en el menú lateral (opcional) Si además quieres que "Mi sección" tenga su propia entrada en el menú lateral del portal (que se convierte automáticamente en pestaña dentro de PortalCliente, ver PortalCliente::getPortalMenu()), regístrala igual que en el punto 1.2 con PortalMenu::add('ListMiSeccion', 'mi-seccion', 'fa-solid fa-star', 150, $callablePermiso) — usando el mismo nombre (ListMiSeccion) que la vista que has creado en createViews().

  1. Resumen del flujo completo

Tu plugin crea Controller/PortalCliente/PortalMiSeccion.php, con namespace MiPlugin\Controller\PortalCliente, extendiendo PortalViewController (ficha propia) o añade Extension/Controller/PortalCliente.php (pestaña dentro del panel principal), o ambas cosas. En Init.php::init(), dentro de if (Plugins::isEnabled('PortalCliente')) { ... }: registra la ruta con Kernel::addRoute(), la entrada de menú con PortalMenu::add(), la sección del buscador con PortalSearch::add() y/o la extensión con $this->loadExtension(). En Init.php::update(), llama a Plugins::deploy(true, true) y Cache::clear() para que el deploy regenere los controladores anidados según qué plugins estén activos en ese momento. En el deploy, Core/Internal/PluginsDeploy.php aplana Controller/PortalCliente/PortalMiSeccion.php en Dinamic/Controller/PortalMiSeccion.php solo si PortalCliente está activo, generando una subclase con use ExtensionsTrait. PluginsDeploy::initControllers() detecta el nuevo controlador, instancia getPageData() y crea/actualiza su Page en el menú PortalCliente. En cada petición, PortalPanelController::commonCore() dispara pipe('createViews'), pipe('loadData', ...), etc.; si registraste una extensión sobre PortalCliente, tu closure se ejecuta ahí y añade tu pestaña reutilizando el propio controlador anfitrión.

  1. Referencias del código fuente (por si necesitas profundizar)

Core/Internal/PluginsDeploy.php — método linkFiles() (líneas ~193-244): lógica de aplanado de Controller/<carpeta>/*.php solo si <carpeta> es un plugin activado; método linkPHPFile() (líneas ~246-290): generación de la subclase en Dinamic/Controller. Core/Template/ExtensionsTrait.php — mecanismo genérico de extensiones (addExtension, pipe, pipeFalse, __call). Core/Template/InitClass.php — método loadExtension() (líneas ~73-149): resuelve la clase objetivo a partir del namespace de la extensión. Plugins/PortalCliente/Lib/PortalController.php — controlador base de todas las páginas del portal (login del contacto, plantilla, menú lateral en modo enlace). Plugins/PortalCliente/Lib/PortalPanelController.php — controlador base con pestañas (commonCore(), ganchos pipe(), addListView/addHtmlView/addEditView). Plugins/PortalCliente/Lib/PortalViewController.php — controlador base de fichas de un único registro (getComposeUrlColumn(), preloadModel()). Plugins/PortalCliente/Lib/PortalMenu.php y PortalSearch.php — registro del menú lateral y del buscador global del portal. Plugins/PortalCliente/Controller/PortalServicio.php y PortalProyecto.php — ejemplos reales de fichas del portal que dependen de otro plugin (Servicios/Proyectos); aunque hoy viven integradas dentro de PortalCliente, son la referencia más completa de cómo implementar un PortalViewController propio con pestañas, permisos e impresión en PDF. Plugins/PortalCliente/Init.php — método addPortalMenu() y loadRoutes(): patrón exacto de registro condicional (Plugins::isEnabled('Servicios') / 'Proyectos') que debes replicar con Plugins::isEnabled('PortalCliente') desde tu propio plugin.

Cookies
Usamos cookies necesarias para el funcionamiento del sitio y cookies opcionales para recordar tus preferencias y mejorar tu experiencia. Puedes aceptarlas todas, rechazarlas o configurar tus preferencias

Soporte