Files
pleno-vue/src/components/pagination/paginatedList.vue
T
7c9eb8b644 chore(release): merge develop into master — XL Vask integration fixes (Aug 15 2026) (#312)
Brings all of the develop branch's commits into master.

## What this contains

The 9 commits on develop that landed in this round — all XL Vask-related
UI fixes plus the 'visible primary-button hover state' visual-diff:

- **PR #303** (TRU-5 / AUT-1) — style(AUT-1): visible primary-button
hover state + visual diff
- **PR #304** (TRU-10 / AUT-6) — fix(invoicing-period): propagate
flagged wash start date to Selvvask view
- **PR #305** (TRU-12 / AUT-8) — feat(xlvask): render friendly notice
for 404 from /modules/xlvask/services/usage/orders
- **PR #306** (TRU-9 / AUT-5) — i18n(test): lock in xlvask_review /
xlvask_usage_log mirroring to the global v2 fallback
- **PR #307** (TRU-13 / AUT-9) — i18n(xlvask_review): translate missing
keys for no, sv, de, en
- **PR #308** (TRU-15 / AUT-11) — test(e2e): add Playwright smoke test
for XL Vask flag → Selvvash navigation
- **PR #309** (TRU-11 / AUT-7) — feat(TRU-11): propagate department
selector to Selvvask usage query
- **PR #310** (TRU-19 / AUT-15) — test(TRU-19): lock self-serve program
number range + button registry contract
- **PR #311** (TRU-8 / AUT-4) — fix(invoicing-flag-list): explain empty
XL Vask hover preview when flag context has no metadata

## Why

The XL Vask integration bug surfaced from the user-reported message "XL
Vask-registreringen er hverken ignoreret eller knyttet til en ordre i
den valgte periode. doesn't show the wash." After dispatching 9
diagnostic + fix tasks and merging all 9 PRs into develop via the
OpenSymphony orchestrator running against MiniMax M3, this PR is the
canonical release to bring develop's accumulated changes into master.

No new code in this PR — just the squash-merged output of the 9 source
PRs combined into a single develop→master merge.

## Verification

All 9 source PRs passed:
- Required CI (Action Runners)
- App Store Readiness  
- Quality lint/i18n/build/unit/e2e suites

The required checks on this PR will run the same gate.

## Notes

- The api repo has its own equivalent PR/merge — see CHANGELOG for that
side.

---------

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-16 08:58:06 +02:00

587 lines
18 KiB
Vue

<script>
import { getCurrentScope, inject, onScopeDispose, ref } from 'vue';
import axios from "axios";
import {API_URL} from "@/config.js";
import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import { exportRowsToExcel } from "@/services/TableExcelExportService.js";
export const PaginatedListKey = Symbol('PaginatedList');
const SEARCH_DEBOUNCE_MS = 250;
/**
* usePaginatedList composable
* Returns a new pagination instance
*/
export function usePaginatedList() {
/** The list of items to paginate */
const list = ref([]);
/** The meta for the list */
const metaCurrentPage = ref(1) // The current page
const metaItemsPerPage = ref(100) // The number of items per page
const metaTotalItems = ref(0); // The total number of items
const metaSearch = ref(null); // The search query string
const filter = ref(null); // The filter (eg. department_id or user_id)
const lastUpdated = ref(null); // The last updated time
const endpoint = ref(null); // The endpoint
const meta = ref(null); // The meta object returned from the API
const orderBy = ref(null); // The order by (eg. id)
const orderDirection = ref(null); // The order direction (eg. asc)
const hideSearchField = ref(false); // Hide the search field
/** Set the loading state */
const isLoading = ref(false);
/** The latest search */
const latestSearch = ref(null);
const latestRequestId = ref(0);
let activeRequestController = null;
let searchDebounceTimeout = null;
/** The last error produced by `paginatedGetRequest` (null on success). */
const lastError = ref(null);
/** Additional query parameters */
const additionalQueryParameters = ref({});
/** Excel export state */
const isExporting = ref(false);
const exportTransform = ref(null);
const resolveRequestLimit = (limit = null) => {
const parsedLimit = Number.parseInt(limit, 10);
if (Number.isFinite(parsedLimit) && parsedLimit > 0) {
return parsedLimit;
}
const parsedCurrentLimit = Number.parseInt(metaItemsPerPage.value, 10);
if (Number.isFinite(parsedCurrentLimit) && parsedCurrentLimit > 0) {
return parsedCurrentLimit;
}
return 100;
};
const buildRequestParams = ({ page = metaCurrentPage.value, limit = metaItemsPerPage.value } = {}) => {
const effectiveOrderBy = orderBy.value || 'id';
const effectiveOrderDirection = orderDirection.value || 'asc';
return {
page: Number.parseInt(page, 10) || 1,
limit: resolveRequestLimit(limit),
search: metaSearch.value,
filters: filter.value,
order: `${effectiveOrderBy}:${effectiveOrderDirection}`,
...additionalQueryParameters.value,
};
};
const buildRequestHeaders = (token) => {
return {
Authorization: `Bearer ${token}`,
// Add X-Customer-Number header if subuser has selected a grant
'X-Customer-Number': localStorage.getItem('selected_customer_number') || '',
};
};
/**
* Set the hide search field
* @param hide true or false
*/
const setHideSearchField = (hide) => {
hideSearchField.value = hide;
};
/** Set the order */
const setOrder = (column, direction) => {
orderBy.value = column;
orderDirection.value = direction;
};
/** Set the last updated time */
const setLastUpdated = () => {
lastUpdated.value = new Date();
};
/** The pagination functions */
const setList = (newList) => {
list.value = newList;
setLastUpdated();
};
const setMeta = (currentPage, itemsPerPage, totalItems) => {
metaCurrentPage.value = currentPage;
metaItemsPerPage.value = itemsPerPage;
metaTotalItems.value = totalItems;
setLastUpdated();
};
const loadSwitch = (bool) => {
isLoading.value = bool;
};
/**
* isLatestSearch
* This function is used to check if the search is the latest search,
* It is used to prevent the results from being overwritten by an older search
* @param search
* @returns {boolean}
*/
const isLatestSearch = (search) => {
return search === latestSearch.value;
};
const clearPendingSearch = () => {
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
searchDebounceTimeout = null;
}
};
const abortActiveRequest = () => {
activeRequestController?.abort();
activeRequestController = null;
};
if (getCurrentScope()) {
onScopeDispose(() => {
clearPendingSearch();
abortActiveRequest();
});
}
const isCanceledRequest = (error) => {
return error?.code === "ERR_CANCELED"
|| error?.name === "CanceledError"
|| error?.name === "AbortError"
|| (typeof axios.isCancel === "function" && axios.isCancel(error));
};
/** The paginated get request */
const paginatedGetRequest = async () => {
// Set the latest search
latestSearch.value = metaSearch.value;
const tmp_search = metaSearch.value;
const requestId = latestRequestId.value + 1;
latestRequestId.value = requestId;
const token = localStorage.getItem('token');
if (!token) {
loadSwitch(false);
return null;
}
abortActiveRequest();
const requestController = typeof AbortController !== "undefined" ? new AbortController() : null;
activeRequestController = requestController;
lastError.value = null;
try {
const response = await axios.get(API_URL + endpoint.value, {
params: buildRequestParams(),
headers: buildRequestHeaders(token),
...(requestController ? { signal: requestController.signal } : {}),
});
// Check if the search is the latest search
if (!isLatestSearch(tmp_search) || requestId !== latestRequestId.value) {
return null;
}
setList(response?.data?.data || []);
setMeta(
response?.data?.meta?.pagination?.page || 1,
response?.data?.meta?.pagination?.per_page || resolveRequestLimit(),
response?.data?.meta?.pagination?.total || 0
);
meta.value = response?.data?.meta || null;
setLastUpdated();
return response;
} catch (error) {
if (isCanceledRequest(error)) {
return null;
}
lastError.value = {
status: Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10) || null,
endpoint: endpoint.value,
message: error?.response?.data?.data?.message ?? error?.response?.data?.message ?? error?.message ?? null,
};
parseError(error, 'paginatedGetRequest');
console.log(error);
return null;
} finally {
if (activeRequestController === requestController) {
activeRequestController = null;
}
if (requestId === latestRequestId.value) {
loadSwitch(false);
}
}
};
/** The load list function */
const loadList = () => {
clearPendingSearch();
loadSwitch(true);
// clear the list
return paginatedGetRequest();
};
/** Set the page */
const setPage = (page) => {
// Make sure the page is within the bounds, if not, return
if (page < 1 || (metaTotalItems.value > 0 && page > Math.ceil(metaTotalItems.value / metaItemsPerPage.value))) {
return;
}
metaCurrentPage.value = page;
};
const setEndpoint = (newEndpoint, autoLoad = true) => {
endpoint.value = newEndpoint;
// Reset the errors
clearErrors();
// Reset the page to 1
setPage(1);
// Clear the list
list.value = [];
// Reset the search
metaSearch.value = null;
// Reset the meta
meta.value = null;
// Reset the filter
filter.value = null;
// Reset the order
setOrder('id', 'asc');
// Reset the items per page
metaItemsPerPage.value = 100
// Clear the additional query parameters
additionalQueryParameters.value = {};
// Load the list (If the endpoint is set)
if (autoLoad) {
loadList();
}
};
const setMetaItemsPerPage = (newItemsPerPage, autoload = true) => {
metaItemsPerPage.value = newItemsPerPage;
// Reset the page to 1
setPage(1);
// Load the list
if (autoload) {
loadList();
}
};
/**
* Set the filter
* @param newFilter eg. department_id
* @param value eg. 3
* @param autoLoad
*/
const setFilter = (newFilter, value, autoLoad = true) => {
if (value === '*') {
// Remove the filter if the value is *
filter.value = filter.value ? filter.value.split(',').filter(f => !f.startsWith(`${newFilter}:`)).join(',') : null;
} else if (filter.value && filter.value.includes(`${newFilter}:`)) {
// If the filter is already set, replace the value
filter.value = filter.value.split(',').map(f => f.startsWith(`${newFilter}:`) ? `${newFilter}:${value}` : f).join(',');
} else {
// If the filter is not set, add it to the filter
filter.value = filter.value ? `${filter.value},${newFilter}:${value}` : `${newFilter}:${value}`;
}
// Remove duplicates
filter.value = filter.value ? [...new Set(filter.value.split(','))].join(',') : null;
// If the filter is empty, set it to null
if (filter.value === '') {
filter.value = null;
}
// Reset the page to 1
setPage(1);
// Load the list (If the endpoint is set)
if (autoLoad) {
loadList();
}
};
/**
* Search function
* @param searchValue
* @param autoLoad BE AWARE, if autoLoad is set to false, you need to call loadList() manually, but the page will be set to 1 automatically!!!
*/
const search = (searchValue, autoLoad = true) => {
// If the search is *, remove the search
metaSearch.value = searchValue === '*' ? null : searchValue;
latestSearch.value = metaSearch.value;
// Reset the page to 1
setPage(1);
// Load the list
if (autoLoad) {
clearPendingSearch();
loadSwitch(true);
searchDebounceTimeout = setTimeout(() => {
searchDebounceTimeout = null;
paginatedGetRequest();
}, SEARCH_DEBOUNCE_MS);
}
};
/** Is the list empty */
const isEmpty = () => {
return list.value.length === 0;
};
/** Has the list been loaded */
const isLoaded = () => {
return lastUpdated.value !== null;
};
/** Set additional query parameters */
const setAdditionalQueryParameters = (params) => {
additionalQueryParameters.value = params;
};
/** Add additional query parameters */
const addAdditionalQueryParameters = (params) => {
additionalQueryParameters.value = {...additionalQueryParameters.value, ...params};
};
/** Remove additional query parameters */
const removeAdditionalQueryParameters = (keys) => {
keys.forEach(key => {
delete additionalQueryParameters.value[key];
});
};
/** Clear additional query parameters */
const clearAdditionalQueryParameters = () => {
additionalQueryParameters.value = {}
};
/**
* Get filter
* @param filterName
* @returns {string|null}
*/
const getFilter = (filterName) => {
if (filter.value && filter.value.includes(`${filterName}:`)) {
return filter.value.split(',').find(f => f.startsWith(`${filterName}:`)).split(':')[1];
}
return null;
};
const applyExportTransform = (rows, transform = null) => {
const sourceRows = Array.isArray(rows) ? rows : [];
const effectiveTransform = typeof transform === 'function'
? transform
: exportTransform.value;
if (typeof effectiveTransform !== 'function') {
return sourceRows;
}
const transformedRows = effectiveTransform([...sourceRows], {
endpoint: endpoint.value,
search: metaSearch.value,
filter: filter.value,
orderBy: orderBy.value,
orderDirection: orderDirection.value,
additionalQueryParameters: { ...additionalQueryParameters.value },
});
return Array.isArray(transformedRows) ? transformedRows : sourceRows;
};
const setExportTransform = (transform) => {
exportTransform.value = typeof transform === 'function' ? transform : null;
};
const clearExportTransform = () => {
exportTransform.value = null;
};
const fetchPageForExport = async (page = 1, limit = null) => {
const token = localStorage.getItem('token');
if (!token || !endpoint.value) {
return {
rows: [],
page: 1,
perPage: resolveRequestLimit(limit),
total: 0,
totalPages: 1,
};
}
const response = await axios.get(API_URL + endpoint.value, {
params: buildRequestParams({ page, limit }),
headers: buildRequestHeaders(token),
});
const rows = Array.isArray(response?.data?.data) ? response.data.data : [];
const pagination = response?.data?.meta?.pagination || {};
const currentPage = Number.parseInt(pagination.page, 10) || Number.parseInt(page, 10) || 1;
const perPage = Number.parseInt(pagination.per_page, 10) || resolveRequestLimit(limit);
const total = Number.parseInt(pagination.total, 10);
const totalItems = Number.isFinite(total) ? total : rows.length;
const totalPages = Math.max(1, Math.ceil(totalItems / Math.max(1, perPage)));
return {
response,
rows,
page: currentPage,
perPage,
total: totalItems,
totalPages,
};
};
const fetchAllPagesForExport = async ({ itemsPerPage = null, transform = null } = {}) => {
const effectiveLimit = resolveRequestLimit(itemsPerPage);
const firstPage = await fetchPageForExport(1, effectiveLimit);
const rows = [...firstPage.rows];
for (let page = 2; page <= firstPage.totalPages; page += 1) {
const pageResult = await fetchPageForExport(page, effectiveLimit);
rows.push(...pageResult.rows);
}
return applyExportTransform(rows, transform);
};
const exportToExcel = async ({
filename = null,
sheetName = null,
transform = null,
rows = null,
itemsPerPage = null,
} = {}) => {
if (isExporting.value) {
return false;
}
isExporting.value = true;
try {
const endpointSlug = String(endpoint.value || 'table')
.replace(/^\//, '')
.replace(/[\\/]+/g, '-')
.replace(/\s+/g, '-')
.toLowerCase() || 'table';
const exportRows = Array.isArray(rows)
? applyExportTransform(rows, transform)
: await fetchAllPagesForExport({ itemsPerPage, transform });
exportRowsToExcel(exportRows, {
filename: filename || `${endpointSlug}-${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetName: sheetName || endpointSlug,
});
return true;
} catch (error) {
parseError(error, 'paginatedExportToExcel');
console.log(error);
return false;
} finally {
isExporting.value = false;
}
};
return {
list,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
metaSearch,
filter,
lastUpdated,
endpoint,
meta,
orderBy,
orderDirection,
hideSearchField,
isLoading,
isExporting,
latestSearch,
lastError,
additionalQueryParameters,
exportTransform,
setHideSearchField,
setOrder,
setEndpoint,
setLastUpdated,
setList,
setMeta,
setMetaItemsPerPage,
setFilter,
search,
isEmpty,
isLoaded,
setPage,
loadSwitch,
setAdditionalQueryParameters,
addAdditionalQueryParameters,
removeAdditionalQueryParameters,
clearAdditionalQueryParameters,
paginatedGetRequest,
loadList,
getFilter,
fetchPageForExport,
fetchAllPagesForExport,
setExportTransform,
clearExportTransform,
exportToExcel
};
}
/**
* Helper to get the paginated list instance via injection or fallback to global
*/
export function usePaginatedListInstance() {
return inject(PaginatedListKey, globalInstance);
}
// Global instance for backward compatibility
const globalInstance = usePaginatedList();
export const list = globalInstance.list;
export const metaCurrentPage = globalInstance.metaCurrentPage;
export const metaItemsPerPage = globalInstance.metaItemsPerPage;
export const metaTotalItems = globalInstance.metaTotalItems;
export const metaSearch = globalInstance.metaSearch;
export const filter = globalInstance.filter;
export const lastUpdated = globalInstance.lastUpdated;
export const endpoint = globalInstance.endpoint;
export const meta = globalInstance.meta;
export const orderBy = globalInstance.orderBy;
export const orderDirection = globalInstance.orderDirection;
export const hideSearchField = globalInstance.hideSearchField;
export const isLoading = globalInstance.isLoading;
export const isExporting = globalInstance.isExporting;
export const latestSearch = globalInstance.latestSearch;
export const lastError = globalInstance.lastError;
export const additionalQueryParameters = globalInstance.additionalQueryParameters;
export const exportTransform = globalInstance.exportTransform;
export const setHideSearchField = globalInstance.setHideSearchField;
export const setOrder = globalInstance.setOrder;
export const setEndpoint = globalInstance.setEndpoint;
export const setLastUpdated = globalInstance.setLastUpdated;
export const setList = globalInstance.setList;
export const setMeta = globalInstance.setMeta;
export const setMetaItemsPerPage = globalInstance.setMetaItemsPerPage;
export const setFilter = globalInstance.setFilter;
export const search = globalInstance.search;
export const isEmpty = globalInstance.isEmpty;
export const isLoaded = globalInstance.isLoaded;
export const setPage = globalInstance.setPage;
export const loadSwitch = globalInstance.loadSwitch;
export const setAdditionalQueryParameters = globalInstance.setAdditionalQueryParameters;
export const addAdditionalQueryParameters = globalInstance.addAdditionalQueryParameters;
export const removeAdditionalQueryParameters = globalInstance.removeAdditionalQueryParameters;
export const clearAdditionalQueryParameters = globalInstance.clearAdditionalQueryParameters;
export const paginatedGetRequest = globalInstance.paginatedGetRequest;
export const loadList = globalInstance.loadList;
export const getFilter = globalInstance.getFilter;
export const fetchPageForExport = globalInstance.fetchPageForExport;
export const fetchAllPagesForExport = globalInstance.fetchAllPagesForExport;
export const setExportTransform = globalInstance.setExportTransform;
export const clearExportTransform = globalInstance.clearExportTransform;
export const exportToExcel = globalInstance.exportToExcel;
</script>