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:
Jeppe Bundgaard
2026-03-18 12:46:40 +01:00
parent 25f9d7c071
commit f619487f6f
26 changed files with 1103 additions and 76 deletions
+105 -1
View File
@@ -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
View File
@@ -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">&nbsp;</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>
+109 -22
View File
@@ -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);
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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();
+159
View File
@@ -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"
+123
View File
@@ -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', () => {
+10
View File
@@ -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"');
});
});
+185
View File
@@ -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]);
});
});