Compare commits

..
Author SHA1 Message Date
Jeppe B 18ab007f87 Debounce self-serve wash data fetches 2026-06-02 13:41:55 +02:00
40 changed files with 457 additions and 668 deletions
+10 -29
View File
@@ -41,7 +41,6 @@
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
@@ -5070,6 +5069,7 @@
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
@@ -5085,6 +5085,7 @@
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
@@ -5736,6 +5737,7 @@
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
@@ -6423,6 +6425,7 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
@@ -8985,6 +8988,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9567,6 +9571,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"dev": true,
"license": "MIT"
},
"node_modules/mkdirp": {
@@ -10104,6 +10109,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"dev": true,
"license": "MIT"
},
"node_modules/performance-now": {
@@ -10165,34 +10171,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pixelmatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
@@ -10750,6 +10728,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT"
},
"node_modules/rgbcolor": {
@@ -11667,6 +11646,7 @@
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -11938,6 +11918,7 @@
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
-1
View File
@@ -86,7 +86,6 @@
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
@@ -1,100 +0,0 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BSkeleton } from "buefy";
const props = defineProps<{
src: string | null;
alt?: string;
}>();
const emit = defineEmits<{
(e: "error"): void;
(e: "load"): void;
}>();
const isLoading = ref(false);
watch(
() => props.src,
(src) => {
isLoading.value = !!src;
},
{ immediate: true }
);
const onLoad = () => {
isLoading.value = false;
emit("load");
};
const onError = () => {
isLoading.value = false;
emit("error");
};
</script>
<template>
<div
v-if="src"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="src"
:src="src"
:alt="alt || 'Machine status'"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isLoading }"
@load="onLoad"
@error="onError"
/>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { BIcon, BMessage, BRadioButton } from "buefy";
const props = defineProps<{
defineProps<{
lanes: Array<any>;
selectedLaneId: number | string | null;
washType: string;
@@ -15,8 +14,6 @@ const emit = defineEmits<{
(e: "update:selectedLaneId", value: number): void;
(e: "update:washType", value: string): void;
}>();
const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props.selectedLaneId));
</script>
<template>
@@ -95,16 +92,16 @@ const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props
:model-value="washType"
native-value="Machine"
type="is-link"
:disabled="!isSelectedMachineAvailable"
:title="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
:aria-label="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
:disabled="!isMachineAvailable(selectedLaneId)"
:title="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
:aria-label="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:washType', 'Machine')"
@input="emit('update:washType', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isSelectedMachineAvailable">
<span v-if="!isMachineAvailable(selectedLaneId)">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
@@ -120,6 +117,14 @@ const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props
</b-radio-button>
</div>
</div>
<b-message
v-if="!isMachineAvailable(selectedLaneId)"
type="is-warning"
aria-close-label="Luk besked"
data-testid="self-serve-machine-unavailable-guidance"
>
{{ $t("self_wash.machine_unavailable_for_lane") }}
</b-message>
</section>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { BIcon } from "buefy";
import SelfServeDynamicImageFrame from "@/components/displays/selfServe/SelfServeDynamicImageFrame.vue";
import { ref, watch } from "vue";
import { BIcon, BSkeleton } from "buefy";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{
@@ -18,6 +18,12 @@ const emit = defineEmits<{
(e: "clear-dynamic-image"): void;
}>();
const isDynamicImageLoading = ref(false);
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
}, { immediate: true });
const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value);
};
@@ -26,7 +32,12 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
emit("download-attachment", taskId, attachmentId, attachment);
};
const onDynamicImageLoad = () => {
isDynamicImageLoading.value = false;
};
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
emit("clear-dynamic-image");
};
</script>
@@ -47,11 +58,30 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<SelfServeDynamicImageFrame
:src="dynamicImageUrl"
alt="Machine status"
@error="onDynamicImageError"
/>
<div
v-if="dynamicImageUrl"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isDynamicImageLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="dynamicImageUrl"
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isDynamicImageLoading }"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
</div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
@@ -65,3 +95,41 @@ const onDynamicImageError = () => {
</div>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -252,7 +252,7 @@ const onResendInvite = async (subuser) => {
<td>
<div class="buttons is-justify-content-flex-end action-buttons">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)" display-actions-directly>
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
<template #actions>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
+43 -70
View File
@@ -1,5 +1,4 @@
import { computed, ref } from "vue";
import { createPinia, defineStore, getActivePinia, setActivePinia, storeToRefs } from "pinia";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
@@ -272,26 +271,7 @@ const mergeByNumericId = (existingItems, incomingItems) => {
return merged;
};
let selfServeLogicStoreInstance = 0;
let fallbackSelfServePinia = null;
const ensureSelfServePinia = () => {
if (typeof document === "undefined") {
fallbackSelfServePinia ||= createPinia();
setActivePinia(fallbackSelfServePinia);
return fallbackSelfServePinia;
}
const activePinia = getActivePinia();
if (activePinia) {
return activePinia;
}
fallbackSelfServePinia ||= createPinia();
setActivePinia(fallbackSelfServePinia);
return fallbackSelfServePinia;
};
const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
export function useSelfServeLogic() {
const loading = ref(false);
const requestError = ref(null);
const preview = ref(null);
@@ -317,6 +297,30 @@ const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
const summaryVisibleQuestionIds = ref([]);
const summaryQuestionOrder = ref({});
const latestFetchRequestId = ref(0);
const latestSuccessfulFetchKey = ref(null);
const inFlightFetchKey = ref(null);
const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => {
const normalizedDepartmentId = parseInt(departmentId);
const normalizedLaneId = parseInt(laneId);
const normalizedReg = String(reg || "").trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0
? normalizedVehicleTypeId
: "";
if (
Number.isNaN(normalizedDepartmentId)
|| normalizedDepartmentId <= 0
|| Number.isNaN(normalizedLaneId)
|| normalizedLaneId <= 0
|| normalizedReg.length < 2
) {
return null;
}
return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|");
};
const beginFetchRequest = () => {
latestFetchRequestId.value += 1;
@@ -595,10 +599,17 @@ const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
}
};
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => {
const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg);
if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) {
return null;
}
const requestId = beginFetchRequest();
if (!laneId || !reg || reg.trim().length < 2) {
latestSuccessfulFetchKey.value = null;
inFlightFetchKey.value = null;
preview.value = null;
summary.value = null;
session.value = null;
@@ -626,6 +637,7 @@ const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
loading.value = true;
requestError.value = null;
inFlightFetchKey.value = fetchKey;
try {
const normalizedReg = reg.trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
@@ -707,6 +719,10 @@ const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
setSummaryVisibleQuestions(questions.value);
}
if (isFetchRequestActive(requestId)) {
latestSuccessfulFetchKey.value = fetchKey;
}
return previewData;
} catch (error) {
console.error("Error fetching self-serve preview:", error);
@@ -715,6 +731,9 @@ const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
}
return null;
} finally {
if (inFlightFetchKey.value === fetchKey) {
inFlightFetchKey.value = null;
}
if (isFetchRequestActive(requestId)) {
loading.value = false;
}
@@ -769,7 +788,7 @@ const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
}
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
answers.value = {
...answers.value,
[parseInt(questionId)]: value,
@@ -834,7 +853,7 @@ const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
? normalizedRefreshVehicleTypeId
: null;
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
return { deletedCount: conditionIdsToDelete.length };
} catch (error) {
@@ -1095,50 +1114,4 @@ const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
answerQuestion,
removeAnswer,
};
});
export function useSelfServeLogic(options = {}) {
const pinia = ensureSelfServePinia();
const explicitStoreId = typeof options === "string" ? options : options?.storeId;
const storeId = explicitStoreId || `selfServeLogic:${++selfServeLogicStoreInstance}`;
const store = createSelfServeLogicStore(storeId)(pinia);
const refs = storeToRefs(store);
const {
fetchSelfServeData,
fetchWashSummary,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
evaluateCondition,
isQuestionVisible,
isTaskActive,
isServiceAllowed,
updateLaneAllowedServices,
enableMachineRelay,
isImage,
downloadAttachment,
reset,
answerQuestion,
removeAnswer,
} = store;
return {
...refs,
fetchSelfServeData,
fetchWashSummary,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
evaluateCondition,
isQuestionVisible,
isTaskActive,
isServiceAllowed,
updateLaneAllowedServices,
enableMachineRelay,
isImage,
downloadAttachment,
reset,
answerQuestion,
removeAnswer,
};
}
+1 -1
View File
@@ -250,7 +250,7 @@ export function useWashSessionActions(options) {
const startResponse = await executeSelfServeCommand(laneId, "START", {
customer_number: parseInt(customerNumber),
license_plate: licensePlate.trim().toUpperCase(),
defer_relay_side_effects: false,
defer_relay_side_effects: true,
});
if (!startResponse) {
return false;
+1
View File
@@ -4595,6 +4595,7 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.indlæser'} @:{'words.generated.tilgængelige'} @:{'words.generated.køretøjstyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "@.capitalize:{'words.generated.maskine'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Optaget",
"open_property_access_gate": "@:{'words.generated.abn'} adgangsport",
+1
View File
@@ -4705,6 +4705,7 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.verf'}?@:{'words.generated.gbare'} @:{'words.generated.fahrzeugtypen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "@:{'words.generated.maschine'}",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Belegt",
"open_property_access_gate": "Grundstueckszugangstor @:{'words.generated.oeffnen'}",
+1
View File
@@ -4429,6 +4429,7 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.loading'} @:{'words.generated.available'} @:{'words.generated.vehicle'} @:{'words.generated.types'}...",
"lane_unavailable": "Lane unavailable",
"machine": "@.capitalize:{'words.generated.machine'}",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Occupied",
"open_property_access_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.access'} @:{'words.generated.gate'}",
+1
View File
@@ -4224,6 +4224,7 @@
"loading_vehicle_types": "@:{'templates.generated.compat.self_wash.loading_vehicle_types'}",
"lane_unavailable": "@:{'templates.generated.compat.self_wash.lane_unavailable'}",
"machine": "@:{'templates.generated.compat.self_wash.machine'}",
"machine_unavailable_for_lane": "@:{'templates.generated.compat.self_wash.machine_unavailable_for_lane'}",
"no_departments_found": "@:{'templates.generated.compat.self_wash.no_departments_found'}",
"no_vehicles_found": "@:vehicles.no_vehicles",
"occupied": "@:{'templates.generated.compat.self_wash.occupied'}",
+1
View File
@@ -4706,6 +4706,7 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.laster'} @:{'words.generated.inn'} @:{'words.generated.tilgjengelige'} @:{'words.generated.kjøretøytyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "@.capitalize:{'words.generated.maskin'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Okkupert",
"open_property_access_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} inngangsport",
+1
View File
@@ -4756,6 +4756,7 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.laser'} @:{'words.generated.in'} @:{'words.generated.tillgangliga'} @:{'words.generated.fordonstyper'}...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "@.capitalize:{'words.generated.machine'}",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"no_departments_found": "@.capitalize:{'words.generated.inga'} @:{'words.generated.avdelningar'} @:{'words.generated.hittades'}",
"occupied": "Upptagen",
"open_property_access_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} ingangsgrind",
+1
View File
@@ -3663,6 +3663,7 @@
"loading_vehicle_types": "Indlæser tilgængelige køretøjstyper...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "Maskine",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsætte.",
"vehicle_step_missing_department": "Vent på, at nærmeste afdeling er indlæst, før du fortsætter.",
"vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
+1
View File
@@ -3663,6 +3663,7 @@
"loading_vehicle_types": "Verf?gbare Fahrzeugtypen werden geladen...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "Maschine",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"vehicle_step_missing_customer_number": "Geben Sie Ihre Kundennummer ein, um fortzufahren.",
"vehicle_step_missing_department": "Warten Sie, bis die nachste Abteilung geladen ist, bevor Sie fortfahren.",
"vehicle_step_missing_registration": "Geben Sie das Kennzeichen ein, um fortzufahren.",
+1
View File
@@ -3662,6 +3662,7 @@
"loading_vehicle_types": "Loading available vehicle types...",
"lane_unavailable": "Lane unavailable",
"machine": "Machine",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"vehicle_step_missing_customer_number": "Enter your customer number to continue.",
"vehicle_step_missing_department": "Wait for the nearest department to load before continuing.",
"vehicle_step_missing_registration": "Enter your registration number to continue.",
+1
View File
@@ -3663,6 +3663,7 @@
"loading_vehicle_types": "Laster inn tilgjengelige kjøretøytyper...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "Maskin",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"vehicle_step_missing_customer_number": "Skriv inn kundenummer for a fortsette.",
"vehicle_step_missing_department": "Vent til naermeste avdeling er lastet inn for du fortsetter.",
"vehicle_step_missing_registration": "Skriv inn registreringsnummer for a fortsette.",
+1
View File
@@ -3663,6 +3663,7 @@
"loading_vehicle_types": "Läser in tillgängliga fordonstyper...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "Machine",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"vehicle_step_missing_customer_number": "Ange kundnummer for att fortsatta.",
"vehicle_step_missing_department": "Vanta tills narmaste avdelning har laddats innan du fortsatter.",
"vehicle_step_missing_registration": "Ange registreringsnummer for att fortsatta.",
@@ -18,6 +18,7 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.indlæser'} @:{'terms.glossary.tilgængelige'} @:{'terms.glossary.køretøjstyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "@.capitalize:{'terms.glossary.maskine'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Optaget",
"open_property_access_gate": "@:{'terms.glossary.abn'} adgangsport",
@@ -18,6 +18,7 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.verf'}?@:{'terms.glossary.gbare'} @:{'terms.glossary.fahrzeugtypen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "@:{'terms.glossary.maschine'}",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Belegt",
"open_property_access_gate": "Grundstueckszugangstor @:{'terms.glossary.oeffnen'}",
@@ -18,6 +18,7 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.available'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.types'}...",
"lane_unavailable": "Lane unavailable",
"machine": "@.capitalize:{'terms.glossary.machine'}",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Occupied",
"open_property_access_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.access'} @:{'terms.glossary.gate'}",
@@ -21,6 +21,7 @@
"loading_vehicle_types": "@:{'phrases.compat.self_wash.loading_vehicle_types'}",
"lane_unavailable": "@:{'phrases.compat.self_wash.lane_unavailable'}",
"machine": "@:{'phrases.compat.self_wash.machine'}",
"machine_unavailable_for_lane": "@:{'phrases.compat.self_wash.machine_unavailable_for_lane'}",
"no_departments_found": "@:{'phrases.compat.self_wash.no_departments_found'}",
"no_vehicles_found": "@:vehicles.no_vehicles",
"occupied": "@:{'phrases.compat.self_wash.occupied'}",
@@ -18,6 +18,7 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.inn'} @:{'terms.glossary.tilgjengelige'} @:{'terms.glossary.kjøretøytyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "@.capitalize:{'terms.glossary.maskin'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Okkupert",
"open_property_access_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} inngangsport",
@@ -18,6 +18,7 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laser'} @:{'terms.glossary.in'} @:{'terms.glossary.tillgangliga'} @:{'terms.glossary.fordonstyper'}...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "@.capitalize:{'terms.glossary.machine'}",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"no_departments_found": "@.capitalize:{'terms.glossary.inga'} @:{'terms.glossary.avdelningar'} @:{'terms.glossary.hittades'}",
"occupied": "Upptagen",
"open_property_access_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} ingangsgrind",
-2
View File
@@ -10,7 +10,6 @@ import applyMiddleware from '@/middleware/index';
import store from './store/user.vue'
import { createHead } from "@vueuse/head";
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Router from "@/router";
import App from "@/App.vue";
import Buefy from 'buefy'
@@ -109,7 +108,6 @@ applyMiddleware(Router)
const app = createApp(App)
.use(Router)
.use(createHead())
.use(createPinia())
.use(store)
.use(ToastPlugin)
.use(Buefy)
-152
View File
@@ -1,152 +0,0 @@
import { defineStore } from "pinia";
const createValidationState = () => ({ valid: true, items: [], errors: [], warnings: [] });
const createGatewayWorkspaceState = () => ({ gateways: [], relays: [], lanes: [], issues: [], actions: [] });
const createSimulatorForm = () => ({
lane_id: null,
reg: "TEST123",
customer_number: null,
config_source: "draft",
include_hardware: true,
hardware_mode: "studio",
});
const createPathEditorQuestionDraft = () => ({
question: "New question",
description: "",
scope: "department_lane",
vehicle_mode: "current",
vehicle_type_values: [],
});
const createPathEditorForm = () => ({
path_key: "",
previous_path_key: "",
answers: {},
machine_allowed: true,
task: "Start machine",
description: "",
services: ["MACHINE"],
program_button: "",
press_reset: false,
press_start: true,
extra_buttons: [],
dynamic_images_vehicle_type: "0",
});
const createVirtualHardwareForm = () => ({
gateway_key: "virtual-main",
gateway_label: "Virtual Studio Gateway",
relay_id: "",
role: "MACHINE",
label: "",
});
const createScopeFilters = () => ({
lane_id: "",
vehicle_type_id: "",
});
const createCanvasFilters = () => ({
...createScopeFilters(),
machine_type_id: "",
dynamic_image_id: "",
});
export const useSelfServeStudioStore = defineStore("selfServeStudio", {
state: () => ({
flowNodes: [],
flowEdges: [],
graphPayload: null,
lookups: {},
validation: createValidationState(),
gatewayWorkspace: createGatewayWorkspaceState(),
versions: [],
activePanel: "flow",
selectedNodeId: null,
inspectorForm: {},
simulatorForm: createSimulatorForm(),
simulatorResult: null,
simulatorAnswerOverrides: {},
simulatorShowJson: false,
simulatorDynamicImageHidden: false,
simulatorDynamicImageError: "",
simulatorCompletedTasks: {},
isSimulatorRunning: false,
simulatorRunReason: "",
simulatorRequestSequence: 0,
simulatorLastRequestKey: "",
simulatorLastError: "",
isRestoringSimulatorStorage: false,
pathOutcomes: null,
isLoadingPathOutcomes: false,
pathOutcomesError: "",
pathOutcomesRequestKey: "",
selectedPathOutcomeId: null,
selectedPathCaseSignature: "",
selectedPathCaseIndex: 0,
pathCaseRunSignatures: {},
pathCaseConfirmingSignature: "",
pathEditorDirty: false,
isHydratingPathEditor: false,
pathEditorTaskTitleOverrides: {},
editingPathEditorTaskTitleKey: "",
editingPathEditorTaskTitle: "",
pathVerificationDynamicImageHidden: false,
pathVerificationDynamicImageError: "",
isPathVerificationDynamicImageLoading: false,
pathEditorQuestionDraftVisible: false,
pathEditorQuestionDraft: createPathEditorQuestionDraft(),
pathEditorForm: createPathEditorForm(),
simulatorCustomerSearchQuery: "",
selectedSimulatorCustomer: null,
showSimulatorCustomerResults: false,
scopeGuideDraft: createScopeFilters(),
isScopeGuideManuallyOpen: false,
isSyncingScopeFromRoute: false,
isWritingScopeToRoute: false,
virtualHardwareForm: createVirtualHardwareForm(),
laneEditorMode: "edit",
selectedLaneManagementId: null,
laneForm: {},
lanePreviewHidden: false,
inspectorLanePreviewHidden: false,
inspectorTaskDynamicImageHidden: false,
inspectorTaskDynamicImageError: "",
searchTerm: "",
filters: createCanvasFilters(),
isLoading: false,
isSaving: false,
isUploadingTaskAttachment: false,
deletingTaskAttachmentId: null,
isStudioFullscreen: false,
hasNativeStudioFullscreen: false,
suppressLayoutSave: false,
exportText: "",
importText: "",
draggedTemplateKind: null,
unfilteredNodePositions: null,
}),
actions: {
resetPathEditorQuestionDraft() {
this.pathEditorQuestionDraft = createPathEditorQuestionDraft();
},
resetPathEditorForm() {
this.pathEditorForm = createPathEditorForm();
},
applyRouteScope(scopeFilters = {}) {
const nextScope = {
...createScopeFilters(),
...scopeFilters,
};
this.scopeGuideDraft = nextScope;
this.filters = {
...this.filters,
...nextScope,
};
},
},
});
@@ -95,25 +95,6 @@ const getDominantWeather = (entries) => {
return dominant;
};
const getProductivityStatus = (washes, hours) => {
if (hours <= 0) {
return "unknown";
}
const productivity = washes / hours;
return productivity >= 1.3 ? "healthy" : productivity >= 1.0 ? "degraded" : "unhealthy";
};
const getNormalizedTime = (entry) => {
const timeValue = typeof entry?.time === "string" ? entry.time : "";
const normalizedTime = timeValue.slice(0, 5);
return /^\d{2}:\d{2}$/.test(normalizedTime) ? normalizedTime : null;
};
const formatMergedNumber = (value) => {
return Number.isInteger(value) ? value : Number(value.toFixed(2));
};
const summarizeEntriesByDay = (entries) => {
const groupedByDate = new Map();
@@ -139,48 +120,14 @@ const summarizeEntriesByDay = (entries) => {
time: "00:00",
current: dayEntries.some((entry) => entry?.current === true),
weather: getDominantWeather(dayEntries),
washes: formatMergedNumber(washes),
washes: Number.isInteger(washes) ? washes : Number(washes.toFixed(2)),
hours: Number(hours.toFixed(2)),
/**
* Below 1.0 cars/workhour = unhealthy (red)
* 1.0-1.3 cars/workhour = degraded (yellow)
* Above 1.3 cars/workhour = healthy (green)
*/
status: getProductivityStatus(washes, hours),
};
});
};
const summarizeEntriesByHourOfDay = (entries) => {
const groupedByTime = new Map();
entries.forEach((entry) => {
const time = getNormalizedTime(entry);
if (!time) {
return;
}
if (!groupedByTime.has(time)) {
groupedByTime.set(time, []);
}
groupedByTime.get(time).push(entry);
});
return Array.from(groupedByTime.entries())
.sort(([timeA], [timeB]) => timeA.localeCompare(timeB))
.map(([time, hourEntries]) => {
const washes = hourEntries.reduce((sum, entry) => sum + toNumberOrZero(entry?.washes), 0);
const hours = hourEntries.reduce((sum, entry) => sum + toNumberOrZero(entry?.hours), 0);
return {
date: null,
test_id_date: "multi-day",
time,
current: hourEntries.some((entry) => entry?.current === true),
weather: getDominantWeather(hourEntries),
washes: formatMergedNumber(washes),
hours: Number(hours.toFixed(2)),
status: getProductivityStatus(washes, hours),
status: hours > 0 ? (washes / hours >= 1.3 ? "healthy" : washes / hours >= 1.0 ? "degraded" : "unhealthy") : "unknown",
};
});
};
@@ -189,10 +136,6 @@ const hourlyDisplayedState = computed(() => {
return state.value.filter((entry) => hasOperationalData(entry));
});
const hourlyMergedDisplayedState = computed(() => {
return summarizeEntriesByHourOfDay(hourlyDisplayedState.value).filter((entry) => hasOperationalData(entry));
});
const dailyDisplayedState = computed(() => {
return summarizeEntriesByDay(state.value).filter((entry) => hasOperationalData(entry));
});
@@ -201,9 +144,6 @@ const displayedState = computed(() => {
if (summarizeDaily.value && isMultiDaySelection.value) {
return dailyDisplayedState.value;
}
if (isMultiDaySelection.value) {
return hourlyMergedDisplayedState.value;
}
return hourlyDisplayedState.value;
});
@@ -227,9 +167,7 @@ const getEntryKey = (entry, index) => {
};
const getEntryTestId = (prefix, entry) => {
const date = typeof entry?.test_id_date === "string"
? entry.test_id_date
: isDateString(entry?.date) ? entry.date : "no-date";
const date = isDateString(entry?.date) ? entry.date : "no-date";
const time = typeof entry?.time === "string" && entry.time !== "" ? entry.time : "no-time";
return `department-weather-${prefix}-${date}-${time}`;
};
@@ -239,8 +177,9 @@ const getHourDetailsSlotKey = (entry) => {
return null;
}
const normalizedTime = getNormalizedTime(entry);
if (!normalizedTime) {
const timeValue = typeof entry?.time === "string" ? entry.time : "";
const normalizedTime = timeValue.slice(0, 5);
if (!/^\d{2}:\d{2}$/.test(normalizedTime)) {
return null;
}
@@ -1,6 +1,5 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { useRoute, useRouter } from "vue-router";
import { Handle, Position, VueFlow, useVueFlow } from "@vue-flow/core";
import "@vue-flow/core/dist/style.css";
@@ -35,7 +34,6 @@ import {
formatSelfServeTaskButtons,
normalizeSelfServeTaskButtons,
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
import { useSelfServeStudioStore } from "@/stores/selfServeStudioStore.js";
import { resolveAuditIssueNodeIds } from "./auditIssueNavigation.js";
const currentRoute = useRoute();
@@ -369,88 +367,111 @@ const AUTO_LAYOUT_RUNTIME_STAGE_ORDER = {
finish: 2,
};
const studioStore = useSelfServeStudioStore();
const {
flowNodes,
flowEdges,
graphPayload,
lookups,
validation,
gatewayWorkspace,
versions,
activePanel,
selectedNodeId,
inspectorForm,
simulatorForm,
simulatorResult,
simulatorAnswerOverrides,
simulatorShowJson,
simulatorDynamicImageHidden,
simulatorDynamicImageError,
simulatorCompletedTasks,
isSimulatorRunning,
simulatorRunReason,
simulatorRequestSequence,
simulatorLastRequestKey,
simulatorLastError,
isRestoringSimulatorStorage,
pathOutcomes,
isLoadingPathOutcomes,
pathOutcomesError,
pathOutcomesRequestKey,
selectedPathOutcomeId,
selectedPathCaseSignature,
selectedPathCaseIndex,
pathCaseRunSignatures,
pathCaseConfirmingSignature,
pathEditorDirty,
isHydratingPathEditor,
pathEditorTaskTitleOverrides,
editingPathEditorTaskTitleKey,
editingPathEditorTaskTitle,
pathVerificationDynamicImageHidden,
pathVerificationDynamicImageError,
isPathVerificationDynamicImageLoading,
pathEditorQuestionDraftVisible,
pathEditorQuestionDraft,
pathEditorForm,
simulatorCustomerSearchQuery,
selectedSimulatorCustomer,
showSimulatorCustomerResults,
scopeGuideDraft,
isScopeGuideManuallyOpen,
isSyncingScopeFromRoute,
isWritingScopeToRoute,
virtualHardwareForm,
laneEditorMode,
selectedLaneManagementId,
laneForm,
lanePreviewHidden,
inspectorLanePreviewHidden,
inspectorTaskDynamicImageHidden,
inspectorTaskDynamicImageError,
searchTerm,
filters,
isLoading,
isSaving,
isUploadingTaskAttachment,
deletingTaskAttachmentId,
isStudioFullscreen,
hasNativeStudioFullscreen,
suppressLayoutSave,
exportText,
importText,
draggedTemplateKind,
unfilteredNodePositions,
} = storeToRefs(studioStore);
activePanel.value = panelFromRoute() || "flow";
studioStore.applyRouteScope(scopeFiltersFromRoute());
const flowNodes = ref([]);
const flowEdges = ref([]);
const graphPayload = ref(null);
const lookups = ref({});
const validation = ref({ valid: true, items: [], errors: [], warnings: [] });
const gatewayWorkspace = ref({ gateways: [], relays: [], lanes: [], issues: [], actions: [] });
const versions = ref([]);
const activePanel = ref(panelFromRoute() || "flow");
const selectedNodeId = ref(null);
const inspectorForm = ref({});
const simulatorForm = ref({
lane_id: null,
reg: "TEST123",
customer_number: null,
config_source: "draft",
include_hardware: true,
hardware_mode: "studio",
});
const simulatorResult = ref(null);
const simulatorAnswerOverrides = ref({});
const simulatorShowJson = ref(false);
const simulatorDynamicImageHidden = ref(false);
const simulatorDynamicImageError = ref("");
const simulatorCompletedTasks = ref({});
const isSimulatorRunning = ref(false);
const simulatorRunReason = ref("");
const simulatorRunTimer = ref(null);
const simulatorRequestSequence = ref(0);
const simulatorLastRequestKey = ref("");
const simulatorLastError = ref("");
const isRestoringSimulatorStorage = ref(false);
const pathOutcomes = ref(null);
const isLoadingPathOutcomes = ref(false);
const pathOutcomesError = ref("");
const pathOutcomesRequestKey = ref("");
const pathOutcomesAbortController = ref(null);
const selectedPathOutcomeId = ref(null);
const selectedPathCaseSignature = ref("");
const selectedPathCaseIndex = ref(0);
const pathCaseRunSignatures = ref({});
const pathCaseConfirmingSignature = ref("");
const pathEditorDirty = ref(false);
const isHydratingPathEditor = ref(false);
const pathEditorTaskTitleOverrides = ref({});
const editingPathEditorTaskTitleKey = ref("");
const editingPathEditorTaskTitle = ref("");
const pathVerificationDynamicImageHidden = ref(false);
const pathVerificationDynamicImageError = ref("");
const isPathVerificationDynamicImageLoading = ref(false);
const pathEditorQuestionDraftVisible = ref(false);
const pathEditorQuestionDraft = ref(emptyPathEditorQuestionDraft());
const pathEditorForm = ref({
path_key: "",
previous_path_key: "",
answers: {},
machine_allowed: true,
task: "Start machine",
description: "",
services: ["MACHINE"],
program_button: "",
press_reset: false,
press_start: true,
extra_buttons: [],
dynamic_images_vehicle_type: "0",
});
const simulatorCustomerSearchQuery = ref("");
const selectedSimulatorCustomer = ref(null);
const showSimulatorCustomerResults = ref(false);
const scopeGuideDraft = ref(scopeFiltersFromRoute());
const isScopeGuideManuallyOpen = ref(false);
const isSyncingScopeFromRoute = ref(false);
const isWritingScopeToRoute = ref(false);
const virtualHardwareForm = ref({
gateway_key: "virtual-main",
gateway_label: "Virtual Studio Gateway",
relay_id: "",
role: "MACHINE",
label: "",
});
const laneEditorMode = ref("edit");
const selectedLaneManagementId = ref(null);
const laneForm = ref({});
const lanePreviewHidden = ref(false);
const inspectorLanePreviewHidden = ref(false);
const inspectorTaskDynamicImageHidden = ref(false);
const inspectorTaskDynamicImageError = ref("");
const searchTerm = ref("");
const filters = ref({
...scopeFiltersFromRoute(),
machine_type_id: "",
dynamic_image_id: "",
});
const isLoading = ref(false);
const isSaving = ref(false);
const isUploadingTaskAttachment = ref(false);
const deletingTaskAttachmentId = ref(null);
const isStudioFullscreen = ref(false);
const hasNativeStudioFullscreen = ref(false);
const layoutSaveTimer = ref(null);
const suppressLayoutSave = ref(false);
const exportText = ref("");
const importText = ref("");
const draggedTemplateKind = ref(null);
const studioRoot = ref(null);
const unfilteredNodePositions = ref(null);
const graphStats = computed(() => {
const counts = {};
@@ -55,12 +55,12 @@ const hasStartFormUserInput = ref(false);
const shouldRestoreServerActiveWash = ref(false);
const isRestoringServerActiveWash = ref(false);
const isSyncingActiveWash = ref(false);
const hasConfirmedActiveWashOnServer = ref(false);
const MIN_FINISHING_SCREEN_MS = 250;
const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";
const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;
const ACTIVE_WASH_REFRESH_MS = 5 * 1000;
const WASH_START_SERVER_SYNC_GRACE_MS = 10 * 1000;
const SELF_SERVE_FETCH_DEBOUNCE_MS = 300;
const normalizePositiveInteger = (value: any) => {
const parsed = parseInt(String(value ?? ""), 10);
@@ -638,24 +638,129 @@ const fetchVehicleTypes = async () => {
}
};
const fetchSelfServeData = async () => {
if (isRestoring.value || !nearestDepartment.value) {
return;
type SelfServeFetchRequest = {
key: string;
departmentId: number;
laneId: number;
registration: string;
vehicleTypeId: number | null;
};
let selfServeFetchDebounceTimer: ReturnType<typeof window.setTimeout> | null = null;
let selfServeFetchResolvers: Array<(value: any) => void> = [];
let latestSuccessfulSelfServeFetchKey: string | null = null;
let inFlightSelfServeFetchKey: string | null = null;
let selfServeFetchSequence = 0;
let isSelfServeFetchUnmounted = false;
const resolvePendingSelfServeFetches = (value: any = null) => {
const resolvers = selfServeFetchResolvers;
selfServeFetchResolvers = [];
resolvers.forEach((resolve) => resolve(value));
};
const clearScheduledSelfServeFetch = (resolveValue: any = null) => {
if (selfServeFetchDebounceTimer) {
window.clearTimeout(selfServeFetchDebounceTimer);
selfServeFetchDebounceTimer = null;
}
const normalizedReg = normalizeLicensePlate(licensePlateInput.value);
if (normalizedReg.length < 2) {
return;
if (selfServeFetchResolvers.length > 0) {
resolvePendingSelfServeFetches(resolveValue);
}
};
const createSelfServeFetchRequest = (): SelfServeFetchRequest | null => {
if (isRestoring.value || !nearestDepartment.value) {
return null;
}
const departmentId = normalizePositiveInteger(nearestDepartment.value.id);
if (!departmentId) {
return null;
}
const registration = normalizeLicensePlate(licensePlateInput.value);
if (registration.length < 2) {
return null;
}
const laneId = ensureEffectiveLaneSelection();
if (!laneId) {
return;
return null;
}
const departmentId = nearestDepartment.value.id;
const vehicleTypeId = normalizePositiveInteger(vehicleTypeSelect.value);
const key = [departmentId, laneId, registration, vehicleTypeId ?? ""].join("|");
await fetchSelfServeDataInternal(departmentId, vehicleTypeSelect.value || null, laneId, normalizedReg);
return {
key,
departmentId,
laneId,
registration,
vehicleTypeId,
};
};
const executeSelfServeFetch = async (request: SelfServeFetchRequest | null = createSelfServeFetchRequest()) => {
if (!request || isSelfServeFetchUnmounted) {
return null;
}
if (request.key === latestSuccessfulSelfServeFetchKey || request.key === inFlightSelfServeFetchKey) {
return null;
}
const requestSequence = ++selfServeFetchSequence;
inFlightSelfServeFetchKey = request.key;
try {
const result = await fetchSelfServeDataInternal(
request.departmentId,
request.vehicleTypeId,
request.laneId,
request.registration
);
if (!isSelfServeFetchUnmounted && requestSequence === selfServeFetchSequence) {
latestSuccessfulSelfServeFetchKey = request.key;
}
return result;
} finally {
if (inFlightSelfServeFetchKey === request.key) {
inFlightSelfServeFetchKey = null;
}
}
};
const fetchSelfServeData = async (options: { immediate?: boolean } = {}) => {
const request = createSelfServeFetchRequest();
if (options.immediate) {
clearScheduledSelfServeFetch(null);
return executeSelfServeFetch(request);
}
if (!request || isSelfServeFetchUnmounted) {
clearScheduledSelfServeFetch(null);
return null;
}
return new Promise((resolve) => {
selfServeFetchResolvers.push(resolve);
if (selfServeFetchDebounceTimer) {
window.clearTimeout(selfServeFetchDebounceTimer);
}
selfServeFetchDebounceTimer = window.setTimeout(async () => {
selfServeFetchDebounceTimer = null;
const latestRequest = createSelfServeFetchRequest();
const result = await executeSelfServeFetch(latestRequest);
resolvePendingSelfServeFetches(result);
}, SELF_SERVE_FETCH_DEBOUNCE_MS);
});
};
const SERVER_ACTIVE_WASH_ENDPOINT = "/modules/self-serve/lane/wash/my-active-wash";
@@ -834,7 +939,6 @@ const applyServerActiveWash = async (activeWash: any) => {
}
washInProgress.value = true;
hasConfirmedActiveWashOnServer.value = true;
washLaneId.value = laneId;
radioLaneOption.value = laneId;
licensePlateInput.value = reg;
@@ -919,7 +1023,6 @@ const clearLocalActiveWashFromServer = () => {
completedDurationMs.value = washStartTime.value ? now.value - washStartTime.value : 0;
currentStep.value = steps.COMPLETED;
washInProgress.value = false;
hasConfirmedActiveWashOnServer.value = false;
washLaneId.value = null;
washStartTime.value = null;
shouldRestoreServerActiveWash.value = false;
@@ -955,14 +1058,8 @@ const syncActiveWashWithServer = async () => {
);
const serverStillMatchesCurrentWash =
!!details?.in_progress && (!customerNumber || serverCustomerNumber === customerNumber);
const serverReportedNoActiveWash = details?.in_progress === false;
const shouldPreserveUnconfirmedLocalWash = serverReportedNoActiveWash && !hasConfirmedActiveWashOnServer.value;
if (!serverStillMatchesCurrentWash || isRecentlyCompletedActiveWash({ details, laneId })) {
if (shouldPreserveUnconfirmedLocalWash) {
return;
}
const isWithinStartGracePeriod =
washStartTime.value && Date.now() - washStartTime.value < WASH_START_SERVER_SYNC_GRACE_MS;
if (isWithinStartGracePeriod && !isRecentlyCompletedActiveWash({ details, laneId })) {
@@ -973,7 +1070,6 @@ const syncActiveWashWithServer = async () => {
return;
}
hasConfirmedActiveWashOnServer.value = true;
const session = details?.session || {};
const vehicle = details?.vehicle || {};
const serverReg = normalizeLicensePlate(session?.reg ?? vehicle?.reg);
@@ -1033,7 +1129,7 @@ const retrySelfServeData = async () => {
isSelfServeRetrying.value = true;
try {
await fetchSelfServeData();
await fetchSelfServeData({ immediate: true });
} finally {
isSelfServeRetrying.value = false;
}
@@ -1103,7 +1199,7 @@ const onVehicleStepNext = async () => {
return;
}
await fetchSelfServeData();
await fetchSelfServeData({ immediate: true });
await nextTick();
markQuestionReviewRequired();
currentStep.value = visibleQuestions.value.length > 0 ? steps.QUESTIONS : steps.SELECT_LANE;
@@ -1187,7 +1283,6 @@ const onConfirmNext = async () => {
const onCloseCompleted = () => {
stopElapsedTimer();
washInProgress.value = false;
hasConfirmedActiveWashOnServer.value = false;
washLaneId.value = null;
washStartTime.value = null;
completedDurationMs.value = null;
@@ -1227,6 +1322,9 @@ onUnmounted(() => {
unregisterBeforeUnload.value();
}
isSelfServeFetchUnmounted = true;
clearScheduledSelfServeFetch(null);
selfServeFetchSequence += 1;
stopAutoRefresh();
stopActiveWashRefresh();
markDestroying();
@@ -1358,15 +1456,10 @@ watch(
watch([() => washInProgress.value, () => washLaneId.value], ([inProgress]) => {
if (inProgress) {
if (isRestoring.value) {
return;
}
hasConfirmedActiveWashOnServer.value = true;
startActiveWashRefresh();
return;
}
hasConfirmedActiveWashOnServer.value = false;
stopActiveWashRefresh();
});
@@ -1415,9 +1508,6 @@ watch(
if (!restoring) {
ensureEffectiveLaneSelection();
fetchSelfServeData();
if (washInProgress.value) {
startActiveWashRefresh();
}
}
}
);
@@ -1499,6 +1589,7 @@ watch(
<b-message
v-if="selfServeDataError"
type="is-danger"
has-icon
:closable="false"
data-testid="self-serve-runtime-error"
>
+8 -8
View File
@@ -265,11 +265,11 @@ test.describe("Admin daily report", () => {
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-24");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
await expect(page.getByTestId("department-weather-hours-multi-day-06:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-multi-day-19:00")).toHaveText("19");
await expect(page.getByTestId("department-weather-hours-multi-day-20:00")).toHaveText("21");
await expect(page.getByTestId("department-weather-productivity-multi-day-06:00")).toHaveClass(/red/);
await expect(page.getByTestId("department-weather-productivity-multi-day-08:00")).toHaveClass(/gray/);
await expect(page.getByTestId("department-weather-hours-2026-03-23-06:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-2026-03-23-19:00")).toHaveText("19");
await expect(page.getByTestId("department-weather-hours-2026-03-23-20:00")).toHaveText("21");
await expect(page.getByTestId("department-weather-productivity-2026-03-23-06:00")).toHaveClass(/red/);
await expect(page.getByTestId("department-weather-productivity-2026-03-24-08:00")).toHaveClass(/gray/);
await page.getByTestId("department-weather-summary-toggle").click();
@@ -319,9 +319,9 @@ test.describe("Admin daily report", () => {
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-24");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
await expect(page.getByTestId("department-weather-hours-multi-day-12:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-multi-day-13:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-multi-day-14:00")).toHaveCount(0);
await expect(page.getByTestId("department-weather-hours-2026-03-23-12:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-2026-03-24-13:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-2026-03-24-14:00")).toHaveCount(0);
await page.getByTestId("department-weather-summary-toggle").click();
+2
View File
@@ -31,6 +31,8 @@ const expectedFuldfValues = {
const expectedSelfWashValues = {
"self_wash.lane_unavailable": "Vaskebanen er ikke tilgængelig",
"self_wash.machine_unavailable_for_lane":
"Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"self_wash.vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsætte.",
"self_wash.vehicle_step_missing_department": "Vent på, at nærmeste afdeling er indlæst, før du fortsætter.",
"self_wash.vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
+1 -1
View File
@@ -726,7 +726,7 @@ test.describe("Self-serve wash", () => {
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
command: "START",
customer_number: 12345679,
defer_relay_side_effects: false,
defer_relay_side_effects: true,
});
await page.waitForTimeout(250);
expect(orderFilters.some((filters) => filters.includes("customer_id:777"))).toBe(false);
@@ -64,22 +64,6 @@ vi.mock("@/components/pagination/departmentTabs.vue", async () => {
};
});
vi.mock("@/components/page/wrappers/RestrictedPageWrapper.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/page/wrappers/NotFoundFallBackPageWrapper.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/displays/boxes/ElementTabsBox.vue", () => ({
default: { template: "<div><slot name='form' /><slot name='history' /></div>" },
}));
vi.mock("@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue", async () => {
const { ref } = await import("vue");
@@ -120,52 +104,6 @@ vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentD
};
});
vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentWeather.vue", () => ({
default: { template: "<div />" },
}));
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportCount.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportComplaints.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportHistory.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportNavigation.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportProductSales.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportTodayForm.vue",
() => ({
default: { template: "<div />" },
})
);
import DepartmentDailyReport from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReport.vue";
const flushAll = async () => {
+7 -32
View File
@@ -278,11 +278,11 @@ describe("DepartmentWeather", () => {
await flushAll();
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-06:00").text()).toBe("1");
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-19:00").text()).toBe("19");
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-20:00").text()).toBe("21");
expect(getCellByTestId(wrapper, "department-weather-productivity-multi-day-06:00").classes()).toContain("red");
expect(getCellByTestId(wrapper, "department-weather-productivity-multi-day-08:00").classes()).toContain("gray");
expect(getCellByTestId(wrapper, "department-weather-hours-2026-03-23-06:00").text()).toBe("1");
expect(getCellByTestId(wrapper, "department-weather-hours-2026-03-23-19:00").text()).toBe("19");
expect(getCellByTestId(wrapper, "department-weather-hours-2026-03-23-20:00").text()).toBe("21");
expect(getCellByTestId(wrapper, "department-weather-productivity-2026-03-23-06:00").classes()).toContain("red");
expect(getCellByTestId(wrapper, "department-weather-productivity-2026-03-24-08:00").classes()).toContain("gray");
await wrapper.get('[data-testid="department-weather-summary-toggle"]').trigger("click");
await flushAll();
@@ -325,36 +325,11 @@ describe("DepartmentWeather", () => {
]);
});
it("merges matching hours into one column for multi-day hourly mode", async () => {
getObjectsMock.mockResolvedValueOnce([
makeEntry({ date: "2026-03-23", time: "06:00", weather: "clear", washes: 1, hours: 0.5, status: "unhealthy" }),
makeEntry({ date: "2026-03-24", time: "06:00", weather: "rain", washes: 2, hours: 0.6, status: "healthy" }),
makeEntry({ date: "2026-03-24", time: "07:00", weather: "rain", washes: 4, hours: 2, status: "healthy" }),
]);
const wrapper = mount(DepartmentWeather, {
props: {
department_ids: [1],
date_from: "2026-03-23",
date_to: "2026-03-24",
},
});
await flushAll();
expect(getRenderedHours(wrapper)).toEqual(["06", "07"]);
expect(getRowValues(wrapper, 1)).toEqual(["3", "4"]);
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-06:00").text()).toBe("2");
expect(getCellByTestId(wrapper, "department-weather-productivity-multi-day-06:00").classes()).toContain("green");
expect(wrapper.find('[data-testid="department-weather-hours-2026-03-23-06:00"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="department-weather-hours-2026-03-24-06:00"]').exists()).toBe(false);
});
it("starts in hourly mode and lets users toggle to daily summary in multi-day mode", async () => {
getObjectsMock.mockResolvedValueOnce([
makeEntry({ date: "2026-03-23", time: "06:00", washes: 1, hours: 1 }),
makeEntry({ date: "2026-03-23", time: "07:00", washes: 2, hours: 2 }),
makeEntry({ date: "2026-03-24", time: "06:00", washes: 3, hours: 3 }),
makeEntry({ date: "2026-03-24", time: "08:00", washes: 3, hours: 3 }),
]);
const wrapper = mount(DepartmentWeather, {
@@ -366,7 +341,7 @@ describe("DepartmentWeather", () => {
});
await flushAll();
expect(getRenderedHours(wrapper)).toEqual(["06", "07"]);
expect(getRenderedHours(wrapper)).toEqual(["06", "07", "08"]);
await wrapper.get('[data-testid="department-weather-summary-toggle"]').trigger("click");
await flushAll();
+35
View File
@@ -419,6 +419,8 @@ describe("MyWashStart", () => {
});
it("wires child updates back into the self-serve runtime", async () => {
vi.useFakeTimers();
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
@@ -430,6 +432,7 @@ describe("MyWashStart", () => {
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
await nextTick();
await vi.advanceTimersByTimeAsync(300);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
@@ -449,6 +452,34 @@ describe("MyWashStart", () => {
});
});
it("debounces registration, lane, and vehicle type updates into one self-serve fetch", async () => {
vi.useFakeTimers();
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
mocks.fetchSelfServeDataInternal.mockClear();
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
await wrapper.get('[data-testid="emit-lane"]').trigger("click");
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
await nextTick();
await vi.advanceTimersByTimeAsync(299);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledTimes(1);
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
});
it("hides machine tasks while manual wash is selected", async () => {
mocks.activeTasks.value = [
{ id: 31, task: "Machine checklist", services: ["MACHINE"] },
@@ -597,6 +628,8 @@ describe("MyWashStart", () => {
});
it("falls back to available lane when restored lane is stale for the selected department", async () => {
vi.useFakeTimers();
mocks.restoredProgressPayload = {
washInProgress: false,
washLaneId: null,
@@ -619,6 +652,8 @@ describe("MyWashStart", () => {
},
});
await flushPromises();
await vi.advanceTimersByTimeAsync(300);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
@@ -11,6 +11,9 @@ describe("Danish self-serve labels", () => {
it("uses Danish self-serve validation and availability text", () => {
expect(da.self_wash.lane_unavailable).toBe("Vaskebanen er ikke tilgængelig");
expect(da.self_wash.machine_unavailable_for_lane).toBe(
"Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane."
);
expect(da.self_wash.vehicle_step_missing_customer_number).toBe("Indtast kundenummer for at fortsætte.");
expect(da.self_wash.vehicle_step_missing_department).toBe(
"Vent på, at nærmeste afdeling er indlæst, før du fortsætter."
+10 -5
View File
@@ -78,16 +78,21 @@ describe("SelfServeLaneStep", () => {
expect(wrapper.text()).toContain("Tilgængelig");
});
it("keeps machine wash disabled without rendering extra guidance", () => {
it("shows machine unavailable guidance for the selected lane", () => {
const wrapper = mountLaneStep({
isMachineAvailable: () => false,
});
const machineButton = wrapper.get('[data-testid="self-serve-wash-type-machine"]');
const washTypeGroup = wrapper.get('[data-testid="self-serve-wash-type-group"]');
const washTypeOptions = wrapper.get('[data-testid="self-serve-wash-type-options"]');
const guidance = wrapper.get('[data-testid="self-serve-machine-unavailable-guidance"]');
expect(machineButton.attributes("disabled")).toBeDefined();
expect(machineButton.attributes("title")).toBe("self_wash.unavailable");
expect(machineButton.text()).toContain("self_wash.unavailable");
expect(wrapper.find('[data-testid="self-serve-machine-unavailable-guidance"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain("self_wash.machine_unavailable_for_lane");
expect(machineButton.attributes("title")).toBe("self_wash.machine_unavailable_for_lane");
expect(guidance.text()).toContain("self_wash.machine_unavailable_for_lane");
expect(washTypeOptions.find('[data-testid="self-serve-machine-unavailable-guidance"]').exists()).toBe(false);
expect(Array.from(washTypeGroup.element.children).indexOf(washTypeOptions.element)).toBeLessThan(
Array.from(washTypeGroup.element.children).indexOf(guidance.element)
);
});
});
+3 -3
View File
@@ -207,7 +207,7 @@ describe("useWashSessionActions property gate commands", () => {
command: "START",
customer_number: 12345679,
license_plate: "AB12345",
defer_relay_side_effects: false,
defer_relay_side_effects: true,
});
consoleErrorSpy.mockRestore();
@@ -286,7 +286,7 @@ describe("useWashSessionActions property gate commands", () => {
command: "START",
customer_number: 12345679,
license_plate: "AB12345",
defer_relay_side_effects: false,
defer_relay_side_effects: true,
});
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
lane_id: 7,
@@ -346,7 +346,7 @@ describe("useWashSessionActions property gate commands", () => {
command: "START",
customer_number: 12345679,
license_plate: "AB12345",
defer_relay_side_effects: false,
defer_relay_side_effects: true,
});
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
lane_id: 7,
-9
View File
@@ -10,15 +10,6 @@ export default defineConfig({
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler",
quietDeps: true,
silenceDeprecations: ["import", "global-builtin", "legacy-js-api", "if-function"],
},
},
},
test: {
environment: "node",
include: ["tests/unit/**/*.spec.js"],