diff --git a/Core/Component/ActionResult.php b/Core/Component/ActionResult.php new file mode 100644 index 0000000000..8da26583b8 --- /dev/null +++ b/Core/Component/ActionResult.php @@ -0,0 +1,68 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +/** + * Valor de retorno para los manejadores de eventos de componentes (p. ej. 'save' o 'delete'). + * + * Cuando exit es true el controlador detiene el renderizado y, o bien redirige (si redirect + * está definido), o bien suprime la plantilla por completo. withRedirect() establece ambos + * campos en una sola llamada. Los manejadores que solo necesitan registrar un aviso y + * permanecer en la misma página deben devolver ActionResult::make() sin ningún encadenamiento. + * + * @author Abderrahim Darghal Belkacemi + */ +class ActionResult +{ + public bool $exit = false; + public bool $stop = false; + public string $redirect = ''; + public string $message = ''; + + public static function make(): static + { + return new static(); + } + + public function exit(): static + { + $this->exit = true; + return $this; + } + + public function stop(): static + { + $this->stop = true; + return $this; + } + + public function withRedirect(string $url): static + { + $this->redirect = $url; + $this->exit = true; + return $this; + } + + public function withMessage(string $message): static + { + $this->message = $message; + return $this; + } +} diff --git a/Core/Component/BaseComponent.php b/Core/Component/BaseComponent.php new file mode 100644 index 0000000000..3cfdb50260 --- /dev/null +++ b/Core/Component/BaseComponent.php @@ -0,0 +1,246 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +use FacturaScripts\Core\Request; +use FacturaScripts\Core\Tools; + +/** + * Contrato mínimo de un componente de interfaz. + * + * Responsabilidades exclusivas de esta clase: + * - Identidad: expone el fieldname que lo identifica dentro de un formulario. + * - Valor: almacena y recupera el valor actual del componente. + * - Resolución: extrae el valor de la petición HTTP o del modelo, con soporte + * para un resolver personalizado que cortocircuita la lógica por defecto. + * - Validación: motor de reglas con nombre ('email', 'numeric', 'max:N'…) y + * closures arbitrarios con firma fn(mixed $valor, Translator $lang): ?string. + * - Procesamiento de petición: recibe el POST, valida y escribe en el modelo. + * + * Todo lo relacionado con presentación visual (etiqueta, columnas, icono, + * renderizado HTML) vive en FieldComponent, que extiende esta clase. + * + * @author Abderrahim Darghal Belkacemi + */ +abstract class BaseComponent +{ + protected string $fieldname; + protected mixed $value = null; + + protected array $validationErrors = []; + protected array $validationRules = []; + protected array $customRules = []; + + /** @var callable|null Resolver personalizado: fn(Request, ?object): mixed */ + protected $resolver = null; + + public function __construct(string $fieldname) + { + $this->fieldname = $fieldname; + $this->addDefaultRules(); + } + + /** Crea una instancia con API fluida. */ + public static function make(string $fieldname): static + { + return new static($fieldname); + } + + public function fieldname(): string + { + return $this->fieldname; + } + + public function setValue(mixed $value): static + { + $this->value = $value; + return $this; + } + + public function value(): mixed + { + return $this->value; + } + + /** + * Asigna un resolver personalizado. + * + * El callable recibe (Request, ?object) y devuelve el valor extraído. + * Cuando está presente, sustituye completamente la lógica por defecto. + */ + public function setResolver(callable $fn): static + { + $this->resolver = $fn; + return $this; + } + + /** + * Extrae el valor del componente de la petición o del modelo. + * + * Orden: resolver personalizado → cuerpo POST → propiedad del modelo → null. + */ + public function resolve(Request $request, ?object $model = null): mixed + { + if ($this->resolver !== null) { + return ($this->resolver)($request, $model); + } + + return $request->request->get($this->fieldname) + ?? ($model !== null && property_exists($model, $this->fieldname) + ? $model->{$this->fieldname} + : null); + } + + /** + * Añade una regla con nombre o un closure validador. + * + * Reglas con nombre disponibles: 'email', 'numeric', 'max:N', 'min:N', + * 'min_val:N', 'max_val:N'. + * + * Firma del closure: fn(mixed $valor, Translator $lang): ?string + * Devuelve null para pasar, o un string con el mensaje de error para fallar. + */ + public function addRule(string|callable $rule, mixed ...$params): static + { + if (is_callable($rule)) { + $this->customRules[] = $rule; + } else { + $this->validationRules[] = ['rule' => $rule, 'params' => $params]; + } + return $this; + } + + /** + * Ejecuta todas las reglas sobre el valor dado y devuelve los mensajes de error. + * + * Devuelve un array vacío si el valor es válido. + */ + public function validate(mixed $value): array + { + $errors = []; + $lang = Tools::lang(); + + if ($this->isRequired() && ($value === null || $value === '')) { + $errors[] = $lang->trans('field-required', ['%field%' => $this->fieldname]); + } + + foreach ($this->validationRules as $item) { + $error = $this->applyRule($item['rule'], $value, $item['params']); + if ($error !== null) { + $errors[] = $error; + } + } + + foreach ($this->customRules as $fn) { + $error = $fn($value, $lang); + if ($error !== null) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * Inyecta los errores de validación desde el controlador al componente + * para que el renderizador pueda mostrar el feedback en línea. + */ + public function setValidationErrors(array $errors): static + { + $this->validationErrors = $errors; + return $this; + } + + public function validationErrors(): array + { + return $this->validationErrors; + } + + public function hasValidationErrors(): bool + { + return !empty($this->validationErrors); + } + + /** + * Extrae el valor del POST, lo valida y lo escribe en el modelo si no hay errores. + * + * Devuelve ['success' => bool, 'errors' => string[], 'value' => mixed]. + */ + public function processRequest(Request $request, ?object $model = null): array + { + $value = $this->resolve($request, $model); + $errors = $this->validate($value); + + if (empty($errors) && $model !== null) { + $model->{$this->fieldname} = $value; + } + + return ['success' => empty($errors), 'errors' => $errors, 'value' => $value]; + } + + /** Sobreescribe para registrar reglas por defecto al construir el componente. */ + protected function addDefaultRules(): void + { + } + + /** + * Indica si el campo es obligatorio. + * + * La implementación base devuelve false. FieldComponent sobreescribe este + * método con la propiedad $required que el usuario configura con setRequired(). + */ + protected function isRequired(): bool + { + return false; + } + + private function applyRule(string $rule, mixed $value, array $params): ?string + { + $colonPos = strpos($rule, ':'); + $ruleName = $colonPos !== false ? substr($rule, 0, $colonPos) : $rule; + $ruleParam = $colonPos !== false ? substr($rule, $colonPos + 1) : ($params[0] ?? ''); + + if ($value === null || $value === '') { + return null; + } + + return match ($ruleName) { + 'max' => mb_strlen((string) $value) > (int) $ruleParam + ? Tools::lang()->trans('value-too-long', ['%field%' => $this->fieldname, '%max%' => $ruleParam]) + : null, + 'min' => mb_strlen((string) $value) < (int) $ruleParam + ? Tools::lang()->trans('value-too-short', ['%field%' => $this->fieldname, '%min%' => $ruleParam]) + : null, + 'numeric' => !is_numeric($value) + ? Tools::lang()->trans('value-must-be-numeric', ['%field%' => $this->fieldname]) + : null, + 'email' => !filter_var($value, FILTER_VALIDATE_EMAIL) + ? Tools::lang()->trans('invalid-email') + : null, + 'min_val' => (float) $value < (float) $ruleParam + ? Tools::lang()->trans('value-too-low', ['%field%' => $this->fieldname, '%min%' => $ruleParam]) + : null, + 'max_val' => (float) $value > (float) $ruleParam + ? Tools::lang()->trans('value-too-high', ['%field%' => $this->fieldname, '%max%' => $ruleParam]) + : null, + default => null, + }; + } +} diff --git a/Core/Component/ComponentBlock.php b/Core/Component/ComponentBlock.php new file mode 100644 index 0000000000..62aa4a5dee --- /dev/null +++ b/Core/Component/ComponentBlock.php @@ -0,0 +1,157 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +use FacturaScripts\Core\Request; + +/** + * Contenedor con nombre que agrupa componentes en una pestaña dentro de un PanelController. + * + * Añade un bloque a cualquier controlador que use el trait HasComponentBlocks y luego + * adjunta componentes a él. El bloque se encarga de poblar los valores de los componentes + * desde un modelo en peticiones GET, y de procesar la petición en lote (con propagación + * de errores en línea) en peticiones POST. + * + * El array público $settings expone 'active' (si la pestaña es visible) y 'card' + * (si el contenido se envuelve en una card de Bootstrap) para la capa de plantillas Twig. + * + * @author Abderrahim Darghal Belkacemi + */ +class ComponentBlock +{ + /** @var FieldComponent[] */ + private array $components = []; + + /** @var array */ + private array $errors = []; + + private string $icon; + private string $name; + + /** @var array{active: bool, card: bool} */ + public array $settings = ['active' => true, 'card' => true]; + + private string $title; + + public function __construct(string $name, string $title, string $icon = 'fa-solid fa-puzzle-piece') + { + $this->name = $name; + $this->title = $title; + $this->icon = $icon; + } + + public static function make(string $name, string $title, string $icon = 'fa-solid fa-puzzle-piece'): static + { + return new static($name, $title, $icon); + } + + public function addComponent(FieldComponent $component): FieldComponent + { + $fieldname = $component->fieldname(); + + if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $fieldname)) { + throw new \InvalidArgumentException( + "Invalid component fieldname '{$fieldname}': must start with a letter or underscore and contain only alphanumeric characters and underscores." + ); + } + + if (isset($this->components[$fieldname])) { + throw new \LogicException( + "Duplicate component fieldname '{$fieldname}': a component with this name is already registered." + ); + } + + $this->components[$fieldname] = $component; + return $component; + } + + public function component(string $fieldname): ?FieldComponent + { + return $this->components[$fieldname] ?? null; + } + + public function components(): array + { + return $this->components; + } + + public function removeComponent(string $fieldname): void + { + unset($this->components[$fieldname]); + } + + public function populate(?object $model): void + { + if ($model === null) { + return; + } + + foreach ($this->components as $fieldname => $component) { + if (property_exists($model, $fieldname)) { + $component->setValue($model->{$fieldname}); + } + } + } + + public function process(Request $request, ?object $model = null): bool + { + $this->errors = []; + + foreach ($this->components as $fieldname => $component) { + $result = $component->processRequest($request, $model); + if (!$result['success']) { + $this->errors[$fieldname] = $result['errors']; + $component->setValidationErrors($result['errors']); + } + } + + return empty($this->errors); + } + + public function errors(): array + { + return $this->errors; + } + + public function errorsFor(string $fieldname): array + { + return $this->errors[$fieldname] ?? []; + } + + public function hasErrors(): bool + { + return !empty($this->errors); + } + + public function icon(): string + { + return $this->icon; + } + + public function name(): string + { + return $this->name; + } + + public function title(): string + { + return $this->title; + } +} diff --git a/Core/Component/ComponentCheckbox.php b/Core/Component/ComponentCheckbox.php new file mode 100644 index 0000000000..17b4d7e2d6 --- /dev/null +++ b/Core/Component/ComponentCheckbox.php @@ -0,0 +1,159 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +use FacturaScripts\Core\Request; +use FacturaScripts\Core\Tools; + +/** + * Campo booleano renderizado como checkbox estándar de Bootstrap. + * + * Los checkboxes HTML no se envían cuando están desmarcados, por lo que este + * componente interpreta la ausencia de la clave en el POST como false y su + * presencia con value="TRUE" como true, igual que WidgetCheckbox. En modo solo + * lectura el valor se preserva mediante un input oculto. renderEdit() está + * completamente sobreescrito — inputHtml() no se utiliza. + * + * @author Abderrahim Darghal Belkacemi + */ +class ComponentCheckbox extends FieldComponent +{ + public function processRequest(Request $request, ?object $model = null): array + { + if ($this->isReadOnly()) { + // input oculto lleva 'TRUE' o 'FALSE' + $raw = $request->request->get($this->fieldname); + $value = ($raw === 'TRUE'); + } else { + // checkbox: presente en POST con value="TRUE" → true, ausente → false + $value = $request->request->get($this->fieldname) === 'TRUE'; + } + + if ($model !== null) { + $model->{$this->fieldname} = $value; + } + + return ['success' => true, 'errors' => [], 'value' => $value]; + } + + public function schema(): array + { + return [ + 'type' => 'checkbox', + 'field' => $this->fieldname, + 'label' => $this->label, + 'required' => $this->required, + 'readonly' => $this->readonly, + 'cols' => $this->cols, + ]; + } + + public function renderEdit(mixed $value = null): string + { + if ($value !== null) { + $this->value = $value; + } + + $id = 'checkbox_' . $this->fieldname; + $checked = $this->value ? ' checked=""' : ''; + $readonly = $this->isReadOnly() ? ' onclick="return false;"' : ''; + $tabindex = $this->tabindex >= 0 ? ' tabindex="' . $this->tabindex . '"' : ''; + $class = $this->inputCssClass('form-check-input'); + + $hidden = $this->isReadOnly() + ? '' + : ''; + + $desc = $this->description + ? '
' . htmlspecialchars($this->description) . '
' + : ''; + + return '
' + . $hidden + . '' + . '' + . $desc + . '
'; + } + + protected string $cellAlign = 'center'; + + public function renderCell(mixed $value = null): string + { + if ($value !== null) { + $this->value = $value; + } + + if ($this->value === null) { + return '-'; + } + + $colorClass = $this->value ? ' text-success' : ' text-danger'; + return '' + . htmlspecialchars($this->displayValue()) + . ''; + } + + public function renderReadOnly(mixed $value = null): string + { + if ($value !== null) { + $this->value = $value; + } + + $icon = $this->value + ? '' + : ''; + + return '
' + . '' + . '

' . $icon . '

' + . '
'; + } + + public function renderHidden(): string + { + return ''; + } + + public function colClass(): string + { + return $this->cols <= 0 ? 'col-sm-auto' : parent::colClass(); + } + + protected function templateDir(): string + { + return 'checkbox'; + } + + protected function inputHtml(): string + { + return ''; // renderEdit is fully overridden + } + + protected function displayValue(): string + { + if ($this->value === null) { + return '-'; + } + return $this->value ? Tools::lang()->trans('yes') : Tools::lang()->trans('no'); + } +} diff --git a/Core/Component/ComponentDate.php b/Core/Component/ComponentDate.php new file mode 100644 index 0000000000..16592cf2b5 --- /dev/null +++ b/Core/Component/ComponentDate.php @@ -0,0 +1,101 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +use FacturaScripts\Core\Tools; + +/** + * Input de fecha (y opcionalmente fecha+hora) nativo del navegador. + * + * Genera por defecto. Con setDatetime(true) genera + * . El valor se formatea automáticamente al + * formato que requiere cada tipo (Y-m-d / Y-m-d\TH:i). + * + * Para campos de solo lectura, displayValue() usa Tools::date() y + * Tools::dateTime() para mostrar la fecha en el formato local configurado. + * + * @author Abderrahim Darghal Belkacemi + */ +class ComponentDate extends FieldComponent +{ + private bool $isDatetime = false; + + public function setDatetime(bool $datetime = true): static + { + $this->isDatetime = $datetime; + return $this; + } + + public function schema(): array + { + return [ + 'type' => $this->isDatetime ? 'datetime' : 'date', + 'field' => $this->fieldname, + 'label' => $this->label, + 'required' => $this->required, + 'readonly' => $this->readonly, + 'cols' => $this->cols, + ]; + } + + protected function templateDir(): string + { + return 'date'; + } + + protected function inputHtml(): string + { + $type = $this->isDatetime ? 'datetime-local' : 'date'; + + return 'inputExtraParams() + . '/>'; + } + + protected function displayValue(): string + { + if ($this->value === null || $this->value === '') { + return '-'; + } + + return $this->isDatetime + ? Tools::dateTime((string) $this->value) + : Tools::date((string) $this->value); + } + + private function formatValueForInput(): string + { + if (empty($this->value)) { + return ''; + } + + $ts = strtotime((string) $this->value); + if ($ts === false) { + return (string) $this->value; + } + + return $this->isDatetime + ? date('Y-m-d\TH:i', $ts) + : date('Y-m-d', $ts); + } +} diff --git a/Core/Component/ComponentModalPicker.php b/Core/Component/ComponentModalPicker.php new file mode 100644 index 0000000000..c9b2b0a87e --- /dev/null +++ b/Core/Component/ComponentModalPicker.php @@ -0,0 +1,365 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +use FacturaScripts\Core\Request; +use FacturaScripts\Core\Tools; + +/** + * Selector modal genérico con búsqueda AJAX. + * + * Captura el patrón compartido por todos los widgets de selección de + * FacturaScripts (WidgetSubcuenta, WidgetVariante, WidgetLibrary…): + * + * - Un que almacena el valor seleccionado. + * - Un botón Bootstrap que abre un modal. + * - Un modal con filtros y tabla/cuadrícula de resultados. + * - Búsqueda AJAX: el JS envía action=widget-XXX-search al servidor; + * la respuesta JSON se dibuja en la tabla via widgetXxxDraw(). + * - En modo solo-lectura muestra un a la ficha del registro. + * + * Las subclases implementan los métodos abstractos que definen: + * - La acción AJAX y el prefijo de las funciones JS globales. + * - El icono, las opciones de ordenación y los filtros extra del modal. + * - La tabla inicial de resultados y el botón "Nuevo". + * - La lógica de búsqueda que genera el JSON AJAX. + * - La URL de la ficha en modo solo-lectura. + * + * @author Abderrahim Darghal Belkacemi + */ +abstract class ComponentModalPicker extends FieldComponent +{ + /** Contador estático para generar IDs únicos sin usar Date.now() ni rand(). */ + private static int $instanceCount = 0; + + /** ID único de esta instancia; usado como base de todos los IDs del DOM. */ + private string $widgetId; + + public function __construct(string $fieldname) + { + parent::__construct($fieldname); + self::$instanceCount++; + $this->widgetId = 'picker_' . $fieldname . '_' . self::$instanceCount; + } + + /** + * Nombre de la acción AJAX que el JS envía en el parámetro 'action'. + * Ejemplo: 'widget-subcuenta-search', 'widget-variante-search'. + */ + abstract protected function widgetActionName(): string; + + /** + * Prefijo de las funciones JS globales del widget. + * Ejemplo: 'widgetSubaccount' → llama a widgetSubaccountSearch(), widgetSubaccountSelect()… + */ + abstract protected function jsFunctionPrefix(): string; + + /** Clase CSS de FontAwesome para el icono del botón del modal. */ + abstract protected function defaultIcon(): string; + + /** + * Realiza la búsqueda y devuelve el cuerpo JSON que se enviará como respuesta AJAX. + * + * Recibe la Request completa para leer query, sort y cualquier filtro extra. + */ + abstract protected function jsonSearch(Request $request): string; + + /** + * Opciones del select de ordenación: ['valor' => 'clave-de-traducción', …]. + * + * El primer elemento se marcará como `selected`. + */ + abstract protected function sortOptions(): array; + + /** + * Tabla (o cuadrícula) de resultados inicial del modal. + * + * Debe incluir un (o equivalente) donde el + * JS inyecta las filas tras cada búsqueda AJAX. + */ + abstract protected function renderResultList(): string; + + /** + * Botón "Nuevo" del pie del modal. + * + * Típicamente un . + */ + abstract protected function renderNewBtn(): string; + + /** + * URL de la ficha del registro actualmente seleccionado. + * + * Se usa en el que se muestra cuando el campo está en readonly. + * Devuelve '#' si no hay valor o el modelo no existe. + */ + abstract protected function readOnlyUrl(): string; + + // registerAssets() se hereda de FieldComponent (no-op) y cada subclase + // concreta la sobreescribe para registrar su propio JS en AssetManager. + + /** + * Texto que se muestra en el span del botón para el valor actual. + * + * La implementación base devuelve el valor tal cual (útil cuando el valor + * almacenado ya es un código legible). La subclase puede sobreescribir para + * mostrar, por ejemplo, la descripción en lugar del código. + */ + protected function displayLabel(): string + { + return (string) ($this->value ?? ''); + } + + public function widgetId(): string + { + return $this->widgetId; + } + + /** + * Responde a la acción AJAX si coincide con widgetActionName() y col_name. + * + * UIController::dispatchWidgetAction() itera todos los componentes y llama + * a este método; el primer componente que devuelve un string no nulo gana. + */ + public function handleWidgetAction(string $action, Request $request): ?string + { + if ($action !== $this->widgetActionName()) { + return null; + } + + if ($request->request->get('col_name') !== $this->fieldname) { + return null; + } + + return $this->jsonSearch($request); + } + + /** + * Renderiza el selector completo: input hidden + etiqueta + botón + modal. + * + * Sobreescribe renderEdit() de FieldComponent porque la estructura HTML + * del picker es completamente distinta a la de un input de texto estándar. + */ + public function renderEdit(mixed $value = null): string + { + if ($value !== null) { + $this->value = $value; + } + + $id = $this->widgetId; + $icon = $this->defaultIcon(); + + $labelText = htmlspecialchars($this->label); + $labelInner = $this->labelUrl + ? '' . $labelText . '' + : $labelText; + $label = ''; + + $safeValue = htmlspecialchars((string) ($this->value ?? '')); + $displayText = ($this->value !== null && $this->value !== '') + ? htmlspecialchars($this->displayLabel()) + : Tools::lang()->trans('select'); + + $hidden = ''; + $errors = $this->renderInlineErrors(); + + if ($this->isReadOnly()) { + $btnClass = $this->hasValidationErrors() ? 'btn btn-outline-danger' : 'btn btn-outline-secondary'; + $btn = '' + . ' ' . $displayText + . ''; + return '
' + . $hidden . $label . $btn . $errors + . '
'; + } + + $btnClass = $this->hasValidationErrors() ? 'btn btn-outline-danger' : 'btn btn-outline-secondary'; + $btn = '' + . ' ' + . '' . $displayText . '' + . ''; + + return '
' + . $hidden . $label . $btn . $errors + . '
' + . $this->renderModal($icon, $labelText); + } + + public function renderHidden(): string + { + return ''; + } + + /** + * Envoltorio Bootstrap del modal; delega el contenido en renderModalBody() + * y renderModalFooter(). + */ + protected function renderModal(string $icon, string $label): string + { + $id = $this->widgetId; + return ''; + } + + /** + * Cuerpo del modal: fila de filtros + lista de resultados. + * + * La fila de filtros incluye el buscador de texto (siempre), los filtros + * extra específicos de cada widget (vía renderExtraFilters()) y el + * selector de ordenación (siempre). + */ + protected function renderModalBody(): string + { + return '' + . $this->renderResultList(); + } + + /** + * Filtros adicionales específicos del widget (ejercicio, fabricante, familia…). + * + * Implementación base vacía. La subclase sobreescribe para añadir sus propios + * filtros envueltos en
. + */ + protected function renderExtraFilters(): string + { + return ''; + } + + /** + * Pie del modal: botón "Nuevo" + botón "Ninguno" (si no es obligatorio). + */ + protected function renderModalFooter(): string + { + return ''; + } + + /** + * Input de búsqueda de texto libre con botón de lupa. + * + * Las funciones JS se construyen a partir de jsFunctionPrefix(): + * widgetSubaccountSearchKp, widgetSubaccountSearch, etc. + */ + protected function renderQueryFilter(): string + { + $id = $this->widgetId; + $prefix = $this->jsFunctionPrefix(); + return '
' + . '' + . '' + . '
'; + } + + /** + * Select de ordenación construido a partir de sortOptions(). + * + * El onChange llama a widgetXxxSearch(id) vía jsFunctionPrefix(). + */ + protected function renderSortFilter(): string + { + $id = $this->widgetId; + $prefix = $this->jsFunctionPrefix(); + $options = ''; + $first = true; + foreach ($this->sortOptions() as $val => $transKey) { + $selected = $first ? ' selected' : ''; + $options .= ''; + $first = false; + } + return ''; + } + + /** + * Botón "Ninguno" para desseleccionar el valor actual. + * + * Solo se renderiza si el campo no es obligatorio. + */ + protected function renderNoneBtn(): string + { + if ($this->required) { + return ''; + } + $id = $this->widgetId; + $prefix = $this->jsFunctionPrefix(); + return ''; + } + + public function schema(): array + { + return [ + 'type' => 'modal-picker', + 'field' => $this->fieldname, + 'label' => $this->label, + 'required' => $this->required, + 'readonly' => $this->readonly, + 'cols' => $this->cols, + 'action' => $this->widgetActionName(), + ]; + } + + /** No se usa: renderEdit() controla el HTML completo. */ + protected function inputHtml(): string + { + return ''; + } + + protected function templateDir(): string + { + return 'modal-picker'; + } +} diff --git a/Core/Component/ComponentModelPicker.php b/Core/Component/ComponentModelPicker.php new file mode 100644 index 0000000000..3ac9e42ade --- /dev/null +++ b/Core/Component/ComponentModelPicker.php @@ -0,0 +1,322 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +use FacturaScripts\Core\Lib\AssetManager; +use FacturaScripts\Core\Request; +use FacturaScripts\Core\Tools; +use FacturaScripts\Core\Where; + +/** + * Selector modal genérico para cualquier modelo de FacturaScripts. + * + * No contiene lógica de dominio: el modelo, las columnas, los campos de búsqueda, + * el icono y las opciones de ordenación se configuran mediante setters. + * + * Uso mínimo: + * ComponentModelPicker::make('codsubcuenta') + * ->setModel(\FacturaScripts\Dinamic\Model\Subcuenta::class) + * ->setMatch('codsubcuenta') + * ->setIcon('fa-solid fa-book') + * ->setColumns(['codsubcuenta' => 'subaccount', 'descripcion' => 'description']) + * ->setSearchFields('codsubcuenta|descripcion') + * + * Opciones de ordenación (formato completo con ORDER BY): + * ->setSortOptions([ + * 'cod-asc' => ['sort-by-code-asc', ['codsubcuenta' => 'ASC']], + * 'cod-desc' => ['sort-by-code-desc', ['codsubcuenta' => 'DESC']], + * ]) + * + * Filtros extra (p. ej. filtro de ejercicio para subcuentas): + * ->setExtraFilters(function(string $widgetId, string $jsPrefix): string { ... }) + * + * @author Abderrahim Darghal Belkacemi + */ +class ComponentModelPicker extends ComponentModalPicker +{ + private string $modelClass = ''; + private string $match = 'id'; + + /** @var array fieldname → trans-key */ + private array $columns = []; + + /** Campos de búsqueda en formato pipe: 'field1|field2'. */ + private string $searchFields = ''; + + private string $pickerIcon = 'fa-solid fa-list'; + + /** + * Opciones de ordenación: ['sort-value' => ['trans-key', ['field' => 'ASC|DESC']]]. + * @var array}> + */ + private array $customSortOptions = []; + + /** @var callable|null fn(string $widgetId, string $jsPrefix): string */ + private $extraFiltersRenderer = null; + + /** URL del botón "Nuevo" del modal. Si está vacía, el botón no se renderiza. */ + private string $newUrl = ''; + + /** @var callable|null fn(Request): Where[] — condiciones adicionales para el AJAX. */ + private $extraWhereCallback = null; + + /** Clase del modelo a buscar (FQCN). */ + public function setModel(string $modelClass): static + { + $this->modelClass = $modelClass; + return $this; + } + + /** Campo del modelo cuyo valor se guarda al seleccionar. */ + public function setMatch(string $match): static + { + $this->match = $match; + return $this; + } + + /** + * Columnas a mostrar en la tabla del modal. + * + * @param array $columns ['fieldname' => 'trans-key'] + */ + public function setColumns(array $columns): static + { + $this->columns = $columns; + return $this; + } + + /** Campos de búsqueda en formato pipe de FacturaScripts: 'field1|field2'. */ + public function setSearchFields(string $fields): static + { + $this->searchFields = $fields; + return $this; + } + + /** Clase CSS de FontAwesome para el icono del botón y del modal. */ + public function setIcon(string $icon): static + { + $this->pickerIcon = $icon; + return $this; + } + + /** + * Opciones del selector de ordenación con su ORDER BY asociado. + * + * @param array}> $options + */ + public function setSortOptions(array $options): static + { + $this->customSortOptions = $options; + return $this; + } + + /** + * Callback para renderizar filtros extra en el modal (p. ej. filtro de ejercicio). + * + * Firma: fn(string $widgetId, string $jsPrefix): string + * El resultado debe incluir uno o más
. + */ + public function setExtraFilters(callable $fn): static + { + $this->extraFiltersRenderer = $fn; + return $this; + } + + /** URL del botón "Nuevo" del pie del modal. */ + public function setNewUrl(string $url): static + { + $this->newUrl = $url; + return $this; + } + + /** + * Callback para añadir condiciones Where al buscar en el AJAX. + * + * Firma: fn(Request $request): array — debe devolver un array de Where. + * Útil para filtros dependientes del dominio (p. ej. codejercicio en subcuentas). + */ + public function setExtraWhere(callable $fn): static + { + $this->extraWhereCallback = $fn; + return $this; + } + + protected function widgetActionName(): string + { + return 'widget-model-picker'; + } + + protected function jsFunctionPrefix(): string + { + return 'widgetModelPicker'; + } + + protected function defaultIcon(): string + { + return $this->pickerIcon; + } + + protected function jsonSearch(Request $request): string + { + $query = $request->request->get('query', ''); + $sort = $request->request->get('sort', ''); + + $model = new $this->modelClass(); + $where = []; + $list = []; + + if (!empty($this->value) && $model->loadWhere([Where::eq($this->match, $this->value)])) { + $list[] = clone $model; + $where[] = Where::notEq($model->primaryColumn(), $model->id()); + } + + if ($query && $this->searchFields) { + $where[] = Where::like($this->searchFields, $query); + } + + if ($this->extraWhereCallback !== null) { + array_push($where, ...($this->extraWhereCallback)($request)); + } + + $data = array_map([$this, 'itemToArray'], $list); + foreach ($model->all($where, $this->resolveOrderBy($sort), 0, 50) as $item) { + $data[] = $this->itemToArray($item); + } + + return json_encode($data); + } + + protected function sortOptions(): array + { + $result = []; + foreach ($this->customSortOptions as $value => [$transKey]) { + $result[$value] = $transKey; + } + return $result; + } + + protected function renderExtraFilters(): string + { + if ($this->extraFiltersRenderer !== null) { + return ($this->extraFiltersRenderer)($this->widgetId(), $this->jsFunctionPrefix()); + } + return ''; + } + + protected function renderResultList(): string + { + $id = $this->widgetId(); + $lang = Tools::lang(); + + $headers = ''; + foreach ($this->columns as $transKey) { + $headers .= '' . $lang->trans($transKey) . ''; + } + + $model = new $this->modelClass(); + $rows = ''; + foreach ($model->all([], [], 0, 50) as $item) { + $rows .= $this->renderRow($item); + } + + $colKeys = htmlspecialchars(json_encode(array_keys($this->columns))); + + return '
' + . '' + . '' . $headers . '' + . '' + . $rows + . '' + . '
' + . '
'; + } + + protected function renderNewBtn(): string + { + if (empty($this->newUrl)) { + return ''; + } + + return '' + . ' ' . Tools::lang()->trans('new') + . ''; + } + + protected function readOnlyUrl(): string + { + $model = new $this->modelClass(); + if (!empty($this->value)) { + $model->loadWhere([Where::eq($this->match, $this->value)]); + } + return method_exists($model, 'url') ? $model->url() : '#'; + } + + public function registerAssets(): void + { + $route = Tools::config('route'); + AssetManager::addJs($route . '/Core/Assets/JS/ComponentModelPicker.js?v=' . Tools::date()); + } + + private function itemToArray(object $item): array + { + $row = [ + '_match' => (string) ($item->{$this->match} ?? ''), + '_url' => method_exists($item, 'url') ? $item->url() : '#', + ]; + foreach (array_keys($this->columns) as $field) { + $row[$field] = $item->{$field} ?? null; + } + return $row; + } + + private function renderRow(object $item): string + { + $id = $this->widgetId(); + $matchVal = htmlspecialchars((string) ($item->{$this->match} ?? '')); + $url = htmlspecialchars(method_exists($item, 'url') ? $item->url() : '#'); + + $cells = '' + . '' + . '' + . ''; + + foreach (array_keys($this->columns) as $field) { + $cells .= '' . htmlspecialchars((string) ($item->{$field} ?? '')) . ''; + } + + return '' + . $cells + . ''; + } + + private function resolveOrderBy(string $sort): array + { + if (isset($this->customSortOptions[$sort][1])) { + return $this->customSortOptions[$sort][1]; + } + + $firstField = array_key_first($this->columns) ?? 'id'; + return [$firstField => 'ASC']; + } +} diff --git a/Core/Component/ComponentNumber.php b/Core/Component/ComponentNumber.php new file mode 100644 index 0000000000..797570a1c4 --- /dev/null +++ b/Core/Component/ComponentNumber.php @@ -0,0 +1,169 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +use FacturaScripts\Core\Request; +use FacturaScripts\Core\Tools; + +/** + * Input numérico con precisión decimal, límites mínimo/máximo y paso configurables. + * + * Registra automáticamente la regla 'numeric' en addDefaultRules(). processRequest() + * siempre convierte el valor a float, de modo que el modelo recibe un número en lugar + * de una cadena sin procesar. El formateo decimal se delega a Tools::number(). + * + * @author Abderrahim Darghal Belkacemi + */ +class ComponentNumber extends FieldComponent +{ + protected int $decimal; + protected string $max = ''; + protected string $min = ''; + protected bool $showTotals = false; + protected string $step = 'any'; + protected string $cellAlign = 'end'; + + public function __construct(string $fieldname) + { + parent::__construct($fieldname); + $this->decimal = (int) FS_NF0; + } + + public function setDecimals(int $decimal): static + { + $this->decimal = $decimal; + return $this; + } + + public function setMax(float|int|string $max): static + { + $this->max = (string) $max; + return $this; + } + + public function setMin(float|int|string $min): static + { + $this->min = (string) $min; + return $this; + } + + public function setStep(float|int|string $step): static + { + $this->step = (string) $step; + return $this; + } + + public function setShowTotals(bool $show = true): static + { + $this->showTotals = $show; + return $this; + } + + public function decimal(): int + { + return $this->decimal; + } + + public function max(): string + { + return $this->max; + } + + public function min(): string + { + return $this->min; + } + + public function step(): string + { + return $this->step; + } + + public function showTotals(): bool + { + return $this->showTotals; + } + + public function processRequest(Request $request, ?object $model = null): array + { + $value = (float) $request->request->get($this->fieldname, 0); + $errors = $this->validate($value); + + if (empty($errors) && $model !== null) { + $model->{$this->fieldname} = $value; + } + + return ['success' => empty($errors), 'errors' => $errors, 'value' => $value]; + } + + public function schema(): array + { + return [ + 'type' => 'number', + 'field' => $this->fieldname, + 'label' => $this->label, + 'description' => $this->description, + 'required' => $this->required, + 'readonly' => $this->readonly, + 'cols' => $this->cols, + 'decimal' => $this->decimal, + 'min' => $this->min, + 'max' => $this->max, + 'step' => $this->step, + 'showTotals' => $this->showTotals, + 'validations' => $this->validationRules, + ]; + } + + protected function templateDir(): string + { + return 'number'; + } + + protected function addDefaultRules(): void + { + $this->addRule('numeric'); + } + + protected function inputHtml(): string + { + $class = $this->inputCssClass('form-control'); + $min = $this->min !== '' ? ' min="' . $this->min . '"' : ''; + $max = $this->max !== '' ? ' max="' . $this->max . '"' : ''; + + return 'inputExtraParams() + . '/>'; + } + + protected function displayValue(): string + { + if ($this->value === null) { + return '-'; + } + + return Tools::number((float) $this->value, $this->decimal); + } +} diff --git a/Core/Component/ComponentSelect.php b/Core/Component/ComponentSelect.php new file mode 100644 index 0000000000..07b7870474 --- /dev/null +++ b/Core/Component/ComponentSelect.php @@ -0,0 +1,350 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +use FacturaScripts\Core\Lib\AssetManager; +use FacturaScripts\Core\Model\CodeModel; +use FacturaScripts\Core\Request; +use FacturaScripts\Core\Tools; + +/** + * Selector simple o múltiple potenciado por select2. + * + * Las opciones pueden proporcionarse de forma estática mediante la familia de métodos + * setValues*(), o de forma perezosa a través de setOptionsResolver() (un callable + * evaluado en tiempo de renderizado). Para selectores con carga AJAX, usa setSource() + * para configurar los atributos data-* que lee WidgetSelect.js al consultar el servidor. + * + * La selección múltiple serializa los valores elegidos como una cadena separada por + * comas en el campo del modelo. El estado solo lectura se comunica mediante un input + * oculto para que el valor se envíe igualmente cuando el '; + } else { + $nameAttr = $this->multiple + ? ' name="' . $this->fieldname . '[]"' + : ' name="' . $this->fieldname . '"'; + } + + $html = $hiddenInput . 'multiple ? ' multiple' : '') + . ($this->isReadOnly() ? ' disabled' : '') + . ($this->required ? ' required=""' : '') + . '>'; + + $allValues = $this->values(); + $found = false; + + foreach ($allValues as $option) { + $optValue = $option['value'] ?? ''; + $optTitle = $option['title'] ?? $optValue; + $group = $option['group'] ?? ''; + + $selected = $this->valuesMatch($optValue, $this->value) && (!$found || $this->multiple); + if ($selected) { + $found = true; + } + + $html .= ''; + } + + // Value not found in the pre-loaded list — fall back to a DB lookup (mirrors WidgetSelect behaviour). + if (!$this->multiple && !$found && $this->value !== null && $this->value !== '' && !empty($this->source)) { + $codeModel = new CodeModel(); + $description = $codeModel->getDescription($this->source, $this->fieldcode, $this->value, $this->fieldtitle); + $html .= ''; + } + + $html .= ''; + + return $html; + } + + protected function displayValue(): string + { + if ($this->value === null) { + return '-'; + } + + foreach ($this->values() as $option) { + if ($this->valuesMatch($option['value'] ?? '', $this->value)) { + return (string) ($option['title'] ?? $this->value); + } + } + + if (!empty($this->source)) { + $codeModel = new CodeModel(); + $description = $codeModel->getDescription($this->source, $this->fieldcode, $this->value, $this->fieldtitle); + if ($description !== '') { + return $description; + } + } + + return (string) $this->value; + } + + private function applyTranslations(): void + { + foreach ($this->values as $key => $value) { + if (!empty($value['title']) && $value['title'] !== '------') { + $this->values[$key]['title'] = Tools::lang()->trans($value['title']); + } + } + } + + private function valuesMatch(mixed $a, mixed $b): bool + { + if (is_bool($a)) { + $a = $a ? '1' : '0'; + } + if (is_bool($b)) { + $b = $b ? '1' : '0'; + } + + return (string) $a === (string) $b; + } +} diff --git a/Core/Component/ComponentText.php b/Core/Component/ComponentText.php new file mode 100644 index 0000000000..10952abc29 --- /dev/null +++ b/Core/Component/ComponentText.php @@ -0,0 +1,97 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +/** + * Input de texto de una sola línea con icono, placeholder y longitud máxima opcionales. + * + * Renderiza un envuelto en un input-group cuando se define un icono. + * Los errores de validación en línea se muestran mediante la clase CSS is-invalid y un + * div invalid-feedback inyectado por inputCssClass() y renderInlineErrors(). + * + * @author Abderrahim Darghal Belkacemi + */ +class ComponentText extends FieldComponent +{ + protected int $maxlength = 0; + protected string $placeholder = ''; + + public function setMaxLength(int $max): static + { + $this->maxlength = $max; + return $this; + } + + public function setPlaceholder(string $placeholder): static + { + $this->placeholder = $placeholder; + return $this; + } + + public function maxlength(): int + { + return $this->maxlength; + } + + public function placeholder(): string + { + return $this->placeholder; + } + + public function schema(): array + { + return [ + 'type' => 'text', + 'field' => $this->fieldname, + 'label' => $this->label, + 'description' => $this->description, + 'required' => $this->required, + 'readonly' => $this->readonly, + 'cols' => $this->cols, + 'maxlength' => $this->maxlength, + 'placeholder' => $this->placeholder, + 'icon' => $this->icon, + 'validations' => $this->validationRules, + ]; + } + + protected function templateDir(): string + { + return 'text'; + } + + protected function inputHtml(): string + { + $class = $this->inputCssClass('form-control'); + $maxlength = $this->maxlength > 0 ? ' maxlength="' . $this->maxlength . '"' : ''; + $placeholder = $this->placeholder + ? ' placeholder="' . htmlspecialchars($this->placeholder) . '"' + : ''; + + return 'inputExtraParams() + . '/>'; + } +} diff --git a/Core/Component/ComponentTextarea.php b/Core/Component/ComponentTextarea.php new file mode 100644 index 0000000000..b556e213fe --- /dev/null +++ b/Core/Component/ComponentTextarea.php @@ -0,0 +1,94 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +/** + * Área de texto multilínea. + * + * Los valores mostrados en renderCell y renderReadOnly se truncan a 80 caracteres + * con puntos suspensivos para mantener las tablas legibles. El valor completo + * siempre se escribe en el modelo al guardar. + * + * @author Abderrahim Darghal Belkacemi + */ +class ComponentTextarea extends FieldComponent +{ + protected int $rows = 3; + + public function setRows(int $rows): static + { + $this->rows = $rows; + return $this; + } + + public function rows(): int + { + return $this->rows; + } + + public function schema(): array + { + return [ + 'type' => 'textarea', + 'field' => $this->fieldname, + 'label' => $this->label, + 'description' => $this->description, + 'required' => $this->required, + 'readonly' => $this->readonly, + 'cols' => $this->cols, + 'rows' => $this->rows, + 'icon' => $this->icon, + 'validations' => $this->validationRules, + ]; + } + + protected function templateDir(): string + { + return 'textarea'; + } + + protected function inputHtml(): string + { + $class = $this->inputCssClass('form-control'); + + return 'inputExtraParams() + . '>' + . htmlspecialchars((string) ($this->value ?? '')) + . ''; + } + + protected function displayValue(): string + { + if ($this->value === null) { + return '-'; + } + + $text = (string) $this->value; + if (mb_strlen($text) > 80) { + return mb_substr($text, 0, 80) . '…'; + } + + return $text; + } +} diff --git a/Core/Component/FieldComponent.php b/Core/Component/FieldComponent.php new file mode 100644 index 0000000000..15a74a9697 --- /dev/null +++ b/Core/Component/FieldComponent.php @@ -0,0 +1,465 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +use FacturaScripts\Core\Request; +use FacturaScripts\Core\Tools; + +/** + * Capa de presentación visual sobre BaseComponent. + * + * Añade todo lo relacionado con cómo se muestra un componente en la UI: + * - Configuración visual: etiqueta, columnas Bootstrap, icono, descripción, + * estado obligatorio, estado solo lectura y clase CSS extra. + * - Renderizado: renderEdit() para formularios, renderCell() para tablas y + * renderReadOnly() para vistas de detalle. + * - Helpers de renderizado: inputCssClass(), inputExtraParams(), + * renderInlineErrors() y displayValue(). + * + * Las clases concretas (ComponentText, ComponentNumber, etc.) extienden esta + * clase e implementan los tres métodos abstractos: inputHtml(), schema() y + * templateDir(). + * + * @author Abderrahim Darghal Belkacemi + */ +abstract class FieldComponent extends BaseComponent +{ + protected string $label = ''; + protected string $labelUrl = ''; + protected string $description = ''; + protected string $icon = ''; + protected bool $required = false; + + /** + * Controla el estado de solo lectura. + * 'false' → editable siempre. + * 'true' → siempre solo lectura. + * 'dinamic' → solo lectura cuando el valor actual no está vacío. + */ + protected string $readonly = 'false'; + + /** + * Visibilidad del campo en formularios y tablas. + * 'none' → oculto (no se renderiza el input visible; en lista se salta la columna). + * Cualquier otro valor → visible y controla la alineación de celda (left/right/center). + */ + protected string $display = 'left'; + + protected string $cssClass = ''; + protected int $cols = 0; + protected int $tabindex = -1; + protected string $cellAlign = 'start'; + + /** Posición de la columna en la tabla (equivalente al atributo order del XML). */ + protected int $order = 0; + + /** Nivel mínimo de usuario necesario para ver este campo. 0 = sin restricción. */ + protected int $level = 0; + + public function __construct(string $fieldname) + { + parent::__construct($fieldname); + $this->label = $fieldname; + } + /** Traduce la clave dada y la usa como etiqueta visible del campo. */ + public function setLabel(string $label, array $params = []): static + { + $this->label = Tools::lang()->trans($label, $params); + return $this; + } + + /** URL para convertir la etiqueta del campo en un enlace (label). */ + public function setLabelUrl(string $url): static + { + $this->labelUrl = $url; + return $this; + } + + /** Traduce la clave dada y la muestra como texto de ayuda bajo el campo. */ + public function setDescription(string $description, array $params = []): static + { + $this->description = Tools::lang()->trans($description, $params); + return $this; + } + + /** Clase CSS de FontAwesome que se muestra como prefijo del input. */ + public function setIcon(string $icon): static + { + $this->icon = $icon; + return $this; + } + + /** Marca el campo como obligatorio: el motor de validación rechaza valores vacíos. */ + public function setRequired(bool $required = true): static + { + $this->required = $required; + return $this; + } + + /** Fuerza el campo a solo lectura independientemente del valor actual. */ + public function setReadOnly(bool $readonly = true): static + { + $this->readonly = $readonly ? 'true' : 'false'; + return $this; + } + + /** + * Modo dinámico: el campo es solo lectura si ya tiene un valor (registro existente) + * y editable si está vacío (registro nuevo). Útil para claves primarias. + */ + public function setReadOnlyDynamic(): static + { + $this->readonly = 'dinamic'; + return $this; + } + + /** Anchura del campo en columnas Bootstrap (1-12). 0 oculta el campo en la cuadrícula. */ + public function setCols(int $cols): static + { + $this->cols = $cols; + return $this; + } + + /** + * Establece la visibilidad del campo. + * 'none' lo excluye del formulario (igual que display="none" en los XML antiguos). + * + * Cuando se llama desde applyColumnOptions() con un valor distinto de 'none', + * la alineación se sincroniza allí de forma explícita mediante setAlign() para + * no pisar una alineación fijada manualmente por el desarrollador en createUI(). + */ + public function setDisplay(string $display): static + { + $this->display = $display; + return $this; + } + + /** Posición de la columna en la tabla (equivalente al atributo order del XML antiguo). */ + public function setOrder(int $order): static + { + $this->order = $order; + return $this; + } + + public function order(): int + { + return $this->order; + } + + /** Nivel mínimo de usuario requerido para ver este campo. 0 = sin restricción. */ + public function setLevel(int $level): static + { + $this->level = $level; + return $this; + } + + public function level(): int + { + return $this->level; + } + + /** Devuelve true cuando el campo está marcado como invisible (display='none'). */ + public function isHidden(): bool + { + return $this->display === 'none'; + } + + /** Añade una clase CSS extra al elemento input, complementando las clases base. */ + public function setCssClass(string $class): static + { + $this->cssClass = $class; + return $this; + } + + /** Define el orden de tabulación con teclado. -1 usa el orden natural del DOM. */ + public function setTabIndex(int $index): static + { + $this->tabindex = $index; + return $this; + } + + /** Alineación de la celda en la tabla de listado: 'left'/'start', 'right'/'end' o 'center'. */ + public function setAlign(string $align): static + { + $this->cellAlign = match($align) { + 'left' => 'start', + 'right' => 'end', + default => $align, + }; + return $this; + } + + /** Devuelve la clase Bootstrap 5 de alineación (start, end, center). */ + public function align(): string + { + return $this->cellAlign; + } + public function label(): string + { + return $this->label; + } + + public function description(): string + { + return $this->description; + } + + public function icon(): string + { + return $this->icon; + } + + public function required(): bool + { + return $this->required; + } + + public function cols(): int + { + return $this->cols; + } + + /** + * Devuelve las clases Bootstrap de columna para el wrapper del campo en el formulario. + * + * Replica la lógica de ColumnItem::getColumnClasses() del sistema antiguo: + * - cols=0 → col-12 col-sm-6 col-md-4 col-xl (adaptativo) + * - cols=12 → col-12 + * - cols=N → col-12 col-sm-6 col-md-4 col-xl-N + * + * ComponentCheckbox sobreescribe este método para devolver col-sm-auto cuando cols=0. + */ + public function colClass(): string + { + if ($this->cols <= 0) { + return 'col-12 col-sm-6 col-md-4 col-xl'; + } + if ($this->cols === 12) { + return 'col-12'; + } + return 'col-12 col-md-' . $this->cols; + } + + public function cssClass(): string + { + return $this->cssClass; + } + + public function tabindex(): int + { + return $this->tabindex; + } + + /** + * Devuelve true si el campo está en modo solo lectura en este momento. + * + * En modo 'dinamic' depende de si el valor actual está vacío o no. + */ + public function isReadOnly(): bool + { + if ($this->readonly === 'dinamic') { + return !empty($this->value); + } + + return $this->readonly === 'true'; + } + /** + * Renderiza el campo como input de formulario editable. + * + * Envuelve inputHtml() en la estructura Bootstrap estándar: etiqueta, + * input-group con icono opcional, errores en línea y texto de ayuda. + * + * Renderiza el campo como un input oculto preservando el valor actual cuando + * el componente tiene display='none'. La plantilla Twig llama a renderHidden() + * en lugar de renderEdit() para los campos ocultos. + */ + public function renderHidden(): string + { + return ''; + } + + public function renderEdit(mixed $value = null): string + { + if ($value !== null) { + $this->value = $value; + } + + $labelText = htmlspecialchars($this->label); + $labelInner = $this->labelUrl + ? '' . $labelText . '' + : $labelText; + $label = ''; + + $desc = $this->description + ? '' . htmlspecialchars($this->description) . '' + : ''; + + $input = $this->inputHtml(); + + if ($this->icon) { + $input = '
' + . '' + . $input + . $this->renderInlineErrors() + . '
'; + } else { + $input .= $this->renderInlineErrors(); + } + + return '
' . $label . $input . $desc . '
'; + } + + /** + * Renderiza el valor del componente como celda de tabla (). + * + * Usa displayValue() para obtener una representación textual legible. + * Las subclases pueden sobreescribir este método para renderizar HTML especial + * (por ejemplo, ComponentCheckbox muestra un icono en lugar de texto). + */ + public function renderCell(mixed $value = null): string + { + if ($value !== null) { + $this->value = $value; + } + + return '' + . htmlspecialchars($this->displayValue()) + . ''; + } + + /** + * Renderiza el campo en modo solo lectura para vistas de detalle. + * + * Muestra la etiqueta y el valor como texto plano, sin input HTML. + */ + public function renderReadOnly(mixed $value = null): string + { + if ($value !== null) { + $this->value = $value; + } + + return '
' + . '' + . '

' . htmlspecialchars($this->displayValue()) . '

' + . '
'; + } + /** + * Devuelve una representación estructurada del componente para APIs JSON o + * para pasar configuración a JavaScript. + */ + /** Representación en texto plano del valor actual. Usada por el sistema de exportación. */ + public function textValue(): string + { + return $this->displayValue(); + } + + /** + * Despacha una acción de widget recibida por AJAX. + * + * Los componentes que necesiten responder a peticiones AJAX del tipo + * `action=widget-*` deben sobreescribir este método y devolver el cuerpo + * JSON de la respuesta. Devolver null indica que el componente no reconoce + * la acción y el controlador responde con []. + */ + public function handleWidgetAction(string $action, Request $request): ?string + { + return null; + } + + /** + * Registra los activos JS/CSS necesarios en AssetManager. + * + * Se invoca desde UIController antes de setTemplate() para que los scripts + * queden registrados antes de que Twig evalúe assetManager.get('js') en el . + * La implementación base es un no-op; los componentes con JS propio (ComponentModalPicker…) + * sobreescriben este método. + */ + public function registerAssets(): void + { + } + + abstract public function schema(): array; + + /** Directorio de plantillas Twig del componente, relativo a Component/. */ + abstract protected function templateDir(): string; + + /** Devuelve el HTML del elemento input, sin envoltorio externo ni errores. */ + abstract protected function inputHtml(): string; + /** + * Representación textual del valor actual para renderCell() y renderReadOnly(). + * + * Sobreescribe en la subclase para aplicar formato específico (número con + * decimales, fecha localizada, etc.). La implementación base devuelve el valor + * como string o '-' si es null. + */ + protected function displayValue(): string + { + return $this->value === null ? '-' : (string) $this->value; + } + + /** + * Genera los divs invalid-feedback con los errores de validación del componente. + * + * Se inyecta dentro de renderEdit() tras el elemento input. + */ + protected function renderInlineErrors(): string + { + $html = ''; + foreach ($this->validationErrors as $error) { + $html .= '
' . htmlspecialchars($error) . '
'; + } + return $html; + } + + /** + * Construye el atributo class del input combinando las clases base con la clase + * extra del usuario y, si hay errores, 'is-invalid'. + */ + protected function inputCssClass(string ...$base): string + { + $classes = array_filter($base); + if ($this->cssClass) { + $classes[] = $this->cssClass; + } + if ($this->hasValidationErrors()) { + $classes[] = 'is-invalid'; + } + return implode(' ', $classes); + } + + /** + * Genera los atributos HTML extra comunes: required, readonly y tabindex. + * + * Se añade directamente al elemento input mediante concatenación de cadenas. + */ + protected function inputExtraParams(): string + { + $params = $this->required ? ' required=""' : ''; + $params .= $this->isReadOnly() ? ' readonly=""' : ''; + $params .= $this->tabindex >= 0 ? ' tabindex="' . $this->tabindex . '"' : ''; + return $params; + } + + /** Sobreescribe isRequired() de BaseComponent con la propiedad configurable. */ + protected function isRequired(): bool + { + return $this->required; + } +} diff --git a/Core/Component/HasComponentBlocks.php b/Core/Component/HasComponentBlocks.php new file mode 100644 index 0000000000..844747ea9a --- /dev/null +++ b/Core/Component/HasComponentBlocks.php @@ -0,0 +1,105 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +use Exception; + +/** + * Trait que añade pestañas de bloques de componentes con nombre a cualquier subclase de BaseController. + * + * Incluye este trait en una subclase de PanelController y llama a addComponentBlock() + * dentro de createViews() para registrar las pestañas. PanelController::privateCore() + * debe invocar processActiveComponentBlock() tras el bucle de vistas habitual para que + * el manejo de POST funcione correctamente. En GET puebla los valores de los componentes + * desde el modelo principal; en POST valida todos los componentes del bloque activo y llama + * a execAfterComponentBlock() si no hay errores — sobreescribe ese hook para persistir cambios. + * + * La capa Twig expone los bloques a través de fsc.componentBlocks() y renderiza cada uno + * mediante Component/block.html.twig, que ya está integrado en PanelController. + * + * @author Abderrahim Darghal Belkacemi + */ +trait HasComponentBlocks +{ + /** @var ComponentBlock[] keyed by block name */ + private array $componentBlocks = []; + + protected function addComponentBlock(ComponentBlock $block): ComponentBlock + { + $block->settings['card'] = $this->tabsPosition !== 'top'; + $this->componentBlocks[$block->name()] = $block; + + // if nothing is active yet, activate this block + if (empty($this->active)) { + $this->active = $block->name(); + } + + return $block; + } + + public function componentBlock(string $name): ComponentBlock + { + if (!isset($this->componentBlocks[$name])) { + throw new Exception("ComponentBlock '{$name}' not found"); + } + + return $this->componentBlocks[$name]; + } + + public function componentBlocks(): array + { + return $this->componentBlocks; + } + + protected function processActiveComponentBlock(): void + { + $block = $this->componentBlocks[$this->active] ?? null; + if ($block === null || !$block->settings['active']) { + return; + } + + $model = $this->getMainModelForBlock(); + + if ($this->request->isMethod('POST') && $this->validateFormToken()) { + if ($block->process($this->request, $model)) { + $this->execAfterComponentBlock($block->name(), $block); + } + } else { + $block->populate($model); + } + } + + /** + * Sobreescribe en la subclase para reaccionar tras procesar correctamente un bloque de componentes. + */ + protected function execAfterComponentBlock(string $blockName, ComponentBlock $block): void + { + } + + private function getMainModelForBlock(): ?object + { + try { + $mainViewName = $this->getMainViewName(); + return $this->views[$mainViewName]->model ?? null; + } catch (\Exception $e) { + return null; + } + } +} diff --git a/Core/Component/UIController.php b/Core/Component/UIController.php new file mode 100644 index 0000000000..dd380d61be --- /dev/null +++ b/Core/Component/UIController.php @@ -0,0 +1,413 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Component; + +use FacturaScripts\Core\Base\Controller; + +/** + * Controlador base para páginas construidas íntegramente con el sistema de componentes. + * + * Las subclases declaran su formulario en createUI() usando addComponent() y onEvent(). + * El ciclo de vida es: createUI → (POST: processComponents → evento save) | (GET: + * populateFromModel) → modifyUI → renderizado. Si processComponents encuentra errores + * de validación el formulario se vuelve a renderizar con feedback en línea; en caso de + * éxito se despacha el evento 'save' para que la subclase persista los datos y, + * opcionalmente, redirija. + * + * Sobreescribe resolveTemplate() para devolver una plantilla Twig diferente según el + * estado interno del controlador (por ejemplo, modo lista vs. modo edición). + * + * @author Abderrahim Darghal Belkacemi + */ +abstract class UIController extends Controller +{ + /** @var FieldComponent[] keyed by fieldname */ + private array $components = []; + + /** @var array fieldname → error messages */ + private array $errors = []; + + /** Nombre del grupo activo para las siguientes llamadas a addComponent(). */ + private string $currentGroup = '__default__'; + + /** @var array */ + private array $groups = []; + + /** @var array event name → controller method */ + private array $eventHandlers = []; + + /** + * Construye el árbol de componentes. Se invoca una vez al inicio de cada petición, + * antes de cualquier procesamiento o renderizado. Registra los componentes con + * addComponent() y los manejadores de eventos con onEvent() aquí. + */ + abstract protected function createUI(): void; + + /** + * Se invoca tras el procesamiento o la población y antes de resolver la plantilla. + * Sobreescribe este método para ajustar el árbol de componentes según el estado + * procesado (por ejemplo, ocultar un campo una vez que su valor ha sido confirmado). + */ + protected function modifyUI(): void + { + } + + /** + * Devuelve la instancia del modelo cuyas propiedades se mapearán a los valores de + * los componentes en GET (populateFromModel) y se actualizarán en POST (processComponents). + * Sobreescribe y almacena en caché el resultado para evitar consultas redundantes a la BD; + * la implementación base devuelve null, lo que significa que no se realiza ningún mapeo. + */ + protected function loadModel(): ?object + { + return null; + } + + /** + * Devuelve true para omitir processComponents() en un POST concreto. + * + * Sobreescribe en subclases para evitar que un POST de una vista embebida + * (p. ej. un ListView incrustado) sea tratado como un envío del formulario + * de edición. Cuando devuelve true, se llama a populateFromModel() en su lugar. + */ + protected function skipFormProcessing(): bool + { + return false; + } + + /** + * Inicia un nuevo grupo de campos. Los componentes añadidos con addComponent() a + * continuación pertenecerán a este grupo hasta que se llame a startGroup() de nuevo. + * + * En la plantilla cada grupo se renderiza como: + *
...
+ * + * @param string $title Título visible del grupo (separador visual). Cadena vacía = sin título. + * @param bool $alignBottom si true, añade align-items-end al row interno (útil para checkboxes) + */ + protected function startGroup(string $name, string $title = '', bool $alignBottom = false): void + { + $this->currentGroup = $name; + if (!isset($this->groups[$name])) { + $this->groups[$name] = ['title' => $title, 'alignBottom' => $alignBottom, 'components' => []]; + } + } + + /** + * Registra un componente en el controlador y lo asigna al grupo activo. + * + * Lanza InvalidArgumentException si el fieldname no cumple el patrón + * /^[a-zA-Z_][a-zA-Z0-9_]*$/ y LogicException si ya existe otro componente + * con el mismo nombre. + */ + protected function addComponent(FieldComponent $component): FieldComponent + { + $fieldname = $component->fieldname(); + + if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $fieldname)) { + throw new \InvalidArgumentException( + "Invalid component fieldname '{$fieldname}': must start with a letter or underscore and contain only alphanumeric characters and underscores." + ); + } + + if (isset($this->components[$fieldname])) { + throw new \LogicException( + "Duplicate component fieldname '{$fieldname}': a component with this name is already registered." + ); + } + + $this->components[$fieldname] = $component; + + // assign to current group + if (!isset($this->groups[$this->currentGroup])) { + $this->groups[$this->currentGroup] = ['title' => '', 'alignBottom' => false, 'components' => []]; + } + $this->groups[$this->currentGroup]['components'][] = $fieldname; + + return $component; + } + + /** + * Devuelve los grupos de componentes para la plantilla Twig. + * + * Cada elemento es ['alignBottom' => bool, 'components' => FieldComponent[]]. + * Si no se usó startGroup(), devuelve un único grupo con todos los componentes. + */ + public function componentGroups(): array + { + $result = []; + foreach ($this->groups as $groupDef) { + $comps = []; + foreach ($groupDef['components'] as $fieldname) { + if (isset($this->components[$fieldname])) { + $comps[$fieldname] = $this->components[$fieldname]; + } + } + $result[] = [ + 'title' => $groupDef['title'] ?? '', + 'alignBottom' => $groupDef['alignBottom'], + 'components' => $comps, + ]; + } + return $result; + } + + /** Devuelve el componente registrado con ese fieldname, o null si no existe. */ + protected function component(string $fieldname): ?FieldComponent + { + return $this->components[$fieldname] ?? null; + } + + /** Elimina el componente con ese fieldname del árbol. No lanza error si no existe. */ + protected function removeComponent(string $fieldname): void + { + unset($this->components[$fieldname]); + } + + /** + * Registra un callable para un evento con nombre. + * + * El evento 'save' se dispara automáticamente tras superar la validación de todos + * los componentes. Cualquier otro nombre puede activarse enviando _event= + * por POST. El manejador no recibe argumentos y debe devolver un ActionResult + * (o null para continuar el renderizado con normalidad). + */ + protected function onEvent(string $event, callable $handler): void + { + $this->eventHandlers[$event] = $handler; + } + + /** Indica si hay un handler registrado para el evento dado. */ + protected function hasEventHandler(string $event): bool + { + return isset($this->eventHandlers[$event]); + } + + /** Devuelve todos los componentes registrados, indexados por fieldname. Usado por Twig. */ + public function components(): array + { + return $this->components; + } + + /** Devuelve el mapa completo de errores de validación: fieldname → string[]. */ + public function errors(): array + { + return $this->errors; + } + + /** Indica si algún componente falló la validación en el último POST. */ + public function hasErrors(): bool + { + return !empty($this->errors); + } + + /** Devuelve los mensajes de error asociados a un fieldname concreto. */ + public function errorsFor(string $fieldname): array + { + return $this->errors[$fieldname] ?? []; + } + + /** + * Punto de entrada principal del controlador. + * + * Flujo: createUI → (si hay _event: dispatchEvent) | (POST sin event: + * processComponents) | (GET: populateFromModel) → modifyUI → setTemplate. + */ + public function privateCore(&$response, $user, $permissions): void + { + parent::privateCore($response, $user, $permissions); + + $this->createUI(); + $this->pipe('createUI'); + + // dispatch widget AJAX actions (action=widget-*) sent by WidgetSubcuenta.js etc. + $widgetAction = $this->request->request->get('action', ''); + if (!empty($widgetAction) && str_starts_with($widgetAction, 'widget-')) { + $this->dispatchWidgetAction($widgetAction); + return; + } + + $action = $this->request->queryOrInput('_event', ''); + + if (!empty($action)) { + if (false === $this->pipeFalse('execPreviousAction', $action)) { + return; + } + $result = $this->dispatchEvent($action); + if ($result !== null && $result->exit) { + if (!empty($result->redirect)) { + $this->redirect($result->redirect); + } else { + $this->setTemplate(false); + } + return; + } + } + + if ($this->request->isMethod('POST') && empty($action)) { + if ($this->skipFormProcessing()) { + $this->populateFromModel(); + } else { + if (false === $this->pipeFalse('execPreviousAction', 'save')) { + return; + } + if ($this->processComponents()) { + return; + } + } + } else { + $this->populateFromModel(); + } + + $this->pipe('loadData', $this->loadModel()); + + $this->modifyUI(); + $this->pipe('modifyUI'); + + $this->pipeFalse('execAfterAction', $action); + + // Registrar activos JS/CSS de cada componente ANTES de que Twig evalúe + // assetManager.get('js') en el . Si se registrase dentro de renderEdit() + // sería demasiado tarde (el ya está renderizado). + foreach ($this->components as $component) { + $component->registerAssets(); + } + + $this->setTemplate($this->resolveTemplate()); + } + + /** + * Devuelve el nombre de la plantilla Twig a renderizar. + * + * Sobreescribe en la subclase para cambiar de plantilla según el estado + * interno (p. ej. lista vs. edición). La implementación base devuelve la + * plantilla genérica de componentes. + */ + protected function resolveTemplate(): string + { + return 'Master/ComponentController'; + } + + /** + * Procesa todos los componentes del formulario POST. + * + * Por cada componente invoca processRequest(). Si hay errores de validación + * los almacena en $errors y los inyecta en el componente para que renderEdit() + * muestre el feedback en línea. Si todos pasan, dispara el evento 'save'. + * Devuelve true si se ha gestionado una redirección (la llamada debe salir + * inmediatamente), false para continuar con el renderizado normal. + */ + private function processComponents(): bool + { + $model = $this->loadModel(); + + foreach ($this->components as $fieldname => $component) { + if ($component->isHidden()) { + continue; // el campo oculto no se procesa; el modelo conserva el valor de BD + } + $result = $component->processRequest($this->request, $model); + if (!$result['success']) { + $this->errors[$fieldname] = $result['errors']; + $component->setValidationErrors($result['errors']); + } + } + + if (empty($this->errors)) { + $result = $this->dispatchEvent('save'); + if ($result !== null && $result->exit) { + if (!empty($result->redirect)) { + $this->redirect($result->redirect); + } else { + $this->setTemplate(false); + } + return true; + } + } + + return false; + } + + /** + * En GET, rellena los valores de los componentes desde el modelo devuelto por loadModel(). + * + * Solo copia propiedades que existan tanto en el modelo como en el árbol de componentes; + * las propiedades sin componente correspondiente se ignoran silenciosamente. + */ + private function populateFromModel(): void + { + $model = $this->loadModel(); + if ($model === null) { + return; + } + + foreach ($this->components as $fieldname => $component) { + if (property_exists($model, $fieldname)) { + $component->setValue($model->{$fieldname}); + } + } + } + + /** + * Despacha una acción de widget AJAX (action=widget-*) al componente que la reconozca. + * + * Itera por todos los componentes llamando a handleWidgetAction(). El primero que + * devuelva un string no nulo gana: se envía como JSON y se suprime la plantilla. + * Si ningún componente reconoce la acción se devuelve un array vacío. + */ + private function dispatchWidgetAction(string $widgetAction): void + { + foreach ($this->components as $component) { + $json = $component->handleWidgetAction($widgetAction, $this->request); + if ($json !== null) { + $this->response->headers->set('Content-Type', 'application/json'); + $this->response->setContent($json); + $this->setTemplate(false); + return; + } + } + + $this->response->headers->set('Content-Type', 'application/json'); + $this->response->setContent('[]'); + $this->setTemplate(false); + } + + /** + * Despacha un evento por nombre. + * + * Busca primero en los handlers registrados con onEvent(); si no hay ninguno, + * intenta llamar a un método del mismo nombre en la subclase. Devuelve el + * ActionResult retornado por el handler, o null si no hay handler o no devuelve + * un ActionResult. + */ + private function dispatchEvent(string $name): ?ActionResult + { + if (isset($this->eventHandlers[$name])) { + $result = ($this->eventHandlers[$name])(); + return $result instanceof ActionResult ? $result : null; + } + + if (method_exists($this, $name)) { + $result = $this->{$name}(); + return $result instanceof ActionResult ? $result : null; + } + + return null; + } +} diff --git a/Core/Controller/DashboardComponents.php b/Core/Controller/DashboardComponents.php new file mode 100644 index 0000000000..2b97555d0d --- /dev/null +++ b/Core/Controller/DashboardComponents.php @@ -0,0 +1,113 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Component\ActionResult; +use FacturaScripts\Core\Component\ComponentNumber; +use FacturaScripts\Core\Component\ComponentSelect; +use FacturaScripts\Core\Component\ComponentText; +use FacturaScripts\Core\Component\ComponentTextarea; +use FacturaScripts\Core\Component\UIController; +use FacturaScripts\Core\Tools; + +/** + * Controlador de demostración que ejercita todos los tipos de componentes disponibles. + * + * Sirve como ejemplo vivo y prueba de integración del sistema de componentes. Muestra + * ComponentText (con icono y validación de email), ComponentSelect (con claves + * traducidas), ComponentNumber y ComponentTextarea en un único formulario. + * Accesible en /DashboardComponents. + * + * @author Abderrahim Darghal Belkacemi + */ +class DashboardComponents extends UIController +{ + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'reports'; + $data['title'] = 'dashboard-components'; + $data['icon'] = 'fa-solid fa-puzzle-piece'; + return $data; + } + + protected function createUI(): void + { + // -- Contact block ------------------------------------------------ + $this->addComponent( + ComponentText::make('nombre') + ->setLabel('name') + ->setRequired() + ->setCols(4) + ); + + $this->addComponent( + ComponentText::make('email') + ->setLabel('email') + ->setIcon('fa-solid fa-envelope') + ->addRule('email') + ->setCols(4) + ); + + $this->addComponent( + ComponentText::make('telefono') + ->setLabel('phone') + ->setIcon('fa-solid fa-phone') + ->setCols(4) + ); + + // -- Extra data --------------------------------------------------- + $this->addComponent( + ComponentSelect::make('tipo') + ->setLabel('type') + ->setCols(3) + ->setValuesFromArrayKeys([ + 'cliente' => 'Cliente', + 'proveedor' => 'Proveedor', + 'otro' => 'Otro', + ], true) + ); + + $this->addComponent( + ComponentNumber::make('importe') + ->setLabel('amount') + ->setMin(0) + ->setDecimals(2) + ->setCols(3) + ); + + $this->addComponent( + ComponentTextarea::make('observaciones') + ->setLabel('observations') + ->setRows(4) + ->setCols(12) + ); + + // -- Register save handler ---------------------------------------- + $this->onEvent('save', fn() => $this->save()); + } + + protected function save(): ActionResult + { + Tools::log()->notice('record-updated-correctly'); + + return ActionResult::make(); + } +} diff --git a/Core/Controller/NewEditAsiento.php b/Core/Controller/NewEditAsiento.php new file mode 100644 index 0000000000..ed2373ce62 --- /dev/null +++ b/Core/Controller/NewEditAsiento.php @@ -0,0 +1,361 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Lib\AjaxForms\AccountingFooterHTML; +use FacturaScripts\Core\Lib\AjaxForms\AccountingHeaderHTML; +use FacturaScripts\Core\Lib\AjaxForms\AccountingLineHTML; +use FacturaScripts\Core\Lib\AjaxForms\AccountingModalHTML; +use FacturaScripts\Core\Lib\Export\AsientoExport; +use FacturaScripts\Core\Lib\ExtendedController\BaseView; +use FacturaScripts\Core\Lib\ExtendedController\DocFilesTrait; +use FacturaScripts\Core\Lib\ExtendedController\LogAuditTrait; +use FacturaScripts\Core\Tools; +use FacturaScripts\Core\UIComponents\UIPanelController; +use FacturaScripts\Dinamic\Lib\AssetManager; +use FacturaScripts\Dinamic\Model\Asiento; +use FacturaScripts\Dinamic\Model\Partida; + +/** + * Formulario de edición de asientos contables construido sobre UIPanelController. + * + * Réplica funcional de EditAsiento usando el nuevo sistema UI: mantiene el mismo + * formulario AJAX interactivo (Tab/AccountingEntry) y los mismos paneles de + * ficheros adjuntos y auditoría de log, sin botones adicionales ni lógica nueva. + * + * @author Carlos Garcia Gomez + * @author Jose Antonio Cuello Principal + * @author Abderrahim Darghal Belkacemi + */ +class NewEditAsiento extends UIPanelController +{ + use DocFilesTrait; + use LogAuditTrait; + + const MAIN_VIEW_NAME = 'main'; + const MAIN_VIEW_TEMPLATE = 'Tab/AccountingEntry'; + + /** @var array */ + private $logLevels = ['critical', 'error', 'info', 'notice', 'warning']; + + /** + * Devuelve el modelo principal cargado desde la vista HtmlView. + * La plantilla Tab/AccountingEntry lo obtiene via fsc.getCurrentView().model, + * pero este método lo expone para los manejadores AJAX internos. + */ + public function getModel(): Asiento + { + if ($this->views[static::MAIN_VIEW_NAME]->model->id()) { + return $this->views[static::MAIN_VIEW_NAME]->model; + } + + $primaryKey = $this->request->input($this->views[static::MAIN_VIEW_NAME]->model->primaryColumn()); + $code = $this->request->query('code', $primaryKey); + if (empty($code)) { + return $this->views[static::MAIN_VIEW_NAME]->model; + } + + $this->views[static::MAIN_VIEW_NAME]->model->load($code); + return $this->views[static::MAIN_VIEW_NAME]->model; + } + + public function getModelClassName(): string + { + return 'Asiento'; + } + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'accounting'; + $data['title'] = 'accounting-entry'; + $data['icon'] = 'fa-solid fa-balance-scale'; + $data['showonmenu'] = false; + return $data; + } + + /** + * Genera el HTML del formulario contable (cabecera + líneas + pie + modal). + * Llamado desde la plantilla Tab/AccountingEntry via fsc.renderAccEntryForm(). + * + * @param Partida[] $lines + */ + public function renderAccEntryForm(Asiento $model, array $lines): string + { + AccountingLineHTML::calculateUnbalance($model, $lines); + return '
' . AccountingHeaderHTML::render($model) . '
' + . '
' . AccountingLineHTML::render($lines, $model) . '
' + . '
' . AccountingFooterHTML::render($model) . '
' + . AccountingModalHTML::render($model); + } + + protected function createPanels(): void + { + $this->setTabsPosition('top'); + + $this->addHtmlView( + static::MAIN_VIEW_NAME, + static::MAIN_VIEW_TEMPLATE, + $this->getModelClassName(), + 'accounting-entry', + 'fa-solid fa-balance-scale' + ); + $this->setSettings(static::MAIN_VIEW_NAME, 'btnPrint', true); + + $route = Tools::config('route'); + AssetManager::addCss($route . '/node_modules/jquery-ui-dist/jquery-ui.min.css', 2); + AssetManager::addJs($route . '/node_modules/jquery-ui-dist/jquery-ui.min.js', 2); + AssetManager::addJs($route . '/Dinamic/Assets/JS/WidgetAutocomplete.js'); + + $this->createViewDocFiles(); + $this->createViewLogAudit(); + } + + protected function execPreviousAction($action) + { + switch ($action) { + case 'add-file': + return $this->addFileAction(); + + case 'delete-file': + return $this->deleteFileAction(); + + case 'delete-doc': + return $this->deleteDocAction(); + + case 'edit-file': + return $this->editFileAction(); + + case 'find-subaccount': + return $this->findSubaccountAction(); + + case 'lock-doc': + return $this->unlockAction(false); + + case 'new-line': + case 'rm-line': + case 'recalculate': + return $this->recalculateAction($action !== 'recalculate'); + + case 'save-doc': + return $this->saveDocAction(); + + case 'sort-files': + return $this->sortFilesAction(); + + case 'unlink-file': + return $this->unlinkFileAction(); + + case 'unlock-doc': + return $this->unlockAction(true); + } + + return parent::execPreviousAction($action); + } + + protected function exportAction() + { + if (false === $this->views[$this->active]->settings['btnPrint'] || false === $this->permissions->allowExport) { + Tools::log()->warning('no-print-permission'); + return; + } + + $this->setTemplate(false); + AsientoExport::show( + $this->getModel(), + $this->request->queryOrInput('option', ''), + $this->title, + (int)$this->request->input('idformat', ''), + $this->request->input('langcode', ''), + $this->response + ); + } + + protected function loadData($viewName, $view) + { + $primaryKey = $this->request->input($view->model->primaryColumn()); + $code = $this->request->query('code', $primaryKey); + + switch ($viewName) { + case 'docfiles': + $this->loadDataDocFiles($view, $this->getModelClassName(), $code); + break; + + case 'ListLogMessage': + $this->loadDataLogAudit($view, $this->getModelClassName(), $code); + break; + + case static::MAIN_VIEW_NAME: + if (empty($code)) { + $view->model->clear(); + break; + } + + $view->loadData($code); + $action = $this->request->input('action', ''); + if ('' === $action && false === $view->model->exists()) { + Tools::log()->warning('record-not-found'); + break; + } + + if (false === $view->model->isBalanced()) { + Tools::log()->warning('unbalanced-entry'); + break; + } + + $this->title .= ' ' . $view->model->primaryDescription(); + $this->addButton($viewName, [ + 'action' => 'CopyModel?model=' . $this->getModelClassName() . '&code=' . $view->model->id(), + 'icon' => 'fa-solid fa-cut', + 'label' => 'copy', + 'type' => 'link', + ]); + break; + } + } + + private function applyMainFormData(Asiento &$model, array &$lines, bool $applyModal = false): void + { + $formData = json_decode($this->request->input('data'), true); + AccountingHeaderHTML::apply($model, $formData); + AccountingFooterHTML::apply($model, $formData); + AccountingLineHTML::apply($model, $lines, $formData); + if ($applyModal) { + AccountingModalHTML::apply($model, $formData); + } + } + + protected function deleteDocAction(): bool + { + $this->setTemplate(false); + if (false === $this->permissions->allowDelete) { + Tools::log()->warning('not-allowed-delete'); + return $this->sendJsonError(); + } elseif (false === $this->validateFileActionToken()) { + return $this->sendJsonError(); + } + + $model = $this->getModel(); + if (false === $model->delete()) { + return $this->sendJsonError(); + } + + $this->response->json(['ok' => true, 'newurl' => $model->url('list')]); + return false; + } + + protected function findSubaccountAction(): bool + { + $this->setTemplate(false); + $model = $this->getModel(); + $lines = []; + $this->applyMainFormData($model, $lines, true); + $content = [ + 'header' => '', + 'lines' => '', + 'footer' => '', + 'list' => AccountingModalHTML::renderSubaccountList($model), + 'messages' => Tools::log()::read('master', $this->logLevels), + ]; + $this->response->json($content); + return false; + } + + protected function recalculateAction(bool $renderLines): bool + { + $this->setTemplate(false); + $model = $this->getModel(); + $lines = $model->getLines(); + $this->applyMainFormData($model, $lines); + $content = [ + 'header' => AccountingHeaderHTML::render($model), + 'lines' => $renderLines ? AccountingLineHTML::render($lines, $model) : '', + 'footer' => AccountingFooterHTML::render($model), + 'list' => '', + 'messages' => Tools::log()::read('master', $this->logLevels), + ]; + $this->response->json($content); + return false; + } + + protected function saveDocAction(): bool + { + $this->setTemplate(false); + if (false === $this->permissions->allowUpdate) { + Tools::log()->warning('not-allowed-modify'); + return $this->sendJsonError(); + } + + $this->dataBase->beginTransaction(); + $model = $this->getModel(); + $lines = $model->getLines(); + $this->applyMainFormData($model, $lines); + + if (false === $model->save()) { + $this->dataBase->rollback(); + return $this->sendJsonError(); + } + + foreach ($lines as $line) { + $line->idasiento = $line->idasiento ?? $model->idasiento; + if (false === $line->save()) { + $this->dataBase->rollback(); + return $this->sendJsonError(); + } + } + + foreach ($model->getLines() as $oldLine) { + if (in_array($oldLine->idpartida, AccountingLineHTML::getDeletedLines()) && false === $oldLine->delete()) { + $this->dataBase->rollback(); + return $this->sendJsonError(); + } + } + + $this->response->json(['ok' => true, 'newurl' => $model->url() . '&action=save-ok']); + $this->dataBase->commit(); + return false; + } + + protected function sendJsonError(): bool + { + $this->response->json(['ok' => false, 'messages' => Tools::log()::read('master', $this->logLevels)]); + return false; + } + + protected function unlockAction(bool $value): bool + { + $this->setTemplate(false); + if (false === $this->permissions->allowUpdate) { + Tools::log()->warning('not-allowed-modify'); + return $this->sendJsonError(); + } elseif (false === $this->validateFileActionToken()) { + return $this->sendJsonError(); + } + + $model = $this->getModel(); + $model->editable = $value; + if (false === $model->save()) { + return $this->sendJsonError(); + } + + $this->response->json(['ok' => true, 'newurl' => $model->url() . '&action=save-ok']); + return false; + } +} diff --git a/Core/Controller/NewEditAtributo.php b/Core/Controller/NewEditAtributo.php new file mode 100644 index 0000000000..30afc7a1c8 --- /dev/null +++ b/Core/Controller/NewEditAtributo.php @@ -0,0 +1,122 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Component\ComponentNumber; +use FacturaScripts\Core\Component\ComponentText; +use FacturaScripts\Core\UIComponents\UIEditController; +use FacturaScripts\Core\Where; + +/** + * Formulario de edición de atributos de artículo construido sobre UIEditController. + * + * Replica EditAtributo mostrando el formulario del atributo y una lista inline + * de sus valores (AtributoValor) filtrada por codatributo. + * + * @author Abderrahim Darghal Belkacemi + */ +class NewEditAtributo extends UIEditController +{ + public function getModelClassName(): string + { + return 'Atributo'; + } + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'warehouse'; + $data['title'] = 'attribute'; + $data['icon'] = 'fa-solid fa-tshirt'; + return $data; + } + + public function listUrl(): string + { + return 'NewListAtributo'; + } + + protected function getViewName(): string + { + return 'EditAtributo'; + } + + protected function buildForm(): void + { + $this->loadModel(); + + $this->startGroup('data'); + + $this->addComponent( + ComponentText::make('nombre') + ->setLabel('name') + ->setRequired() + ->setMaxLength(100) + ); + + $this->addComponent( + ComponentText::make('codatributo') + ->setLabel('code') + ->setDescription('optional') + ->setIcon('fa-solid fa-hashtag') + ->setMaxLength(20) + ->setReadOnlyDynamic() + ->setCols(2) + ); + + $this->addComponent( + ComponentNumber::make('num_selector') + ->setLabel('selector-number') + ->setIcon('fa-solid fa-folder-tree') + ->setMin(0) + ->setDecimals(0) + ->setCols(2) + ); + + $this->addEditListView('EditAtributoValor', 'AtributoValor', 'attribute-values') + ->setInLine(true); + } + + protected function modifyUI(): void + { + parent::modifyUI(); + + $model = $this->editModel; + if ($model === null || !$model->exists()) { + return; + } + + $list = $this->listView('EditAtributoValor'); + if ($list === null) { + return; + } + + $list->processFormData($this->request, 'load'); + + $code = $model->codatributo ?? ''; + if (empty($code)) { + return; + } + + $where = [Where::eq('codatributo', $code)]; + $list->loadData('', $where, ['orden' => 'ASC', 'id' => 'DESC']); + $list->disableColumn('attribute'); + } +} diff --git a/Core/Controller/NewEditCuentaBanco.php b/Core/Controller/NewEditCuentaBanco.php new file mode 100644 index 0000000000..f8e16c8bce --- /dev/null +++ b/Core/Controller/NewEditCuentaBanco.php @@ -0,0 +1,315 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Component\ActionResult; +use FacturaScripts\Core\Component\ComponentCheckbox; +use FacturaScripts\Core\Component\ComponentNumber; +use FacturaScripts\Core\Component\ComponentSelect; +use FacturaScripts\Core\Component\ComponentModelPicker; +use FacturaScripts\Core\Component\ComponentText; +use FacturaScripts\Core\Model\Empresa; +use FacturaScripts\Core\Tools; +use FacturaScripts\Core\UIComponents\UIEditController; +use FacturaScripts\Core\Where; +use FacturaScripts\Dinamic\Model\Ejercicio; +use FacturaScripts\Dinamic\Model\Subcuenta; + +/** + * Formulario de edición de cuentas bancarias propias de la empresa. + * + * Replica EditCuentaBanco usando el sistema de componentes UI. + * No visible en el menú; se accede desde NewListFormaPago (pestaña ListCuentaBanco). + * + * @author Abderrahim Darghal Belkacemi + */ +class NewEditCuentaBanco extends UIEditController +{ + public function getModelClassName(): string + { + return 'CuentaBanco'; + } + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'accounting'; + $data['title'] = 'bank-account'; + $data['icon'] = 'fa-solid fa-piggy-bank'; + return $data; + } + + public function listUrl(): string + { + return 'NewListFormaPago?activetab=ListCuentaBanco'; + } + + protected function getViewName(): string + { + return 'EditCuentaBanco'; + } + + protected function buildForm(): void + { + $this->loadModel(); + + // Grupo principal: datos de la cuenta + $this->startGroup('data'); + + $this->addComponent( + ComponentNumber::make('codcuenta') + ->setLabel('code') + ->setReadOnly() + ->setDisplay('none') + ); + + $this->addComponent( + ComponentText::make('descripcion') + ->setLabel('description') + ->setRequired() + ->setMaxLength(100) + ); + + $this->addComponent( + ComponentText::make('swift') + ->setLabel('swift') + ->setMaxLength(11) + ->setCols(2) + ); + + $this->addComponent( + ComponentText::make('iban') + ->setLabel('iban') + ->setMaxLength(34) + ); + + // Grupo contabilidad: empresa, sufijo SEPA, subcuentas + $this->startGroup('accounting'); + + $empresas = (new Empresa())->all(); + if (count($empresas) > 1) { + $this->addComponent( + ComponentSelect::make('idempresa') + ->setLabel('company') + ->setLabelUrl('ListEmpresa') + ->setRequired() + ->setReadOnlyDynamic() + ->setCols(2) + ->setSource('empresas', 'idempresa', 'nombrecorto') + ->setOptionsResolver(fn() => array_map( + fn($e) => ['value' => $e->idempresa, 'title' => $e->nombrecorto, 'group' => ''], + $empresas + )) + ); + } else { + $this->addComponent( + ComponentSelect::make('idempresa') + ->setDisplay('none') + ->setValue($empresas[0]->idempresa ?? null) + ); + } + + $this->addComponent( + ComponentText::make('sufijosepa') + ->setLabel('sepa-suffix') + ->setMaxLength(3) + ->setCols(2) + ); + + $idempresa = isset($this->editModel->idempresa) ? (int) $this->editModel->idempresa : null; + $ejWhere = $idempresa ? [Where::eq('idempresa', $idempresa)] : []; + + $subcuentaExtraFilters = function (string $id, string $prefix) use ($ejWhere): string { + $ejercicios = Ejercicio::all($ejWhere, ['codejercicio' => 'DESC']); + $options = ''; + $first = true; + foreach ($ejercicios as $ej) { + $sel = $first ? ' selected' : ''; + $options .= ''; + $first = false; + } + return '
'; + }; + + $subcuentaExtraWhere = function ($request) use ($ejWhere): array { + $codej = $request->request->get('codejercicio', ''); + if (empty($codej)) { + $ejercicios = Ejercicio::all($ejWhere, ['codejercicio' => 'DESC'], 0, 1); + $codej = $ejercicios[0]->codejercicio ?? ''; + } + return $codej ? [Where::eq('codejercicio', $codej)] : []; + }; + + $this->addComponent( + ComponentModelPicker::make('codsubcuenta') + ->setModel(Subcuenta::class) + ->setMatch('codsubcuenta') + ->setIcon('fa-solid fa-book') + ->setColumns(['codsubcuenta' => 'subaccount', 'descripcion' => 'description']) + ->setSearchFields('codsubcuenta|descripcion') + ->setSortOptions([ + 'cod-asc' => ['sort-by-code-asc', ['codsubcuenta' => 'ASC']], + 'cod-desc' => ['sort-by-code-desc', ['codsubcuenta' => 'DESC']], + 'desc-asc' => ['sort-by-description-asc', ['descripcion' => 'ASC']], + 'desc-desc' => ['sort-by-description-desc', ['descripcion' => 'DESC']], + ]) + ->setExtraFilters($subcuentaExtraFilters) + ->setExtraWhere($subcuentaExtraWhere) + ->setNewUrl((new Subcuenta())->url('new')) + ->setLabel('subaccount') + ->setLabelUrl('ListCuenta') + ->setDescription('related-subaccount-purchases-sales') + ->setCols(4) + ); + + $this->addComponent( + ComponentModelPicker::make('codsubcuentagasto') + ->setModel(Subcuenta::class) + ->setMatch('codsubcuenta') + ->setIcon('fa-solid fa-book') + ->setColumns(['codsubcuenta' => 'subaccount', 'descripcion' => 'description']) + ->setSearchFields('codsubcuenta|descripcion') + ->setSortOptions([ + 'cod-asc' => ['sort-by-code-asc', ['codsubcuenta' => 'ASC']], + 'cod-desc' => ['sort-by-code-desc', ['codsubcuenta' => 'DESC']], + 'desc-asc' => ['sort-by-description-asc', ['descripcion' => 'ASC']], + 'desc-desc' => ['sort-by-description-desc', ['descripcion' => 'DESC']], + ]) + ->setExtraFilters($subcuentaExtraFilters) + ->setExtraWhere($subcuentaExtraWhere) + ->setNewUrl((new Subcuenta())->url('new')) + ->setLabel('expense-subaccount') + ->setLabelUrl('ListCuenta') + ->setDescription('related-subaccount-bank-charges') + ->setCols(4) + ); + + // Grupo flags: alineados al fondo + $this->startGroup('extra', alignBottom: true); + + $this->addComponent(ComponentCheckbox::make('activa')->setLabel('active')); + + // Vista de subcuentas debajo del formulario (igual que EditCuentaBanco original) + $this->addListView('ListSubcuenta', 'Subcuenta', 'subaccounts', 'fa-solid fa-book') + ->addSearchFields(['codsubcuenta', 'descripcion', 'codejercicio']) + ->addOrderBy(['codejercicio'], 'exercise', 2) + ->setSettings('btnNew', false) + ->setSettings('btnDelete', false); + + // Evento para generar la subcuenta contable + $this->onEvent('generate-subaccount', fn() => $this->generateSubaccountAction()); + } + + protected function modifyUI(): void + { + parent::modifyUI(); + + $model = $this->editModel; + if ($model === null || !$model->exists()) { + return; + } + + $list = $this->listView('ListSubcuenta'); + if ($list === null) { + return; + } + + // Procesa parámetros de búsqueda/orden/paginación del request + $list->processFormData($this->request, 'load'); + + $codsubcuenta = $model->codsubcuenta ?? ''; + $codejercicios = $this->getExerciseCodesOfCompany($model->idempresa ?? null); + + if (empty($codejercicios) || empty($codsubcuenta)) { + return; + } + + $where = [ + Where::in('codejercicio', $codejercicios), + Where::eq('codsubcuenta', $codsubcuenta), + ]; + $codsubcuentagasto = $model->codsubcuentagasto ?? ''; + if ($codsubcuentagasto && $codsubcuentagasto !== $codsubcuenta) { + $where[] = Where::orEq('codsubcuenta', $codsubcuentagasto); + } + + $list->loadData('', $where, ['codejercicio' => 'DESC']); + unset($list->totalAmounts['saldo']); + } + + protected function generateSubaccountAction(): ActionResult + { + if (false === $this->permissions->allowUpdate) { + Tools::log()->warning('not-allowed-update'); + return ActionResult::make(); + } + + if (false === $this->validateFormToken()) { + return ActionResult::make(); + } + + $model = $this->loadModel(); + if ($model === null || !$model->exists()) { + Tools::log()->warning('record-not-found'); + return ActionResult::make(); + } + + if (!empty($model->codsubcuenta)) { + return ActionResult::make(); + } + + $ejercicio = new Ejercicio(); + $where = [ + Where::eq('idempresa', $model->idempresa), + Where::eq('estado', Ejercicio::EXERCISE_STATUS_OPEN), + ]; + if (false === $ejercicio->loadWhere($where, ['fechainicio' => 'DESC'])) { + Tools::log()->warning('exercise-not-found'); + return ActionResult::make(); + } + + $subcuenta = $model->createSubcuenta($ejercicio->codejercicio); + if (empty($subcuenta->codsubcuenta)) { + Tools::log()->error('record-save-error'); + return ActionResult::make(); + } + + Tools::log()->notice('record-updated-correctly'); + return ActionResult::make()->withRedirect( + $this->url() . '?code=' . urlencode($model->primaryColumnValue()) . '&action=save-ok' + ); + } + + private function getExerciseCodesOfCompany(?int $idempresa): array + { + if ($idempresa === null) { + return []; + } + + $result = []; + foreach (Ejercicio::all([Where::eq('idempresa', $idempresa)], [], 0, 0) as $ej) { + $result[] = $ej->codejercicio; + } + return $result; + } +} diff --git a/Core/Controller/NewEditFabricante.php b/Core/Controller/NewEditFabricante.php new file mode 100644 index 0000000000..861b9450fd --- /dev/null +++ b/Core/Controller/NewEditFabricante.php @@ -0,0 +1,264 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Base\DataBase\DataBaseWhere; +use FacturaScripts\Core\Component\ActionResult; +use FacturaScripts\Core\Component\ComponentNumber; +use FacturaScripts\Core\Component\ComponentText; +use FacturaScripts\Core\DataSrc\Impuestos; +use FacturaScripts\Core\Model\CodeModel; +use FacturaScripts\Core\Tools; +use FacturaScripts\Core\UIComponents\UIEditController; +use FacturaScripts\Core\Where; +use FacturaScripts\Dinamic\Model\Producto; + +/** + * Formulario de edición de fabricantes construido sobre UIEditController. + * + * Replica EditFabricante con: + * - Formulario del fabricante (nombre, codfabricante, numproductos) + * - Lista de productos asignados con botón remove-product + * - Lista de productos sin fabricante para añadir con botón add-product + * - Filtros completos en ambas listas (estado, familia, precio, stock, impuesto...) + * + * @author Abderrahim Darghal Belkacemi + */ +class NewEditFabricante extends UIEditController +{ + public function getModelClassName(): string + { + return 'Fabricante'; + } + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'warehouse'; + $data['title'] = 'manufacturer'; + $data['icon'] = 'fa-solid fa-industry'; + return $data; + } + + public function listUrl(): string + { + return 'NewListFabricante'; + } + + protected function getViewName(): string + { + return 'EditFabricante'; + } + + protected function buildForm(): void + { + $this->loadModel(); + + $this->startGroup('data'); + + $this->addComponent( + ComponentText::make('nombre') + ->setLabel('name') + ->setRequired() + ->setMaxLength(100) + ); + + $this->addComponent( + ComponentText::make('codfabricante') + ->setLabel('code') + ->setIcon('fa-solid fa-hashtag') + ->setMaxLength(8) + ->setReadOnlyDynamic() + ->setCols(2) + ); + + $this->addComponent( + ComponentNumber::make('numproductos') + ->setLabel('products') + ->setReadOnly() + ->setDecimals(0) + ->setCols(2) + ); + + $this->buildProductViews(); + + $this->onEvent('add-product', fn() => $this->addProductAction()); + $this->onEvent('remove-product', fn() => $this->removeProductAction()); + } + + protected function modifyUI(): void + { + parent::modifyUI(); + + $model = $this->editModel; + if ($model === null || !$model->exists()) { + foreach ($this->listViews() as $view) { + $view->settings['active'] = false; + } + return; + } + + $code = $model->codfabricante ?? ''; + + $listAssigned = $this->listView('ListProducto'); + if ($listAssigned !== null) { + $listAssigned->processFormData($this->request, 'load'); + $listAssigned->loadData('', [Where::eq('codfabricante', $code)]); + $listAssigned->disableColumn('manufacturer'); + } + + $listNew = $this->listView('ListProducto-new'); + if ($listNew !== null) { + $listNew->processFormData($this->request, 'load'); + $listNew->loadData('', [new DataBaseWhere('codfabricante', null, 'IS')]); + $listNew->disableColumn('manufacturer'); + } + } + + private function buildProductViews(): void + { + $i18n = Tools::lang(); + $families = CodeModel::all('familias', 'codfamilia', 'descripcion'); + $taxes = Impuestos::codeModel(); + + $statusFilter = [ + ['label' => $i18n->trans('only-active'), 'where' => [new DataBaseWhere('bloqueado', false)]], + ['label' => $i18n->trans('blocked'), 'where' => [new DataBaseWhere('bloqueado', true)]], + ['label' => $i18n->trans('public'), 'where' => [new DataBaseWhere('publico', true)]], + ['label' => $i18n->trans('all'), 'where' => []], + ]; + + // productos asignados a este fabricante + $listAssigned = $this->addListView('ListProducto', 'Producto', 'products', 'fa-solid fa-cubes'); + $listAssigned->addSearchFields(['descripcion', 'referencia']) + ->addOrderBy(['referencia'], 'reference', 1) + ->addOrderBy(['precio'], 'price') + ->addOrderBy(['stockfis'], 'stock') + ->addFilterSelectWhere('status', $statusFilter) + ->addFilterSelect('codfamilia', 'family', 'codfamilia', $families) + ->addFilterNumber('min-price', 'price', 'precio', '<=') + ->addFilterNumber('max-price', 'price', 'precio', '>=') + ->addFilterNumber('min-stock', 'stock', 'stockfis', '<=') + ->addFilterNumber('max-stock', 'stock', 'stockfis', '>=') + ->addFilterSelect('codimpuesto', 'tax', 'codimpuesto', $taxes) + ->addFilterCheckbox('nostock', 'no-stock', 'nostock') + ->addFilterCheckbox('ventasinstock', 'allow-sale-without-stock', 'ventasinstock') + ->addFilterCheckbox('secompra', 'for-purchase', 'secompra') + ->addFilterCheckbox('sevende', 'for-sale', 'sevende') + ->addFilterCheckbox('publico', 'public', 'publico') + ->setSettings('btnNew', false) + ->setSettings('btnDelete', false); + + $listAssigned->addButton([ + 'action' => 'remove-product', + 'color' => 'danger', + 'confirm' => true, + 'icon' => 'fa-solid fa-folder-minus', + 'label' => 'remove-from-list', + ]); + + // productos sin fabricante (para añadir a este) + $listNew = $this->addListView('ListProducto-new', 'Producto', 'add', 'fa-solid fa-folder-plus'); + $listNew->addSearchFields(['descripcion', 'referencia']) + ->addOrderBy(['referencia'], 'reference', 1) + ->addOrderBy(['precio'], 'price') + ->addOrderBy(['stockfis'], 'stock') + ->addFilterSelectWhere('status', $statusFilter) + ->addFilterSelect('codfamilia', 'family', 'codfamilia', $families) + ->addFilterNumber('min-price', 'price', 'precio', '<=') + ->addFilterNumber('max-price', 'price', 'precio', '>=') + ->addFilterNumber('min-stock', 'stock', 'stockfis', '<=') + ->addFilterNumber('max-stock', 'stock', 'stockfis', '>=') + ->addFilterSelect('codimpuesto', 'tax', 'codimpuesto', $taxes) + ->addFilterCheckbox('nostock', 'no-stock', 'nostock') + ->addFilterCheckbox('ventasinstock', 'allow-sale-without-stock', 'ventasinstock') + ->addFilterCheckbox('secompra', 'for-purchase', 'secompra') + ->addFilterCheckbox('sevende', 'for-sale', 'sevende') + ->addFilterCheckbox('publico', 'public', 'publico') + ->setSettings('btnNew', false) + ->setSettings('btnDelete', false); + + $listNew->addButton([ + 'action' => 'add-product', + 'color' => 'success', + 'icon' => 'fa-solid fa-folder-plus', + 'label' => 'add', + ]); + } + + private function addProductAction(): ActionResult + { + if (false === $this->permissions->allowUpdate) { + Tools::log()->warning('not-allowed-update'); + return ActionResult::make(); + } + + if (false === $this->validateFormToken()) { + return ActionResult::make(); + } + + $num = 0; + $codfabricante = $this->request->query('code'); + $codes = $this->request->request->getArray('codes', false); + + foreach ($codes as $code) { + $product = new Producto(); + if (false === $product->loadFromCode($code)) { + continue; + } + $product->codfabricante = $codfabricante; + if ($product->save()) { + $num++; + } + } + + Tools::log()->notice('items-added-correctly', ['%num%' => $num]); + return ActionResult::make(); + } + + private function removeProductAction(): ActionResult + { + if (false === $this->permissions->allowUpdate) { + Tools::log()->warning('not-allowed-update'); + return ActionResult::make(); + } + + if (false === $this->validateFormToken()) { + return ActionResult::make(); + } + + $num = 0; + $codes = $this->request->request->getArray('codes', false); + + foreach ($codes as $code) { + $product = new Producto(); + if (false === $product->loadFromCode($code)) { + continue; + } + $product->codfabricante = null; + if ($product->save()) { + $num++; + } + } + + Tools::log()->notice('items-removed-correctly', ['%num%' => $num]); + return ActionResult::make(); + } +} diff --git a/Core/Controller/NewEditFormaPago.php b/Core/Controller/NewEditFormaPago.php new file mode 100644 index 0000000000..10e961a08b --- /dev/null +++ b/Core/Controller/NewEditFormaPago.php @@ -0,0 +1,164 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Component\ComponentCheckbox; +use FacturaScripts\Core\Component\ComponentNumber; +use FacturaScripts\Core\Component\ComponentSelect; +use FacturaScripts\Core\Component\ComponentText; +use FacturaScripts\Core\Model\CuentaBanco; +use FacturaScripts\Core\Model\Empresa; +use FacturaScripts\Core\Tools; +use FacturaScripts\Core\UIComponents\UIEditController; + +/** + * Formulario de edición y creación de formas de pago construido sobre UIEditController. + * + * No visible en el menú (showonmenu = false, heredado de UIEditController). Se accede + * desde NewListFormaPago mediante el parámetro ?code=. Sin código crea un + * registro nuevo. Los handlers de guardado y borrado se registran automáticamente. + * + * @author Abderrahim Darghal Belkacemi + */ +class NewEditFormaPago extends UIEditController +{ + public function getModelClassName(): string + { + return 'FormaPago'; + } + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'accounting'; + $data['title'] = 'payment-method'; + $data['icon'] = 'fa-solid fa-credit-card'; + return $data; + } + + public function listUrl(): string + { + return 'NewListFormaPago'; + } + + protected function getViewName(): string + { + return 'EditFormaPago'; + } + + protected function buildForm(): void + { + $this->loadModel(); + + // Grupo principal: datos del pago + $this->startGroup('data'); + + $this->addComponent( + ComponentText::make('codpago') + ->setLabel('code') + ->setIcon('fa-solid fa-hashtag') + ->setMaxLength(10) + ->setRequired() + ->setReadOnlyDynamic() + ->setDisplay('none') // oculto por defecto, igual que display="none" en EditFormaPago.xml + ->addRule(fn($v, $lang) => + !preg_match('/^[A-Z0-9_+.\- ]{1,10}$/i', (string) $v) + ? $lang->trans('invalid-alphanumeric-code') + : null + ) + ->setCols(3) + ); + + $this->addComponent( + ComponentText::make('descripcion') + ->setLabel('description') + ->setRequired() + ->setMaxLength(100) + ); + + $this->addComponent( + ComponentNumber::make('plazovencimiento') + ->setLabel('expiration') + ->setMin(0) + ->setDecimals(0) + ->setCols(2) + ); + + $this->addComponent( + ComponentSelect::make('tipovencimiento') + ->setLabel('expiration-type') + ->setRequired() + ->setCols(2) + ->setValuesFromArrayKeys([ + 'days' => Tools::lang()->trans('days'), + 'weeks' => Tools::lang()->trans('weeks'), + 'months' => Tools::lang()->trans('months'), + 'years' => Tools::lang()->trans('years'), + ]) + ); + + $empresas = (new Empresa())->all(); + if (count($empresas) > 1) { + $this->addComponent( + ComponentSelect::make('idempresa') + ->setLabel('company') + ->setRequired() + ->setReadOnlyDynamic() + ->setCols(4) + ->setSource('empresas', 'idempresa', 'nombrecorto') + ->setOptionsResolver(fn() => array_map( + fn($e) => ['value' => $e->idempresa, 'title' => $e->nombrecorto, 'group' => ''], + $empresas + )) + ); + } else { + // Single company: hidden input preserving the value + $this->addComponent( + ComponentSelect::make('idempresa') + ->setDisplay('none') + ->setValue($empresas[0]->idempresa ?? null) + ); + } + + $cuentas = (new CuentaBanco())->all([], ['codcuenta' => 'ASC']); + $this->addComponent( + ComponentSelect::make('codcuentabanco') + ->setLabel('bank-account') + ->setLabelUrl('NewListFormaPago?activetab=ListCuentaBanco') + ->setCols(4) + ->setSource('cuentasbanco', 'codcuenta', 'descripcion') + ->setOptionsResolver(function () use ($cuentas) { + $opts = [['value' => '', 'title' => '------', 'group' => '']]; + foreach ($cuentas as $c) { + $opts[] = ['value' => $c->codcuenta, 'title' => $c->descripcion, 'group' => '']; + } + return $opts; + }) + ); + + // Grupo secundario: flags booleanos, alineados al fondo + $this->startGroup('advanced', alignBottom: true); + + $this->addComponent(ComponentCheckbox::make('activa')->setLabel('active')); + $this->addComponent(ComponentCheckbox::make('domiciliado')->setLabel('domiciled')); + $this->addComponent(ComponentCheckbox::make('pagado')->setLabel('paid')); + $this->addComponent(ComponentCheckbox::make('imprimir')->setLabel('print-bank-data')); + } +} diff --git a/Core/Controller/NewEditProducto.php b/Core/Controller/NewEditProducto.php new file mode 100644 index 0000000000..9380b6bea0 --- /dev/null +++ b/Core/Controller/NewEditProducto.php @@ -0,0 +1,469 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Component\ComponentCheckbox; +use FacturaScripts\Core\Component\ComponentDate; +use FacturaScripts\Core\Component\ComponentNumber; +use FacturaScripts\Core\Component\ComponentSelect; +use FacturaScripts\Core\Component\ComponentText; +use FacturaScripts\Core\Component\ComponentTextarea; +use FacturaScripts\Core\DataSrc\Almacenes; +use FacturaScripts\Core\DataSrc\Impuestos; +use FacturaScripts\Core\Lib\ExtendedController\BaseView; +use FacturaScripts\Core\Lib\ExtendedController\DocFilesTrait; +use FacturaScripts\Core\Lib\ExtendedController\ProductImagesTrait; +use FacturaScripts\Core\Lib\ProductType; +use FacturaScripts\Core\Lib\TaxExceptions; +use FacturaScripts\Core\Model\CodeModel; +use FacturaScripts\Core\Model\ProductoImagen; +use FacturaScripts\Core\UIComponents\UIEditController; +use FacturaScripts\Core\Where; +use FacturaScripts\Dinamic\Model\Atributo; + +/** + * Formulario de edición de productos construido sobre UIEditController. + * + * @author Abderrahim Darghal Belkacemi + */ +class NewEditProducto extends UIEditController +{ + use DocFilesTrait; + use ProductImagesTrait; + + public function getModelClassName(): string + { + return 'Producto'; + } + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'warehouse'; + $data['title'] = 'product'; + $data['icon'] = 'fa-solid fa-cube'; + return $data; + } + + public function listUrl(): string + { + return 'NewListProducto'; + } + + protected function getViewName(): string + { + return 'EditProducto'; + } + + protected function buildForm(): void + { + $this->loadModel(); + + CodeModel::setLimit(9999); + + // Grupo principal: identificación y clasificación + $this->startGroup('main'); + + $this->addComponent( + ComponentText::make('referencia') + ->setLabel('reference') + ->setIcon('fa-solid fa-hashtag') + ->setMaxLength(30) + ->setReadOnlyDynamic() + ->setCols(3) + ); + + $manufacturerOpts = [['value' => '', 'title' => '------', 'group' => '']]; + foreach (CodeModel::all('fabricantes', 'codfabricante', 'nombre', false) as $c) { + $manufacturerOpts[] = ['value' => $c->code, 'title' => $c->description, 'group' => '']; + } + $this->addComponent( + ComponentSelect::make('codfabricante') + ->setLabel('manufacturer') + ->setLabelUrl('ListFabricante') + ->setSource('fabricantes', 'codfabricante', 'nombre') + ->setOptionsResolver(fn() => $manufacturerOpts) + ); + + $familyOpts = [['value' => '', 'title' => '------', 'group' => '']]; + foreach (CodeModel::all('familias', 'codfamilia', 'descripcion', false) as $c) { + $familyOpts[] = ['value' => $c->code, 'title' => $c->description, 'group' => '']; + } + $this->addComponent( + ComponentSelect::make('codfamilia') + ->setLabel('family') + ->setLabelUrl('ListFamilia') + ->setSource('familias', 'codfamilia', 'descripcion') + ->setOptionsResolver(fn() => $familyOpts) + ); + + $taxOpts = [['value' => '', 'title' => '------', 'group' => '']]; + foreach (Impuestos::codeModel(false) as $c) { + $taxOpts[] = ['value' => $c->code, 'title' => $c->description, 'group' => '']; + } + $this->addComponent( + ComponentSelect::make('codimpuesto') + ->setLabel('tax') + ->setLabelUrl('ListImpuesto') + ->setSource('impuestos', 'codimpuesto', 'descripcion') + ->setOptionsResolver(fn() => $taxOpts) + ); + + $this->addComponent( + ComponentSelect::make('excepcioniva') + ->setLabel('vat-exception') + ->setValuesFromArrayKeys(TaxExceptions::all(), false, true) + ); + + // Grupo descripción + $this->startGroup('description'); + + $this->addComponent( + ComponentTextarea::make('descripcion') + ->setLabel('description') + ->setRequired() + ->setCols(12) + ); + + $this->addComponent( + ComponentTextarea::make('observaciones') + ->setLabel('observations') + ->setCols(12) + ); + + // Grupo opciones: flags booleanos alineados al fondo + $this->startGroup('options', alignBottom: true); + + $this->addComponent(ComponentCheckbox::make('nostock')->setLabel('no-stock')); + $this->addComponent(ComponentCheckbox::make('secompra')->setLabel('for-purchase')); + $this->addComponent(ComponentCheckbox::make('sevende')->setLabel('for-sale')); + $this->addComponent(ComponentCheckbox::make('ventasinstock')->setLabel('allow-sale-without-stock')); + $this->addComponent(ComponentCheckbox::make('bloqueado')->setLabel('blocked')); + $this->addComponent(ComponentCheckbox::make('publico')->setLabel('public')); + + // Grupo avanzado: datos técnicos y fechas + $this->startGroup('advanced'); + + $this->addComponent( + ComponentNumber::make('stockfis') + ->setLabel('stock') + ->setReadOnly() + ->setDecimals(2) + ->setCols(2) + ); + + $this->addComponent( + ComponentSelect::make('tipo') + ->setLabel('type') + ->setValuesFromArrayKeys(ProductType::all(), true, true) + ->setCols(2) + ); + + $this->addComponent( + ComponentDate::make('fechaalta') + ->setLabel('creation-date') + ->setReadOnly() + ->setCols(2) + ); + + $this->addComponent( + ComponentDate::make('actualizado') + ->setLabel('last-update') + ->setReadOnly() + ->setDatetime() + ->setCols(2) + ); + + // Grupo contabilidad + $this->startGroup('accounting', title: 'accounting'); + + $this->addComponent( + ComponentText::make('codsubcuentacom') + ->setLabel('subaccount-purchases') + ->setLabelUrl('ListCuenta') + ->setDescription('optional') + ->setCols(2) + ); + + $this->addComponent( + ComponentText::make('codsubcuentaven') + ->setLabel('subaccount-sales') + ->setLabelUrl('ListCuenta') + ->setDescription('optional') + ->setCols(2) + ); + + $this->addComponent( + ComponentText::make('codsubcuentairpfcom') + ->setLabel('subaccount-irpf') + ->setLabelUrl('ListCuenta') + ->setDescription('optional') + ->setDisplay('none') + ->setCols(2) + ); + + $this->addEditListView('EditVariante', 'Variante', 'variants', 'fa-solid fa-project-diagram'); + $this->addEditListView('EditStock', 'Stock', 'stock', 'fa-solid fa-dolly'); + $this->addEditListView('EditProductoProveedor', 'ProductoProveedor', 'suppliers', 'fa-solid fa-users'); + + $pedidosCliente = $this->addListView('ListLineaPedidoCliente', 'LineaPedidoCliente', 'reserved', 'fa-solid fa-lock'); + $pedidosCliente->addSearchFields(['referencia', 'descripcion']) + ->addOrderBy(['referencia'], 'reference') + ->addOrderBy(['cantidad'], 'quantity') + ->addOrderBy(['servido'], 'quantity-served') + ->addOrderBy(['descripcion'], 'description') + ->addOrderBy(['pvptotal'], 'amount') + ->addOrderBy(['idlinea'], 'code', 2) + ->addFilterSelect('referencia', 'reference', 'referencia', []) + ->addFilterNumber('cantidad-gt', 'quantity', 'cantidad', '>=') + ->addFilterNumber('cantidad-lt', 'quantity', 'cantidad', '<=') + ->setSettings('btnNew', false) + ->setSettings('btnDelete', false) + ->setSettings('checkBoxes', false); + $pedidosCliente->disableColumn('product'); + + $pedidosProv = $this->addListView('ListLineaPedidoProveedor', 'LineaPedidoProveedor', 'pending-reception', 'fa-solid fa-ship'); + $pedidosProv->addSearchFields(['referencia', 'descripcion']) + ->addOrderBy(['referencia'], 'reference') + ->addOrderBy(['cantidad'], 'quantity') + ->addOrderBy(['servido'], 'quantity-served') + ->addOrderBy(['descripcion'], 'description') + ->addOrderBy(['pvptotal'], 'amount') + ->addOrderBy(['idlinea'], 'code', 2) + ->addFilterSelect('referencia', 'reference', 'referencia', []) + ->addFilterNumber('cantidad-gt', 'quantity', 'cantidad', '>=') + ->addFilterNumber('cantidad-lt', 'quantity', 'cantidad', '<=') + ->setSettings('btnNew', false) + ->setSettings('btnDelete', false) + ->setSettings('checkBoxes', false); + $pedidosProv->disableColumn('product'); + + $this->createViewsProductImages(); + $this->createViewDocFiles(); + } + + protected function execHtmlAction(string $action): void + { + switch ($action) { + case 'add-image': + $this->addImageAction(); + break; + case 'delete-image': + $this->deleteImageAction(); + break; + case 'sort-images': + $this->sortImagesAction(); + break; + case 'add-file': + $this->addFileAction(); + break; + case 'delete-file': + $this->deleteFileAction(); + break; + case 'edit-file': + $this->editFileAction(); + break; + case 'unlink-file': + $this->unlinkFileAction(); + break; + case 'sort-files': + $this->sortFilesAction(); + break; + } + } + + protected function modifyUI(): void + { + parent::modifyUI(); + + $model = $this->editModel; + if ($model === null || !$model->exists()) { + foreach ($this->listViews() as $view) { + $view->settings['active'] = false; + } + foreach ($this->htmlViews() as $view) { + $view->settings['active'] = false; + } + return; + } + + $where = [Where::eq('idproducto', $model->idproducto)]; + + $variantesView = $this->listView('EditVariante'); + if ($variantesView !== null) { + $variantesView->processFormData($this->request, 'load'); + $variantesView->loadData('', $where, ['idvariante' => 'DESC']); + + $attCount = (new Atributo())->count(); + if ($attCount < 4) $variantesView->disableColumn('attribute-value-4'); + if ($attCount < 3) $variantesView->disableColumn('attribute-value-3'); + if ($attCount < 2) $variantesView->disableColumn('attribute-value-2'); + if ($attCount < 1) $variantesView->disableColumn('attribute-value-1'); + + $this->loadCustomAttributeWidgets('EditVariante'); + } + + $stockView = $this->listView('EditStock'); + if ($stockView !== null) { + if ($model->nostock) { + $stockView->settings['active'] = false; + } else { + $stockView->processFormData($this->request, 'load'); + $stockView->loadData('', $where, ['idstock' => 'DESC']); + if (count(Almacenes::all()) <= 1) { + $stockView->disableColumn('warehouse'); + } + $this->loadCustomReferenceWidget('EditStock'); + } + } + + $suppliersView = $this->listView('EditProductoProveedor'); + if ($suppliersView !== null) { + $suppliersView->processFormData($this->request, 'load'); + $suppliersView->loadData('', $where, ['id' => 'DESC']); + $this->loadCustomReferenceWidget('EditProductoProveedor'); + } + + $pedidosClienteView = $this->listView('ListLineaPedidoCliente'); + if ($pedidosClienteView !== null) { + $pedidosClienteView->processFormData($this->request, 'load'); + $whereCliente = array_merge($where, [Where::eq('actualizastock', -2)]); + $this->loadReferenceFilter($pedidosClienteView, $model->idproducto); + $pedidosClienteView->loadData('', $whereCliente); + $pedidosClienteView->settings['active'] = $pedidosClienteView->model->count($whereCliente) > 0; + } + + $pedidosProvView = $this->listView('ListLineaPedidoProveedor'); + if ($pedidosProvView !== null) { + $pedidosProvView->processFormData($this->request, 'load'); + $whereProv = array_merge($where, [Where::eq('actualizastock', 2)]); + $this->loadReferenceFilter($pedidosProvView, $model->idproducto); + $pedidosProvView->loadData('', $whereProv); + $pedidosProvView->settings['active'] = $pedidosProvView->model->count($whereProv) > 0; + } + + $imagesView = $this->htmlViews()['EditProductoImagen'] ?? null; + if ($imagesView !== null) { + $imagesView->loadData('', $where, ['orden' => 'ASC'], 0, 0); + } + + $docfilesView = $this->htmlViews()['docfiles'] ?? null; + if ($docfilesView !== null) { + $this->loadDataDocFiles($docfilesView, $this->getModelClassName(), $model->primaryColumnValue()); + } + } + + public function extraHeaderButtons(): string + { + $model = $this->editModel; + if ($model === null || !$model->exists()) { + return ''; + } + + $url = 'CopyModel?model=' . $this->getModelClassName() . '&code=' . urlencode($model->primaryColumnValue()); + $label = \FacturaScripts\Core\Tools::lang()->trans('copy'); + return '' + . '' . $label + . ''; + } + + protected function loadCustomAttributeWidgets(string $viewName): void + { + $columnsName = ['attribute-value-1', 'attribute-value-2', 'attribute-value-3', 'attribute-value-4']; + $view = $this->listView($viewName); + foreach ($columnsName as $key => $colName) { + $column = $view?->columnForName($colName); + if (empty($column) || $column->widget->getType() !== 'select') { + continue; + } + + $atributos = Atributo::all([ + Where::eq('num_selector', $key + 1), + Where::orEq('num_selector', 0), + ], ['nombre' => 'ASC']); + + $valoresAtributos = []; + foreach ($atributos as $atributo) { + foreach ($atributo->getValues() as $valor) { + $valoresAtributos[] = [ + 'value' => $valor->id, + 'title' => $valor->valor, + 'group' => $atributo->nombre, + ]; + } + } + + $column->widget->setValuesFromArray($valoresAtributos, false, true, 'value', 'title', 'group'); + } + } + + protected function loadCustomReferenceWidget(string $viewName): void + { + $id = $this->editModel?->idproducto; + if (empty($id)) { + return; + } + + $where = [Where::eq('idproducto', $id)]; + $references = []; + foreach (CodeModel::all('variantes', 'referencia', 'referencia', false, $where) as $code) { + $references[] = ['value' => $code->code, 'title' => $code->description]; + } + + $column = $this->listView($viewName)?->columnForName('reference'); + if ($column && $column->widget->getType() === 'select') { + $column->widget->setValuesFromArray($references, false); + } + } + + protected function loadReferenceFilter(BaseView $view, int $idproducto): void + { + if (!isset($view->filters['referencia'])) { + return; + } + + $values = [['code' => '', 'description' => '------']]; + $where = [Where::eq('idproducto', $idproducto)]; + foreach (CodeModel::all('variantes', 'referencia', 'referencia', false, $where) as $code) { + $values[] = ['code' => $code->code, 'description' => $code->description]; + } + + $view->filters['referencia']->values = $values; + } + + protected function sortImagesAction(): void + { + $idsOrdenadas = $this->request->request->getArray('orden', false); + if (!empty($idsOrdenadas) && is_array($idsOrdenadas)) { + $orden = 1; + foreach ($idsOrdenadas as $idImagen) { + $productoImagen = new ProductoImagen(); + $productoImagen->load($idImagen); + $productoImagen->orden = $orden; + if ($productoImagen->save()) { + $orden++; + } + } + } + + $this->setTemplate(false); + $this->response->json(['status' => 'ok']); + } +} diff --git a/Core/Controller/NewListAsiento.php b/Core/Controller/NewListAsiento.php new file mode 100644 index 0000000000..bae57af063 --- /dev/null +++ b/Core/Controller/NewListAsiento.php @@ -0,0 +1,306 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Base\DataBase\DataBaseWhere; +use FacturaScripts\Core\Component\ComponentCheckbox; +use FacturaScripts\Core\Component\ComponentNumber; +use FacturaScripts\Core\Component\ComponentText; +use FacturaScripts\Core\Lib\MultiRequestProtection; +use FacturaScripts\Core\Model\Asiento; +use FacturaScripts\Core\Tools; +use FacturaScripts\Core\UIComponents\UIListController; +use FacturaScripts\Dinamic\Model\Ejercicio; + +/** + * Listado de asientos contables construido sobre UIListController. + * + * Replica el comportamiento de ListAsiento con cuatro pestañas: + * - ListAsiento: asientos contables (Asiento) + * - ListAsiento-not: asientos desbalanceados (Asiento con filtro SQL) + * - ListConceptoPartida: conceptos predefinidos (ConceptoPartida) + * - ListDiario: diarios (Diario) + * + * @author Abderrahim Darghal Belkacemi + */ +class NewListAsiento extends UIListController +{ + public function getModelClassName(): string + { + return 'Asiento'; + } + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'accounting'; + $data['title'] = 'new-accounting-entries'; + $data['icon'] = 'fa-solid fa-balance-scale'; + return $data; + } + + protected function createUI(): void + { + $this->createViewsAccountEntries(); + $this->createViewsNotBalanced(); + $this->createViewsConcepts(); + $this->createViewsJournals(); + } + + protected function createViewsAccountEntries(string $tabName = 'ListAsiento'): void + { + $tab = $this->addTab($tabName, 'Asiento', 'accounting-entries', 'fa-solid fa-balance-scale'); + + $tab->addColumn(ComponentText::make('numero')->setLabel('number')->setCols(2)); + $tab->addColumn(ComponentText::make('fecha')->setLabel('date')->setCols(2)); + $tab->addColumn(ComponentText::make('concepto')->setLabel('concept')); + $tab->addColumn(ComponentText::make('documento')->setLabel('document')->setCols(2)); + $tab->addColumn(ComponentNumber::make('importe')->setLabel('amount')->setCols(2)); + $tab->addColumn(ComponentCheckbox::make('editable')->setLabel('editable')); + + $tab->addSearchField('concepto', 'documento', 'CAST(numero AS char(255))'); + + $tab->addOrderBy(['fecha', 'numero'], 'date', 2); + $tab->addOrderBy(['numero', 'idasiento'], 'number'); + $tab->addOrderBy(['importe', 'idasiento'], 'amount'); + + $tab->addColor('editable', false, 'table-warning', 'locked'); + + $tab->setNewUrl('NewEditAsiento'); + $tab->setRowUrlCallback(fn($r) => 'NewEditAsiento?code=' . urlencode((string)$r->idasiento)); + + if ($this->permissions->allowUpdate) { + $tab->addButtonGroup('entry-actions', 'fa-solid fa-circle-check', 'actions') + ->addGroupButton('entry-actions', [ + 'action' => 'lock-entries', + 'confirm' => true, + 'icon' => 'fa-solid fa-lock', + 'label' => 'lock-entry', + ]) + ->addGroupButton('entry-actions', [ + 'action' => 'renumber', + 'icon' => 'fa-solid fa-sort-numeric-down', + 'label' => 'renumber', + 'type' => 'modal', + 'target' => 'renumberModal', + ]); + } + } + + protected function createViewsNotBalanced(string $tabName = 'ListAsiento-not'): void + { + $db = $this->dataBase; + + $tab = $this->addTab($tabName, 'Asiento', 'unbalance', 'fa-solid fa-exclamation-circle'); + + $tab->addColumn(ComponentText::make('numero')->setLabel('number')->setCols(2)); + $tab->addColumn(ComponentText::make('fecha')->setLabel('date')->setCols(2)); + $tab->addColumn(ComponentText::make('concepto')->setLabel('concept')); + $tab->addColumn(ComponentText::make('documento')->setLabel('document')->setCols(2)); + $tab->addColumn(ComponentNumber::make('importe')->setLabel('amount')->setCols(2)); + + $tab->addSearchField('concepto', 'documento', 'CAST(numero AS char(255))'); + + $tab->addOrderBy(['fecha', 'idasiento'], 'date', 2); + $tab->addOrderBy(['numero', 'idasiento'], 'number'); + $tab->addOrderBy(['importe', 'idasiento'], 'amount'); + + $tab->addColor('editable', false, 'table-warning', 'locked'); + + $tab->setRowUrlCallback(fn($r) => 'NewEditAsiento?code=' . urlencode((string)$r->idasiento)); + + // Filtro: solo asientos desbalanceados (calculado via SQL al cargar) + $tab->setExtraWhere(function () use ($db): array { + $sql = Tools::config('db_type') === 'postgresql' + ? 'SELECT partidas.idasiento FROM partidas GROUP BY 1 HAVING ABS(SUM(partidas.debe) - SUM(partidas.haber)) >= 0.01' + : 'SELECT partidas.idasiento FROM partidas GROUP BY 1 HAVING ROUND(ABS(SUM(partidas.debe) - SUM(partidas.haber)), 2) >= 0.01'; + + $ids = []; + foreach ($db->select($sql) as $row) { + $ids[] = (int)$row['idasiento']; + } + + // Si no hay desbalanceados, usar condición imposible para retornar vacío + return empty($ids) + ? [new DataBaseWhere('idasiento', 0)] + : [new DataBaseWhere('idasiento', implode(',', $ids), 'IN')]; + }); + } + + protected function createViewsConcepts(string $tabName = 'ListConceptoPartida'): void + { + $tab = $this->addTab($tabName, 'ConceptoPartida', 'predefined-concepts', 'fa-solid fa-indent'); + + $tab->addColumn(ComponentText::make('codconcepto')->setLabel('code')->setCols(3)); + $tab->addColumn(ComponentText::make('descripcion')->setLabel('description')); + + $tab->addSearchField('codconcepto', 'descripcion'); + + $tab->addOrderBy(['codconcepto'], 'code'); + $tab->addOrderBy(['descripcion'], 'description', 1); + + $tab->setNewUrl('EditConceptoPartida?code=new'); + $tab->setRowUrlCallback(fn($r) => 'EditConceptoPartida?code=' . urlencode((string)$r->codconcepto)); + } + + protected function createViewsJournals(string $tabName = 'ListDiario'): void + { + $tab = $this->addTab($tabName, 'Diario', 'journals', 'fa-solid fa-book'); + + $tab->addColumn(ComponentNumber::make('iddiario')->setLabel('code')->setDecimals(0)->setCols(2)); + $tab->addColumn(ComponentText::make('descripcion')->setLabel('description')); + + $tab->addSearchField('descripcion'); + + $tab->addOrderBy(['iddiario'], 'code'); + $tab->addOrderBy(['descripcion'], 'description', 1); + + $tab->setNewUrl('EditDiario?code=new'); + $tab->setRowUrlCallback(fn($r) => 'EditDiario?code=' . urlencode((string)$r->iddiario)); + } + + protected function execPreviousAction(string $action): bool + { + switch ($action) { + case 'lock-entries': + $this->lockEntriesAction(); + return true; + + case 'renumber': + $this->renumberAction(); + return true; + } + + return parent::execPreviousAction($action); + } + + protected function lockEntriesAction(): void + { + if (false === $this->permissions->allowUpdate) { + Tools::log()->warning('not-allowed-modify'); + return; + } + + if (false === $this->validateFormToken()) { + return; + } + + $codes = $this->request->request->getArray('codes'); + if (false === is_array($codes) || empty($codes)) { + Tools::log()->warning('no-selected-item'); + return; + } + + $model = new \FacturaScripts\Dinamic\Model\Asiento(); + + $this->dataBase->beginTransaction(); + foreach ($codes as $code) { + if (false === $model->loadFromCode($code)) { + Tools::log()->error('record-not-found'); + continue; + } + + if (false === $model->editable) { + continue; + } + + $model->editable = false; + if (false === $model->save()) { + Tools::log()->error('record-save-error'); + $this->dataBase->rollback(); + $model->clear(); + return; + } + } + + Tools::log()->notice('record-updated-correctly'); + $this->dataBase->commit(); + $model->clear(); + } + + protected function renumberAction(): void + { + if (false === $this->permissions->allowUpdate) { + Tools::log()->warning('not-allowed-modify'); + return; + } + + if (false === $this->validateFormToken()) { + return; + } + + $codejercicio = $this->request->input('exercise'); + $model = new \FacturaScripts\Dinamic\Model\Asiento(); + + $this->dataBase->beginTransaction(); + if ($model->renumber($codejercicio)) { + Tools::log()->notice('renumber-accounting-ok'); + $this->dataBase->commit(); + return; + } + + $this->dataBase->rollback(); + Tools::log()->error('record-save-error'); + } + + public function tabModals(string $tabName): string + { + if ($tabName !== 'ListAsiento' || false === $this->permissions->allowUpdate) { + return ''; + } + + $lang = Tools::lang(); + $ejercicios = Ejercicio::all([], ['codejercicio' => 'DESC'], 0, 0); + + $options = ''; + foreach ($ejercicios as $ej) { + $options .= ''; + } + + $token = ''; + + return ''; + } +} diff --git a/Core/Controller/NewListAtributo.php b/Core/Controller/NewListAtributo.php new file mode 100644 index 0000000000..d3be0f3421 --- /dev/null +++ b/Core/Controller/NewListAtributo.php @@ -0,0 +1,94 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Component\ComponentNumber; +use FacturaScripts\Core\Component\ComponentText; +use FacturaScripts\Core\Model\Atributo; +use FacturaScripts\Core\UIComponents\UIListController; + +/** + * Listado de atributos y sus valores construido sobre UIListController. + * + * Replica ListAtributo con dos pestañas: + * - ListAtributo: atributos de artículo + * - ListAtributoValor: valores de atributo con filtro por atributo + * + * @author Abderrahim Darghal Belkacemi + */ +class NewListAtributo extends UIListController +{ + public function getModelClassName(): string + { + return 'Atributo'; + } + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'warehouse'; + $data['title'] = 'new-atributos'; + $data['icon'] = 'fa-solid fa-tshirt'; + return $data; + } + + protected function createUI(): void + { + $this->createViewsAttributes(); + $this->createViewsValues(); + } + + protected function createViewsAttributes(string $tabName = 'ListAtributo'): void + { + $tab = $this->addTab($tabName, 'Atributo', 'attributes', 'fa-solid fa-tshirt'); + + $tab->addColumn(ComponentText::make('codatributo')->setLabel('code')->setCols(2)); + $tab->addColumn(ComponentText::make('nombre')->setLabel('name')); + $tab->addColumn(ComponentNumber::make('num_selector')->setLabel('selector-number')->setDecimals(0)->setCols(2)); + + $tab->addSearchField('codatributo', 'nombre'); + + $tab->addOrderBy(['codatributo'], 'code', 1); + $tab->addOrderBy(['nombre'], 'name'); + + $tab->setNewUrl('NewEditAtributo'); + $tab->setRowUrlCallback(fn($record) => 'NewEditAtributo?code=' . urlencode($record->codatributo)); + } + + protected function createViewsValues(string $tabName = 'ListAtributoValor'): void + { + $tab = $this->addTab($tabName, 'AtributoValor', 'values', 'fa-solid fa-list'); + + $tab->addColumn(ComponentText::make('codatributo')->setLabel('attribute')->setCols(2)); + $tab->addColumn(ComponentText::make('valor')->setLabel('value')); + $tab->addColumn(ComponentNumber::make('orden')->setLabel('sort')->setDecimals(0)->setAlign('right')->setCols(2)); + + $tab->addSearchField('valor', 'codatributo'); + + $tab->addOrderBy(['codatributo', 'orden', 'valor'], 'sort', 1); + $tab->addOrderBy(['valor'], 'value'); + + $atributos = (new Atributo())->all([], ['nombre' => 'ASC']); + $options = array_map(fn($a) => ['value' => $a->codatributo, 'title' => $a->nombre], $atributos); + $tab->addFilterSelect('codatributo', 'attribute', 'codatributo', $options); + + $tab->setRowUrlCallback(fn($record) => 'NewEditAtributo?code=' . urlencode($record->codatributo)); + } +} diff --git a/Core/Controller/NewListFabricante.php b/Core/Controller/NewListFabricante.php new file mode 100644 index 0000000000..1f749f2335 --- /dev/null +++ b/Core/Controller/NewListFabricante.php @@ -0,0 +1,71 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Component\ComponentNumber; +use FacturaScripts\Core\Component\ComponentText; +use FacturaScripts\Core\UIComponents\UIListController; + +/** + * Listado de fabricantes construido sobre UIListController. + * + * Replica ListFabricante mostrando código, nombre y número de productos. + * + * @author Abderrahim Darghal Belkacemi + */ +class NewListFabricante extends UIListController +{ + public function getModelClassName(): string + { + return 'Fabricante'; + } + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'warehouse'; + $data['title'] = 'manufacturers'; + $data['icon'] = 'fa-solid fa-industry'; + return $data; + } + + protected function createUI(): void + { + $this->createViewsManufacturers(); + } + + protected function createViewsManufacturers(string $tabName = 'ListFabricante'): void + { + $tab = $this->addTab($tabName, 'Fabricante', 'manufacturers', 'fa-solid fa-industry'); + + $tab->addColumn(ComponentText::make('codfabricante')->setLabel('code')->setCols(2)); + $tab->addColumn(ComponentText::make('nombre')->setLabel('name')); + $tab->addColumn(ComponentNumber::make('numproductos')->setLabel('products')->setDecimals(0)->setAlign('right')->setCols(2)); + + $tab->addSearchField('codfabricante', 'nombre'); + + $tab->addOrderBy(['codfabricante'], 'code', 1); + $tab->addOrderBy(['nombre'], 'name'); + $tab->addOrderBy(['numproductos'], 'products'); + + $tab->setNewUrl('NewEditFabricante'); + $tab->setRowUrlCallback(fn($record) => 'NewEditFabricante?code=' . urlencode($record->codfabricante)); + } +} diff --git a/Core/Controller/NewListFormaPago.php b/Core/Controller/NewListFormaPago.php new file mode 100644 index 0000000000..d4f2b92fda --- /dev/null +++ b/Core/Controller/NewListFormaPago.php @@ -0,0 +1,113 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Component\ComponentCheckbox; +use FacturaScripts\Core\Component\ComponentNumber; +use FacturaScripts\Core\Component\ComponentSelect; +use FacturaScripts\Core\Component\ComponentText; +use FacturaScripts\Core\Model\Empresa; +use FacturaScripts\Core\UIComponents\UIListController; + +/** + * Listado de formas de pago construido sobre UIListController. + * + * Replica el comportamiento de ListFormaPago con dos pestañas: + * - ListFormaPago: formas de pago (FormaPago) + * - ListCuentaBanco: cuentas bancarias (CuentaBanco) + * + * @author Abderrahim Darghal Belkacemi + */ +class NewListFormaPago extends UIListController +{ + public function getModelClassName(): string + { + return 'FormaPago'; + } + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'accounting'; + $data['title'] = 'new-forma-pago'; + $data['icon'] = 'fa-solid fa-credit-card'; + return $data; + } + + protected function createUI(): void + { + $this->createViewsPaymentMethods(); + $this->createViewsBankAccounts(); + } + + protected function createViewsPaymentMethods(string $tabName = 'ListFormaPago'): void + { + $tab = $this->addTab($tabName, 'FormaPago', 'payment-methods', 'fa-solid fa-credit-card'); + + $tab->addColumn(ComponentText::make('codpago')->setLabel('code')->setCols(2)); + $tab->addColumn(ComponentText::make('descripcion')->setLabel('description')->setCols(5)); + $tab->addColumn(ComponentNumber::make('plazovencimiento')->setLabel('expiration')->setDecimals(0)->setCols(2)); + $tab->addColumn( + ComponentSelect::make('tipovencimiento') + ->setLabel('expiration-type') + ->setValuesFromArrayKeys(['days' => 'days', 'weeks' => 'weeks', 'months' => 'months', 'years' => 'years'], true) + ->setCols(2) + ); + $tab->addColumn(ComponentCheckbox::make('pagado')->setLabel('paid')); + $tab->addColumn(ComponentCheckbox::make('domiciliado')->setLabel('domiciled')); + $tab->addColumn(ComponentText::make('codcuentabanco')->setLabel('bank-account')->setAlign('right')); + + $tab->addSearchField('codpago', 'descripcion'); + + $tab->addOrderBy(['codpago'], 'code', 1); + $tab->addOrderBy(['descripcion'], 'description'); + + $tab->addColor('activa', false, 'table-warning', 'inactive'); + + $tab->setNewUrl('NewEditFormaPago'); + $tab->setRowUrlCallback(fn($record) => 'NewEditFormaPago?code=' . urlencode($record->codpago)); + + $empresas = (new Empresa())->all(); + if (count($empresas) > 1) { + $options = array_map(fn($e) => ['value' => $e->idempresa, 'title' => $e->nombrecorto], $empresas); + $tab->addFilterSelect('idempresa', 'company', 'idempresa', $options); + } + $tab->addFilterCheckbox('pagado', 'paid', 'pagado'); + $tab->addFilterCheckbox('domiciliado', 'domiciled', 'domiciliado'); + } + + protected function createViewsBankAccounts(string $tabName = 'ListCuentaBanco'): void + { + $tab = $this->addTab($tabName, 'CuentaBanco', 'bank-accounts', 'fa-solid fa-piggy-bank'); + + $tab->addColumn(ComponentText::make('codcuenta')->setLabel('code')->setCols(2)); + $tab->addColumn(ComponentText::make('descripcion')->setLabel('description')->setCols(6)); + $tab->addColumn(ComponentText::make('swift')->setLabel('swift')->setCols(2)); + $tab->addColumn(ComponentCheckbox::make('activa')->setLabel('active')); + + $tab->addSearchField('descripcion', 'codcuenta'); + + $tab->addOrderBy(['codcuenta'], 'code', 1); + $tab->addOrderBy(['descripcion'], 'description'); + + $tab->setNewUrl('NewEditCuentaBanco'); + $tab->setRowUrlCallback(fn($record) => 'NewEditCuentaBanco?code=' . urlencode($record->codcuenta)); + } +} diff --git a/Core/Controller/NewListProducto.php b/Core/Controller/NewListProducto.php new file mode 100644 index 0000000000..b2a17d87ff --- /dev/null +++ b/Core/Controller/NewListProducto.php @@ -0,0 +1,164 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Component\ComponentNumber; +use FacturaScripts\Core\Component\ComponentText; +use FacturaScripts\Core\DataSrc\Almacenes; +use FacturaScripts\Core\DataSrc\Impuestos; +use FacturaScripts\Core\Lib\ProductType; +use FacturaScripts\Core\Model\CodeModel; +use FacturaScripts\Core\Tools; +use FacturaScripts\Core\UIComponents\UIListController; + +/** + * Listado de productos, variantes y stock construido sobre UIListController. + * + * Replica ListProducto con tres pestañas: + * - ListProducto: productos con filtros completos + * - ListVariante: variantes con búsqueda y ordenación + * - ListStock: stock por almacén + * + * @author Abderrahim Darghal Belkacemi + */ +class NewListProducto extends UIListController +{ + public function getModelClassName(): string + { + return 'Producto'; + } + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'warehouse'; + $data['title'] = 'new-products'; + $data['icon'] = 'fa-solid fa-cubes'; + return $data; + } + + protected function createUI(): void + { + $this->createViewProducto(); + $this->createViewVariante(); + $this->createViewStock(); + } + + protected function createViewProducto(string $tabName = 'ListProducto'): void + { + $tab = $this->addTab($tabName, 'Producto', 'products', 'fa-solid fa-cubes'); + + $tab->addColumn(ComponentText::make('referencia')->setLabel('reference')->setCols(2)); + $tab->addColumn(ComponentText::make('descripcion')->setLabel('description')); + $tab->addColumn(ComponentText::make('codfabricante')->setLabel('manufacturer')->setCols(2)); + $tab->addColumn(ComponentText::make('codfamilia')->setLabel('family')->setCols(2)); + $tab->addColumn(ComponentNumber::make('precio')->setLabel('price')->setDecimals(2)->setAlign('right')->setCols(2)); + $tab->addColumn(ComponentNumber::make('stockfis')->setLabel('stock')->setDecimals(2)->setAlign('right')->setCols(2)); + + $tab->addSearchField('referencia', 'descripcion', 'observaciones'); + + $tab->addOrderBy(['referencia'], 'reference', 1); + $tab->addOrderBy(['descripcion'], 'description'); + $tab->addOrderBy(['fechaalta'], 'creation-date'); + $tab->addOrderBy(['precio'], 'price'); + $tab->addOrderBy(['stockfis'], 'stock'); + $tab->addOrderBy(['actualizado'], 'update-time'); + + $manufacturers = CodeModel::all('fabricantes', 'codfabricante', 'nombre'); + $manufacturerOpts = [['value' => '', 'title' => '------']]; + foreach ($manufacturers as $c) { + $manufacturerOpts[] = ['value' => $c->code, 'title' => $c->description]; + } + $tab->addFilterSelect('codfabricante', 'manufacturer', 'codfabricante', $manufacturerOpts); + + $tab->addFilterAutocomplete('codfamilia', 'family', 'codfamilia', 'familias', 'codfamilia', 'descripcion'); + + $types = [['value' => '', 'title' => '------']]; + foreach (ProductType::all() as $key => $value) { + $types[] = ['value' => $key, 'title' => Tools::trans($value)]; + } + $tab->addFilterSelect('tipo', 'type', 'tipo', $types); + + $taxOpts = [['value' => '', 'title' => '------']]; + foreach (Impuestos::codeModel() as $c) { + $taxOpts[] = ['value' => $c->code, 'title' => $c->description]; + } + $tab->addFilterSelect('codimpuesto', 'tax', 'codimpuesto', $taxOpts); + + $tab->addFilterCheckbox('bloqueado', 'blocked', 'bloqueado'); + $tab->addFilterCheckbox('publico', 'public', 'publico'); + $tab->addFilterCheckbox('nostock', 'no-stock', 'nostock'); + $tab->addFilterCheckbox('secompra', 'for-purchase', 'secompra'); + $tab->addFilterCheckbox('sevende', 'for-sale', 'sevende'); + $tab->addFilterCheckbox('ventasinstock', 'allow-sale-without-stock', 'ventasinstock'); + + $tab->setNewUrl('NewEditProducto'); + $tab->setRowUrlCallback(fn($record) => 'NewEditProducto?code=' . urlencode($record->idproducto)); + } + + protected function createViewVariante(string $tabName = 'ListVariante'): void + { + $tab = $this->addTab($tabName, 'Variante', 'variants', 'fa-solid fa-project-diagram'); + + $tab->addColumn(ComponentText::make('referencia')->setLabel('reference')->setCols(3)); + $tab->addColumn(ComponentText::make('codbarras')->setLabel('barcode')->setCols(3)); + $tab->addColumn(ComponentNumber::make('precio')->setLabel('price')->setDecimals(2)->setAlign('right')->setCols(2)); + $tab->addColumn(ComponentNumber::make('coste')->setLabel('cost-price')->setDecimals(2)->setAlign('right')->setCols(2)); + $tab->addColumn(ComponentNumber::make('stockfis')->setLabel('stock')->setDecimals(2)->setAlign('right')->setCols(2)); + + $tab->addSearchField('referencia', 'codbarras'); + + $tab->addOrderBy(['referencia'], 'reference', 1); + $tab->addOrderBy(['codbarras'], 'barcode'); + $tab->addOrderBy(['precio'], 'price'); + $tab->addOrderBy(['coste'], 'cost-price'); + $tab->addOrderBy(['stockfis'], 'stock'); + + $tab->setRowUrlCallback(fn($record) => 'NewEditProducto?code=' . urlencode($record->idproducto)); + } + + protected function createViewStock(string $tabName = 'ListStock'): void + { + $tab = $this->addTab($tabName, 'Stock', 'stock', 'fa-solid fa-dolly'); + + $tab->addColumn(ComponentText::make('referencia')->setLabel('reference')->setCols(3)); + $tab->addColumn(ComponentText::make('codalmacen')->setLabel('warehouse')->setCols(2)); + $tab->addColumn(ComponentNumber::make('cantidad')->setLabel('quantity')->setDecimals(2)->setAlign('right')->setCols(2)); + $tab->addColumn(ComponentNumber::make('disponible')->setLabel('available')->setDecimals(2)->setAlign('right')->setCols(2)); + $tab->addColumn(ComponentNumber::make('reservada')->setLabel('reserved')->setDecimals(2)->setAlign('right')->setCols(2)); + $tab->addColumn(ComponentNumber::make('pterecibir')->setLabel('pending-reception')->setDecimals(2)->setAlign('right')->setCols(2)); + + $tab->addSearchField('referencia', 'ubicacion'); + + $tab->addOrderBy(['referencia'], 'reference', 1); + $tab->addOrderBy(['cantidad'], 'quantity'); + $tab->addOrderBy(['disponible'], 'available'); + $tab->addOrderBy(['reservada'], 'reserved'); + $tab->addOrderBy(['pterecibir'], 'pending-reception'); + + if (count(Almacenes::all()) > 1) { + $warehouseOpts = [['value' => '', 'title' => '------']]; + foreach (Almacenes::codeModel() as $c) { + $warehouseOpts[] = ['value' => $c->code, 'title' => $c->description]; + } + $tab->addFilterSelect('codalmacen', 'warehouse', 'codalmacen', $warehouseOpts); + } + } +} diff --git a/Core/Lib/ExtendedController/ListView.php b/Core/Lib/ExtendedController/ListView.php index 187232a974..0d37d9ea0c 100644 --- a/Core/Lib/ExtendedController/ListView.php +++ b/Core/Lib/ExtendedController/ListView.php @@ -128,6 +128,10 @@ public function addSearchFields(array $fields): ListView public function btnNewUrl(): string { + if (!empty($this->settings['btnNewUrl'])) { + return $this->settings['btnNewUrl']; + } + $url = empty($this->model) ? '' : $this->model->url('new'); $params = []; foreach (DataBaseWhere::getFieldsFilter($this->where) as $key => $value) { diff --git a/Core/Lib/ExtendedController/PanelController.php b/Core/Lib/ExtendedController/PanelController.php index 0b0e131804..8e00652501 100644 --- a/Core/Lib/ExtendedController/PanelController.php +++ b/Core/Lib/ExtendedController/PanelController.php @@ -20,6 +20,8 @@ namespace FacturaScripts\Core\Lib\ExtendedController; use FacturaScripts\Core\Base\ControllerPermissions; +use FacturaScripts\Core\Component\ComponentBlock; +use FacturaScripts\Core\Component\HasComponentBlocks; use FacturaScripts\Core\Response; use FacturaScripts\Core\Tools; use FacturaScripts\Dinamic\Model\User; @@ -32,6 +34,7 @@ */ abstract class PanelController extends BaseController { + use HasComponentBlocks; /** * Indicates if the main view has data or is empty. * @@ -107,6 +110,9 @@ public function privateCore(&$response, $user, $permissions) } } + // Process active component block (if any) + $this->processActiveComponentBlock(); + // General operations with the loaded data $this->execAfterAction($action); $this->pipeFalse('execAfterAction', $action); @@ -141,6 +147,10 @@ public function setTabsPosition(string $position): void foreach (array_keys($this->views) as $viewName) { $this->views[$viewName]->settings['card'] = $this->tabsPosition !== 'top'; } + + foreach ($this->componentBlocks as $block) { + $block->settings['card'] = $this->tabsPosition !== 'top'; + } } /** diff --git a/Core/Lib/PDF/PDFCore.php b/Core/Lib/PDF/PDFCore.php index d9d34ad384..c1a23acb57 100644 --- a/Core/Lib/PDF/PDFCore.php +++ b/Core/Lib/PDF/PDFCore.php @@ -230,7 +230,12 @@ protected function getTableData(array $cursor, array $tableCols, array $tableOpt // Extracts the data from the cursos foreach ($cursor as $key => $row) { foreach ($tableCols as $col) { - $value = $tableOptions['cols'][$col]['widget']->plainText($row); + // Compatibilidad con UIListController: sin widget, lee la propiedad del modelo directamente + if (isset($tableOptions['cols'][$col]['widget'])) { + $value = $tableOptions['cols'][$col]['widget']->plainText($row); + } else { + $value = $row->{$col} ?? ''; + } $tableData[$key][$col] = $this->fixValue($value); } } diff --git a/Core/UIComponents/HasListFilters.php b/Core/UIComponents/HasListFilters.php new file mode 100644 index 0000000000..a14d96511c --- /dev/null +++ b/Core/UIComponents/HasListFilters.php @@ -0,0 +1,304 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\UIComponents; + +use FacturaScripts\Core\Base\DataBase\DataBaseWhere; +use FacturaScripts\Core\Request; +use FacturaScripts\Core\Tools; + +/** + * Trait que añade el sistema de filtros de columna a UIListController y UIListTab. + * + * Uso: + * $tab->addFilterSelect('idempresa', 'company', 'idempresa', $opciones); + * $tab->addFilterCheckbox('pagado', 'paid', 'pagado'); + * $tab->addFilterPeriod('fecha', 'date', 'fecha'); + * $tab->addFilterAutocomplete('codcliente', 'customer', 'codcliente', 'clientes', 'codcliente', 'nombre'); + * + * @author Abderrahim Darghal Belkacemi + */ +trait HasListFilters +{ + /** @var array */ + private array $filterDefs = []; + + /** @var array Valores actuales leídos de la request. */ + private array $filterValues = []; + + /** + * Declara un filtro de selección (desplegable con valores estáticos). + * + * @param string $key Identificador único del filtro. + * @param string $label Clave de traducción para la etiqueta. + * @param string $field Columna de la BD sobre la que aplica el WHERE. + * @param array $options Array de opciones: [['value' => ..., 'title' => ...], ...] o [value => title, ...]. + */ + public function addFilterSelect(string $key, string $label, string $field, array $options): static + { + $normalized = []; + foreach ($options as $k => $v) { + if (is_array($v)) { + $normalized[] = $v; + } else { + $normalized[] = ['value' => $k, 'title' => $v]; + } + } + + $this->filterDefs[$key] = [ + 'type' => 'select', + 'label' => $label, + 'field' => $field, + 'options' => $normalized, + ]; + return $this; + } + + /** + * Declara un filtro booleano (checkbox). + * Cuando está marcado aplica WHERE field = 1; desmarcado no filtra. + */ + public function addFilterCheckbox(string $key, string $label, string $field): static + { + $this->filterDefs[$key] = [ + 'type' => 'checkbox', + 'label' => $label, + 'field' => $field, + ]; + return $this; + } + + /** + * Declara un filtro de rango de fechas (desde/hasta). + * Aplica WHERE field >= desde AND field <= hasta según los valores presentes. + */ + public function addFilterPeriod(string $key, string $label, string $field): static + { + $this->filterDefs[$key] = [ + 'type' => 'period', + 'label' => $label, + 'field' => $field, + ]; + return $this; + } + + /** + * Declara un filtro de autocompletar (select2 con búsqueda AJAX). + * + * @param string $source Tabla de origen para CodeModel::search(). + * @param string $fieldcode Campo clave de la tabla. + * @param string $fieldtitle Campo título de la tabla (visible al usuario). + */ + public function addFilterAutocomplete( + string $key, + string $label, + string $field, + string $source, + string $fieldcode = 'id', + string $fieldtitle = '' + ): static { + $this->filterDefs[$key] = [ + 'type' => 'autocomplete', + 'label' => $label, + 'field' => $field, + 'source' => $source, + 'fieldcode' => $fieldcode, + 'fieldtitle' => $fieldtitle ?: $fieldcode, + ]; + return $this; + } + + /** Indica si se ha declarado algún filtro. */ + public function hasFilters(): bool + { + return !empty($this->filterDefs); + } + + /** Indica si algún filtro tiene valor activo en la request actual. */ + public function hasActiveFilters(): bool + { + foreach ($this->filterValues as $val) { + if ($val !== null && $val !== '') { + return true; + } + } + return false; + } + + /** + * Lee los valores de los filtros de la request y los almacena en $filterValues. + * Debe llamarse en loadRecords() antes de buildFilterWhere(). + */ + protected function readFilterValues(Request $request): void + { + foreach ($this->filterDefs as $key => $def) { + if ($def['type'] === 'period') { + $from = $request->inputOrQuery('filter_' . $key . '_from', ''); + $to = $request->inputOrQuery('filter_' . $key . '_to', ''); + $this->filterValues[$key . '_from'] = $from !== '' ? $from : null; + $this->filterValues[$key . '_to'] = $to !== '' ? $to : null; + } else { + $val = $request->inputOrQuery('filter_' . $key, ''); + $this->filterValues[$key] = $val !== '' ? $val : null; + } + } + } + + /** + * Construye el array de DataBaseWhere a partir de los valores de filtro actuales. + * Llamar readFilterValues() antes de este método. + * + * @return DataBaseWhere[] + */ + protected function buildFilterWhere(): array + { + $where = []; + + foreach ($this->filterDefs as $key => $def) { + if ($def['type'] === 'period') { + $from = $this->filterValues[$key . '_from'] ?? null; + $to = $this->filterValues[$key . '_to'] ?? null; + if ($from !== null) { + $where[] = new DataBaseWhere($def['field'], $from, '>='); + } + if ($to !== null) { + $where[] = new DataBaseWhere($def['field'], $to, '<='); + } + } else { + $val = $this->filterValues[$key] ?? null; + if ($val === null || $val === '') { + continue; + } + + if ($def['type'] === 'checkbox') { + $where[] = new DataBaseWhere($def['field'], 1, '='); + } else { + $where[] = new DataBaseWhere($def['field'], $val, '='); + } + } + } + + return $where; + } + + /** + * Genera el HTML del row de filtros para inyectarlo en la plantilla Twig. + * + * Devuelve cadena vacía si no hay filtros declarados. + * + * @param string $formName ID del formulario padre (para el onchange submit). + */ + public function renderFiltersHtml(string $formName): string + { + if (empty($this->filterDefs)) { + return ''; + } + + $html = ''; + foreach ($this->filterDefs as $key => $def) { + $html .= $this->renderSingleFilter($key, $def, $formName); + } + return $html; + } + + private function renderSingleFilter(string $key, array $def, string $formName): string + { + $label = htmlspecialchars(Tools::lang()->trans($def['label'])); + $name = 'filter_' . $key; + $submit = 'document.getElementById(\'' . htmlspecialchars($formName) . '\').submit();'; + + switch ($def['type']) { + case 'select': + return $this->renderFilterSelect($name, $label, $def['options'], $this->filterValues[$key] ?? null, $submit); + + case 'checkbox': + return $this->renderFilterCheckbox($name, $label, !empty($this->filterValues[$key]), $submit); + + case 'period': + return $this->renderFilterPeriod($name, $label, $this->filterValues[$key . '_from'] ?? null, $this->filterValues[$key . '_to'] ?? null); + + case 'autocomplete': + return $this->renderFilterAutocomplete($name, $label, $def, $this->filterValues[$key] ?? null, $submit); + + default: + return ''; + } + } + + private function renderFilterSelect(string $name, string $label, array $options, mixed $current, string $submit): string + { + $html = '
' + . '
'; + return $html; + } + + private function renderFilterCheckbox(string $name, string $label, bool $checked, string $submit): string + { + $chk = $checked ? ' checked' : ''; + return '
' + . '
' + . '' + . '' + . '
'; + } + + private function renderFilterPeriod(string $name, string $label, ?string $from, ?string $to): string + { + $fromVal = htmlspecialchars($from ?? ''); + $toVal = htmlspecialchars($to ?? ''); + return '
' + . '
' + . '' . $label . '' + . '' + . '' + . '
'; + } + + private function renderFilterAutocomplete(string $name, string $label, array $def, mixed $current, string $submit): string + { + $html = '
' + . '
' + . '' . $label . '' + . '
'; + return $html; + } +} diff --git a/Core/UIComponents/HasToolbarButtons.php b/Core/UIComponents/HasToolbarButtons.php new file mode 100644 index 0000000000..aec93c76e0 --- /dev/null +++ b/Core/UIComponents/HasToolbarButtons.php @@ -0,0 +1,87 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\UIComponents; + +/** + * Provides toolbar button and button-group support for UIListTab and UIListController. + * + * Button config keys: + * - action (string) Value set on input[name=action] when clicked. + * - icon (string) FontAwesome class for the icon. + * - label (string) Translation key for the button text. + * - confirm (bool) If true, a confirm() dialog is shown before the action fires. + * - type (string) 'modal' → opens a modal instead of submitting the form. + * - target (string) Modal element ID (required when type='modal'). + */ +trait HasToolbarButtons +{ + /** @var array[] Groups: ['name', 'icon', 'label', 'buttons' => array[]] */ + private array $buttonGroups = []; + + /** @var array[] Standalone buttons outside any group */ + private array $actionButtons = []; + + /** + * Registers a dropdown button group in the toolbar. + * Returns $this for chaining; add buttons to the group with addGroupButton(). + */ + public function addButtonGroup(string $name, string $icon, string $labelKey): static + { + $this->buttonGroups[$name] = [ + 'name' => $name, + 'icon' => $icon, + 'label' => $labelKey, + 'buttons' => [], + ]; + return $this; + } + + /** + * Adds a button to an existing group. + * See class docblock for valid config keys. + */ + public function addGroupButton(string $groupName, array $config): static + { + if (isset($this->buttonGroups[$groupName])) { + $this->buttonGroups[$groupName]['buttons'][] = $config; + } + return $this; + } + + /** + * Adds a standalone button (outside any group) to the toolbar. + * See class docblock for valid config keys. + */ + public function addActionButton(array $config): static + { + $this->actionButtons[] = $config; + return $this; + } + + public function buttonGroups(): array + { + return $this->buttonGroups; + } + + public function actionButtons(): array + { + return $this->actionButtons; + } +} diff --git a/Core/UIComponents/UIEditController.php b/Core/UIComponents/UIEditController.php new file mode 100644 index 0000000000..e326396b64 --- /dev/null +++ b/Core/UIComponents/UIEditController.php @@ -0,0 +1,691 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\UIComponents; + +use FacturaScripts\Core\Base\DataBase\DataBaseWhere; +use FacturaScripts\Core\Component\ActionResult; +use FacturaScripts\Core\Component\ComponentBlock; +use FacturaScripts\Core\Component\UIController; +use FacturaScripts\Core\Lib\ExtendedController\BaseView; +use FacturaScripts\Core\Lib\ExtendedController\EditListView; +use FacturaScripts\Core\Lib\ExtendedController\HtmlView; +use FacturaScripts\Core\Lib\ExtendedController\ListView; +use FacturaScripts\Core\Tools; +use FacturaScripts\Dinamic\Lib\ExportManager; +use FacturaScripts\Dinamic\Model\PageOption; + +/** + * Controlador base para formularios de edición construidos con el sistema de componentes UI. + * + * Replica la funcionalidad de PanelController con la excepción de que los campos del + * formulario se declaran mediante instancias de FieldComponent en lugar de XMLView. + * + * La subclase implementa getModelClassName() y buildForm(). Cuando se añaden paneles extra + * con addPanel(), la plantilla muestra la misma navegación lateral de nav-pills que + * PanelController; sin paneles extra, muestra una card única sin tabs. + * + * Ciclo de vida: + * createUI → buildForm() + auto-registro de handlers 'save'/'delete' + * → (POST) processComponents → save | (GET) populateFromModel → populatePanels + * → modifyUI → setTemplate + * + * Uso mínimo: + * public function getModelClassName(): string { return 'MiModelo'; } + * protected function buildForm(): void { + * $this->addComponent(ComponentText::make('nombre')->setRequired()); + * $panel = $this->addPanel('extra', 'Extra', 'fa-solid fa-list'); + * $panel->addComponent(ComponentText::make('observaciones')); + * } + * + * @author Abderrahim Darghal Belkacemi + */ +abstract class UIEditController extends UIController +{ + const MODEL_NAMESPACE = '\\FacturaScripts\\Dinamic\\Model\\'; + + /** + * Indica si el registro existe en la base de datos. + * false → modo creación, true → modo edición. + */ + public bool $hasData = false; + + /** Instancia cacheada del modelo. Se inicializa en loadModel(). */ + protected mixed $editModel = null; + + /** Gestor de exportación (PDF, XLS, CSV…). Disponible en Twig como fsc.exportManager. */ + public ExportManager $exportManager; + + /** @var ComponentBlock[] Paneles extra indexados por nombre. */ + private array $extraPanels = []; + + /** @var ListView[] Listas relacionadas (vistas debajo del formulario) indexadas por nombre. */ + private array $listViews = []; + + /** @var HtmlView[] Vistas HTML (imágenes, archivos…) indexadas por nombre. */ + private array $htmlViews = []; + + /** Nombre de la vista de lista activa para que Twig llame a getCurrentView(). */ + private string $currentListViewName = ''; + + /** + * Devuelve el nombre de la clase del modelo a editar (sin namespace). + * Ejemplo: 'FormaPago', 'Cliente'. + */ + abstract public function getModelClassName(): string; + + /** + * Declara los campos del formulario principal usando addComponent() y los + * paneles adicionales usando addPanel(). Los handlers 'save' y 'delete' + * se registran automáticamente; declara los tuyos con onEvent() si necesitas + * comportamiento personalizado. + */ + abstract protected function buildForm(): void; + + public function getPageData(): array + { + $data = parent::getPageData(); + $data['showonmenu'] = false; + return $data; + } + + /** + * Devuelve el modelo activo. Útil en Twig para acceder a datos del registro + * sin pasar por los componentes (p. ej. para mostrar relaciones). + */ + public function getModel(): mixed + { + return $this->editModel; + } + + /** + * Devuelve la URL de una imagen representativa del registro, o cadena vacía. + * La subclase puede sobreescribir para mostrar una imagen en la cabecera. + */ + public function getImageUrl(): string + { + return ''; + } + + /** + * Registra un panel extra con nombre, título e icono. + * + * Devuelve el ComponentBlock asociado para añadirle componentes. + * El panel aparece en la navegación lateral junto al formulario principal + * solo cuando hay al menos un panel extra registrado. + */ + public function addPanel(string $name, string $title, string $icon = 'fa-solid fa-folder'): ComponentBlock + { + $block = ComponentBlock::make($name, $title, $icon); + $this->extraPanels[$name] = $block; + return $block; + } + + /** + * Devuelve los paneles extra registrados, indexados por nombre. + * Usado por la plantilla Twig para construir la navegación lateral. + */ + public function extraPanels(): array + { + return $this->extraPanels; + } + + /** + * Registra un ListView real debajo del formulario de edición. + * + * Crea una instancia de ListView con el modelo y la configuración XML del viewName, + * aplica loadPageOptions() con el usuario actual y almacena la vista para que la + * plantilla la renderice con {{ include(listView.template) }}. + * + * Llama a este método desde buildForm(). En modifyUI() usa listView($name) para + * acceder a la vista, llama processFormData($request, 'load') y luego loadData(). + * + * @param string $viewName Nombre de la vista (coincide con el fichero XML, p. ej. 'ListSubcuenta'). + * @param string $modelName Nombre del modelo sin namespace (p. ej. 'Subcuenta'). + * @param string $viewTitle Clave de traducción del título. + * @param string $icon Clase FontAwesome del icono. + */ + public function addListView(string $viewName, string $modelName, string $viewTitle, string $icon = 'fa-solid fa-list'): ListView + { + $view = new ListView($viewName, $viewTitle, self::MODEL_NAMESPACE . $modelName, $icon); + $view->settings['card'] = true; + $view->loadPageOptions($this->user); + $this->listViews[$viewName] = $view; + return $view; + } + + public function addEditListView(string $viewName, string $modelName, string $viewTitle, string $icon = 'fa-solid fa-bars'): EditListView + { + $view = new EditListView($viewName, $viewTitle, self::MODEL_NAMESPACE . $modelName, $icon); + $view->settings['card'] = true; + $view->loadPageOptions($this->user); + $this->listViews[$viewName] = $view; + return $view; + } + + public function addHtmlView(string $viewName, string $fileName, string $modelName, string $viewTitle, string $viewIcon = 'fa-brands fa-html5'): HtmlView + { + $view = new HtmlView($viewName, $viewTitle, self::MODEL_NAMESPACE . $modelName, $fileName, $viewIcon); + $view->loadPageOptions($this->user); + $this->htmlViews[$viewName] = $view; + return $view; + } + + public function htmlViews(): array + { + return $this->htmlViews; + } + + /** + * Expone fsc.views | first para compatibilidad con plantillas Tab/* que + * usan ese patrón para obtener el modelo principal. + */ + public function getViews(): array + { + $wrapper = new \stdClass(); + $wrapper->model = $this->editModel; + return [$wrapper]; + } + + /** + * Devuelve las vistas de lista registradas, indexadas por nombre. + */ + public function listViews(): array + { + return $this->listViews; + } + + /** + * Vistas que aparecen como pestañas en la nav izquierda: + * ListView/EditListView (no inline) + HtmlViews. + */ + public function panelListViews(): array + { + $listPanel = array_filter($this->listViews, fn($v) => !str_contains($v->template, 'InLine')); + return array_merge($listPanel, $this->htmlViews); + } + + /** + * Vistas inline (setInLine(true)): se renderizan debajo del formulario principal. + */ + public function inlineListViews(): array + { + return array_filter($this->listViews, fn($v) => str_contains($v->template, 'InLine')); + } + + /** + * Devuelve el ListView con el nombre dado, o null si no existe. + * Útil en modifyUI() para cargar datos: + * $this->listView('ListSubcuenta')?->loadData('', $where); + */ + protected function listView(string $name): ?BaseView + { + return $this->listViews[$name] ?? null; + } + + /** + * Establece la vista de lista activa para que {{ include(listView.template) }} en Twig + * pueda llamar a fsc.getCurrentView() y obtener el objeto correcto. + */ + public function setCurrentView(string $viewName): void + { + $this->currentListViewName = $viewName; + } + + /** + * Devuelve la vista de lista activa. Llamado por ListView.html.twig via fsc.getCurrentView(). + */ + public function getCurrentView(): BaseView + { + return $this->listViews[$this->currentListViewName] + ?? $this->htmlViews[$this->currentListViewName]; + } + + /** + * Omite processComponents() cuando el POST proviene de un ListView o HtmlView embebido. + */ + protected function skipFormProcessing(): bool + { + $activetab = $this->request->request->get('activetab', ''); + return isset($this->listViews[$activetab]) || isset($this->htmlViews[$activetab]); + } + + /** + * Devuelve el nombre del panel activo según el parámetro activetab de la petición. + * '__main__' indica que está activo el formulario principal. + */ + public function activeTab(): string + { + $tab = $this->request->inputOrQuery('activetab', '__main__'); + if ($tab === '__main__' || isset($this->extraPanels[$tab])) { + return $tab; + } + if (isset($this->listViews[$tab]) && !str_contains($this->listViews[$tab]->template, 'InLine')) { + return $tab; + } + if (isset($this->htmlViews[$tab])) { + return $tab; + } + return '__main__'; + } + + /** + * Carga el modelo desde la base de datos usando el parámetro 'code' de la URL. + * + * El modelo se cachea en $this->editModel. Si no se encuentra, la instancia queda + * vacía y hasData = false. Si el usuario no tiene permisos, se activa la plantilla + * de acceso denegado y se devuelve null. + */ + protected function loadModel(): ?object + { + if ($this->editModel !== null) { + return $this->editModel; + } + + $modelClass = self::MODEL_NAMESPACE . $this->getModelClassName(); + $this->editModel = new $modelClass(); + + $primaryKey = $this->request->input($this->editModel->primaryColumn(), ''); + $code = $this->request->query('code', $primaryKey); + + if (!empty($code)) { + if ($this->editModel->loadFromCode($code)) { + if (false === $this->checkOwnerData($this->editModel)) { + $this->setTemplate('Error/AccessDenied'); + $this->editModel = null; + return null; + } + + $this->hasData = true; + $this->title .= ' ' . $this->editModel->primaryDescription(); + } else { + Tools::log()->warning('record-not-found'); + } + } + + return $this->editModel; + } + + protected function resolveTemplate(): string + { + return 'Master/UIEditController'; + } + + /** + * Nombre de la vista XML equivalente en el sistema antiguo. + * + * Devuelve '' por defecto (sin carga de opciones). La subclase puede + * sobreescribir para enlazar con un PageOption existente (p. ej. + * return 'EditFormaPago') y así respetar la visibilidad configurada + * por el usuario a través del botón Opciones. + */ + protected function getViewName(): string + { + return ''; + } + + /** + * Punto de extensión para manejar acciones de formularios HtmlView + * (add-image, delete-image, add-file, delete-file, edit-file, …). + * La subclase sobreescribe este método y llama a los métodos del trait correspondiente. + */ + protected function execHtmlAction(string $action): void + { + } + + /** + * Sobreescribe modifyUI() para poblar los paneles extra desde el modelo en GET + * y para aplicar la configuración de visibilidad guardada en pages_options. + */ + protected function modifyUI(): void + { + // Procesa acciones insert/edit/delete de EditListView embebidas. + // UIController despacha eventos por _event, pero EditListView usa el campo + // 'action'; cuando skipFormProcessing() devuelve true solo se llama a + // populateFromModel(), por lo que interceptamos aquí antes de recargar datos. + if ($this->request->isMethod('POST')) { + $activetab = $this->request->request->get('activetab', ''); + if (isset($this->listViews[$activetab]) && $this->listViews[$activetab] instanceof EditListView) { + $editAction = $this->request->request->get('action', ''); + if (in_array($editAction, ['insert', 'edit', 'delete'], true)) { + $this->editListViewAction($editAction); + } + } elseif (isset($this->htmlViews[$activetab])) { + $htmlAction = $this->request->request->get('action', ''); + if (!empty($htmlAction)) { + $this->execHtmlAction($htmlAction); + } + } + } + + $model = $this->loadModel(); + if ($model !== null && !empty($this->extraPanels)) { + foreach ($this->extraPanels as $panel) { + $panel->populate($model); + } + } + + $this->applyPageOptions(); + } + + /** + * Lee el PageOption para getViewName() y aplica el estado display de cada + * campo al componente correspondiente por fieldname. + */ + protected function applyPageOptions(): void + { + $viewName = $this->getViewName(); + if (empty($viewName)) { + return; + } + + $pageOption = new PageOption(); + $where = [ + new DataBaseWhere('name', $viewName), + new DataBaseWhere('nick', $this->user->nick), + new DataBaseWhere('nick', null, 'IS', 'OR'), + ]; + + if (!$pageOption->loadWhere($where, ['nick' => 'ASC'])) { + return; + } + + $map = []; + foreach ((array)$pageOption->columns as $group) { + foreach ((array)($group['columns'] ?? []) as $col) { + $fieldname = $col['widget']['fieldname'] ?? null; + if ($fieldname !== null) { + $map[$fieldname] = $col['display'] ?? 'left'; + } + } + } + + foreach ($this->components() as $fieldname => $component) { + if (isset($map[$fieldname])) { + $component->setDisplay($map[$fieldname]); + } + } + } + + /** + * Implementación interna de createUI(): llama a buildForm() y registra + * los handlers por defecto si la subclase no los declaró explícitamente. + */ + final protected function createUI(): void + { + $this->exportManager = new ExportManager(); + + $this->buildForm(); + + if (!$this->hasEventHandler('save')) { + $this->onEvent('save', fn() => $this->defaultSave()); + } + + if (!$this->hasEventHandler('delete')) { + $this->onEvent('delete', fn() => $this->defaultDelete()); + } + + if (!$this->hasEventHandler('export')) { + $this->onEvent('export', fn() => $this->exportAction()); + } + } + + /** + * Devuelve HTML de botones extra inyectados en la cabecera del formulario. + * + * La implementación base retorna cadena vacía. Las subclases pueden sobreescribir + * este método para añadir botones específicos (p. ej. bloquear/desbloquear). + * El HTML se renderiza crudo en el template con `{{ fsc.extraHeaderButtons() | raw }}`. + */ + public function extraHeaderButtons(): string + { + return ''; + } + + /** + * Devuelve la URL del listado asociado a este formulario de edición. + * + * Se usa en las redirecciones post-guardado y post-borrado, y en el botón + * «Volver» de la plantilla Twig. La implementación base delega en + * model->url('list'). Las subclases pueden sobreescribir este método para + * apuntar a un controlador de listado personalizado. + */ + public function listUrl(): string + { + $model = $this->editModel; + return ($model !== null && method_exists($model, 'url')) + ? $model->url('list') + : $this->url(); + } + + /** + * Exporta el registro activo al formato solicitado (PDF, XLS, CSV…). + * + * Construye adaptadores de columna compatibles con el motor de exportación antiguo + * usando los componentes del formulario (se omiten los ocultos). El resultado se + * escribe directamente en la respuesta HTTP y se suprime la plantilla Twig. + */ + protected function exportAction(): ActionResult + { + if (false === $this->permissions->allowExport) { + Tools::log()->warning('no-print-permission'); + return ActionResult::make(); + } + + $model = $this->loadModel(); + if (null === $model) { + return ActionResult::make(); + } + + $option = $this->request->get('option', ExportManager::defaultOption()); + $idformat = (int) $this->request->get('idformat', 0); + $langcode = $this->request->get('langcode', ''); + + $this->exportManager->newDoc($option, $this->title, $idformat, $langcode); + + $columns = []; + foreach ($this->components() as $fieldname => $component) { + if ($component->isHidden()) { + continue; + } + + $fn = $fieldname; + $comp = $component; + + $col = new class ($fn, $comp) { + public string $title; + public string $display = 'left'; + public object $widget; + + public function __construct(string $fn, object $comp) + { + $this->title = $comp->label(); + $fnInner = $fn; + $compInner = $comp; + $this->widget = new class ($fnInner, $compInner) { + public string $fieldname; + private object $comp; + + public function __construct(string $fn, object $comp) + { + $this->fieldname = $fn; + $this->comp = $comp; + } + + public function plainText(object $model): string + { + $val = property_exists($model, $this->fieldname) + ? $model->{$this->fieldname} + : null; + $this->comp->setValue($val); + return $this->comp->textValue(); + } + }; + } + + public function hidden(): bool + { + return false; + } + }; + + $columns[] = $col; + } + + $this->exportManager->addModelPage($model, $columns, $this->title); + $this->exportManager->show($this->response); + + return ActionResult::make()->exit(); + } + + /** + * Guarda el modelo en la base de datos. + * + * Comprueba permisos de escritura. Si es un registro nuevo, redirige a la URL + * del registro recién creado. En edición muestra la notificación de éxito. + */ + protected function defaultSave(): ActionResult + { + if (false === $this->permissions->allowUpdate) { + Tools::log()->warning('not-allowed-modify'); + return ActionResult::make(); + } + + $model = $this->loadModel(); + if ($model === null) { + return ActionResult::make(); + } + + $isNew = !$model->exists(); + + if ($model->save()) { + if ($isNew) { + $editUrl = $this->url() . '?code=' . urlencode($model->primaryColumnValue()) . '&action=save-ok'; + return ActionResult::make()->withRedirect($editUrl); + } + + Tools::log()->notice('record-updated-correctly'); + } else { + Tools::log()->error('record-save-error'); + } + + return ActionResult::make(); + } + + /** + * Elimina el modelo de la base de datos. + * + * Comprueba permisos y token de formulario. Redirige al listado con + * action=delete-ok para que UIListController muestre la notificación. + */ + protected function defaultDelete(): ActionResult + { + if (false === $this->permissions->allowDelete) { + Tools::log()->warning('not-allowed-delete'); + return ActionResult::make(); + } + + if (false === $this->validateFormToken()) { + return ActionResult::make(); + } + + $model = $this->loadModel(); + if ($model !== null && $model->exists()) { + $listUrl = $this->listUrl(); + + if ($model->delete()) { + $redirect = strpos($listUrl, '?') === false + ? $listUrl . '?action=delete-ok' + : $listUrl . '&action=delete-ok'; + + return ActionResult::make()->withRedirect($redirect); + } + + Tools::log()->error('record-deleted-error'); + } + + return ActionResult::make(); + } + + private function editListViewAction(string $action): ActionResult + { + $activetab = $this->request->request->get('activetab', ''); + $view = $this->listViews[$activetab] ?? null; + if (!($view instanceof EditListView)) { + return ActionResult::make(); + } + + if ($action === 'delete') { + if (false === $this->permissions->allowDelete) { + Tools::log()->warning('not-allowed-delete'); + return ActionResult::make(); + } + } else { + if (false === $this->permissions->allowUpdate) { + Tools::log()->warning('not-allowed-modify'); + return ActionResult::make(); + } + } + + if (false === $this->validateFormToken()) { + return ActionResult::make(); + } + + $view->processFormData($this->request, 'edit'); + + if ($action === 'delete') { + if ($view->model->delete()) { + Tools::log()->notice('record-deleted-correctly'); + } + } else { + if ($view->model->save()) { + Tools::log()->notice('record-updated-correctly'); + } + } + + return ActionResult::make(); + } + + /** + * Comprueba que el usuario activo tenga permisos sobre el registro cargado. + * Replica la lógica de BaseController::checkOwnerData(). + */ + protected function checkOwnerData(object $model): bool + { + if (false === $this->permissions->onlyOwnerData || empty($model->primaryColumnValue())) { + return true; + } + + if (property_exists($model, 'nick')) { + if (null === $model->nick || $model->nick === $this->user->nick) { + return true; + } + if (property_exists($model, 'codagente') && $this->user->codagente) { + return $model->codagente === $this->user->codagente; + } + return false; + } + + if (property_exists($model, 'codagente')) { + return $model->codagente === $this->user->codagente; + } + + return true; + } +} diff --git a/Core/UIComponents/UIListController.php b/Core/UIComponents/UIListController.php new file mode 100644 index 0000000000..52ef92ff72 --- /dev/null +++ b/Core/UIComponents/UIListController.php @@ -0,0 +1,865 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\UIComponents; + +use FacturaScripts\Core\Base\Controller; +use FacturaScripts\Core\Base\ControllerPermissions; +use FacturaScripts\Core\Base\DataBase\DataBaseWhere; +use FacturaScripts\Core\Component\FieldComponent; +use FacturaScripts\Core\Lib\ExportManager; +use FacturaScripts\Core\Response; +use FacturaScripts\Core\Tools; +use FacturaScripts\Dinamic\Model\CodeModel; +use FacturaScripts\Dinamic\Model\PageOption; +use FacturaScripts\Dinamic\Model\User; +use FacturaScripts\Core\UIComponents\UIListTab; + +/** + * Controlador base para listados construidos con el sistema de componentes UI. + * + * Replica la funcionalidad de ListController con la excepción de que las columnas + * se declaran mediante instancias de FieldComponent en lugar de XMLView. Esto permite + * aprovechar el motor de renderizado del sistema de componentes (renderCell, displayValue) + * directamente en la tabla de resultados. + * + * Ciclo de vida: createUI → execPreviousAction → loadRecords → execAfterAction → setTemplate. + * + * Uso mínimo en subclase: + * public function getModelClassName(): string { return 'MiModelo'; } + * protected function createUI(): void { + * $this->addColumn(ComponentText::make('codigo')->setLabel('code')->setCols(2)); + * $this->addSearchField('codigo', 'descripcion'); + * $this->addOrderBy(['codigo'], 'code', 1); + * } + * + * @author Abderrahim Darghal Belkacemi + */ +abstract class UIListController extends Controller +{ + use HasListFilters; + use HasToolbarButtons; + + const MODEL_NAMESPACE = '\\FacturaScripts\\Dinamic\\Model\\'; + + /** @var FieldComponent[] keyed by fieldname */ + protected array $columns = []; + + /** Campos de la tabla sobre los que actúa la búsqueda por texto libre. */ + protected array $searchFields = []; + + /** + * Opciones de ordenación declaradas con addOrderBy(). + * Cada elemento: ['fields' => string[], 'label' => string, 'default' => int]. + * El valor 'default': 0 = sin orden por defecto, 1 = ASC por defecto, 2 = DESC. + */ + protected array $orderOptions = []; + + /** + * Condiciones de coloreado de filas declaradas con addColor(). + * Cada elemento: ['field' => string, 'value' => mixed, 'color' => string, 'title' => string]. + * El color es una clase CSS de Bootstrap (p. ej. 'table-danger', 'table-success'). + */ + protected array $colorConditions = []; + + + /** + * Pestañas adicionales declaradas con addTab(). + * Cuando este array no está vacío, el controlador opera en modo multi-pestaña: + * solo se cargan los registros de la pestaña activa y la plantilla muestra + * una navegación de tabs en la cabecera. + * + * @var UIListTab[] indexado por nombre de pestaña + */ + private array $tabs = []; + + /** Registros cargados desde la base de datos para la página actual. */ + protected array $records = []; + + /** Total de registros que coinciden con los filtros activos (sin paginar). */ + protected int $count = 0; + + /** Desplazamiento de la página actual. */ + protected int $offset = 0; + + /** Número máximo de registros por página. */ + protected int $limit = 50; + + /** Texto de búsqueda activo, extraído de la petición. */ + protected string $query = ''; + + protected ExportManager $exportManager; + + /** WHERE y ORDER usados en loadRecords(), reutilizados en exportAction(). */ + protected array $lastWhere = []; + protected array $lastOrder = []; + + /** + * Devuelve el nombre de la clase del modelo a listar (sin namespace). + * Ejemplo: 'FormaPago', 'Cliente'. + */ + abstract public function getModelClassName(): string; + + /** + * Declara las columnas, campos de búsqueda y opciones de ordenación. + * + * Usa addColumn(), addSearchField() y addOrderBy() aquí. + * No cargues datos en este método; eso ocurre en loadRecords(). + */ + abstract protected function createUI(): void; + + public function privateCore(&$response, $user, $permissions): void + { + parent::privateCore($response, $user, $permissions); + + $this->exportManager = new ExportManager(); + $this->limit = defined('FS_ITEM_LIMIT') ? (int)FS_ITEM_LIMIT : 50; + + $this->createUI(); + $this->pipe('createUI'); + $this->applyPageOptions(); + + $action = $this->request->inputOrQuery('action', ''); + + if (false === $this->execPreviousAction($action) + || false === $this->pipeFalse('execPreviousAction', $action)) { + return; + } + + if (!empty($this->tabs)) { + $activeTabName = $this->activeTabName(); + foreach ($this->tabs as $tabName => $tab) { + if ($tabName !== $activeTabName) { + $tab->loadCount(); + } + } + $activeTab = $this->activeTab(); + if ($activeTab !== null) { + $activeTab->loadRecords($this->request, $this->limit); + } + } else { + $this->loadRecords(); + } + + $this->pipeFalse('loadData', $this->records); + + $this->execAfterAction($action); + $this->pipeFalse('execAfterAction', $action); + + if ($this->getTemplate() !== false) { + $this->setTemplate($this->resolveTemplate()); + } + } + + /** + * Registra una pestaña en el controlador multi-pestaña. + * + * Devuelve el UIListTab para que la subclase le añada columnas, búsqueda y ordenación. + * Si se llama al menos una vez, el controlador opera en modo multi-pestaña. + */ + public function addTab( + string $name, + string $modelClassName, + string $titleKey, + string $icon = 'fa-solid fa-list' + ): UIListTab { + $tab = UIListTab::make($name, $modelClassName, $titleKey, $icon); + $this->tabs[$name] = $tab; + return $tab; + } + + /** + * Devuelve todas las pestañas registradas, indexadas por nombre. + * Usado por la plantilla para construir la navegación. + */ + public function tabs(): array + { + return $this->tabs; + } + + /** + * Devuelve el nombre de la pestaña activa según el parámetro activetab. + * Si no hay pestañas o el valor no es válido, devuelve el nombre de la primera pestaña. + */ + public function activeTabName(): string + { + if (empty($this->tabs)) { + return ''; + } + + $requested = $this->request->inputOrQuery('activetab', ''); + if (isset($this->tabs[$requested])) { + return $requested; + } + + return array_key_first($this->tabs); + } + + /** + * Devuelve la instancia UIListTab activa, o null si no hay pestañas. + */ + public function activeTab(): ?UIListTab + { + $name = $this->activeTabName(); + return $name !== '' ? ($this->tabs[$name] ?? null) : null; + } + + protected function resolveTemplate(): string + { + return 'Master/UIListController'; + } + + /** + * Acciones ejecutadas antes de cargar los datos. + * + * Maneja: 'autocomplete' (devuelve JSON y aborta), 'delete' (elimina registros). + * Devuelve false para interrumpir el ciclo de vida (cuando ya se envió respuesta). + */ + protected function execPreviousAction(string $action): bool + { + switch ($action) { + case 'autocomplete': + $this->setTemplate(false); + $this->response->json($this->autocompleteAction()); + return false; + + case 'delete': + $this->deleteAction(); + break; + } + + return true; + } + + /** + * Devuelve el nombre de clase del modelo a usar en deleteAction(). + * + * En modo multi-pestaña, usa el modelo de la pestaña activa. + * En modo single-tab, usa getModelClassName(). + */ + protected function activeModelClassName(): string + { + if (!empty($this->tabs)) { + $tab = $this->activeTab(); + if ($tab !== null) { + return $tab->modelClassName(); + } + } + return $this->getModelClassName(); + } + + /** + * Acciones ejecutadas después de cargar los datos. + * + * Maneja: 'delete-ok' (muestra notificación de éxito), 'export' (PDF/XLS/CSV/MAIL). + */ + protected function execAfterAction(string $action): void + { + switch ($action) { + case 'delete-ok': + Tools::log()->notice('record-deleted-correctly'); + break; + + case 'export': + $this->exportAction(); + break; + } + } + + protected function exportAction(): void + { + if (false === $this->permissions->allowExport) { + Tools::log()->warning('no-print-permission'); + return; + } + + $option = $this->request->queryOrInput('option', ''); + + if (!empty($this->tabs)) { + $tab = $this->activeTab(); + $model = new (self::MODEL_NAMESPACE . $tab->modelClassName())(); + $where = $tab->lastWhere(); + $order = $tab->lastOrder(); + $cols = array_keys($tab->columns()); + } else { + $model = new (self::MODEL_NAMESPACE . $this->getModelClassName())(); + $where = $this->lastWhere; + $order = $this->lastOrder; + $cols = array_keys($this->columns); + } + + $this->setTemplate(false); + $this->exportManager->newDoc($option, $this->title); + $this->exportManager->addListModelPage($model, $where, $order, 0, $cols, $this->title); + $this->exportManager->show($this->response); + } + + /** + * Registra un FieldComponent como columna de la tabla. + * + * El fieldname del componente determina qué propiedad del modelo se muestra en la celda. + * setCols() en el componente no afecta a la anchura de columna en modo tabla. + */ + protected function addColumn(FieldComponent $component): FieldComponent + { + $this->columns[$component->fieldname()] = $component; + return $component; + } + + /** + * Declara uno o varios campos de la tabla sobre los que actúa la búsqueda. + * + * Internamente se combinan con '|' para construir un DataBaseWhere con OR implícito. + */ + protected function addSearchField(string ...$fields): void + { + foreach ($fields as $field) { + $this->searchFields[] = $field; + } + } + + /** + * Declara una opción de ordenación para la cabecera de la tabla. + * + * @param array $fields Columnas de la BD por las que ordenar (se aplican en orden). + * @param string $label Clave de traducción o texto de la etiqueta visible. + * @param int $default 0 = sin activar, 1 = ASC por defecto, 2 = DESC por defecto. + */ + protected function addOrderBy(array $fields, string $label, int $default = 0): void + { + $this->orderOptions[] = [ + 'fields' => $fields, + 'label' => $label, + 'default' => $default, + ]; + } + + /** + * Añade una condición de coloreado de fila. + * + * Cuando el campo $field del registro tiene el valor $value, la fila recibe la clase + * Bootstrap $color (p. ej. 'table-danger'). Las condiciones se evalúan en orden; + * la primera que coincida gana. + */ + protected function addColor(string $field, mixed $value, string $color, string $title = ''): void + { + $this->colorConditions[] = [ + 'field' => $field, + 'value' => $value, + 'color' => $color, + 'title' => $title, + ]; + } + + /** + * Calcula la clase CSS de Bootstrap para la fila de un registro según colorConditions. + * + * Devuelve una cadena vacía si ninguna condición aplica. + */ + public function rowClass(object $record): string + { + foreach ($this->colorConditions as $cond) { + $field = $cond['field']; + if (property_exists($record, $field) && (string)$record->{$field} === (string)$cond['value']) { + return $cond['color']; + } + } + + return ''; + } + + /** + * Carga los registros del modelo aplicando búsqueda, orden y paginación. + * + * Extrae 'query', 'offset' y 'order' de la petición, construye los DataBaseWhere + * correspondientes y ejecuta la consulta. Los resultados quedan en $this->records. + */ + protected function loadRecords(): void + { + $modelClass = self::MODEL_NAMESPACE . $this->getModelClassName(); + if (!class_exists($modelClass)) { + return; + } + + $model = new $modelClass(); + + $where = $this->permissions->onlyOwnerData ? $this->getOwnerFilter($model) : []; + + $this->readFilterValues($this->request); + $where = array_merge($where, $this->buildFilterWhere()); + + $this->query = $this->request->inputOrQuery('query', ''); + if (!empty($this->query) && !empty($this->searchFields)) { + $where[] = new DataBaseWhere( + implode('|', $this->searchFields), + $this->query, + 'LIKE' + ); + } + + $this->offset = max(0, (int)$this->request->inputOrQuery('offset', 0)); + $order = $this->resolveOrder(); + + $this->lastWhere = $where; + $this->lastOrder = $order; + $this->count = $model->count($where); + $this->records = $model->all($where, $order, $this->offset, $this->limit); + } + + /** + * Determina la ordenación activa a partir del parámetro 'order' de la petición + * y las opciones declaradas con addOrderBy(). + * + * Devuelve un array ['campo' => 'ASC'|'DESC'] listo para pasarlo a model->all(). + */ + protected function resolveOrder(): array + { + $orderIndex = (int)$this->request->inputOrQuery('order', -1); + + if (isset($this->orderOptions[$orderIndex])) { + $opt = $this->orderOptions[$orderIndex]; + $dir = ($orderIndex % 2 === 0) ? 'ASC' : 'DESC'; + $order = []; + foreach ($opt['fields'] as $field) { + $order[$field] = $dir; + } + return $order; + } + + // Usar la primera opción marcada como default + foreach ($this->orderOptions as $opt) { + if ($opt['default'] > 0) { + $dir = $opt['default'] === 2 ? 'DESC' : 'ASC'; + $order = []; + foreach ($opt['fields'] as $field) { + $order[$field] = $dir; + } + return $order; + } + } + + return []; + } + + /** + * Elimina el registro (o registros) indicados en la petición. + * + * Comprueba permisos y token de formulario antes de actuar. Soporta eliminación + * masiva mediante el array 'codes' y eliminación individual mediante 'code'. + */ + protected function deleteAction(): bool + { + if (false === $this->permissions->allowDelete) { + Tools::log()->warning('not-allowed-delete'); + return false; + } + + if (false === $this->validateFormToken()) { + return false; + } + + $modelClass = self::MODEL_NAMESPACE . $this->activeModelClassName(); + $model = new $modelClass(); + + $codes = $this->request->request->getArray('codes'); + $code = $this->request->input('code'); + + if (empty($codes) && empty($code)) { + Tools::log()->warning('no-selected-item'); + return false; + } + + if (!empty($codes)) { + $this->dataBase->beginTransaction(); + $deleted = 0; + + foreach ($codes as $cod) { + if ($model->loadFromCode($cod) && $model->delete()) { + $deleted++; + continue; + } + $this->dataBase->rollback(); + Tools::log()->warning('record-deleted-error'); + $model->clear(); + return false; + } + + $model->clear(); + $this->dataBase->commit(); + + if ($deleted > 0) { + Tools::log()->notice('record-deleted-correctly'); + return true; + } + } elseif ($model->loadFromCode($code) && $model->delete()) { + Tools::log()->notice('record-deleted-correctly'); + $model->clear(); + return true; + } + + Tools::log()->warning('record-deleted-error'); + $model->clear(); + return false; + } + + /** + * Construye el filtro de propietario para cuando el permiso onlyOwnerData está activo. + * + * Replica la lógica de BaseController::getOwnerFilter(): filtra por nick o por codagente + * según las propiedades que tenga el modelo. + */ + protected function getOwnerFilter(object $model): array + { + $where = []; + + if (property_exists($model, 'nick')) { + $where[] = new DataBaseWhere('nick', $this->user->nick); + $where[] = new DataBaseWhere('nick', null, 'IS', 'OR'); + if (property_exists($model, 'codagente') && $this->user->codagente) { + $where[] = new DataBaseWhere('codagente', $this->user->codagente, '=', 'OR'); + } + return $where; + } + + if (property_exists($model, 'codagente')) { + $where[] = new DataBaseWhere('codagente', $this->user->codagente); + } + + return $where; + } + + /** + * Maneja la acción 'autocomplete': busca en el CodeModel y devuelve JSON. + * + * Replica el comportamiento de BaseController::autocompleteAction() para mantener + * compatibilidad con los widgets select2 del frontend. + */ + protected function autocompleteAction(): array + { + $source = $this->request->queryOrInput('source', ''); + $fieldcode = $this->request->queryOrInput('fieldcode', 'id'); + $fieldtitle = $this->request->queryOrInput('fieldtitle', $fieldcode); + $term = $this->request->queryOrInput('term', ''); + $strict = $this->request->queryOrInput('strict', '1'); + + if (empty($source)) { + return []; + } + + $where = []; + $fieldfilter = $this->request->queryOrInput('fieldfilter', ''); + foreach (DataBaseWhere::applyOperation($fieldfilter) as $field => $operation) { + if (1 !== preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?$/', $field)) { + continue; + } + $value = $this->request->queryOrInput($field); + $where[] = new DataBaseWhere($field, $value, '=', $operation); + } + + $codeModel = new CodeModel(); + $results = []; + + foreach ($codeModel->search($source, $fieldcode, $fieldtitle, $term, $where) as $value) { + $results[] = ['key' => Tools::fixHtml($value->code), 'value' => Tools::fixHtml($value->description)]; + } + + if (empty($results) && $strict === '0') { + $results[] = ['key' => $term, 'value' => $term]; + } elseif (empty($results)) { + $results[] = ['key' => null, 'value' => Tools::trans('no-data')]; + } + + return $results; + } + + /** + * Devuelve la URL de edición para un registro dado. + * + * La implementación base devuelve cadena vacía (sin enlace). Las subclases + * deben sobreescribir este método para enlazar cada fila con su controlador + * de edición correspondiente. + */ + public function rowUrl(object $record): string + { + return ''; + } + + /** + * Devuelve la URL para crear un nuevo registro. + * + * Si devuelve cadena vacía no se muestra el botón «Nuevo» en la cabecera. + * Las subclases sobreescriben este método para apuntar a su controlador + * de edición correspondiente sin parámetro de código. + */ + public function newUrl(): string + { + return ''; + } + + /** Devuelve las columnas registradas, indexadas por fieldname. */ + public function columns(): array + { + return $this->columns; + } + + /** Devuelve los registros de la página actual. */ + public function records(): array + { + return $this->records; + } + + /** Devuelve el total de registros sin paginar. */ + public function count(): int + { + return $this->count; + } + + /** Devuelve el desplazamiento actual (para la paginación). */ + public function offset(): int + { + return $this->offset; + } + + /** Devuelve el límite de registros por página. */ + public function limit(): int + { + return $this->limit; + } + + /** Devuelve el texto de búsqueda activo. */ + public function query(): string + { + return $this->query; + } + + /** Devuelve los campos de búsqueda declarados. */ + public function searchFields(): array + { + return $this->searchFields; + } + + /** Devuelve las opciones de ordenación declaradas. */ + public function orderOptions(): array + { + return $this->orderOptions; + } + + /** + * Devuelve el índice de la opción de ordenación activa, o -1 si no hay ninguna activa. + * + * Usado por la plantilla para marcar la opción del dropdown como activa y para + * mantener el parámetro order en los enlaces de paginación. + */ + public function orderIndex(): int + { + $index = (int) $this->request->inputOrQuery('order', -1); + return isset($this->orderOptions[$index]) ? $index : -1; + } + + public function isClickable(): bool + { + return false; + } + + /** + * Devuelve HTML de modales para la pestaña indicada. + * + * La implementación base retorna cadena vacía. Las subclases sobreescriben este + * método cuando necesitan inyectar modales con contenido dinámico complejo que no + * puede expresarse con addButtonGroup()/addGroupButton(). + * El HTML se renderiza fuera del form con `{{ fsc.tabModals(tabName) | raw }}`. + */ + public function tabModals(string $tabName): string + { + return ''; + } + + public function colorLegend(): string + { + $html = ''; + foreach ($this->colorConditions as $cond) { + if (!empty($cond['title'])) { + $label = Tools::lang()->trans($cond['title']); + $textClass = str_replace('table-', 'text-', $cond['color']); + $html .= '' + . '' + . htmlspecialchars($label) + . ''; + } + } + return $html; + } + + /** + * Lee la configuración de columnas guardada en pages_options y la aplica + * a las columnas de cada pestaña registrada (display, order, seguridad de nivel). + * + * El nombre de la pestaña (p. ej. 'ListFormaPago') coincide con el nombre + * de la view XML del sistema antiguo, por lo que EditPageOption funciona de + * forma transparente y sus cambios se reflejan aquí. + */ + private function applyPageOptions(): void + { + $userLevel = (int)($this->user->level ?? 0); + + if (!empty($this->tabs)) { + foreach ($this->tabs as $tabName => $tab) { + $this->ensurePageOption($tabName, $tab->columns()); + [$displayMap, $orderMap] = $this->loadPageOptionMaps($tabName); + $tab->applyColumnOptions($displayMap, $orderMap, $userLevel); + } + return; + } + + // modo single: usar el nombre del propio controlador + $pageName = $this->getPageData()['name'] ?? ''; + if (empty($pageName)) { + return; + } + + $this->ensurePageOption($pageName, $this->columns); + [$displayMap, $orderMap] = $this->loadPageOptionMaps($pageName); + + foreach ($this->columns as $fieldname => $component) { + if (isset($displayMap[$fieldname])) { + $display = $displayMap[$fieldname]; + $component->setDisplay($display); + if ($display !== 'none') { + $component->setAlign($display); + } + } + if (isset($orderMap[$fieldname])) { + $component->setOrder($orderMap[$fieldname]); + } + if ($component->level() > 0 && $userLevel < $component->level()) { + $component->setDisplay('none'); + } + } + + uasort($this->columns, fn($a, $b) => $a->order() <=> $b->order()); + } + + /** + * Carga el PageOption para el nombre de vista dado y extrae dos mapas: + * uno fieldname→display y otro fieldname→order. + * + * @return array{0: array, 1: array} + */ + private function loadPageOptionMaps(string $viewName): array + { + $displayMap = []; + $orderMap = []; + + $pageOption = new PageOption(); + $where = [ + new DataBaseWhere('name', $viewName), + new DataBaseWhere('nick', $this->user->nick), + new DataBaseWhere('nick', null, 'IS', 'OR'), + ]; + + if (!$pageOption->loadWhere($where, ['nick' => 'ASC'])) { + return [$displayMap, $orderMap]; + } + + foreach ((array)$pageOption->columns as $entry) { + if (($entry['tag'] ?? '') === 'column') { + // Columnas en nivel raíz (estructura generada por XMLView o installXML) + $fieldname = $entry['children'][0]['fieldname'] ?? null; + if ($fieldname !== null) { + $displayMap[$fieldname] = $entry['display'] ?? 'start'; + if (isset($entry['order'])) { + $orderMap[$fieldname] = (int)$entry['order']; + } + } + continue; + } + + // Columnas dentro de un grupo (estructura generada por ensurePageOption) + foreach ((array)($entry['children'] ?? []) as $col) { + $fieldname = $col['children'][0]['fieldname'] ?? null; + if ($fieldname === null) { + continue; + } + $displayMap[$fieldname] = $col['display'] ?? 'start'; + if (isset($col['order'])) { + $orderMap[$fieldname] = (int)$col['order']; + } + } + } + + return [$displayMap, $orderMap]; + } + + /** + * Crea el PageOption por defecto (nick=null) para una vista si aún no existe en BD. + * + * Construye la estructura de columnas a partir de los FieldComponent registrados, + * de modo que EditPageOption pueda mostrar y editar las columnas del UIListController + * en lugar de intentar cargar el XML antiguo. + * + * @param string $viewName Nombre de la vista / pestaña + * @param FieldComponent[] $columns Array fieldname → FieldComponent + */ + private function ensurePageOption(string $viewName, array $columns): void + { + $pageOption = new PageOption(); + $where = [ + new DataBaseWhere('name', $viewName), + new DataBaseWhere('nick', null, 'IS'), + ]; + if ($pageOption->loadWhere($where)) { + return; + } + + $order = 10; + $children = []; + foreach ($columns as $fieldname => $component) { + $schema = $component->schema(); + $children[$fieldname] = [ + 'tag' => 'column', + 'name' => $fieldname, + 'title' => $fieldname, + 'order' => (string)($component->order() > 0 ? $component->order() : $order), + 'display' => $component->isHidden() ? 'none' : 'start', + 'level' => (string)$component->level(), + 'children' => [ + [ + 'tag' => 'widget', + 'type' => $schema['type'] ?? 'text', + 'fieldname' => $fieldname, + 'readonly' => 'false', + ] + ] + ]; + $order += 10; + } + + $pageOption->name = $viewName; + $pageOption->nick = null; + $pageOption->columns = [ + 'main' => [ + 'tag' => 'group', + 'name' => 'main', + 'title' => '', + 'children' => $children, + ] + ]; + $pageOption->save(); + } +} diff --git a/Core/UIComponents/UIListTab.php b/Core/UIComponents/UIListTab.php new file mode 100644 index 0000000000..efb91eaecc --- /dev/null +++ b/Core/UIComponents/UIListTab.php @@ -0,0 +1,383 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\UIComponents; + +use FacturaScripts\Core\Base\DataBase\DataBaseWhere; +use FacturaScripts\Core\Component\FieldComponent; +use FacturaScripts\Core\Request; +use FacturaScripts\Core\Tools; + +/** + * Representa una pestaña dentro de un UIListController multi-pestaña. + * + * Cada pestaña tiene su propio modelo, columnas, búsqueda, ordenación y + * registros cargados. El controlador solo carga los registros de la pestaña activa. + * + * @author Abderrahim Darghal Belkacemi + */ +class UIListTab +{ + use HasListFilters; + use HasToolbarButtons; + + const MODEL_NAMESPACE = '\\FacturaScripts\\Dinamic\\Model\\'; + + private string $name; + private string $modelClassName; + private string $titleKey; + private string $icon; + + /** @var FieldComponent[] keyed by fieldname */ + private array $columns = []; + private array $searchFields = []; + private array $orderOptions = []; + private array $colorConditions = []; + + private array $records = []; + private int $count = 0; + private int $offset = 0; + private int $limit = 50; + private string $query = ''; + private int $resolvedOrderIndex = -1; + + private string $newUrlValue = ''; + /** @var callable|null */ + private $rowUrlCallback = null; + + /** @var callable|null Callback que devuelve array de DataBaseWhere extra aplicados en loadRecords(). */ + private $extraWhereCallback = null; + + private array $lastWhere = []; + private array $lastOrder = []; + + private function __construct(string $name, string $modelClassName, string $titleKey, string $icon) + { + $this->name = $name; + $this->modelClassName = $modelClassName; + $this->titleKey = $titleKey; + $this->icon = $icon; + } + + public static function make( + string $name, + string $modelClassName, + string $titleKey, + string $icon = 'fa-solid fa-list' + ): self { + return new self($name, $modelClassName, $titleKey, $icon); + } + + public function addColumn(FieldComponent $component): FieldComponent + { + $this->columns[$component->fieldname()] = $component; + return $component; + } + + /** + * Aplica configuración de display, order y nivel de seguridad a las columnas, + * y reordena el array interno según el order resultante. + * + * Llamado desde UIListController::applyPageOptions() después de cargar PageOption. + * + * @param array $displayMap fieldname → 'none'|'left'|'right'|'center' + * @param array $orderMap fieldname → int (posición numérica) + * @param int $userLevel nivel del usuario actual; oculta columnas con level > userLevel + */ + public function applyColumnOptions(array $displayMap, array $orderMap, int $userLevel = 0): void + { + foreach ($this->columns as $fieldname => $component) { + if (isset($displayMap[$fieldname])) { + $display = $displayMap[$fieldname]; + $component->setDisplay($display); + if ($display !== 'none') { + $component->setAlign($display); + } + } + if (isset($orderMap[$fieldname])) { + $component->setOrder($orderMap[$fieldname]); + } + if ($component->level() > 0 && $userLevel < $component->level()) { + $component->setDisplay('none'); + } + } + uasort($this->columns, fn($a, $b) => $a->order() <=> $b->order()); + } + + public function addSearchField(string ...$fields): static + { + foreach ($fields as $field) { + $this->searchFields[] = $field; + } + return $this; + } + + public function addOrderBy(array $fields, string $label, int $default = 0): static + { + $this->orderOptions[] = [ + 'fields' => $fields, + 'label' => $label, + 'default' => ($default === 1) ? 1 : 0, + ]; + $this->orderOptions[] = [ + 'fields' => $fields, + 'label' => $label, + 'default' => ($default === 2) ? 2 : 0, + ]; + return $this; + } + + public function addColor(string $field, mixed $value, string $color, string $title = ''): static + { + $this->colorConditions[] = [ + 'field' => $field, + 'value' => $value, + 'color' => $color, + 'title' => $title, + ]; + return $this; + } + + public function setNewUrl(string $url): static + { + $this->newUrlValue = $url; + return $this; + } + + public function setRowUrlCallback(callable $fn): static + { + $this->rowUrlCallback = $fn; + return $this; + } + + /** + * Registra un callback que devuelve condiciones WHERE extra para loadRecords(). + * + * El callback se invoca sin parámetros y debe retornar array. + * Útil para pestañas que necesitan filtros derivados de una consulta previa, + * como la pestaña de asientos desbalanceados. + */ + public function setExtraWhere(callable $fn): static + { + $this->extraWhereCallback = $fn; + return $this; + } + + public function loadCount(array $extraWhere = []): void + { + $modelClass = self::MODEL_NAMESPACE . $this->modelClassName; + if (!class_exists($modelClass)) { + return; + } + $model = new $modelClass(); + $where = array_merge($extraWhere, $this->extraWhereCallback ? ($this->extraWhereCallback)() : []); + $this->count = $model->count($where); + } + + public function loadRecords(Request $request, int $limit, array $extraWhere = []): void + { + $modelClass = self::MODEL_NAMESPACE . $this->modelClassName; + if (!class_exists($modelClass)) { + return; + } + + $this->limit = $limit; + $model = new $modelClass(); + $where = array_merge($extraWhere, $this->extraWhereCallback ? ($this->extraWhereCallback)() : []); + + $this->readFilterValues($request); + $where = array_merge($where, $this->buildFilterWhere()); + + $this->query = $request->inputOrQuery('query', ''); + if (!empty($this->query) && !empty($this->searchFields)) { + $where[] = new DataBaseWhere( + implode('|', $this->searchFields), + $this->query, + 'LIKE' + ); + } + + $this->offset = max(0, (int)$request->inputOrQuery('offset', 0)); + + $rawIndex = (int)$request->inputOrQuery('order', -1); + $this->resolvedOrderIndex = isset($this->orderOptions[$rawIndex]) ? $rawIndex : -1; + + if ($this->resolvedOrderIndex === -1) { + foreach ($this->orderOptions as $index => $opt) { + if ($opt['default'] > 0) { + $this->resolvedOrderIndex = $index; + break; + } + } + } + + $order = $this->resolveOrder(); + + $this->lastWhere = $where; + $this->lastOrder = $order; + $this->count = $model->count($where); + $this->records = $model->all($where, $order, $this->offset, $limit); + } + + public function lastWhere(): array + { + return $this->lastWhere; + } + + public function lastOrder(): array + { + return $this->lastOrder; + } + + public function name(): string + { + return $this->name; + } + + public function modelClassName(): string + { + return $this->modelClassName; + } + + public function title(): string + { + return $this->titleKey; + } + + public function icon(): string + { + return $this->icon; + } + + public function columns(): array + { + return $this->columns; + } + + public function records(): array + { + return $this->records; + } + + public function count(): int + { + return $this->count; + } + + public function offset(): int + { + return $this->offset; + } + + public function query(): string + { + return $this->query; + } + + public function orderOptions(): array + { + return $this->orderOptions; + } + + public function searchFields(): array + { + return $this->searchFields; + } + + public function limit(): int + { + return $this->limit; + } + + public function orderIndex(): int + { + return $this->resolvedOrderIndex; + } + + public function rowClass(object $record): string + { + foreach ($this->colorConditions as $cond) { + $field = $cond['field']; + if (property_exists($record, $field) && (string)$record->{$field} === (string)$cond['value']) { + return $cond['color']; + } + } + return ''; + } + + public function rowUrl(object $record): string + { + if ($this->rowUrlCallback !== null) { + return ($this->rowUrlCallback)($record); + } + return ''; + } + + public function newUrl(): string + { + return $this->newUrlValue; + } + + public function isClickable(): bool + { + return $this->rowUrlCallback !== null; + } + + public function colorLegend(): string + { + $html = ''; + foreach ($this->colorConditions as $cond) { + if (!empty($cond['title'])) { + $label = Tools::lang()->trans($cond['title']); + $textClass = str_replace('table-', 'text-', $cond['color']); + $html .= '' + . '' + . htmlspecialchars($label) + . ''; + } + } + return $html; + } + + private function resolveOrder(): array + { + if ($this->resolvedOrderIndex >= 0 && isset($this->orderOptions[$this->resolvedOrderIndex])) { + $opt = $this->orderOptions[$this->resolvedOrderIndex]; + $dir = ($this->resolvedOrderIndex % 2 === 0) ? 'ASC' : 'DESC'; + $order = []; + foreach ($opt['fields'] as $field) { + $order[$field] = $dir; + } + return $order; + } + + foreach ($this->orderOptions as $opt) { + if ($opt['default'] > 0) { + $dir = $opt['default'] === 2 ? 'DESC' : 'ASC'; + $order = []; + foreach ($opt['fields'] as $field) { + $order[$field] = $dir; + } + return $order; + } + } + + return []; + } +} diff --git a/Core/UIComponents/UIPanelController.php b/Core/UIComponents/UIPanelController.php new file mode 100644 index 0000000000..0fff7bc766 --- /dev/null +++ b/Core/UIComponents/UIPanelController.php @@ -0,0 +1,186 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\UIComponents; + +use FacturaScripts\Core\Component\ActionResult; +use FacturaScripts\Dinamic\Lib\ExtendedController\PanelController; + +/** + * Controlador base para formularios con paneles/pestañas construido sobre PanelController. + * + * Completa la triada del nuevo sistema UI junto a UIEditController y UIListController: + * + * UIEditController — formulario simple de edición, sin pestañas, con componentes UI + * UIListController — listado con columnas declaradas como componentes UI + * UIPanelController — formulario con pestañas (DocFiles, LogAudit, vistas relacionadas) + * + * A diferencia de UIEditController, UIPanelController reutiliza toda la maquinaria de + * PanelController: plantillas de pestañas (top/left/bottom), DocFilesTrait, LogAuditTrait, + * addHtmlView(), addListView(), addEditView(), addComponentBlock(), etc. + * + * La subclase implementa: + * - getModelClassName() — nombre del modelo principal sin namespace + * - createPanels() — declara las vistas/paneles (reemplaza createViews()) + * - loadData() — carga los datos para cada vista (heredado de BaseController) + * + * Para controladores con formulario principal estándar (cabecera + líneas AJAX), usa + * addHtmlView() en createPanels() con la plantilla correspondiente y registra las acciones + * directamente en execPreviousAction(). Para acciones sencillas sin AJAX también puedes + * usar onEvent() con un callable que devuelva ActionResult. + * + * Uso mínimo: + * class MiController extends UIPanelController + * { + * public function getModelClassName(): string { return 'MiModelo'; } + * + * protected function createPanels(): void + * { + * $this->addHtmlView('main', 'Tab/MiVista', 'MiModelo', 'my-title', 'fa-solid fa-file'); + * $this->createViewDocFiles(); + * $this->createViewLogAudit(); + * } + * + * protected function loadData($viewName, $view): void { ... } + * } + * + * @author Abderrahim Darghal Belkacemi + */ +abstract class UIPanelController extends PanelController +{ + /** + * Devuelve el nombre de la clase del modelo principal (sin namespace). + * Ejemplo: 'Asiento', 'FormaPago'. + */ + abstract public function getModelClassName(): string; + + /** + * Declara las vistas y paneles del controlador. + * + * Llama aquí a addHtmlView(), addListView(), addEditView(), addComponentBlock(), + * createViewDocFiles(), createViewLogAudit(), setTabsPosition(), etc. + * Es el equivalente a createViews() de PanelController con nombre más explícito. + */ + abstract protected function createPanels(): void; + + /** @var array Manejadores de eventos registrados con onEvent(). */ + private array $panelEventHandlers = []; + + /** + * Registra un callable para una acción con nombre. + * + * Alternativa ligera a sobreescribir execPreviousAction() cuando la acción no + * necesita lógica AJAX compleja. El callable no recibe argumentos; debe devolver + * ActionResult (o null para continuar el ciclo de vida normal). + * + * Para acciones AJAX que ya devuelven false y gestionan la respuesta directamente, + * sobreescribe execPreviousAction() en la subclase. + */ + public function onEvent(string $event, callable $handler): void + { + $this->panelEventHandlers[$event] = $handler; + } + + /** + * Delega createViews() en createPanels() para que la subclase use el nombre + * semánticamente correcto para el nuevo sistema UI. + */ + final protected function createViews(): void + { + $this->createPanels(); + } + + /** + * Enruta acciones a los manejadores registrados con onEvent() antes de delegar + * en la lógica estándar de PanelController (edit, delete, etc.). + * + * Devuelve false para detener el ciclo (cuando el manejador ya ha enviado la + * respuesta o ha redirigido). Devuelve true para continuar con loadData(). + */ + protected function execPreviousAction($action) + { + if ($action !== '' && isset($this->panelEventHandlers[$action])) { + $result = ($this->panelEventHandlers[$action])(); + + if ($result instanceof ActionResult) { + if ($result->exit) { + if (!empty($result->redirect)) { + $this->redirect($result->redirect); + } else { + $this->setTemplate(false); + } + return false; + } + + if ($result->stop) { + return false; + } + } + + // El manejador se ejecutó pero no requiere detener el ciclo. + return true; + } + + return parent::execPreviousAction($action); + } + + /** + * Carga y cachea el modelo principal desde la base de datos. + * + * Lee el código del registro desde el parámetro 'code' de la URL o desde la + * clave primaria enviada por POST. Devuelve la instancia del modelo (vacía si + * no se encontró el registro). Útil en los manejadores de eventos para acceder + * al registro sin duplicar la lógica de carga. + */ + protected function loadMainModel(): mixed + { + $mainViewName = $this->getMainViewName(); + if (!isset($this->views[$mainViewName])) { + return null; + } + + $view = $this->views[$mainViewName]; + if ($view->model->exists()) { + return $view->model; + } + + $primaryKey = $this->request->input($view->model->primaryColumn(), ''); + $code = $this->request->query('code', $primaryKey); + + if (!empty($code)) { + $view->model->loadFromCode($code); + } + + return $view->model; + } + + /** + * URL del listado asociado a este formulario. + * + * Usada en redirecciones post-guardado/borrado. La implementación base delega en + * model->url('list'). Las subclases sobreescriben para apuntar a un listado custom. + */ + public function listUrl(): string + { + $model = $this->loadMainModel(); + return ($model !== null && method_exists($model, 'url')) + ? $model->url('list') + : $this->url(); + } +} diff --git a/Core/View/Component/block.html.twig b/Core/View/Component/block.html.twig new file mode 100644 index 0000000000..f14c84fe47 --- /dev/null +++ b/Core/View/Component/block.html.twig @@ -0,0 +1,53 @@ +{# + Template for a ComponentBlock tab inside a PanelController. + Variables: + block — ComponentBlock object + blockName — string identifier (equals block.name()) +#} +{% set card = block.settings.card ?? true %} + +{% if card %} +
+
+{% endif %} + +{# Global error summary #} +{% if block.hasErrors() %} +
+ + {% for fieldname, errs in block.errors() %} + {% for err in errs %} +
{{ err }}
+ {% endfor %} + {% endfor %} +
+{% endif %} + +
+ {{ formToken() }} + + + +
+ {% for component in block.components() %} + {% set col_class = component.cols() > 0 ? 'col-sm-' ~ component.cols() : 'col-sm' %} +
+ {{ component.renderEdit()|raw }} +
+ {% endfor %} +
+ + {% if block.components()|length > 0 %} +
+ +
+ {% endif %} +
+ +{% if card %} +
+
+{% endif %} diff --git a/Core/View/Master/ComponentController.html.twig b/Core/View/Master/ComponentController.html.twig new file mode 100644 index 0000000000..1e6a4eb2ed --- /dev/null +++ b/Core/View/Master/ComponentController.html.twig @@ -0,0 +1,62 @@ +{% extends "Master/MenuTemplate.html.twig" %} + +{% block body %} + {{ parent() }} + + {# Formulario auxiliar para eventos sin datos de formulario #} +
+ {{ formToken() }} + +
+ +
+ + {# Mensajes de error globales #} + {% if fsc.hasErrors() %} +
+ + {% for field, errs in fsc.errors() %} + {% for err in errs %} +
{{ err }}
+ {% endfor %} + {% endfor %} +
+ {% endif %} + + {# Formulario principal con los componentes #} +
+ {{ formToken() }} + +
+ {% for component in fsc.components() %} + {% set col_class = component.cols() > 0 ? 'col-sm-' ~ component.cols() : 'col-sm' %} +
+ {{ component.renderEdit()|raw }} +
+ {% endfor %} +
+ + {% if fsc.components()|length > 0 %} +
+ +
+ {% endif %} + +
+ +
+{% endblock %} + +{% block javascripts %} + {{ parent() }} + + +{% endblock %} diff --git a/Core/View/Master/PanelController.html.twig b/Core/View/Master/PanelController.html.twig index e20ef820a2..e5b1bad1d8 100644 --- a/Core/View/Master/PanelController.html.twig +++ b/Core/View/Master/PanelController.html.twig @@ -123,6 +123,17 @@ {% endif %} {% endfor %} + {# -- Component block tabs -- #} + {% for blockName, block in fsc.componentBlocks() %} + {% if block.settings.active %} + {% set active = (blockName == fsc.active) ? ' active' : '' %} + + + {{ block.title() }} + + {% endif %} + {% endfor %} {% endif %} @@ -139,6 +150,16 @@ {{ include(view.template) }} {% endfor %} + {# -- Component block content -- #} + {% for blockName, block in fsc.componentBlocks() %} + {% if block.settings.active %} + {% set active = (blockName == fsc.active) ? ' show active' : '' %} +
+ {{ include('Component/block.html.twig', {'block': block, 'blockName': blockName}) }} +
+ {% endif %} + {% endfor %} diff --git a/Core/View/Master/UIEditController.html.twig b/Core/View/Master/UIEditController.html.twig new file mode 100644 index 0000000000..f724257085 --- /dev/null +++ b/Core/View/Master/UIEditController.html.twig @@ -0,0 +1,544 @@ +{# +/** + * This file is part of FacturaScripts + * Copyright (C) 2023-2025 Carlos Garcia Gomez + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +#} +{% extends "Master/MenuBgTemplate.html.twig" %} + +{% block css %} + {{ parent() }} + +{% endblock %} + +{% block bodyHeaderOptions %} + {{ parent() }} + {% set pageData = fsc.getPageData() %} + {% set model = fsc.getModel() %} +
+
+
+ {# Breadcrumb móvil #} + + + {# Botones cabecera izquierda #} +
+ + + {{ trans('all') }} + + {% if fsc.hasData and model %} + + + + {% else %} + + + + {% endif %} +
+ + {# Botón Opciones #} +
+ +
+ + {# Botón Nuevo #} + {% if fsc.hasData %} + + + {{ trans('new') }} + + {% endif %} + + {# Botones extra inyectados por subclases de UIEditController #} + {{ fsc.extraHeaderButtons() | raw }} + + {# Botón Imprimir #} + {% if fsc.hasData and model %} +
+ + + {{ trans('print') }} + + + +
+ {% endif %} +
+ + {# Título y descripción (escritorio) #} +
+

{{ trans(pageData.title) }}

+ {% if fsc.hasData and model %} +

{{ model.primaryDescription() | raw }}

+ {% else %} +

{{ trans('new') }}

+ {% endif %} +
+ +
+ {% set image = fsc.getImageUrl() %} + {% if image is empty %} + + {% else %} + {{ fsc.title }} + {% endif %} +
+
+
+{% endblock %} + +{% block body %} + {{ parent() }} + {% set pageData = fsc.getPageData() %} + {% set model = fsc.getModel() %} + {% set extraPanels = fsc.extraPanels() %} + {% set panelListViews = fsc.panelListViews() %} + {% set inlineListViews = fsc.inlineListViews() %} + {% set hasMultiplePanels = extraPanels | length > 0 or panelListViews | length > 0 %} + {% set activeTab = fsc.activeTab() %} + +
+
+ + {# Columna izquierda: navegación de paneles (solo si hay paneles extra editables) #} + {% if hasMultiplePanels %} +
+ +
+ {% endif %} + + {# Columna derecha: contenido #} + {% set rightClass = hasMultiplePanels ? 'col-12 col-lg' : 'col-12' %} +
+
+ + {# Tab principal: formulario de edición #} + {% set mainActive = (not hasMultiplePanels or activeTab == '__main__') ? ' show active' : '' %} +
+ + {# Alertas de validación #} + {% if fsc.hasErrors() %} +
+ + {% for field, errs in fsc.errors() %} + {% for err in errs %} +
{{ err }}
+ {% endfor %} + {% endfor %} +
+ {% endif %} + +
+ {{ formToken() }} + {% if model %} + + {% endif %} + {% if hasMultiplePanels %} + + {% endif %} + +
+
+
+ {% for group in fsc.componentGroups() %} +
+ {% if group.title %} + {{ trans(group.title) }} + {% endif %} +
+ {% for fieldname, component in group.components %} + {% if component.isHidden() %} + {{ component.renderHidden() | raw }} + {% else %} +
+ {{ component.renderEdit() | raw }} +
+ {% endif %} + {% endfor %} +
+
+ {% endfor %} +
+
+ + +
+
+
+ + {# Tabs de paneles extra #} + {% for panelName, panel in extraPanels %} + {% set panelActive = (activeTab == panelName) ? ' show active' : '' %} +
+
+ {{ formToken() }} + {% if model %} + + {% endif %} + + +
+
+
+ {% for fieldname, component in panel.components() %} + {% if component.isHidden() %} + {# campo oculto: sin HTML visible #} + {% else %} +
+ {{ component.renderEdit() | raw }} +
+ {% endif %} + {% endfor %} +
+
+ +
+
+
+ {% endfor %} + + {# Tabs de vistas de lista laterales #} + {% for panelName, panelView in panelListViews %} + {% if panelView.settings.active %} + {% set panelActive = (activeTab == panelName) ? ' show active' : '' %} +
+ {% do fsc.setCurrentView(panelName) %} + {{ include(panelView.template) }} +
+ {% endif %} + {% endfor %} + +
+
+
+ + {# Vistas inline (setInLine=true): se renderizan debajo del formulario. #} + {% for listName, listView in inlineListViews %} + {% if listView.settings.active %} + {% do fsc.setCurrentView(listName) %} + {{ include(listView.template) }} + {% endif %} + {% endfor %} +
+ + {# Modal + form de confirmación de borrado #} + {% if fsc.hasData %} +
+ {{ formToken() }} + {% if model %} + + {% endif %} + +
+ + + {% endif %} + + {# Modal de exportación avanzada #} + {% if fsc.hasData and model %} +
+ {{ formToken() }} + + + +
+ {% endif %} +{% endblock %} + +{% block javascripts %} + {{ parent() }} + +{% endblock %} diff --git a/Core/View/Master/UIListController.html.twig b/Core/View/Master/UIListController.html.twig new file mode 100644 index 0000000000..0e81cdcfea --- /dev/null +++ b/Core/View/Master/UIListController.html.twig @@ -0,0 +1,569 @@ +{# +/** + * This file is part of FacturaScripts + * Copyright (C) 2023-2025 Carlos Garcia Gomez + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +#} +{% extends "Master/MenuBghTemplate.html.twig" %} + +{% block bodyHeaderOptions %} + {{ parent() }} + {% set pageData = fsc.getPageData() %} + {% set hasTabs = fsc.tabs() | length > 0 %} + {% set activeTabName = hasTabs ? fsc.activeTabName() : '' %} + +
+
+
+ +
+ + + + {% if pageData.name == fsc.user.homepage %} + + + + {% else %} + + + + {% endif %} +
+
+ +
+
+
+

+ {{ fsc.title }} +

+
+
+
+ +{% endblock %} + +{% block body %} + {{ parent() }} + + {% set hasTabs = fsc.tabs() | length > 0 %} + {% set activeTabName = hasTabs ? fsc.activeTabName() : '' %} + + + +
+ {% if hasTabs %} + {% for tabName, tab in fsc.tabs() %} + {% set isActive = tabName == activeTabName %} +
+ {% if isActive %} + {{ _self.tabBody(tab, tabName, fsc) }} + {% endif %} +
+ {% endfor %} + {% else %} + {% set tabName = fsc.getPageData().name %} +
+ {{ _self.tabBody(fsc, tabName, fsc) }} +
+ {% endif %} +
+{% endblock %} + +{# Renders one action item — shared by standalone buttons inside dropdown groups #} +{% macro dropdownItem(btn, tabName) %} + {% if btn.type is defined and btn.type == 'modal' %} + + {% elseif btn.confirm is defined and btn.confirm %} + + {% else %} + + {% endif %} +{% endmacro %} + +{# Renders standalone action buttons and dropdown button groups declared via addActionButton/addButtonGroup #} +{% macro toolbarButtons(src, tabName) %} + {% for btn in src.actionButtons() %} + {% if btn.type is defined and btn.type == 'modal' %} + + {% elseif btn.confirm is defined and btn.confirm %} + + {% else %} + + {% endif %} + {% endfor %} + {% for groupName, group in src.buttonGroups() %} +
+ + +
+ {% endfor %} +{% endmacro %} + +{# Renders the full form + toolbar + table + pagination for one tab #} +{% macro tabBody(src, tabName, fsc) %} + {% set formName = 'form' ~ tabName %} + {% set columns = src.columns() %} + {% set records = src.records() %} + {% set orderOptions = src.orderOptions() %} + {% set orderIndex = src.orderIndex() %} + {% set colorLegend = src.colorLegend() %} + {% set clickable = src.isClickable() %} + {% set visibleColCount = 0 %} + {% for fieldname, col in columns %} + {% if not col.isHidden() %} + {% set visibleColCount = visibleColCount + 1 %} + {% endif %} + {% endfor %} +
+ {{ formToken() }} + + + + + +
+
+
+
+ {% if src.newUrl() is not empty %} + + + {{ trans('new') }} + + {% endif %} + {% if src.count() > 0 %} + + {% endif %} + {% if src.count() > 0 %} +
+ +
+ {% endif %} + {% if clickable and src.count() > 1 %} + + {% endif %} + {{ _self.toolbarButtons(src, tabName) }} +
+
+ {% if src.searchFields() | length > 0 %} +
+ + +
+ {% endif %} +
+
+ {% if src.hasFilters() %} + + {% endif %} + {% if orderOptions | length > 0 %} + {% set activeOrder = null %} + {% if orderIndex >= 0 %} + {% set activeOrder = orderOptions[orderIndex] %} + {% endif %} + {% if activeOrder is null %} + {% for opt in orderOptions %} + {% if opt.default > 0 and activeOrder is null %} + {% set activeOrder = opt %} + {% endif %} + {% endfor %} + {% endif %} + {% if activeOrder is null %} + {% set activeOrder = orderOptions | first %} + {% endif %} + {% set sortIcon = (orderIndex >= 0 and orderIndex % 2 != 0) ? 'fa-solid fa-angles-down' : 'fa-solid fa-angles-up' %} +
+ + +
+ {% endif %} + {% if colorLegend is not empty %} +
+ +
+ {% endif %} +
+
+ {% if src.hasFilters() %} + {% set filtersHtml = src.renderFiltersHtml(formName) %} +
+ {{ filtersHtml | raw }} +
+ {% endif %} +
+
+ {% set tableClass = settings('default', 'tablesize') == 'small' ? 'table-sm' : '' %} + + + + {% if src.count() > 0 %} + + {% else %} + + {% endif %} + {% for fieldname, col in columns %} + {% if not col.isHidden() %} + {# Determine sort state for this column #} + {% set _sortNextKey = null %} + {% set _sortIsActive = false %} + {% set _sortMode = '' %} + {% for i, opt in orderOptions %} + {% if fieldname in opt.fields %} + {% if i == orderIndex %} + {% set _sortIsActive = true %} + {% set _sortMode = (i % 2 == 0) ? 'ASC' : 'DESC' %} + {% elseif _sortNextKey is null %} + {% set _sortNextKey = i %} + {% endif %} + {% endif %} + {% endfor %} + + {% endif %} + {% endfor %} + + + + {% for record in records %} + {% set editUrl = src.rowUrl(record) %} + {% set trClass = (clickable ? 'clickableListRow ' : '') ~ src.rowClass(record) %} + + + {% for fieldname, col in columns %} + {% if not col.isHidden() %} + {{ col.renderCell(attribute(record, fieldname) ?? null) | raw }} + {% endif %} + {% endfor %} + + {% else %} + + + + {% endfor %} + +
+ + {% if clickable %} + + {% endif %} + + {% if _sortIsActive %} + {% if _sortNextKey is not null %} + + {{ col.label() }} + + {% else %} + {{ col.label() }} + {% endif %} + {% elseif _sortNextKey is not null %} + + {{ col.label() }} + + {% else %} + {{ col.label() }} + {% endif %} +
+ + {% if clickable %} + + + + {% endif %} +
+ {% if src.query() is not empty %} +
+ +
+

{{ trans('no-results-found') }}

+ {% else %} +
+ +
+

{{ trans('no-data-empty-list') }}

+ {% if src.newUrl() is not empty %} +

{{ trans('no-data-click-new') }}

+ {% endif %} + {% endif %} +
+
+ {% set totalPages = src.count() > 0 ? ((src.count() / src.limit()) | round(0, 'ceil')) : 0 %} + {% set currentPage = src.limit() > 0 ? ((src.offset() / src.limit()) | round(0, 'floor')) : 0 %} + {% if totalPages > 1 %} + + {% endif %} +
+
+ {{ fsc.tabModals(tabName) | raw }} +
+{% endmacro %} + +{% block css %} + {{ parent() }} + +{% endblock %} + +{% block javascripts %} + {{ parent() }} + + +{% endblock %}