diff --git a/Core/Lib/ExtendedController/BaseView.php b/Core/Lib/ExtendedController/BaseView.php index 8295960926..8fd0a37db6 100644 --- a/Core/Lib/ExtendedController/BaseView.php +++ b/Core/Lib/ExtendedController/BaseView.php @@ -183,6 +183,7 @@ public function __construct(string $name, string $title, string $modelName, stri 'customized' => false, 'itemLimit' => Tools::settings('default', 'item_limit', 50), 'megasearch' => false, + 'navigation' => true, 'saveFilters' => false, ]; $this->template = static::DEFAULT_TEMPLATE; diff --git a/Core/Lib/ExtendedController/ListController.php b/Core/Lib/ExtendedController/ListController.php index caa46a3f3d..cd2bf1d798 100644 --- a/Core/Lib/ExtendedController/ListController.php +++ b/Core/Lib/ExtendedController/ListController.php @@ -81,6 +81,17 @@ public function privateCore(&$response, $user, $permissions) $this->pipeFalse('loadData', $viewName, $view); } + // Save the navigation snapshot of every view, except on actions without page render. + // The active view is extended with one page before and one after; the others only keep + // their loaded cursor, because their tabs are rendered on the same page. + if (false === in_array($action, ['export', 'megasearch'])) { + foreach ($this->views as $viewName => $view) { + if ($view instanceof ListView) { + $view->saveNavigation($viewName === $this->active); + } + } + } + // Execute actions after loading data $this->execAfterAction($action); $this->pipeFalse('execAfterAction', $action); diff --git a/Core/Lib/ExtendedController/ListView.php b/Core/Lib/ExtendedController/ListView.php index 9948844cb9..d165eee091 100644 --- a/Core/Lib/ExtendedController/ListView.php +++ b/Core/Lib/ExtendedController/ListView.php @@ -19,13 +19,16 @@ namespace FacturaScripts\Core\Lib\ExtendedController; +use FacturaScripts\Core\Base\DataBase; use FacturaScripts\Core\Base\DataBase\DataBaseWhere; use FacturaScripts\Core\Cache; use FacturaScripts\Core\Model\Base\BusinessDocument; +use FacturaScripts\Core\Model\Base\ModelClass as LegacyModelClass; use FacturaScripts\Core\Request; use FacturaScripts\Core\Session; use FacturaScripts\Core\Template\ModelClass; use FacturaScripts\Core\Tools; +use FacturaScripts\Core\Where; use FacturaScripts\Dinamic\Lib\AssetManager; use FacturaScripts\Dinamic\Lib\ExportManager; use FacturaScripts\Dinamic\Lib\Widget\ColumnItem; @@ -44,6 +47,16 @@ class ListView extends BaseView const DEFAULT_TEMPLATE = 'Master/ListView.html.twig'; + /** Number of tokens for view and user. Used for edit navigator */ + const NAVIGATION_TOKENS = 10; + + /** + * Token for actual render. + * + * @var string + */ + public $navToken = ''; + /** @var string */ public $orderKey = ''; @@ -288,6 +301,52 @@ public function processFormData($request, $case) } } + /** + * Guarda en caché la foto de los códigos visibles, identificada por un token + * nuevo que las filas añaden a sus enlaces para poder navegar entre registros + * desde el EditController de destino. + * + * @param bool $extended (true = amplía la foto una página antes y otra después) + */ + public function saveNavigation(bool $extended = false): void + { + // only when the row link opens the record itself: if the code in the link is not + // the primary key (document lines, line joins), there is nothing to navigate. + if (empty($this->settings['navigation']) + || empty($this->cursor) + || false === $this->rowCodeIsPrimaryKey() + ) { + return; + } + + $start = $this->offset; + $codes = $this->navigationCodes($start, $extended); + if (empty($codes)) { + return; + } + + // save seed with new token + $nick = Session::user()->nick; + $this->navToken = bin2hex(random_bytes(8)); + Cache::set('nav-' . $nick . '-' . $this->navToken, [ + 'codes' => $codes, + 'count' => $this->count, + 'start' => $start, + ]); + + // only keep the latest tokens from this view and delete expired seeds + $tokensKey = 'nav-tokens-' . Session::get('controllerName') . '-' . $this->getViewName() . '-' . $nick; + $tokens = Cache::get($tokensKey); + if (false === is_array($tokens)) { + $tokens = []; + } + $tokens[] = $this->navToken; + while (count($tokens) > self::NAVIGATION_TOKENS) { + Cache::delete('nav-' . $nick . '-' . array_shift($tokens)); + } + Cache::set($tokensKey, $tokens); + } + /** * Adds assets to the asset manager. */ @@ -426,4 +485,76 @@ protected function setSelectedOrderBy(string $orderKey): void $this->orderKey = $orderKey; } } + + /** + * Returns the codes for the navigation seed. By default, these are the codes for the + * already loaded cursor (no cost). In extended mode and with a direct model, + * one page before and one after are extended by querying only the + * primary key, adjusting $start to the actual start of the window. + * + * @param int &$start + * @param bool $extended + * @return array + */ + private function navigationCodes(int &$start, bool $extended): array + { + // Standard Model and Older Model + $direct = $this->model instanceof ModelClass || $this->model instanceof LegacyModelClass; + if ($extended && $direct) { + $sql = 'SELECT ' . $this->model->primaryColumn() + . ' FROM ' . $this->model->tableName() + . Where::multiSqlLegacy($this->where); + + if ($this->order) { + $orderBy = []; + foreach ($this->order as $field => $direction) { + $orderBy[] = $field . ' ' . $direction; + } + $sql .= ' ORDER BY ' . implode(', ', $orderBy); + } + + // have 3 record pages (previous - actual - next) + $limit = 3 * $this->settings['itemLimit']; + $start = max(0, $this->offset - $this->settings['itemLimit']); + + $codes = []; + $db = new DataBase(); + foreach ($db->selectLimit($sql, $limit, $start) as $row) { + $codes[] = $row[$this->model->primaryColumn()]; + } + return $codes; + } + + // Other cases: the primary key of each record on the visible page + $codes = []; + foreach ($this->cursor as $model) { + $code = method_exists($model, 'id') + ? $model->id() + : $model->primaryColumnValue(); + + if (null === $code || '' === $code) { + return []; + } + $codes[] = $code; + } + return $codes; + } + + /** + * Returns true when the row link opens the record itself: the code in the link + * matches the primary key of the model, checked on the first loaded record. + * False when url() is overridden to open another model (document lines, line joins). + */ + private function rowCodeIsPrimaryKey(): bool + { + $model = reset($this->cursor); + parse_str(parse_url($model->url(), PHP_URL_QUERY) ?: '', $params); + $code = $params['code'] ?? ''; + if ('' === $code || false === is_string($code)) { + return false; + } + + $pk = method_exists($model, 'id') ? $model->id() : $model->primaryColumnValue(); + return (string)$pk === $code; + } } diff --git a/Core/Lib/ExtendedController/PanelController.php b/Core/Lib/ExtendedController/PanelController.php index 6c830926c1..f56f232c13 100644 --- a/Core/Lib/ExtendedController/PanelController.php +++ b/Core/Lib/ExtendedController/PanelController.php @@ -20,6 +20,7 @@ namespace FacturaScripts\Core\Lib\ExtendedController; use FacturaScripts\Core\Base\ControllerPermissions; +use FacturaScripts\Core\Cache; use FacturaScripts\Core\Response; use FacturaScripts\Core\Tools; use FacturaScripts\Dinamic\Model\User; @@ -63,6 +64,44 @@ public function getImageUrl(): string return ''; } + /** + * Returns the data to navigate between the records in the source list. + * - Array: When accessed from a row link with a navigation token. + * - Empty: When no token, the seed has expired or the current record is not in it. + * + * @return array + */ + public function getNavigation(): array + { + $token = $this->request->query->getAlnum('navfrom'); + if (empty($token)) { + return []; + } + + // search the seed of code list for user. + $data = Cache::get('nav-' . $this->user->nick . '-' . $token); + if (false === is_array($data) || empty($data['codes'])) { + return []; + } + + // locate the record into the seed + $model = $this->tab($this->getMainViewName())->model; + $code = method_exists($model, 'id') ? $model->id() : $model->primaryColumnValue(); + $codes = array_map('strval', $data['codes']); + $position = array_search((string)$code, $codes, true); + if (false === $position) { + return []; + } + + $url = $this->url() . '?code=%s&navfrom=' . $token; + return [ + 'count' => $data['count'] ?? count($codes), + 'next' => isset($codes[$position + 1]) ? sprintf($url, rawurlencode($codes[$position + 1])) : '', + 'position' => ($data['start'] ?? 0) + $position + 1, + 'prev' => $position > 0 ? sprintf($url, rawurlencode($codes[$position - 1])) : '', + ]; + } + /** * Runs the controller's private logic. * @@ -105,6 +144,11 @@ public function privateCore(&$response, $user, $permissions) if ($viewName === $mainViewName && $view->model->exists()) { $this->hasData = true; } + + // save the navigation snapshot of list tabs, except on actions without page render + if ('export' !== $action && $view instanceof ListView) { + $view->saveNavigation(); + } } // General operations with the loaded data diff --git a/Core/View/Master/ListView.html.twig b/Core/View/Master/ListView.html.twig index 4429904806..5b24fa6137 100644 --- a/Core/View/Master/ListView.html.twig +++ b/Core/View/Master/ListView.html.twig @@ -152,7 +152,11 @@ {% for model in currentView.cursor %} {% set trClass = currentView.settings.clickable ? 'clickableListRow ' ~ rowStatus.trClass(model) : rowStatus.trClass(model) %} {% set trTitle = rowStatus.trTitle(model) %} - + {% set rowUrl = model.url() %} + {% if rowUrl and currentView.navToken %} + {% set rowUrl = rowUrl ~ ('?' in rowUrl ? '&' : '?') ~ 'navfrom=' ~ currentView.navToken %} + {% endif %} + {% if currentView.settings.checkBoxes or currentView.settings.clickable %} {% if currentView.settings.checkBoxes %} @@ -162,7 +166,7 @@ {% endif %} {% if currentView.settings.clickable %} - diff --git a/Core/View/Master/PanelController.html.twig b/Core/View/Master/PanelController.html.twig index e20ef820a2..18aa82f0c3 100644 --- a/Core/View/Master/PanelController.html.twig +++ b/Core/View/Master/PanelController.html.twig @@ -1,7 +1,7 @@ {# /** * This file is part of FacturaScripts - * Copyright (C) 2017-2025 Carlos Garcia Gomez + * Copyright (C) 2017-2026 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 @@ -60,6 +60,33 @@ + {# -- Navigation buttons -- #} + {% set navData = fsc.getNavigation() %} + {% if navData %} +
+ {% if navData.prev %} + + + + {% else %} + + {% endif %} + + {% if navData.next %} + + + + {% else %} + + {% endif %} +
+ {% endif %} {# -- Options button -- #} {{ _self.optionsButton(fsc, firstView) }} {# -- New button -- #} diff --git a/Core/View/Master/PanelControllerTop.html.twig b/Core/View/Master/PanelControllerTop.html.twig index ab9a78bc26..0a6a5eac9c 100644 --- a/Core/View/Master/PanelControllerTop.html.twig +++ b/Core/View/Master/PanelControllerTop.html.twig @@ -1,7 +1,7 @@ {# /** * This file is part of FacturaScripts - * Copyright (C) 2017-2025 Carlos Garcia Gomez + * Copyright (C) 2017-2026 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 @@ -64,6 +64,33 @@ + {# -- Navigation buttons -- #} + {% set navData = fsc.getNavigation() %} + {% if navData %} +
+ {% if navData.prev %} + + + + {% else %} + + {% endif %} + + {% if navData.next %} + + + + {% else %} + + {% endif %} +
+ {% endif %} {# -- Options button -- #} {{ _self.optionsButton(fsc, firstView, i18n) }} {# -- New button -- #} diff --git a/Core/View/Tab/AccountingEntry.html.twig b/Core/View/Tab/AccountingEntry.html.twig index 10bac52770..1f23955183 100644 --- a/Core/View/Tab/AccountingEntry.html.twig +++ b/Core/View/Tab/AccountingEntry.html.twig @@ -131,6 +131,10 @@ data.messages.forEach(item => alert(item.message)); } if (data.ok) { + const navfrom = new URLSearchParams(window.location.search).get('navfrom'); + if (navfrom) { + data.newurl += (data.newurl.includes('?') ? '&' : '?') + 'navfrom=' + encodeURIComponent(navfrom); + } window.location.replace(data.newurl); } else { animateSpinner('remove', true); diff --git a/Core/View/Tab/PurchasesDocument.html.twig b/Core/View/Tab/PurchasesDocument.html.twig index c3b17afe15..2e03c24f15 100644 --- a/Core/View/Tab/PurchasesDocument.html.twig +++ b/Core/View/Tab/PurchasesDocument.html.twig @@ -282,6 +282,10 @@ data.messages.forEach(item => alert(item.message)); } if (data.ok) { + const navfrom = new URLSearchParams(window.location.search).get('navfrom'); + if (navfrom) { + data.newurl += (data.newurl.includes('?') ? '&' : '?') + 'navfrom=' + encodeURIComponent(navfrom); + } window.location.replace(data.newurl); } else { animateSpinner('remove', true); diff --git a/Core/View/Tab/SalesDocument.html.twig b/Core/View/Tab/SalesDocument.html.twig index 3bd6801d9e..ff1edbe6a8 100644 --- a/Core/View/Tab/SalesDocument.html.twig +++ b/Core/View/Tab/SalesDocument.html.twig @@ -279,6 +279,10 @@ data.messages.forEach(item => alert(item.message)); } if (data.ok) { + const navfrom = new URLSearchParams(window.location.search).get('navfrom'); + if (navfrom) { + data.newurl += (data.newurl.includes('?') ? '&' : '?') + 'navfrom=' + encodeURIComponent(navfrom); + } window.location.replace(data.newurl); } else { animateSpinner('remove', true);