Implement automatic table Excel exports with unit tests:
- Add `AutoTableExportService` for dynamic Excel export button generation and visible row extraction. - Integrate auto-export initialization in `main.js` for legacy non-paginated tables. - Extend `PaginationDisplay.vue` with support for paginated Excel export. - Add export functionality to vehicle views and dependencies, including full-table export handling. - Add tests for `TableExcelExportService.js`, export workflows, and normalization logic. - Enhance localization with `download_excel` translation key.
This commit is contained in:
Generated
+105
-1
@@ -53,7 +53,8 @@
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-toast-notification": "^3.1.3",
|
||||
"vue3-cookies": "^1.0.6",
|
||||
"vuex": "^4.1.0"
|
||||
"vuex": "^4.1.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@creativebulma/bulma-divider": "^1.1.0",
|
||||
@@ -5052,6 +5053,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@@ -5722,6 +5732,19 @@
|
||||
"follow-redirects": "^1.15.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
@@ -5904,6 +5927,15 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
|
||||
@@ -6060,6 +6092,18 @@
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7263,6 +7307,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz",
|
||||
@@ -11030,6 +11083,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -13604,6 +13669,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-background-sync": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
|
||||
@@ -14000,6 +14083,27 @@
|
||||
"xtend": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
+2
-1
@@ -62,7 +62,8 @@
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-toast-notification": "^3.1.3",
|
||||
"vue3-cookies": "^1.0.6",
|
||||
"vuex": "^4.1.0"
|
||||
"vuex": "^4.1.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@creativebulma/bulma-divider": "^1.1.0",
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
<script setup>
|
||||
defineProps(['metaItemsPerPage', 'loadFunction', 'isLoading', 'setMetaItemsPerPage', 'allowSmallScreen'])
|
||||
import { ref } from 'vue'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ShowErrorField from "@/components/global/ShowErrorField.vue";
|
||||
import * as paginatedListModule from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const { t } = useI18n()
|
||||
const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule);
|
||||
|
||||
const perPageOptions = ref([5, 10, 15, 20, 25, 50, 100, 500, 1000])
|
||||
|
||||
const selectedPerPage = ref(100) // Default number of items per page
|
||||
const canExport = computed(() => typeof paginatedList?.exportToExcel === 'function');
|
||||
const isExporting = computed(() => Boolean(paginatedList?.isExporting?.value));
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
if (!canExport.value || isExporting.value) {
|
||||
return;
|
||||
}
|
||||
await paginatedList.exportToExcel();
|
||||
};
|
||||
|
||||
// If the screen is mobile, set the is small variable to true
|
||||
const isSmall = ref(window.innerWidth < 1024);
|
||||
@@ -37,6 +48,17 @@ window.addEventListener('resize', () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow" v-if="canExport" :class="{ 'my-3': !isSmall }">
|
||||
<label class="label is-small"> </label>
|
||||
<button
|
||||
class="button is-dark button-same-width"
|
||||
:class="{ 'is-loading': isExporting }"
|
||||
:disabled="isLoading || isExporting"
|
||||
@click="handleExportExcel"
|
||||
>
|
||||
{{ t('pagination.download_excel') }}
|
||||
</button>
|
||||
</div>
|
||||
<slot name="paginationColumns"></slot>
|
||||
</div>
|
||||
<!-- Display the pagination error, if any -->
|
||||
@@ -46,4 +68,4 @@ window.addEventListener('resize', () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -28,7 +28,7 @@ const isSmall = ref(window.innerWidth < 1024);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-disable-auto-excel-export="1">
|
||||
<!-- Label -->
|
||||
<h2 class="title is-4 mb-4">{{ label }}</h2>
|
||||
<!-- Search & reload -->
|
||||
@@ -59,4 +59,4 @@ const isSmall = ref(window.innerWidth < 1024);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
setOrder,
|
||||
setPage,
|
||||
search,
|
||||
setFilter
|
||||
setFilter,
|
||||
setExportTransform,
|
||||
clearExportTransform
|
||||
} from "@/components/pagination/paginatedList.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||
@@ -23,15 +25,29 @@ import PaginationDisplay from "@/components/displays/pagination/PaginationDispla
|
||||
import VehiclesTable from "@/components/displays/user/vehicles/vehiclesTable.vue";
|
||||
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
|
||||
import InvoicesTable from "@/components/displays/user/invoices/invoicesTable.vue";
|
||||
import {defineProps} from "vue";
|
||||
import PaginationDisplayGeneralSearchReload
|
||||
from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
|
||||
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
|
||||
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {exportCsvFile} from "@/services/CSVService.js";
|
||||
import { onUnmounted } from "vue";
|
||||
import { getDepartmentName } from "@/components/pagination/departmentTabs.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formatVehicleRegistrations = (item) => {
|
||||
return [item.reg_1, item.reg_2, item.reg_3]
|
||||
.filter((value) => value !== null && value !== undefined && value !== '')
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
const formatOrderAmount = (item) => {
|
||||
if (item.total_net_amount === null || item.total_net_amount === undefined || item.total_net_amount === '') {
|
||||
return '';
|
||||
}
|
||||
return SessionUser.functions.currency.toLocal(item.total_net_amount);
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
onlyFromInvoiceCollection: {
|
||||
type: Number,
|
||||
@@ -61,41 +77,22 @@ if (props.autoLoad) {
|
||||
*
|
||||
*/
|
||||
|
||||
const downloadCsv = () => {
|
||||
const blob = exportCsvFile(
|
||||
list.value.map((item) => ({
|
||||
id: item.id,
|
||||
department_id: item.department_id,
|
||||
reg_1: item.reg_1,
|
||||
reference: item.reference,
|
||||
notes: item.notes,
|
||||
created_at: item.created_at,
|
||||
total_net_amount: item.total_net_amount
|
||||
})),
|
||||
[
|
||||
SessionUser.objects.orders.columns.id.label,
|
||||
SessionUser.objects.orders.columns.department_id.label,
|
||||
SessionUser.functions.ucFirst(SessionUser.objects.vehicles.meta.labels.multiple),
|
||||
SessionUser.objects.orders.columns.reference.label,
|
||||
SessionUser.objects.orders.columns.notes.label,
|
||||
SessionUser.objects.orders.columns.created_at.label,
|
||||
t('tables.orders.amount')
|
||||
]
|
||||
)
|
||||
setExportTransform((rows = []) => {
|
||||
return rows.map((item) => ({
|
||||
[SessionUser.objects.orders.columns.id.label]: item.id ?? "",
|
||||
[SessionUser.objects.orders.columns.department_id.label]: getDepartmentName(item.department_id) || item.department_id || "",
|
||||
[SessionUser.functions.ucFirst(SessionUser.objects.vehicles.meta.labels.multiple)]: formatVehicleRegistrations(item),
|
||||
[SessionUser.objects.orders.columns.reference.label]: item.reference ?? "",
|
||||
[SessionUser.objects.orders.columns.notes.label]: item.notes ?? "",
|
||||
[SessionUser.objects.orders.columns.po?.label || 'PO']: item.po ?? "",
|
||||
[SessionUser.objects.orders.columns.created_at.label]: item.created_at ?? "",
|
||||
[t('tables.orders.amount')]: formatOrderAmount(item),
|
||||
}));
|
||||
});
|
||||
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', 'orders.csv');
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
}
|
||||
onUnmounted(() => {
|
||||
clearExportTransform();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -105,11 +102,6 @@ const downloadCsv = () => {
|
||||
<PaginationDisplayFilters
|
||||
:departmentColumn="'department_id'"
|
||||
/>
|
||||
<div class="column is-narrow">
|
||||
<button @click="downloadCsv" class="button is-dark button-same-width mt-5">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<orders-table
|
||||
@@ -123,4 +115,4 @@ const downloadCsv = () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ defineProps<{
|
||||
visibleQuestions: Array<{ id: number; question: string; description?: string }>;
|
||||
answers: Record<number, boolean | undefined>;
|
||||
loadingQuestionId?: number | null;
|
||||
loadingAnswerValue?: boolean | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -21,7 +22,6 @@ const emit = defineEmits<{
|
||||
:default-open="true"
|
||||
:force-state="true"
|
||||
:force-state-footer="true"
|
||||
:loading="loadingQuestionId === question.id"
|
||||
>
|
||||
<template #content>
|
||||
<p class="title is-5">{{ question.question }}</p>
|
||||
@@ -30,7 +30,10 @@ const emit = defineEmits<{
|
||||
<div class="card-footer-item">
|
||||
<button
|
||||
class="button is-fullwidth"
|
||||
:class="answers[question.id] === true ? 'is-success' : 'is-light'"
|
||||
:class="[
|
||||
answers[question.id] === true ? 'is-success' : 'is-light',
|
||||
loadingQuestionId === question.id && loadingAnswerValue === true ? 'is-loading' : '',
|
||||
]"
|
||||
:data-testid="`self-serve-question-${question.id}-yes`"
|
||||
:disabled="loadingQuestionId === question.id"
|
||||
@click="emit('answer-question', question.id, true)"
|
||||
@@ -41,7 +44,10 @@ const emit = defineEmits<{
|
||||
<div class="card-footer-item">
|
||||
<button
|
||||
class="button is-fullwidth"
|
||||
:class="answers[question.id] === false ? 'is-danger' : 'is-light'"
|
||||
:class="[
|
||||
answers[question.id] === false ? 'is-danger' : 'is-light',
|
||||
loadingQuestionId === question.id && loadingAnswerValue === false ? 'is-loading' : '',
|
||||
]"
|
||||
:data-testid="`self-serve-question-${question.id}-no`"
|
||||
:disabled="loadingQuestionId === question.id"
|
||||
@click="emit('answer-question', question.id, false)"
|
||||
|
||||
@@ -7,6 +7,7 @@ defineProps<{
|
||||
visibleQuestions: Array<any>;
|
||||
answers: Record<number, boolean | undefined>;
|
||||
loadingQuestionId?: number | null;
|
||||
loadingAnswerValue?: boolean | null;
|
||||
editAnswers: boolean;
|
||||
showDebug: boolean;
|
||||
conditions: Array<any>;
|
||||
@@ -136,6 +137,7 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
|
||||
:visibleQuestions="visibleQuestions"
|
||||
:answers="answers"
|
||||
:loading-question-id="loadingQuestionId"
|
||||
:loading-answer-value="loadingAnswerValue"
|
||||
@answer-question="emitAnswerQuestion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -488,8 +488,10 @@ 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 additionalQueryParameters = globalInstance.additionalQueryParameters;
|
||||
export const exportTransform = globalInstance.exportTransform;
|
||||
|
||||
export const setHideSearchField = globalInstance.setHideSearchField;
|
||||
export const setOrder = globalInstance.setOrder;
|
||||
@@ -511,5 +513,10 @@ export const clearAdditionalQueryParameters = globalInstance.clearAdditionalQuer
|
||||
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>
|
||||
|
||||
@@ -56,39 +56,104 @@ const buildAnswersMap = (questions) => questions.reduce((accumulator, question)
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
const mergeQuestionSets = (existingQuestions, incomingQuestions) => {
|
||||
const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object || {}, key);
|
||||
|
||||
const isBooleanAnswer = (value) => value === true || value === false;
|
||||
|
||||
const sortByOrderPriority = (list) => [...list].sort((a, b) => {
|
||||
const priorityDiff = parseInt(a?.order_priority ?? 0) - parseInt(b?.order_priority ?? 0);
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
return parseInt(a?.id ?? 0) - parseInt(b?.id ?? 0);
|
||||
});
|
||||
|
||||
const mergeQuestion = (existingQuestion, incomingQuestion, incomingRaw = {}) => {
|
||||
if (!existingQuestion) {
|
||||
return incomingQuestion;
|
||||
}
|
||||
|
||||
const shouldUseIncomingAnswer = hasOwn(incomingRaw, "answer")
|
||||
&& (isBooleanAnswer(incomingQuestion.answer) || !isBooleanAnswer(existingQuestion.answer));
|
||||
|
||||
return {
|
||||
...existingQuestion,
|
||||
...incomingQuestion,
|
||||
question: hasOwn(incomingRaw, "question") ? incomingQuestion.question : existingQuestion.question,
|
||||
description: hasOwn(incomingRaw, "description") ? incomingQuestion.description : existingQuestion.description,
|
||||
condition_id: hasOwn(incomingRaw, "condition_id") ? incomingQuestion.condition_id : existingQuestion.condition_id,
|
||||
order_priority: hasOwn(incomingRaw, "order_priority") ? incomingQuestion.order_priority : existingQuestion.order_priority,
|
||||
answer: shouldUseIncomingAnswer ? incomingQuestion.answer : existingQuestion.answer,
|
||||
answered_at: hasOwn(incomingRaw, "answered_at") ? incomingQuestion.answered_at : existingQuestion.answered_at,
|
||||
};
|
||||
};
|
||||
|
||||
const mergeQuestionSets = (existingQuestions, incomingQuestions, incomingRawQuestions = []) => {
|
||||
if (!Array.isArray(existingQuestions) || existingQuestions.length === 0) {
|
||||
return [...incomingQuestions].sort((a, b) => a.order_priority - b.order_priority);
|
||||
return sortByOrderPriority(incomingQuestions);
|
||||
}
|
||||
|
||||
if (!Array.isArray(incomingQuestions) || incomingQuestions.length === 0) {
|
||||
return [...existingQuestions].sort((a, b) => a.order_priority - b.order_priority);
|
||||
return sortByOrderPriority(existingQuestions);
|
||||
}
|
||||
|
||||
const incomingMap = incomingQuestions.reduce((accumulator, question) => {
|
||||
accumulator[question.id] = question;
|
||||
const incomingMap = incomingQuestions.reduce((accumulator, question, index) => {
|
||||
accumulator[question.id] = {
|
||||
question,
|
||||
raw: incomingRawQuestions[index] || {},
|
||||
};
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
const merged = existingQuestions.map((question) => {
|
||||
if (!incomingMap[question.id]) {
|
||||
const incomingEntry = incomingMap[question.id];
|
||||
if (!incomingEntry) {
|
||||
return question;
|
||||
}
|
||||
|
||||
return {
|
||||
...question,
|
||||
...incomingMap[question.id],
|
||||
};
|
||||
return mergeQuestion(question, incomingEntry.question, incomingEntry.raw);
|
||||
});
|
||||
|
||||
incomingQuestions.forEach((question) => {
|
||||
incomingQuestions.forEach((question, index) => {
|
||||
const exists = merged.some((entry) => entry.id === question.id);
|
||||
if (!exists) {
|
||||
merged.push(question);
|
||||
merged.push(mergeQuestion(null, question, incomingRawQuestions[index] || {}));
|
||||
}
|
||||
});
|
||||
|
||||
return merged.sort((a, b) => a.order_priority - b.order_priority);
|
||||
return sortByOrderPriority(merged);
|
||||
};
|
||||
|
||||
const mergeByNumericId = (existingItems, incomingItems) => {
|
||||
if (!Array.isArray(existingItems) || existingItems.length === 0) {
|
||||
return Array.isArray(incomingItems) ? [...incomingItems] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(incomingItems) || incomingItems.length === 0) {
|
||||
return [...existingItems];
|
||||
}
|
||||
|
||||
const merged = [...existingItems];
|
||||
incomingItems.forEach((incomingItem) => {
|
||||
const incomingId = parseInt(incomingItem?.id ?? 0);
|
||||
if (!incomingId) {
|
||||
merged.push(incomingItem);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIndex = merged.findIndex((item) => parseInt(item?.id ?? 0) === incomingId);
|
||||
if (existingIndex === -1) {
|
||||
merged.push(incomingItem);
|
||||
return;
|
||||
}
|
||||
|
||||
merged[existingIndex] = {
|
||||
...merged[existingIndex],
|
||||
...incomingItem,
|
||||
};
|
||||
});
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
export function useSelfServeLogic() {
|
||||
@@ -170,24 +235,32 @@ export function useSelfServeLogic() {
|
||||
vehicle.value = previewData?.vehicle || null;
|
||||
session.value = previewData?.session || session.value;
|
||||
|
||||
const normalizedQuestions = Array.isArray(previewData?.questions)
|
||||
? previewData.questions.map(normalizeQuestion).sort((a, b) => a.order_priority - b.order_priority)
|
||||
const previewQuestionsRaw = Array.isArray(previewData?.questions) ? previewData.questions : [];
|
||||
const normalizedQuestions = previewQuestionsRaw.length > 0
|
||||
? previewQuestionsRaw.map(normalizeQuestion)
|
||||
: [];
|
||||
|
||||
const nextQuestions = mergeQuestions
|
||||
? mergeQuestionSets(questions.value, normalizedQuestions)
|
||||
? mergeQuestionSets(questions.value, normalizedQuestions, previewQuestionsRaw)
|
||||
: normalizedQuestions;
|
||||
|
||||
questions.value = nextQuestions;
|
||||
answers.value = buildAnswersMap(nextQuestions);
|
||||
|
||||
conditions.value = Array.isArray(previewData?.conditions)
|
||||
const normalizedConditions = Array.isArray(previewData?.conditions)
|
||||
? previewData.conditions.map(normalizeCondition)
|
||||
: [];
|
||||
rules.value = Array.isArray(previewData?.rules)
|
||||
const normalizedRules = Array.isArray(previewData?.rules)
|
||||
? previewData.rules.map(normalizeRule)
|
||||
: [];
|
||||
|
||||
conditions.value = mergeQuestions
|
||||
? mergeByNumericId(conditions.value, normalizedConditions)
|
||||
: normalizedConditions;
|
||||
rules.value = mergeQuestions
|
||||
? mergeByNumericId(rules.value, normalizedRules)
|
||||
: normalizedRules;
|
||||
|
||||
const normalizedTasks = Array.isArray(previewData?.tasks)
|
||||
? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority)
|
||||
: [];
|
||||
@@ -211,7 +284,8 @@ export function useSelfServeLogic() {
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
const normalizedQuestions = summaryData.questions.map((question) => {
|
||||
const summaryQuestionsRaw = summaryData.questions;
|
||||
const normalizedQuestions = summaryQuestionsRaw.map((question) => {
|
||||
const normalized = normalizeQuestion(question);
|
||||
return {
|
||||
...previewQuestionMap[normalized.id],
|
||||
@@ -219,17 +293,17 @@ export function useSelfServeLogic() {
|
||||
};
|
||||
});
|
||||
|
||||
const nextQuestions = mergeQuestionSets(questions.value, normalizedQuestions);
|
||||
const nextQuestions = mergeQuestionSets(questions.value, normalizedQuestions, summaryQuestionsRaw);
|
||||
questions.value = nextQuestions;
|
||||
answers.value = buildAnswersMap(nextQuestions);
|
||||
}
|
||||
|
||||
if (Array.isArray(summaryData?.conditions)) {
|
||||
conditions.value = summaryData.conditions.map(normalizeCondition);
|
||||
conditions.value = mergeByNumericId(conditions.value, summaryData.conditions.map(normalizeCondition));
|
||||
}
|
||||
|
||||
if (Array.isArray(summaryData?.rules)) {
|
||||
rules.value = summaryData.rules.map(normalizeRule);
|
||||
rules.value = mergeByNumericId(rules.value, summaryData.rules.map(normalizeRule));
|
||||
}
|
||||
|
||||
if (Array.isArray(summaryData?.tasks)) {
|
||||
@@ -349,6 +423,10 @@ export function useSelfServeLogic() {
|
||||
}
|
||||
|
||||
await fetchSelfServeData(departmentId, null, laneId, normalizedReg);
|
||||
answers.value = {
|
||||
...answers.value,
|
||||
[parseInt(questionId)]: value,
|
||||
};
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error("Error synchronizing vehicle answer:", error);
|
||||
@@ -381,6 +459,11 @@ export function useSelfServeLogic() {
|
||||
if (!question.condition_id || parseInt(question.condition_id) === 0) {
|
||||
return true;
|
||||
}
|
||||
const hasRulesForCondition = rules.value.some((rule) => parseInt(rule.condition_id) === parseInt(question.condition_id));
|
||||
if (!hasRulesForCondition) {
|
||||
// When backend payloads are partial, trust that included questions are visible.
|
||||
return true;
|
||||
}
|
||||
return evaluateCondition(question.condition_id);
|
||||
})
|
||||
.sort((a, b) => a.order_priority - b.order_priority);
|
||||
@@ -392,6 +475,10 @@ export function useSelfServeLogic() {
|
||||
if (!task.condition_id || parseInt(task.condition_id) === 0) {
|
||||
return true;
|
||||
}
|
||||
const hasRulesForCondition = rules.value.some((rule) => parseInt(rule.condition_id) === parseInt(task.condition_id));
|
||||
if (!hasRulesForCondition) {
|
||||
return true;
|
||||
}
|
||||
return evaluateCondition(task.condition_id);
|
||||
})
|
||||
.sort((a, b) => a.order_priority - b.order_priority);
|
||||
|
||||
@@ -2443,6 +2443,7 @@
|
||||
"pickup": "Afhentning",
|
||||
"previous": "Forrige side",
|
||||
"reload": "Genindlæs",
|
||||
"download_excel": "Download Excel",
|
||||
"show_only_pending": "Vis kun afventende",
|
||||
"show_only_today": "Vis kun i dag",
|
||||
"show_only_todays_pending": "Vis kun dagens afventende",
|
||||
|
||||
@@ -2443,6 +2443,7 @@
|
||||
"pickup": "Abholung",
|
||||
"previous": "Vorherige Seite",
|
||||
"reload": "Neu laden",
|
||||
"download_excel": "Excel herunterladen",
|
||||
"show_only_pending": "Nur ausstehende anzeigen",
|
||||
"show_only_today": "Nur heute anzeigen",
|
||||
"show_only_todays_pending": "Nur heutige ausstehende anzeigen",
|
||||
|
||||
@@ -2443,6 +2443,7 @@
|
||||
"pickup": "Pickup",
|
||||
"previous": "Previous page",
|
||||
"reload": "Reload",
|
||||
"download_excel": "Download Excel",
|
||||
"show_only_pending": "Show only pending",
|
||||
"show_only_today": "Show only today",
|
||||
"show_only_todays_pending": "Show only today's pending",
|
||||
|
||||
@@ -2438,6 +2438,7 @@
|
||||
"pickup": "Henting",
|
||||
"previous": "Forrige side",
|
||||
"reload": "Last inn på nytt",
|
||||
"download_excel": "Last ned Excel",
|
||||
"show_only_pending": "Vis bare venter",
|
||||
"show_only_today": "Vises kun i dag",
|
||||
"show_only_todays_pending": "Vis kun dagens ventende",
|
||||
|
||||
@@ -2438,6 +2438,7 @@
|
||||
"pickup": "Pickup",
|
||||
"previous": "Föregående sida",
|
||||
"reload": "Reload",
|
||||
"download_excel": "Ladda ner Excel",
|
||||
"show_only_pending": "Visa endast väntande",
|
||||
"show_only_today": "Visa endast i dag",
|
||||
"show_only_todays_pending": "Visa endast dagens väntande",
|
||||
|
||||
+4
-1
@@ -13,6 +13,7 @@ import App from "@/App.vue";
|
||||
import Buefy from 'buefy'
|
||||
import 'buefy/dist/css/buefy.css'
|
||||
import i18n from '@/i18n'
|
||||
import { initializeAutoTableExports } from '@/services/AutoTableExportService.js';
|
||||
|
||||
import { API_URL, IS_DEV, POS_STEP_1_VERSION } from './config';
|
||||
// export { API_URL, IS_DEV, POS_STEP_1_VERSION };
|
||||
@@ -38,4 +39,6 @@ createApp(App)
|
||||
.provide('Colors', Colors)
|
||||
.provide('IS_DEV', IS_DEV)
|
||||
.provide('API_URL', API_URL)
|
||||
.mount('#app');
|
||||
.mount('#app');
|
||||
|
||||
initializeAutoTableExports();
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import i18n from '@/i18n';
|
||||
import { exportRowsToExcel } from '@/services/TableExcelExportService.js';
|
||||
|
||||
const AUTO_EXPORT_TABLE_ATTR = 'data-auto-excel-export-attached';
|
||||
const AUTO_EXPORT_BUTTON_ATTR = 'data-auto-excel-export-button';
|
||||
|
||||
const isElementVisible = (element) => {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||
};
|
||||
|
||||
export const extractVisibleRowsFromTable = (tableElement) => {
|
||||
if (!tableElement) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headerCells = [...tableElement.querySelectorAll('thead th')];
|
||||
const headerLabels = headerCells.map((headerCell, index) => {
|
||||
const label = String(headerCell.textContent || '').trim();
|
||||
return label || `Column ${index + 1}`;
|
||||
});
|
||||
|
||||
const bodyRows = tableElement.querySelectorAll('tbody tr');
|
||||
const fallbackRows = tableElement.querySelectorAll('tr');
|
||||
const rows = (bodyRows.length ? [...bodyRows] : [...fallbackRows])
|
||||
.filter((row) => isElementVisible(row));
|
||||
|
||||
return rows
|
||||
.map((row) => {
|
||||
const cells = [...row.querySelectorAll('th, td')];
|
||||
if (!cells.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rowObject = {};
|
||||
cells.forEach((cell, index) => {
|
||||
const key = headerLabels[index] || `Column ${index + 1}`;
|
||||
rowObject[key] = String(cell.textContent || '').trim();
|
||||
});
|
||||
return rowObject;
|
||||
})
|
||||
.filter((row) => row && Object.keys(row).length > 0);
|
||||
};
|
||||
|
||||
const buildDomExportFilename = (tableElement, tableIndex) => {
|
||||
const currentPathname = typeof window !== 'undefined' && window.location
|
||||
? window.location.pathname
|
||||
: 'table';
|
||||
const pathSegment = String(currentPathname || 'table')
|
||||
.replace(/^\//, '')
|
||||
.replace(/[\\/]+/g, '-')
|
||||
.replace(/[^a-zA-Z0-9-_]+/g, '-')
|
||||
.toLowerCase() || 'table';
|
||||
const tableLabel = tableElement.getAttribute('data-export-name')
|
||||
|| tableElement.id
|
||||
|| `table-${tableIndex + 1}`;
|
||||
const safeLabel = String(tableLabel).replace(/[^a-zA-Z0-9-_]+/g, '-').toLowerCase();
|
||||
return `${pathSegment}-${safeLabel}-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
};
|
||||
|
||||
const createAutoExportButton = (tableElement, tableIndex) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mb-2 has-text-right';
|
||||
wrapper.setAttribute(AUTO_EXPORT_BUTTON_ATTR, '1');
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'button is-dark is-small';
|
||||
button.textContent = i18n.global.t('pagination.download_excel');
|
||||
button.addEventListener('click', () => {
|
||||
const rows = extractVisibleRowsFromTable(tableElement);
|
||||
exportRowsToExcel(rows, {
|
||||
filename: buildDomExportFilename(tableElement, tableIndex),
|
||||
sheetName: tableElement.getAttribute('data-export-sheet') || 'Table',
|
||||
});
|
||||
});
|
||||
|
||||
wrapper.appendChild(button);
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
export const ensureAutoTableExportButtons = (root = document) => {
|
||||
if (!root || typeof root.querySelectorAll !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const tables = [...root.querySelectorAll('table.table')].filter((table) => {
|
||||
if (table.closest('.b-table')) {
|
||||
return false;
|
||||
}
|
||||
if (table.closest('[data-disable-auto-excel-export="1"]')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
tables.forEach((tableElement, index) => {
|
||||
if (tableElement.hasAttribute(AUTO_EXPORT_TABLE_ATTR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = createAutoExportButton(tableElement, index);
|
||||
tableElement.parentNode?.insertBefore(button, tableElement);
|
||||
tableElement.setAttribute(AUTO_EXPORT_TABLE_ATTR, '1');
|
||||
});
|
||||
};
|
||||
|
||||
export const initializeAutoTableExports = () => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined' || !document.body) {
|
||||
return () => {};
|
||||
}
|
||||
if (typeof MutationObserver === 'undefined') {
|
||||
ensureAutoTableExportButtons(document);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let scheduled = false;
|
||||
const run = () => {
|
||||
scheduled = false;
|
||||
ensureAutoTableExportButtons(document);
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scheduled) {
|
||||
return;
|
||||
}
|
||||
scheduled = true;
|
||||
window.requestAnimationFrame(run);
|
||||
};
|
||||
|
||||
schedule();
|
||||
const observer = new MutationObserver(schedule);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
window.addEventListener('load', schedule);
|
||||
window.addEventListener('popstate', schedule);
|
||||
window.addEventListener('hashchange', schedule);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('load', schedule);
|
||||
window.removeEventListener('popstate', schedule);
|
||||
window.removeEventListener('hashchange', schedule);
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import RestrictedPageWrapper from '@/components/page/wrappers/RestrictedPageWrap
|
||||
import SuperUserDashboardNavigation from '@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue';
|
||||
import PageTitle from '@/components/global/PageTitle.vue';
|
||||
import VehicleAnalyticsChart from '@/views/dashboards/superUserDashboard/vehicle/displays/VehicleAnalyticsChart.vue';
|
||||
import { exportRowsToExcel } from '@/services/TableExcelExportService.js';
|
||||
import {
|
||||
aggregateDepartmentVisits,
|
||||
aggregateOrderFrequency,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
clampPerPage,
|
||||
fromDatetimeLocalValue,
|
||||
normalizeListResponse,
|
||||
normalizePaginationMeta,
|
||||
normalizeRegistrationNumber,
|
||||
normalizeRelatedOrders,
|
||||
sanitizePositiveInt,
|
||||
@@ -34,6 +36,14 @@ const loading = reactive({
|
||||
customers: false,
|
||||
search: false,
|
||||
});
|
||||
const exportLoading = reactive({
|
||||
washes: false,
|
||||
customers: false,
|
||||
subscriptionUsers: false,
|
||||
vehicles: false,
|
||||
xlvaskVehicles: false,
|
||||
search: false,
|
||||
});
|
||||
|
||||
const errors = reactive({
|
||||
washes: '',
|
||||
@@ -239,6 +249,137 @@ const openOrder = async (orderId) => {
|
||||
if (departmentId) window.open(`/admin/${departmentId}/modules/pos/orders/${orderId}`, '_blank');
|
||||
};
|
||||
|
||||
const buildExportFilename = (suffix) => {
|
||||
const reg = String(registrationNumber.value || 'vehicle').replace(/[^a-zA-Z0-9-_]+/g, '-').toLowerCase();
|
||||
return `${reg}-${suffix}-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
};
|
||||
|
||||
const fetchAllPaginatedRows = async ({ perPage, requestPage }) => {
|
||||
const rows = [];
|
||||
const safePerPage = clampPerPage(perPage, 100);
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const response = await requestPage(page, safePerPage);
|
||||
rows.push(...normalizeListResponse(response));
|
||||
const pagination = normalizePaginationMeta(response, safePerPage);
|
||||
totalPages = pagination.totalPages;
|
||||
page += 1;
|
||||
} while (page <= totalPages);
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
const exportWashesToExcel = async () => {
|
||||
exportLoading.washes = true;
|
||||
try {
|
||||
const allWashes = await fetchAllPaginatedRows({
|
||||
perPage: filters.washes.perPage,
|
||||
requestPage: (page, perPage) => SessionUser.request('/modules/xlvask/usageLog', 'GET', buildUsageLogQuery({
|
||||
dateFrom: fromDatetimeLocalValue(filters.washes.dateFrom),
|
||||
regNr: registrationNumber.value,
|
||||
customerId: filters.washes.customerId || null,
|
||||
vehicleId: filters.washes.vehicleId || null,
|
||||
page,
|
||||
perPage,
|
||||
})),
|
||||
});
|
||||
|
||||
const washIds = allWashes.map((entry) => entry?.WashId).filter(Boolean);
|
||||
const allRelatedOrders = washIds.length
|
||||
? normalizeRelatedOrders(await SessionUser.superUser.modules.xlvask.functions.getRelatedOrders(washIds))
|
||||
: {};
|
||||
|
||||
const exportRows = allWashes.map((entry) => ({
|
||||
...entry,
|
||||
relatedOrderIds: Array.isArray(allRelatedOrders?.[entry?.WashId]) ? allRelatedOrders[entry.WashId].join(', ') : '',
|
||||
}));
|
||||
|
||||
exportRowsToExcel(exportRows, {
|
||||
filename: buildExportFilename('washes'),
|
||||
sheetName: 'Washes',
|
||||
});
|
||||
} catch (error) {
|
||||
errors.washes = parseError(error);
|
||||
} finally {
|
||||
exportLoading.washes = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportCustomersToExcel = () => {
|
||||
exportLoading.customers = true;
|
||||
try {
|
||||
exportRowsToExcel(customers.value, {
|
||||
filename: buildExportFilename('customers'),
|
||||
sheetName: 'Customers',
|
||||
});
|
||||
} finally {
|
||||
exportLoading.customers = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportSubscriptionUsersToExcel = () => {
|
||||
exportLoading.subscriptionUsers = true;
|
||||
try {
|
||||
exportRowsToExcel(relatedSubscriptionUsers.value, {
|
||||
filename: buildExportFilename('subscription-users'),
|
||||
sheetName: 'SubscriptionUsers',
|
||||
});
|
||||
} finally {
|
||||
exportLoading.subscriptionUsers = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportVehiclesToExcel = async () => {
|
||||
exportLoading.vehicles = true;
|
||||
try {
|
||||
const allVehicles = await fetchAllPaginatedRows({
|
||||
perPage: filters.details.perPage,
|
||||
requestPage: (page, perPage) => SessionUser.request('/vehicles', 'GET', buildVehiclesQuery({
|
||||
id: filters.details.id || null,
|
||||
reg: registrationNumber.value,
|
||||
customerId: filters.details.customerId || null,
|
||||
page,
|
||||
perPage,
|
||||
})),
|
||||
});
|
||||
|
||||
exportRowsToExcel(allVehicles, {
|
||||
filename: buildExportFilename('vehicle-details'),
|
||||
sheetName: 'Vehicles',
|
||||
});
|
||||
} catch (error) {
|
||||
errors.details = parseError(error);
|
||||
} finally {
|
||||
exportLoading.vehicles = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportXlvaskVehiclesToExcel = () => {
|
||||
exportLoading.xlvaskVehicles = true;
|
||||
try {
|
||||
exportRowsToExcel(xlvaskVehicles.value, {
|
||||
filename: buildExportFilename('xlvask-vehicles'),
|
||||
sheetName: 'XLVaskVehicles',
|
||||
});
|
||||
} finally {
|
||||
exportLoading.xlvaskVehicles = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportSearchResultsToExcel = () => {
|
||||
exportLoading.search = true;
|
||||
try {
|
||||
exportRowsToExcel(searchResults.value, {
|
||||
filename: buildExportFilename('search'),
|
||||
sheetName: 'SearchResults',
|
||||
});
|
||||
} finally {
|
||||
exportLoading.search = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
filters.search.term = registrationNumber.value;
|
||||
await refreshAll();
|
||||
@@ -283,6 +424,9 @@ watch(registrationNumber, async (next, prev) => {
|
||||
<div class="column is-2"><b-field label="Per page"><b-select v-model="filters.washes.perPage" expanded><option :value="10">10</option><option :value="30">30</option><option :value="50">50</option><option :value="100">100</option></b-select></b-field></div>
|
||||
</div>
|
||||
<b-button type="is-info" icon-left="filter" icon-pack="fas" :loading="loading.washes" @click="loadWashes">Apply filters</b-button>
|
||||
<b-button type="is-dark" icon-left="file-excel" icon-pack="fas" class="ml-2" :loading="exportLoading.washes" @click="exportWashesToExcel">
|
||||
Download Excel
|
||||
</b-button>
|
||||
</div>
|
||||
<b-table :data="pagedUsageLogs" :loading="loading.washes" striped hoverable>
|
||||
<b-table-column field="Customer" label="Customer" v-slot="props">{{ props.row.Customer || props.row.CustomerId }}</b-table-column>
|
||||
@@ -308,6 +452,9 @@ watch(registrationNumber, async (next, prev) => {
|
||||
<b-button type="is-info" icon-left="filter" icon-pack="fas" :loading="loading.customers" @click="loadCustomers">Apply filters</b-button>
|
||||
</div>
|
||||
<h4 class="title is-6">XLVask customers</h4>
|
||||
<b-button type="is-dark" icon-left="file-excel" icon-pack="fas" class="mb-2" :loading="exportLoading.customers" @click="exportCustomersToExcel">
|
||||
Download Excel
|
||||
</b-button>
|
||||
<b-table :data="customers" :loading="loading.customers" striped hoverable>
|
||||
<b-table-column field="customerId" label="Customer ID" v-slot="props">{{ props.row.customerId }}</b-table-column>
|
||||
<b-table-column field="name" label="Name" v-slot="props">{{ props.row.name }}</b-table-column>
|
||||
@@ -315,6 +462,9 @@ watch(registrationNumber, async (next, prev) => {
|
||||
<b-table-column field="vatnumber" label="VAT" v-slot="props">{{ props.row.vatnumber }}</b-table-column>
|
||||
</b-table>
|
||||
<h4 class="title is-6 mt-4">Users with vehicle subscriptions</h4>
|
||||
<b-button type="is-dark" icon-left="file-excel" icon-pack="fas" class="mb-2" :loading="exportLoading.subscriptionUsers" @click="exportSubscriptionUsersToExcel">
|
||||
Download Excel
|
||||
</b-button>
|
||||
<b-table :data="relatedSubscriptionUsers" :loading="loading.customers" striped hoverable>
|
||||
<b-table-column field="id" label="User ID" v-slot="props">{{ props.row.id }}</b-table-column>
|
||||
<b-table-column field="customer_number" label="Customer #" v-slot="props">{{ props.row.customer_number || props.row.customerNumber }}</b-table-column>
|
||||
@@ -335,6 +485,9 @@ watch(registrationNumber, async (next, prev) => {
|
||||
<b-button type="is-info" icon-left="sync" icon-pack="fas" :loading="loading.details" @click="loadDetails">Refresh details</b-button>
|
||||
</div>
|
||||
<h4 class="title is-6">`/vehicles`</h4>
|
||||
<b-button type="is-dark" icon-left="file-excel" icon-pack="fas" class="mb-2" :loading="exportLoading.vehicles" @click="exportVehiclesToExcel">
|
||||
Download Excel
|
||||
</b-button>
|
||||
<b-table :data="vehicles" :loading="loading.details" striped hoverable>
|
||||
<b-table-column field="id" label="ID" v-slot="props">{{ props.row.id }}</b-table-column>
|
||||
<b-table-column field="reg" label="Registration" v-slot="props">{{ props.row.reg }}</b-table-column>
|
||||
@@ -343,6 +496,9 @@ watch(registrationNumber, async (next, prev) => {
|
||||
<b-table-column field="wash_subscription" label="Subscription" v-slot="props">{{ props.row.wash_subscription ? 'Yes' : 'No' }}</b-table-column>
|
||||
</b-table>
|
||||
<h4 class="title is-6 mt-4">`/modules/xlvask/vehicles`</h4>
|
||||
<b-button type="is-dark" icon-left="file-excel" icon-pack="fas" class="mb-2" :loading="exportLoading.xlvaskVehicles" @click="exportXlvaskVehiclesToExcel">
|
||||
Download Excel
|
||||
</b-button>
|
||||
<b-table :data="xlvaskVehicles" :loading="loading.details" striped hoverable>
|
||||
<b-table-column field="vehicleId" label="Vehicle ID" v-slot="props">{{ props.row.vehicleId }}</b-table-column>
|
||||
<b-table-column field="registrationNumber" label="Registration" v-slot="props">{{ props.row.registrationNumber || props.row.RegistrationNumber }}</b-table-column>
|
||||
@@ -353,6 +509,9 @@ watch(registrationNumber, async (next, prev) => {
|
||||
<h4 class="title is-6">`/vehicles/search`</h4>
|
||||
<b-field label="Search term"><b-input v-model="filters.search.term" /></b-field>
|
||||
<b-button type="is-info" icon-left="search" icon-pack="fas" :loading="loading.search" @click="runSearch">Search</b-button>
|
||||
<b-button type="is-dark" icon-left="file-excel" icon-pack="fas" class="ml-2" :loading="exportLoading.search" @click="exportSearchResultsToExcel">
|
||||
Download Excel
|
||||
</b-button>
|
||||
<article class="message is-danger mt-2" v-if="errors.search"><div class="message-body">{{ errors.search }}</div></article>
|
||||
<b-table class="mt-3" :data="searchResults" :loading="loading.search" striped hoverable>
|
||||
<b-table-column field="id" label="ID" v-slot="props">{{ props.row.id }}</b-table-column>
|
||||
|
||||
@@ -153,6 +153,21 @@ export const normalizeListResponse = (response) => {
|
||||
return [];
|
||||
};
|
||||
|
||||
export const normalizePaginationMeta = (response, fallbackPerPage = DEFAULT_PER_PAGE) => {
|
||||
const pagination = response?.data?.meta?.pagination || {};
|
||||
const safePerPage = clampPerPage(pagination.per_page, clampPerPage(fallbackPerPage, DEFAULT_PER_PAGE));
|
||||
const safePage = sanitizePositiveInt(pagination.page, DEFAULT_PAGE, 1);
|
||||
const safeTotal = sanitizePositiveInt(pagination.total, 0, 0);
|
||||
const totalPages = Math.max(1, Math.ceil(safeTotal / Math.max(1, safePerPage)));
|
||||
|
||||
return {
|
||||
page: safePage,
|
||||
perPage: safePerPage,
|
||||
total: safeTotal,
|
||||
totalPages,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeRelatedOrders = (response) => {
|
||||
const data = response?.data?.data ?? response?.data ?? {};
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
@@ -268,4 +283,3 @@ export const buildOverviewStats = (usageLogs = [], relatedOrders = {}) => {
|
||||
latestWashAt: latestWashAt ? latestWashAt.toISOString() : null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ const currentStep = ref(0);
|
||||
const hideDynamicImage = ref(false);
|
||||
const vehicleStepError = ref<string | null>(null);
|
||||
const loadingQuestionId = ref<number | null>(null);
|
||||
const loadingAnswerValue = ref<boolean | null>(null);
|
||||
|
||||
const {
|
||||
guestDepartments,
|
||||
@@ -425,10 +426,12 @@ const submitQuestionAnswer = async (questionId: number, value: boolean) => {
|
||||
}
|
||||
|
||||
loadingQuestionId.value = questionId;
|
||||
loadingAnswerValue.value = value;
|
||||
answerQuestion(questionId, value);
|
||||
|
||||
if (!nearestDepartment.value || !radioLaneOption.value || !licensePlateInput.value?.trim()) {
|
||||
loadingQuestionId.value = null;
|
||||
loadingAnswerValue.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -445,6 +448,7 @@ const submitQuestionAnswer = async (questionId: number, value: boolean) => {
|
||||
console.error("Error synchronizing self-serve answer:", error);
|
||||
} finally {
|
||||
loadingQuestionId.value = null;
|
||||
loadingAnswerValue.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -676,6 +680,7 @@ watch(() => forceNearestDepartmentEvaluationId.value, () => {
|
||||
:visible-questions="visibleQuestions"
|
||||
:answers="answers"
|
||||
:loading-question-id="loadingQuestionId"
|
||||
:loading-answer-value="loadingAnswerValue"
|
||||
:edit-answers="editAnswers"
|
||||
:show-debug="showDebug"
|
||||
:conditions="conditions"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { usePaginatedList } from '@/components/pagination/paginatedList.vue';
|
||||
import * as tableExportService from '@/services/TableExcelExportService.js';
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('paginated list excel export flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
localStorage.setItem('token', 'test-token');
|
||||
});
|
||||
|
||||
it('fetches all pages for export and preserves filters/search/order/query params', async () => {
|
||||
axios.get
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [{ id: 1 }, { id: 2 }],
|
||||
meta: { pagination: { page: 1, per_page: 2, total: 5 } },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [{ id: 3 }, { id: 4 }],
|
||||
meta: { pagination: { page: 2, per_page: 2, total: 5 } },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [{ id: 5 }],
|
||||
meta: { pagination: { page: 3, per_page: 2, total: 5 } },
|
||||
},
|
||||
});
|
||||
|
||||
const list = usePaginatedList();
|
||||
list.setEndpoint('/orders', false);
|
||||
list.search('AB12345', false);
|
||||
list.setFilter('department_id', 7, false);
|
||||
list.setOrder('created_at', 'desc');
|
||||
list.setAdditionalQueryParameters({ include_archived: '1' });
|
||||
list.setMetaItemsPerPage(2, false);
|
||||
|
||||
const rows = await list.fetchAllPagesForExport();
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(axios.get).toHaveBeenCalledTimes(3);
|
||||
expect(axios.get).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('/orders'),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
page: 1,
|
||||
limit: 2,
|
||||
search: 'AB12345',
|
||||
filters: 'department_id:7',
|
||||
order: 'created_at:desc',
|
||||
include_archived: '1',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(axios.get).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.stringContaining('/orders'),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
page: 3,
|
||||
limit: 2,
|
||||
search: 'AB12345',
|
||||
filters: 'department_id:7',
|
||||
order: 'created_at:desc',
|
||||
include_archived: '1',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('supports export transforms for client-side list shaping', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, status: 'active' },
|
||||
{ id: 2, status: 'archived' },
|
||||
],
|
||||
meta: { pagination: { page: 1, per_page: 100, total: 2 } },
|
||||
},
|
||||
});
|
||||
|
||||
const list = usePaginatedList();
|
||||
list.setEndpoint('/users', false);
|
||||
list.setExportTransform((rows) => rows.filter((row) => row.status === 'active'));
|
||||
|
||||
const rows = await list.fetchAllPagesForExport();
|
||||
|
||||
expect(rows).toEqual([{ id: 1, status: 'active' }]);
|
||||
});
|
||||
|
||||
it('exports to excel and resets export loading state', async () => {
|
||||
const exportSpy = vi.spyOn(tableExportService, 'exportRowsToExcel').mockReturnValue('users.xlsx');
|
||||
const list = usePaginatedList();
|
||||
|
||||
list.setEndpoint('/users', false);
|
||||
const result = await list.exportToExcel({
|
||||
rows: [{ id: 1, name: 'Alice' }],
|
||||
filename: 'users',
|
||||
sheetName: 'Users',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(exportSpy).toHaveBeenCalledWith(
|
||||
[{ id: 1, name: 'Alice' }],
|
||||
expect.objectContaining({
|
||||
filename: 'users',
|
||||
sheetName: 'Users',
|
||||
})
|
||||
);
|
||||
expect(list.isExporting.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ describe("SelfServeQuestionsStep", () => {
|
||||
const baseProps = {
|
||||
isLoading: false,
|
||||
loadingQuestionId: null,
|
||||
loadingAnswerValue: null,
|
||||
visibleQuestions: [
|
||||
{ id: 11, question: "Is the trailer closed?" },
|
||||
],
|
||||
@@ -62,10 +63,13 @@ describe("SelfServeQuestionsStep", () => {
|
||||
...baseProps,
|
||||
isLoading: true,
|
||||
loadingQuestionId: 11,
|
||||
loadingAnswerValue: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-question-cards"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="self-serve-question-11-yes"]').classes()).toContain("is-loading");
|
||||
expect(wrapper.find('[data-testid="self-serve-question-11-no"]').classes()).not.toContain("is-loading");
|
||||
expect(wrapper.find('[data-testid="self-serve-question-11-yes"]').attributes("disabled")).toBeDefined();
|
||||
expect(wrapper.find('[data-testid="self-serve-question-11-no"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildUsageLogQuery,
|
||||
buildVehiclesQuery,
|
||||
normalizeListResponse,
|
||||
normalizePaginationMeta,
|
||||
normalizeRegistrationNumber,
|
||||
normalizeRelatedOrders,
|
||||
toIsoDateTimeWithoutTimezone,
|
||||
@@ -77,6 +78,17 @@ describe('vehicle view utility normalization', () => {
|
||||
expect(normalizeRelatedOrders({ data: { data: { washA: [1, 2] } } })).toEqual({ washA: [1, 2] });
|
||||
expect(normalizeRelatedOrders({ data: { data: [] } })).toEqual({});
|
||||
});
|
||||
|
||||
it('normalizes pagination metadata used by all-page exports', () => {
|
||||
expect(normalizePaginationMeta({
|
||||
data: { meta: { pagination: { page: 2, per_page: 25, total: 60 } } }
|
||||
})).toEqual({
|
||||
page: 2,
|
||||
perPage: 25,
|
||||
total: 60,
|
||||
totalPages: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('vehicle view analytics aggregations', () => {
|
||||
|
||||
@@ -48,6 +48,16 @@ describe('superuser vehicle tabbed view contract', () => {
|
||||
expect(vehicleViewSource).toContain('<b-select');
|
||||
expect(vehicleViewSource).toContain('Apply filters');
|
||||
expect(vehicleViewSource).toContain('Refresh details');
|
||||
expect(vehicleViewSource).toContain('Download Excel');
|
||||
});
|
||||
|
||||
it('exposes per-table excel exports for washes/customers/details/search', () => {
|
||||
expect(vehicleViewSource).toContain('exportWashesToExcel');
|
||||
expect(vehicleViewSource).toContain('exportCustomersToExcel');
|
||||
expect(vehicleViewSource).toContain('exportSubscriptionUsersToExcel');
|
||||
expect(vehicleViewSource).toContain('exportVehiclesToExcel');
|
||||
expect(vehicleViewSource).toContain('exportXlvaskVehiclesToExcel');
|
||||
expect(vehicleViewSource).toContain('exportSearchResultsToExcel');
|
||||
});
|
||||
|
||||
it('renders all requested diagrams', () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { existsSync, unlinkSync } from 'node:fs';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
collectExcelColumns,
|
||||
createWorkbookFromRows,
|
||||
ensureExcelFilename,
|
||||
exportRowsToExcel,
|
||||
normalizeExcelValue,
|
||||
normalizeRowsForExcel,
|
||||
sanitizeSheetName,
|
||||
} from '@/services/TableExcelExportService.js';
|
||||
|
||||
describe('table excel export service normalization', () => {
|
||||
it('normalizes primitive, null, date, object and array values deterministically', () => {
|
||||
const date = new Date('2026-03-18T10:00:00.000Z');
|
||||
|
||||
expect(normalizeExcelValue(null)).toBe('');
|
||||
expect(normalizeExcelValue(undefined)).toBe('');
|
||||
expect(normalizeExcelValue(42)).toBe(42);
|
||||
expect(normalizeExcelValue(true)).toBe(true);
|
||||
expect(normalizeExcelValue(date)).toBe('2026-03-18T10:00:00.000Z');
|
||||
expect(normalizeExcelValue({ a: 1 })).toBe('{"a":1}');
|
||||
expect(normalizeExcelValue(['x', 2])).toBe('["x",2]');
|
||||
});
|
||||
|
||||
it('keeps stable union-column order based on first-seen keys', () => {
|
||||
const rows = [
|
||||
{ customer: 'A', amount: 10 },
|
||||
{ amount: 20, note: 'second' },
|
||||
{ created_at: '2026-03-18', customer: 'B' },
|
||||
];
|
||||
|
||||
expect(collectExcelColumns(rows)).toEqual(['customer', 'amount', 'note', 'created_at']);
|
||||
expect(normalizeRowsForExcel(rows).columns).toEqual(['customer', 'amount', 'note', 'created_at']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('table excel export service workbook generation', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sanitizes sheet names and file names for xlsx output', () => {
|
||||
expect(sanitizeSheetName('invoices:2026/03?*[]')).toBe('invoices_2026_03____');
|
||||
expect(ensureExcelFilename('orders')).toBe('orders.xlsx');
|
||||
expect(ensureExcelFilename('orders.xlsx')).toBe('orders.xlsx');
|
||||
expect(ensureExcelFilename('bad<>name')).toBe('bad--name.xlsx');
|
||||
});
|
||||
|
||||
it('creates workbook metadata with normalized rows and writes .xlsx files', () => {
|
||||
const rows = [
|
||||
{ id: 1, amount: 10 },
|
||||
{ id: 2, amount: 20, tags: ['vip'] },
|
||||
];
|
||||
const workbookResult = createWorkbookFromRows(rows, { sheetName: 'Orders/Report' });
|
||||
const filename = exportRowsToExcel(rows, { filename: 'orders-report', sheetName: 'Orders/Report' });
|
||||
|
||||
expect(workbookResult.sheetName).toBe('Orders_Report');
|
||||
expect(workbookResult.columns).toEqual(['id', 'amount', 'tags']);
|
||||
expect(workbookResult.rows[1].tags).toBe('["vip"]');
|
||||
expect(workbookResult.workbook.SheetNames).toEqual(['Orders_Report']);
|
||||
expect(filename).toBe('orders-report.xlsx');
|
||||
expect(existsSync(filename)).toBe(true);
|
||||
unlinkSync(filename);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const root = process.cwd();
|
||||
const paginationModelsRoot = join(root, 'src/components/displays/pagination/models');
|
||||
const paginationDisplaySource = readFileSync(
|
||||
join(root, 'src/components/displays/pagination/PaginationDisplay.vue'),
|
||||
'utf8'
|
||||
);
|
||||
const mainSource = readFileSync(join(root, 'src/main.js'), 'utf8');
|
||||
const userOrdersPaginationSource = readFileSync(
|
||||
join(root, 'src/components/displays/pagination/models/UserDashboard/OrdersPagination.vue'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const collectVueFiles = (dir) => {
|
||||
const entries = readdirSync(dir);
|
||||
return entries.flatMap((entry) => {
|
||||
const fullPath = join(dir, entry);
|
||||
const stats = statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
return collectVueFiles(fullPath);
|
||||
}
|
||||
if (entry.endsWith('.vue')) {
|
||||
return [fullPath];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
describe('table standards contract', () => {
|
||||
it('keeps pagination model templates free from legacy raw <table> markup', () => {
|
||||
const vueFiles = collectVueFiles(paginationModelsRoot);
|
||||
const filesWithRawTableMarkup = vueFiles.filter((filePath) => {
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
return source.includes('<table');
|
||||
});
|
||||
|
||||
expect(filesWithRawTableMarkup).toEqual([]);
|
||||
});
|
||||
|
||||
it('exposes a shared excel export action in pagination display controls', () => {
|
||||
expect(paginationDisplaySource).toContain('pagination.download_excel');
|
||||
expect(paginationDisplaySource).toContain('handleExportExcel');
|
||||
expect(paginationDisplaySource).toContain('exportToExcel');
|
||||
});
|
||||
|
||||
it('initializes automatic visible-row export hooks for legacy non-paginated tables', () => {
|
||||
expect(mainSource).toContain('initializeAutoTableExports');
|
||||
expect(mainSource).toContain('initializeAutoTableExports()');
|
||||
});
|
||||
|
||||
it('uses excel export for historical washes instead of legacy csv download', () => {
|
||||
expect(userOrdersPaginationSource).toContain('setExportTransform');
|
||||
expect(userOrdersPaginationSource).toContain('clearExportTransform');
|
||||
expect(userOrdersPaginationSource).not.toContain('exportCsvFile');
|
||||
expect(userOrdersPaginationSource).not.toContain('orders.csv');
|
||||
expect(userOrdersPaginationSource).not.toContain('@click="downloadExcel"');
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,19 @@ describe("useSelfServeLogic", () => {
|
||||
expect(logic.isServiceAllowed("MACHINE")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps included conditional questions visible when rule payload is missing", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
logic.questions.value = [
|
||||
{ id: 1, question: "Always visible", order_priority: 1, condition_id: null },
|
||||
{ id: 2, question: "Included conditional", order_priority: 2, condition_id: 100 },
|
||||
];
|
||||
logic.rules.value = [];
|
||||
logic.answers.value = { 1: true };
|
||||
|
||||
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("synchronizes answers and refreshes preview/summary state", async () => {
|
||||
mocks.previewAllowed.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
@@ -201,4 +214,176 @@ describe("useSelfServeLogic", () => {
|
||||
expect(logic.answers.value[2]).toBe(true);
|
||||
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it("hides condition-dependent questions again when the parent answer no longer satisfies the condition", async () => {
|
||||
const baseConditions = [
|
||||
{ id: 100, name: "Primary answer is yes" },
|
||||
];
|
||||
const baseRules = [
|
||||
{ id: 501, condition_id: 100, object_type: "question", object_id: 2, type: "IS_TRUE", name: "Q2 yes" },
|
||||
];
|
||||
|
||||
mocks.previewAllowed.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
session: { id: 55, status: "IN_PROGRESS" },
|
||||
questions: [
|
||||
{ id: 2, question: "Primary question", answer: true, order_priority: 1, condition_id: null },
|
||||
{ id: 3, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
|
||||
],
|
||||
tasks: [],
|
||||
conditions: baseConditions,
|
||||
rules: baseRules,
|
||||
});
|
||||
mocks.washSummary.mockResolvedValueOnce({
|
||||
session: { id: 55, status: "IN_PROGRESS" },
|
||||
questions: [
|
||||
{ id: 2, question: "Primary question", answer: true, order_priority: 1, condition_id: null },
|
||||
{ id: 3, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
|
||||
],
|
||||
tasks: [],
|
||||
conditions: baseConditions,
|
||||
rules: baseRules,
|
||||
events: [],
|
||||
});
|
||||
|
||||
mocks.add.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
selfserve: {
|
||||
session: { id: 55, status: "IN_PROGRESS" },
|
||||
questions: [
|
||||
{ id: 2, question: "Primary question", answer: false, order_priority: 1 },
|
||||
{ id: 3, question: "Conditional follow-up", answer: null, order_priority: 2 },
|
||||
],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
tasks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.previewAllowed.mockResolvedValue({
|
||||
allowed: true,
|
||||
session: { id: 55, status: "IN_PROGRESS" },
|
||||
questions: [
|
||||
{ id: 2, question: "Primary question", answer: false, order_priority: 1 },
|
||||
{ id: 3, question: "Conditional follow-up", answer: null, order_priority: 2 },
|
||||
],
|
||||
tasks: [],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
});
|
||||
mocks.washSummary.mockResolvedValue({
|
||||
session: { id: 55, status: "IN_PROGRESS" },
|
||||
questions: [
|
||||
{ id: 2, question: "Primary question", answer: false, order_priority: 1 },
|
||||
{ id: 3, question: "Conditional follow-up", answer: null, order_priority: 2 },
|
||||
],
|
||||
tasks: [],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
events: [],
|
||||
});
|
||||
|
||||
const logic = useSelfServeLogic();
|
||||
await logic.fetchSelfServeData(1, null, 7, "ab12345");
|
||||
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([2, 3]);
|
||||
|
||||
await logic.syncVehicleAnswer({
|
||||
departmentId: 1,
|
||||
laneId: 7,
|
||||
customerNumber: 12345,
|
||||
reg: "ab12345",
|
||||
questionId: 2,
|
||||
value: false,
|
||||
});
|
||||
|
||||
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("keeps condition-dependent questions visible in stable order when the condition remains true", async () => {
|
||||
const baseConditions = [
|
||||
{ id: 100, name: "Primary answer is yes" },
|
||||
];
|
||||
const baseRules = [
|
||||
{ id: 501, condition_id: 100, object_type: "question", object_id: 1, type: "IS_TRUE", name: "Q1 yes" },
|
||||
];
|
||||
|
||||
mocks.previewAllowed.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
session: { id: 77, status: "IN_PROGRESS" },
|
||||
questions: [
|
||||
{ id: 1, question: "Primary question", answer: false, order_priority: 1, condition_id: null },
|
||||
{ id: 2, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
|
||||
],
|
||||
tasks: [],
|
||||
conditions: baseConditions,
|
||||
rules: baseRules,
|
||||
});
|
||||
mocks.washSummary.mockResolvedValueOnce({
|
||||
session: { id: 77, status: "IN_PROGRESS" },
|
||||
questions: [
|
||||
{ id: 1, question: "Primary question", answer: false, order_priority: 1, condition_id: null },
|
||||
{ id: 2, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
|
||||
],
|
||||
tasks: [],
|
||||
conditions: baseConditions,
|
||||
rules: baseRules,
|
||||
events: [],
|
||||
});
|
||||
|
||||
mocks.add.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
selfserve: {
|
||||
session: { id: 77, status: "IN_PROGRESS" },
|
||||
questions: [
|
||||
{ id: 1, question: "Primary question", answer: true },
|
||||
],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
tasks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// Simulate stale follow-up fetch payload that still reports the old answer
|
||||
// and omits conditional metadata/questions.
|
||||
mocks.previewAllowed.mockResolvedValue({
|
||||
allowed: true,
|
||||
session: { id: 77, status: "IN_PROGRESS" },
|
||||
questions: [
|
||||
{ id: 1, question: "Primary question", answer: false },
|
||||
],
|
||||
tasks: [],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
});
|
||||
mocks.washSummary.mockResolvedValue({
|
||||
session: { id: 77, status: "IN_PROGRESS" },
|
||||
questions: [
|
||||
{ id: 1, question: "Primary question", answer: false },
|
||||
],
|
||||
tasks: [],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
events: [],
|
||||
});
|
||||
|
||||
const logic = useSelfServeLogic();
|
||||
await logic.fetchSelfServeData(1, null, 7, "ab12345");
|
||||
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1]);
|
||||
|
||||
await logic.syncVehicleAnswer({
|
||||
departmentId: 1,
|
||||
laneId: 7,
|
||||
customerNumber: 12345,
|
||||
reg: "ab12345",
|
||||
questionId: 1,
|
||||
value: true,
|
||||
});
|
||||
|
||||
expect(logic.answers.value[1]).toBe(true);
|
||||
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user