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>
587 lines
18 KiB
Vue
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>
|