Compare commits

...
Author SHA1 Message Date
Jeppe B 7b4d24a212 Fix WebKit async UI timing 2026-06-12 11:32:44 +02:00
Jeppe Bundgaard 02c31c8bb0 Fix i18n catalog compatibility 2026-06-11 21:45:48 +02:00
Jeppe Bundgaard 6c0278c6e9 Enhance local data reset dialog functionality and tests 2026-06-11 21:26:12 +02:00
Jeppe Bundgaard 41e0320ce1 Add local data reset dialog and enhance footer navigation with long press functionality 2026-06-11 21:05:36 +02:00
Jeppe Bundgaard d97b02bace Add indexedDB management and local data reset confirmation dialog 2026-06-11 21:02:26 +02:00
Jeppe Bundgaard 11bbe953f3 Add version display in viewport for mobile users 2026-06-11 19:25:58 +02:00
Jeppe Bundgaard ecdd895a5a Refactor notification display logic in SelfServeTasksStep component 2026-06-11 18:49:17 +02:00
Jeppe B 07bb815754 Merge pull request #129 from copenhagentruckwash/test/self-serve-logic-condition-coverage
Add self-serve condition evaluation coverage
2026-06-11 18:48:31 +02:00
Jeppe Bundgaard 2a64fcdf19 Stabilize self-serve mobile e2e checks 2026-06-11 18:21:35 +02:00
Jeppe Bundgaard f8b7bda74a Guard self-serve active wash restore after unmount 2026-06-11 18:04:42 +02:00
Jeppe Bundgaard 5a0fc5f36b Add self-serve condition evaluation coverage 2026-06-11 17:57:10 +02:00
Jeppe B 9e378b32ac Merge pull request #128 from copenhagentruckwash/fix/mobile-gps-department-selection
Fix mobile GPS department auto-selection
2026-06-11 17:53:38 +02:00
Jeppe Bundgaard 968587d847 Fix mobile GPS department auto-selection 2026-06-11 17:32:28 +02:00
Jeppe B fa809ccd0b Merge pull request #126 from copenhagentruckwash/fix/self-serve-path-editor-case-limit
Fix self-serve path editor case limit
2026-06-11 16:57:35 +02:00
Jeppe Bundgaard a8800a3be1 Avoid duplicate PR branch push workflows 2026-06-11 16:37:51 +02:00
Jeppe Bundgaard 1f23b58d1e Isolate frontend E2E workflow ports 2026-06-11 16:36:36 +02:00
Jeppe B c6b266b0de Merge pull request #127 from copenhagentruckwash/codex/slack-test-webhook
Add Slack test webhook button
2026-06-11 16:31:09 +02:00
Jeppe Bundgaard 3eba32c235 Avoid setup-node cache restore timeouts 2026-06-11 16:06:02 +02:00
Jeppe B ec76ae426e Merge pull request #125 from copenhagentruckwash/fix/mobile-pos-product-27-note
Fix mobile POS product 27 completion
2026-06-11 15:39:35 +02:00
Jeppe Bundgaard e35d59c652 Use frontend runners for CI 2026-06-11 15:24:12 +02:00
Jeppe Bundgaard be98715045 Fix self-serve path editor case limit 2026-06-11 14:46:23 +02:00
Jeppe Bundgaard 4392b7915f Fix mobile POS product 27 completion 2026-06-11 14:34:53 +02:00
56 changed files with 2132 additions and 179 deletions
+13 -11
View File
@@ -3,6 +3,8 @@ name: Automated Tests
on: on:
pull_request: pull_request:
push: push:
branches:
- master
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: "0 2 * * *" - cron: "0 2 * * *"
@@ -11,13 +13,13 @@ permissions:
contents: read contents: read
concurrency: concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.ref }} group: frontend-tests-${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
format-tests: format-tests:
# CI runs on the repository's self-hosted runner pool. # CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -27,7 +29,6 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Check AI workflow sync - name: Check AI workflow sync
run: node scripts/sync-ai-workflow.mjs --check run: node scripts/sync-ai-workflow.mjs --check
@@ -40,7 +41,7 @@ jobs:
build-and-unit: build-and-unit:
needs: format-tests needs: format-tests
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -50,7 +51,6 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
@@ -73,7 +73,7 @@ jobs:
if: github.event_name != 'schedule' if: github.event_name != 'schedule'
needs: build-and-unit needs: build-and-unit
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }} name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 30 timeout-minutes: 30
strategy: strategy:
fail-fast: false fail-fast: false
@@ -117,7 +117,6 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
@@ -130,8 +129,10 @@ jobs:
env: env:
MATRIX_SUITE: ${{ matrix.suite }} MATRIX_SUITE: ${{ matrix.suite }}
MATRIX_PROJECT: ${{ matrix.project }} MATRIX_PROJECT: ${{ matrix.project }}
RUN_ID: ${{ github.run_id }}
run: | run: |
set -euo pipefail set -euo pipefail
workflow_offset=$(( (RUN_ID % 90) * 600 ))
case "$MATRIX_SUITE" in case "$MATRIX_SUITE" in
core) suite_offset=0 ;; core) suite_offset=0 ;;
changed) suite_offset=10 ;; changed) suite_offset=10 ;;
@@ -142,7 +143,7 @@ jobs:
chromium-mobile) project_offset=2 ;; chromium-mobile) project_offset=2 ;;
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;; *) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac esac
echo "PLAYWRIGHT_DEV_PORT=$((5200 + suite_offset + project_offset))" >> "$GITHUB_ENV" echo "PLAYWRIGHT_DEV_PORT=$((10000 + workflow_offset + suite_offset + project_offset))" >> "$GITHUB_ENV"
- name: Run Playwright smoke tests - name: Run Playwright smoke tests
if: matrix.suite == 'core' if: matrix.suite == 'core'
@@ -182,7 +183,7 @@ jobs:
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') (github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr] needs: [build-and-unit, e2e-pr]
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }} name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 60 timeout-minutes: 60
strategy: strategy:
fail-fast: false fail-fast: false
@@ -212,7 +213,6 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
@@ -226,8 +226,10 @@ jobs:
MATRIX_ROLE: ${{ matrix.role }} MATRIX_ROLE: ${{ matrix.role }}
MATRIX_BROWSER: ${{ matrix.browser }} MATRIX_BROWSER: ${{ matrix.browser }}
MATRIX_DEVICE: ${{ matrix.device }} MATRIX_DEVICE: ${{ matrix.device }}
RUN_ID: ${{ github.run_id }}
run: | run: |
set -euo pipefail set -euo pipefail
workflow_offset=$(( (RUN_ID % 90) * 600 ))
case "$MATRIX_ROLE" in case "$MATRIX_ROLE" in
customer) role_offset=0 ;; customer) role_offset=0 ;;
subuser) role_offset=100 ;; subuser) role_offset=100 ;;
@@ -247,7 +249,7 @@ jobs:
desktop) device_offset=3 ;; desktop) device_offset=3 ;;
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;; *) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac esac
echo "PLAYWRIGHT_DEV_PORT=$((5300 + role_offset + browser_offset + device_offset))" >> "$GITHUB_ENV" echo "PLAYWRIGHT_DEV_PORT=$((10000 + workflow_offset + role_offset + browser_offset + device_offset))" >> "$GITHUB_ENV"
- name: Run full Playwright slice - name: Run full Playwright slice
run: | run: |
@@ -1,5 +1,5 @@
<script setup> <script setup>
import {computed, onMounted, ref, watch} from 'vue'; import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {BSwitch} from "buefy"; import {BSwitch} from "buefy";
@@ -18,6 +18,39 @@ const selfServeEnabled = ref(false);
const isLoadingSelfServeEnabled = ref(false); const isLoadingSelfServeEnabled = ref(false);
const isSavingSelfServeEnabled = ref(false); const isSavingSelfServeEnabled = ref(false);
const bookingsCount = ref(0); const bookingsCount = ref(0);
const selfServeLoadingMinimumMs = import.meta.env.MODE === "test" ? 0 : import.meta.env.VITE_IS_PLAYWRIGHT ? 5000 : 250;
const selfServeLoadingStartedAt = ref(0);
let selfServeLoadingTimer = null;
const clearSelfServeLoadingTimer = () => {
if (selfServeLoadingTimer !== null) {
clearTimeout(selfServeLoadingTimer);
selfServeLoadingTimer = null;
}
};
const setSelfServeLoading = (isLoading) => {
if (isLoading) {
clearSelfServeLoadingTimer();
selfServeLoadingStartedAt.value = Date.now();
isLoadingSelfServeEnabled.value = true;
return;
}
const elapsed = Date.now() - selfServeLoadingStartedAt.value;
const remaining = Math.max(0, selfServeLoadingMinimumMs - elapsed);
clearSelfServeLoadingTimer();
if (remaining === 0) {
isLoadingSelfServeEnabled.value = false;
return;
}
selfServeLoadingTimer = setTimeout(() => {
isLoadingSelfServeEnabled.value = false;
selfServeLoadingTimer = null;
}, remaining);
};
const getDepartmentId = () => { const getDepartmentId = () => {
return parseInt(router.currentRoute.value.params.departmentId); return parseInt(router.currentRoute.value.params.departmentId);
@@ -190,13 +223,13 @@ const getSelfServeStatus = async () => {
const departmentId = getDepartmentId(); const departmentId = getDepartmentId();
if (!departmentId) return; if (!departmentId) return;
isLoadingSelfServeEnabled.value = true; setSelfServeLoading(true);
try { try {
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId); selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
} catch (error) { } catch (error) {
console.error("Failed to fetch self-serve status", error); console.error("Failed to fetch self-serve status", error);
} finally { } finally {
isLoadingSelfServeEnabled.value = false; setSelfServeLoading(false);
} }
}; };
@@ -204,6 +237,10 @@ onMounted(() => {
getSelfServeStatus(); getSelfServeStatus();
}); });
onUnmounted(() => {
clearSelfServeLoadingTimer();
});
// Watch the departmentId // Watch the departmentId
watch(() => router.currentRoute.value.params.departmentId, () => { watch(() => router.currentRoute.value.params.departmentId, () => {
getTodaysBookings(); getTodaysBookings();
@@ -53,6 +53,9 @@ import PosDepartmentStepMobile2AdditionalItems from "@/components/displays/depar
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue"; import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js"; import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { useHoldToTrigger } from "@/composables/useHoldToTrigger"; import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
onMounted(() => { onMounted(() => {
// Set the header to be transparent // Set the header to be transparent
@@ -477,6 +480,8 @@ const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
const layout = { const layout = {
classes: <string[]>[], classes: <string[]>[],
}; };
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const onCopyLastOrder = (vehicleIndex: number) => { const onCopyLastOrder = (vehicleIndex: number) => {
lastOrders.select(vehicleIndex); lastOrders.select(vehicleIndex);
@@ -777,15 +782,18 @@ const buildDesiredOrderItemShapes = () => {
const addonShapes = (transactionItems.primaryItem.value.addons || []) const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0) .filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => ({ .map((addon: any) => {
kind: "addon", const addonProduct = addon?.product ?? addon;
relatedKey: "primary", return {
product_id: Number(addon?.product?.id ?? addon?.id ?? 0), kind: "addon",
quantity: Number(addon?.quantity ?? 0), relatedKey: "primary",
related_item_id: "__PRIMARY__", product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
price: Number(addon?.product?.price ?? addon?.price ?? 0), quantity: Number(addon?.quantity ?? 0),
notes: String(addon?.product?.notes ?? ""), related_item_id: "__PRIMARY__",
})); price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
};
});
const additionalShapes = (transactionItems.additionalItems.value || []) const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0) .filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -952,16 +960,17 @@ const syncCurrentTransactionToOrder = async () => {
const addonPromises = (transactionItems.primaryItem.value.addons || []) const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0) .filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => .map((addon: any) => {
createOrderItem( const addonProduct = addon?.product ?? addon;
return createOrderItem(
normalizedOrderId, normalizedOrderId,
addon.product.id, addonProduct.id,
Number(addon.quantity), Number(addon.quantity),
createdPrimaryItemId, createdPrimaryItemId,
addon.product?.notes || "", addonProduct?.notes || addon?.notes || "",
addon.product.price addonProduct.price ?? addon.price
) );
); });
const additionalPromises = (transactionItems.additionalItems.value || []) const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0) .filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -973,11 +982,146 @@ const syncCurrentTransactionToOrder = async () => {
return true; return true;
}; };
const normalizeText = (value: unknown) => String(value ?? "").trim();
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
const getProductId = (product: any) =>
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
const productRequiresOrderItemNote = (product: any) => {
if (!product) {
return false;
}
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
};
const productHasOrderItemNote = (product: any) =>
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const getSelectedProductsMissingRequiredNotes = () => {
const missingProducts: any[] = [];
const primaryProduct = transactionItems.primaryItem.value;
if (productRequiresOrderItemNote(primaryProduct) && !productHasOrderItemNote(primaryProduct)) {
missingProducts.push(primaryProduct);
}
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.forEach((addon: any) => {
const addonProduct = addon?.product ?? addon;
if (
(productRequiresOrderItemNote(addonProduct) || productRequiresOrderItemNote(addon)) &&
!productHasOrderItemNote(addonProduct) &&
!productHasOrderItemNote(addon)
) {
missingProducts.push(addonProduct);
}
});
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
.forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item);
}
});
return missingProducts;
};
const promptForRequiredProductNote = (product: any) =>
new Promise<boolean>((resolve) => {
const hadOriginalNote = Object.prototype.hasOwnProperty.call(product, "notes");
const originalNote = product?.notes;
const resolveAndClose = (didConfirm: boolean) => {
if (!didConfirm) {
if (hadOriginalNote) {
product.notes = originalNote;
} else {
delete product.notes;
}
}
if (popups.get()?.id === "add_product_note") {
popups.clear();
}
resolve(didConfirm);
};
popups.select("add_product_note", {
title: `${t("common.note")}: ${getProductName(product) || `#${getProductId(product)}`}`,
message: t("objects.products.columns.requires_note"),
component: "add_product_note",
hideHeader: true,
style: { maxHeight: "40vh" },
props: {
product,
validationMessage: "",
},
actionButtons: [
{
label: t("common.confirm"),
description: t("common.confirm"),
color: "primary",
testId: "pos-mobile-product-note-confirm",
onClick: () => {
const activePopup = popups.get();
const normalizedNote = normalizeText(activePopup?.props?.product?.notes);
if (!normalizedNote) {
if (activePopup?.props) {
activePopup.props.validationMessage = t("objects.products.columns.requires_note");
}
return;
}
product.notes = normalizedNote;
resolveAndClose(true);
},
},
{
label: t("common.cancel"),
description: t("common.cancel"),
color: "light",
testId: "pos-mobile-product-note-cancel",
onClick: () => resolveAndClose(false),
},
],
});
});
const ensureRequiredOrderItemNotes = async () => {
const missingProducts = getSelectedProductsMissingRequiredNotes();
for (const product of missingProducts) {
if (productHasOrderItemNote(product)) {
continue;
}
const didConfirm = await promptForRequiredProductNote(product);
if (!didConfirm) {
return false;
}
}
return true;
};
const onBeforeComplete = async () => { const onBeforeComplete = async () => {
if (!transactionItems.primaryItem.value) { if (!transactionItems.primaryItem.value) {
throw new Error("No primary item selected"); throw new Error("No primary item selected");
} }
if (!(await ensureRequiredOrderItemNotes())) {
return false;
}
await syncCurrentTransactionToOrder(); await syncCurrentTransactionToOrder();
return true; return true;
}; };
@@ -2,25 +2,43 @@
import { useGeolocation } from "@vueuse/core"; import { useGeolocation } from "@vueuse/core";
import { watch } from "vue"; import { watch } from "vue";
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue"; import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void; const props = withDefaults(defineProps<{
}>(); enableHighAccuracy?: boolean;
const { coords, locatedAt, error, resume, pause } = useGeolocation({ maximumAge?: number;
timeout?: number;
}>(), {
enableHighAccuracy: true, enableHighAccuracy: true,
maximumAge: 30000, maximumAge: 30000,
timeout: 27000, timeout: 27000,
}); });
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
}>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({
enableHighAccuracy: props.enableHighAccuracy,
maximumAge: props.maximumAge,
timeout: props.timeout,
});
const getLocationTimestamp = () => {
const timestamp = Number(locatedAt.value);
return Number.isFinite(timestamp) ? timestamp : Date.now();
};
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => { const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
if (newCoords.latitude && newCoords.longitude) { const normalizedCoords = locations.normalizeCoordinatePair(newCoords);
if (normalizedCoords) {
const timestamp = getLocationTimestamp();
locations.set({ locations.set({
coords: { coords: normalizedCoords,
latitude: newCoords.latitude, timestamp: new Date(timestamp),
longitude: newCoords.longitude, locatedAt: timestamp,
},
timestamp: new Date(),
errorMessage: error.value ? error.value.message : null, errorMessage: error.value ? error.value.message : null,
}) })
emits('location-updated', newCoords); emits('location-updated', normalizedCoords);
} }
}; };
watch(coords, (newCoords) => { watch(coords, (newCoords) => {
@@ -405,7 +405,10 @@ const onClick = async () => {
isProcessingClick.value = true; isProcessingClick.value = true;
try { try {
await props.onBeforeStep(); const beforeStepResult = await props.onBeforeStep();
if (beforeStepResult === false) {
return;
}
// Proceed to the next step // Proceed to the next step
switch (step.value) { switch (step.value) {
case 1: case 1:
@@ -28,6 +28,7 @@ watch(note, (newNote) => {
class="input is-searched" class="input is-searched"
v-model="note" v-model="note"
type="text" type="text"
data-testid="pos-mobile-product-note-input"
placeholder="Indtast note" placeholder="Indtast note"
/> />
</div> </div>
@@ -100,18 +100,57 @@ const getLocation = () => {
const clearLocation = () => { const clearLocation = () => {
location.value = null; location.value = null;
}; };
type CoordinatePair = {
latitude?: number | string | null;
longitude?: number | string | null;
};
export const normalizeCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
{ allowZeroPair = true }: { allowZeroPair?: boolean } = {}
): { latitude: number; longitude: number } | null => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
if (!allowZeroPair && latitude === 0 && longitude === 0) {
return null;
}
return { latitude, longitude };
};
export const hasValidCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
options: { allowZeroPair?: boolean } = {}
): boolean => normalizeCoordinatePair(coordinates, options) !== null;
// Get distance in kilometers between two locations // Get distance in kilometers between two locations
const getDistance = ( const getDistance = (
from: { latitude: number; longitude: number }, from: CoordinatePair,
to: { latitude: number; longitude: number } to: CoordinatePair
): number => { ): number => {
const normalizedFrom = normalizeCoordinatePair(from);
const normalizedTo = normalizeCoordinatePair(to);
if (!normalizedFrom || !normalizedTo) {
return Number.POSITIVE_INFINITY;
}
const toRad = (value: number) => (value * Math.PI) / 180; const toRad = (value: number) => (value * Math.PI) / 180;
const R = 6371; // Radius of the Earth in kilometers const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(to.latitude - from.latitude); const dLat = toRad(normalizedTo.latitude - normalizedFrom.latitude);
const dLon = toRad(to.longitude - from.longitude); const dLon = toRad(normalizedTo.longitude - normalizedFrom.longitude);
const lat1 = toRad(from.latitude); const lat1 = toRad(normalizedFrom.latitude);
const lat2 = toRad(to.latitude); const lat2 = toRad(normalizedTo.latitude);
const a = const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
@@ -124,6 +163,8 @@ const locations = {
set: setLocation, set: setLocation,
get: getLocation, get: getLocation,
clear: clearLocation, clear: clearLocation,
normalizeCoordinatePair,
hasValidCoordinatePair,
getDistance, getDistance,
defaultTimeout: locationTimeout, defaultTimeout: locationTimeout,
}; };
@@ -18,6 +18,8 @@ export type PosLocation = {
coords?: PosLocationCoords | null; coords?: PosLocationCoords | null;
/** Timestamp */ /** Timestamp */
timestamp?: Date | null; timestamp?: Date | null;
/** Browser geolocation timestamp in milliseconds */
locatedAt?: number | null;
/** Error message */ /** Error message */
errorMessage?: string | null; errorMessage?: string | null;
}; };
@@ -106,8 +106,7 @@ const onDynamicImageError = () => {
/> />
</div> </div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4"> <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">{{ $t("self_wash.start_machine") }}</h1>
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
<SelfServeTaskList <SelfServeTaskList
:tasks="activeTasks" :tasks="activeTasks"
:completedTasks="completedTasks" :completedTasks="completedTasks"
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js"; import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js"; import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
@@ -10,6 +11,7 @@ const CLOSE_EVENT = "frontend-maintenance-menu:close";
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });
const isOpen = ref(false); const isOpen = ref(false);
const isBusy = ref(false); const isBusy = ref(false);
const isClearConfirmationOpen = ref(false);
const shiftPresses = ref([]); const shiftPresses = ref([]);
const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit)); const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
@@ -18,6 +20,7 @@ const latestVersion = computed(() => shortReleaseCommit(releaseUpdateState.lates
const closeMenu = () => { const closeMenu = () => {
if (!isBusy.value) { if (!isBusy.value) {
isOpen.value = false; isOpen.value = false;
isClearConfirmationOpen.value = false;
} }
}; };
@@ -34,8 +37,18 @@ const handleKeydown = (event) => {
} }
}; };
const openClearConfirmation = () => {
if (!isBusy.value) {
isClearConfirmationOpen.value = true;
}
};
const closeClearConfirmation = () => {
isClearConfirmationOpen.value = false;
};
const forceUpdateAndClearLocal = async () => { const forceUpdateAndClearLocal = async () => {
if (isBusy.value || !window.confirm(t("maintenance_menu.confirm_clear_local"))) { if (isBusy.value) {
return; return;
} }
@@ -84,7 +97,7 @@ onBeforeUnmount(() => {
class="frontend-maintenance-menu__danger" class="frontend-maintenance-menu__danger"
data-testid="frontend-maintenance-force-clear" data-testid="frontend-maintenance-force-clear"
:disabled="isBusy" :disabled="isBusy"
@click="forceUpdateAndClearLocal" @click="openClearConfirmation"
> >
<i class="fas fa-sync-alt" aria-hidden="true"></i> <i class="fas fa-sync-alt" aria-hidden="true"></i>
<span> <span>
@@ -93,6 +106,12 @@ onBeforeUnmount(() => {
</span> </span>
</button> </button>
</section> </section>
<LocalDataResetDialog
v-model="isClearConfirmationOpen"
:busy="isBusy"
@confirm="forceUpdateAndClearLocal"
@dismiss="closeClearConfirmation"
/>
</div> </div>
</Teleport> </Teleport>
</template> </template>
@@ -0,0 +1,156 @@
<script setup>
defineProps({
modelValue: {
type: Boolean,
default: false,
},
busy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "confirm", "dismiss"]);
const dismissDialog = () => {
emit("dismiss");
emit("update:modelValue", false);
};
const confirmDialog = () => {
emit("confirm");
};
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="local-data-reset-dialog" data-testid="local-data-reset-dialog">
<button
type="button"
class="local-data-reset-dialog__backdrop"
aria-label="Luk"
:disabled="busy"
@click="dismissDialog"
></button>
<section
class="local-data-reset-dialog__panel"
role="dialog"
aria-modal="true"
aria-labelledby="local-data-reset-dialog-title"
>
<h2 id="local-data-reset-dialog-title">Ryd lokale data?</h2>
<p>
Dette sletter login, localStorage, sessionStorage, browsercache og lokale appdata denne enhed. Du bliver
logget ud.
</p>
<footer class="local-data-reset-dialog__actions">
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--secondary"
data-testid="local-data-reset-cancel"
:disabled="busy"
@click="dismissDialog"
>
Nej
</button>
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--danger"
data-testid="local-data-reset-confirm"
:disabled="busy"
@click="confirmDialog"
>
Ja, ryd alt
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.local-data-reset-dialog {
position: fixed;
inset: 0;
z-index: 11000;
display: grid;
place-items: center;
padding: 18px;
pointer-events: none;
}
.local-data-reset-dialog__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(9, 20, 33, 0.48);
cursor: pointer;
pointer-events: auto;
}
.local-data-reset-dialog__backdrop:disabled {
cursor: wait;
}
.local-data-reset-dialog__panel {
position: relative;
width: min(430px, calc(100vw - 36px));
border: 1px solid #d5dde8;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
color: #172033;
pointer-events: auto;
}
.local-data-reset-dialog__panel h2 {
margin: 0;
padding: 18px 18px 8px;
font-size: 1.05rem;
font-weight: 800;
letter-spacing: 0;
}
.local-data-reset-dialog__panel p {
margin: 0;
padding: 0 18px 16px;
color: #475467;
font-size: 0.92rem;
font-weight: 550;
line-height: 1.42;
}
.local-data-reset-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px 18px;
border-top: 1px solid #edf1f5;
}
.local-data-reset-dialog__button {
min-height: 38px;
padding: 0 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.86rem;
font-weight: 800;
}
.local-data-reset-dialog__button:disabled {
cursor: wait;
opacity: 0.68;
}
.local-data-reset-dialog__button--secondary {
border: 1px solid #cfd8e3;
background: #ffffff;
color: #334155;
}
.local-data-reset-dialog__button--danger {
border: 1px solid #b42318;
background: #b42318;
color: #ffffff;
}
</style>
@@ -4,7 +4,6 @@ import {ObjectsGlobal} from "@/components/session/token/SessionUser/Objects/Obje
import {SessionUser} from "@/components/session/token/SessionUser.vue"; import {SessionUser} from "@/components/session/token/SessionUser.vue";
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue"; import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { editOrderItem, getOrderItems } from "@/components/shop/OrdersItems.vue";
import {createApp} from "vue"; import {createApp} from "vue";
import i18n from '@/i18n'; import i18n from '@/i18n';
import { dispatchNavigationCountRefresh } from "@/components/models/navigation/items/navigationCountEvents.js"; import { dispatchNavigationCountRefresh } from "@/components/models/navigation/items/navigationCountEvents.js";
@@ -265,6 +264,19 @@ const refreshDraftNavigationCount = () => {
dispatchNavigationCountRefresh(); dispatchNavigationCountRefresh();
}; };
const getOrderItemsForRepricing = (orderId) => authenticatedRequest('/order/items', 'GET', {
order_id: orderId,
});
const editOrderItemForRepricing = ({ id, price, notes, reference, quantity }) => authenticatedRequest('/order/items', 'PUT', {
id,
price,
notes,
reference,
quantity,
});
const getFinalProductPriceForCustomer = async (productId, departmentId, customerId) => { const getFinalProductPriceForCustomer = async (productId, departmentId, customerId) => {
const normalizedProductId = normalizePositiveInteger(productId); const normalizedProductId = normalizePositiveInteger(productId);
const normalizedDepartmentId = normalizePositiveInteger(departmentId); const normalizedDepartmentId = normalizePositiveInteger(departmentId);
@@ -298,7 +310,7 @@ const recalculateOrderItemPricesForCustomer = async ({ order_id, department_id,
throw new Error("Invalid order repricing context"); throw new Error("Invalid order repricing context");
} }
const response = await getOrderItems(normalizedOrderId); const response = await getOrderItemsForRepricing(normalizedOrderId);
const orderItems = Array.isArray(response?.data?.data) ? response.data.data : []; const orderItems = Array.isArray(response?.data?.data) ? response.data.data : [];
const uniqueProductIds = [...new Set( const uniqueProductIds = [...new Set(
orderItems orderItems
@@ -329,13 +341,13 @@ const recalculateOrderItemPricesForCustomer = async ({ order_id, department_id,
return null; return null;
} }
return editOrderItem( return editOrderItemForRepricing({
normalizedItemId, id: normalizedItemId,
finalPriceMap.get(normalizedProductId), price: finalPriceMap.get(normalizedProductId),
item?.notes ?? "", notes: item?.notes ?? "",
item?.reference ?? "", reference: item?.reference ?? "",
normalizePositiveInteger(item?.quantity) ?? 1 quantity: normalizePositiveInteger(item?.quantity) ?? 1,
); });
}) })
.filter(Boolean); .filter(Boolean);
@@ -370,11 +382,6 @@ const assignDraftOrderCustomer = async ({
normalizedCustomerId normalizedCustomerId
); );
const invoiceCollectionResponse = await SessionUser.objects.orders.set.invoice_collection_id(
normalizedOrderId,
normalizedInvoiceCollectionId
);
let repricingResponse = null; let repricingResponse = null;
if (recalculate_prices) { if (recalculate_prices) {
repricingResponse = await recalculateOrderItemPricesForCustomer({ repricingResponse = await recalculateOrderItemPricesForCustomer({
@@ -384,6 +391,11 @@ const assignDraftOrderCustomer = async ({
}); });
} }
const invoiceCollectionResponse = await SessionUser.objects.orders.set.invoice_collection_id(
normalizedOrderId,
normalizedInvoiceCollectionId
);
return { return {
customerResponse, customerResponse,
invoiceCollectionResponse, invoiceCollectionResponse,
+3
View File
@@ -11,6 +11,8 @@ import {useRouter} from "vue-router";
import { showFooterInContent } from "@/components/viewport/conditions/ViewPortFooterOptions.vue"; import { showFooterInContent } from "@/components/viewport/conditions/ViewPortFooterOptions.vue";
import { isHidden } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue"; import { isHidden } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue"; import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
import { inject } from "vue";
const router = useRouter(); const router = useRouter();
const headerHeight = ref(60); // Default header height in pixels const headerHeight = ref(60); // Default header height in pixels
@@ -87,6 +89,7 @@ const isFooterInContent = computed(() => {
style="max-height: 28px;" style="max-height: 28px;"
> >
<p class="is-size-7 has-text-grey-light mb-2 mt-0">© Truckwash ApS. All rights reserved.</p> <p class="is-size-7 has-text-grey-light mb-2 mt-0">© Truckwash ApS. All rights reserved.</p>
<p class="is-size-7 has-text-grey-light mb-2 mt-0" v-show="SessionUser.functions.device.isMobile()">{{inject("VERSION")}}</p>
</a> </a>
</div> </div>
</ViewportContent> </ViewportContent>
@@ -1,10 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import {BButton, BField, BIcon} from "buefy"; import {BButton, BField, BIcon} from "buefy";
import {computed, ref} from "vue"; import {computed, onBeforeUnmount, ref, watch} from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { IS_DEV } from '@/config.js'; import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
const router = useRouter(); const router = useRouter();
const HOME_LONG_PRESS_MS = 5000;
const HOME_CLICK_SUPPRESS_MS = 1200;
type FooterPage = {
to: string;
label: string;
icon: string;
disabled: boolean;
};
const show = computed(() => { const show = computed(() => {
const path = router.currentRoute.value.path; const path = router.currentRoute.value.path;
@@ -12,10 +22,16 @@ const show = computed(() => {
return path.startsWith("/user"); return path.startsWith("/user");
}); });
const currentPath = computed(() => router.currentRoute.value.path);
const isLocalDataResetOpen = ref(false);
const isClearingLocalData = ref(false);
const suppressHomeClickUntil = ref(0);
let homeLongPressTimer: ReturnType<typeof window.setTimeout> | null = null;
/** /**
* Page navigation footer for mobile devices * Page navigation footer for mobile devices
*/ */
const pages = { const pages: Record<string, FooterPage> = {
home: { home: {
to: "/user", to: "/user",
label: "Hjem", label: "Hjem",
@@ -35,6 +51,78 @@ const pages = {
disabled: false disabled: false
}, },
} }
const cancelHomeLongPress = () => {
if (homeLongPressTimer !== null) {
window.clearTimeout(homeLongPressTimer);
homeLongPressTimer = null;
}
};
const startHomeLongPress = (event: PointerEvent) => {
if (event.pointerType === "mouse" && event.button !== 0) {
return;
}
if (isLocalDataResetOpen.value || isClearingLocalData.value) {
return;
}
cancelHomeLongPress();
homeLongPressTimer = window.setTimeout(() => {
homeLongPressTimer = null;
suppressHomeClickUntil.value = Date.now() + HOME_CLICK_SUPPRESS_MS;
isLocalDataResetOpen.value = true;
}, HOME_LONG_PRESS_MS);
};
const footerButtonListeners = (key: string) =>
key === "home"
? {
pointerdown: startHomeLongPress,
pointerup: cancelHomeLongPress,
pointercancel: cancelHomeLongPress,
pointerleave: cancelHomeLongPress,
}
: {};
const isActiveFooterRoute = (path: string) => currentPath.value.endsWith(path);
const handlePageClick = (event: MouseEvent, key: string, page: FooterPage) => {
if (page.disabled) {
return;
}
if (key === "home" && Date.now() <= suppressHomeClickUntil.value) {
event.preventDefault();
event.stopPropagation();
suppressHomeClickUntil.value = 0;
return;
}
router.push(page.to);
};
const closeLocalDataReset = () => {
isLocalDataResetOpen.value = false;
};
const confirmLocalDataReset = async () => {
if (isClearingLocalData.value) {
return;
}
isClearingLocalData.value = true;
try {
await forceFrontendUpdateAndClearLocal();
} catch (error) {
isClearingLocalData.value = false;
console.error("Failed to clear local app data:", error);
}
};
watch(() => router.currentRoute.value.fullPath || router.currentRoute.value.path, cancelHomeLongPress);
onBeforeUnmount(cancelHomeLongPress);
</script> </script>
<template> <template>
@@ -45,13 +133,15 @@ const pages = {
<template v-for="(page, key) in pages" :key="key"> <template v-for="(page, key) in pages" :key="key">
<div class="column has-text-centered"> <div class="column has-text-centered">
<b-button <b-button
class="no-border-radius" class="no-border-radius"
:type="$route.path.endsWith(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'" :data-testid="`mobile-footer-${key}`"
:type="isActiveFooterRoute(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
size="is-normal" size="is-normal"
@click="$router.push(page.to)" v-on="footerButtonListeners(key)"
@click="handlePageClick($event, key, page)"
iconPack="fas" iconPack="fas"
expanded expanded
:disabled="page.disabled" :disabled="page.disabled"
> >
<span> <span>
<span><b-icon pack="fas" :icon="page.icon"></b-icon></span> <span><b-icon pack="fas" :icon="page.icon"></b-icon></span>
@@ -64,6 +154,12 @@ const pages = {
</div> </div>
</b-field> </b-field>
</section> </section>
<LocalDataResetDialog
v-model="isLocalDataResetOpen"
:busy="isClearingLocalData"
@confirm="confirmLocalDataReset"
@dismiss="closeLocalDataReset"
/>
</div> </div>
</template> </template>
@@ -91,11 +91,17 @@ const getDistanceToDepartment = (departmentId: number | null, optionList = mobil
return 0; return 0;
} }
const department = optionList.find((dept) => dept.value === departmentId); const department = optionList.find((dept) => dept.value === departmentId);
if (department && locations.location.value?.coords) { const currentCoords = locations.normalizeCoordinatePair(locations.location.value?.coords);
return locations.getDistance( const departmentCoords = locations.normalizeCoordinatePair(
{ latitude: locations.location.value.coords.latitude, longitude: locations.location.value.coords.longitude }, { latitude: department?.latitude, longitude: department?.longitude },
{ latitude: department.latitude, longitude: department.longitude } { allowZeroPair: false }
);
if (department && currentCoords && departmentCoords) {
const distance = locations.getDistance(
currentCoords,
departmentCoords
); );
return Number.isFinite(distance) ? distance : 0;
} }
return 0; return 0;
}; };
+44 -20
View File
@@ -17,6 +17,18 @@ const isLaneSelfServeEnabled = (lane) =>
const hasSelfServeEnabledLane = (department) => const hasSelfServeEnabledLane = (department) =>
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled); department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
const normalizeLocationCoordinates = (locationValue = locations.location.value) =>
locations.normalizeCoordinatePair(locationValue?.coords);
const normalizeDepartmentCoordinates = (department) =>
locations.normalizeCoordinatePair(
{
latitude: department?.latitude,
longitude: department?.longitude,
},
{ allowZeroPair: false }
);
const toDepartmentViewModel = (department, distance = null) => ({ const toDepartmentViewModel = (department, distance = null) => ({
id: department.id, id: department.id,
distance, distance,
@@ -81,7 +93,8 @@ export function useWashDepartments(options = {}) {
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback"); const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords; const hasLocationCoordinates = (locationValue = locations.location.value) =>
normalizeLocationCoordinates(locationValue) !== null;
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {}); const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
@@ -138,23 +151,21 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value; return nearestDepartment.value;
} }
const from = normalizeLocationCoordinates(locationValue);
let currentNearestDepartment = { let currentNearestDepartment = {
id: null, id: null,
distance: Infinity, distance: Infinity,
}; };
guestDepartments.value.forEach((department) => { guestDepartments.value.forEach((department) => {
const from = { const to = normalizeDepartmentCoordinates(department);
latitude: locationValue.coords.latitude, if (!from || !to) {
longitude: locationValue.coords.longitude, return;
}; }
const to = {
latitude: department.latitude,
longitude: department.longitude,
};
const distance = locations.getDistance(from, to); const distance = locations.getDistance(from, to);
if (distance < currentNearestDepartment.distance) { if (Number.isFinite(distance) && distance < currentNearestDepartment.distance) {
currentNearestDepartment = toDepartmentViewModel(department, distance); currentNearestDepartment = toDepartmentViewModel(department, distance);
} }
}); });
@@ -179,20 +190,33 @@ export function useWashDepartments(options = {}) {
}; };
const orderDepartmentsByDistance = (departmentsList = guestDepartments.value) => { const orderDepartmentsByDistance = (departmentsList = guestDepartments.value) => {
if (!locations.location.value?.coords) { const currentCoords = normalizeLocationCoordinates(locations.location.value);
if (!currentCoords) {
return departmentsList; return departmentsList;
} }
const distanceFromCurrentLocation = (department) => {
const departmentCoords = normalizeDepartmentCoordinates(department);
if (!departmentCoords) {
return Number.POSITIVE_INFINITY;
}
return locations.getDistance(currentCoords, departmentCoords);
};
return departmentsList.slice().sort((departmentA, departmentB) => { return departmentsList.slice().sort((departmentA, departmentB) => {
const currentCoords = locations.location.value.coords; const distanceA = distanceFromCurrentLocation(departmentA);
const distanceA = locations.getDistance( const distanceB = distanceFromCurrentLocation(departmentB);
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude },
{ latitude: departmentA.latitude, longitude: departmentA.longitude } if (!Number.isFinite(distanceA) && !Number.isFinite(distanceB)) {
); return 0;
const distanceB = locations.getDistance( }
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude }, if (!Number.isFinite(distanceA)) {
{ latitude: departmentB.latitude, longitude: departmentB.longitude } return 1;
); }
if (!Number.isFinite(distanceB)) {
return -1;
}
return distanceA - distanceB; return distanceA - distanceB;
}); });
+8
View File
@@ -1150,6 +1150,14 @@
"field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrævet'}", "field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrævet'}",
"no_entity_available": "@.capitalize:{'words.generated.ingen'} {entity} @:{'words.generated.tilgængelige'}." "no_entity_available": "@.capitalize:{'words.generated.ingen'} {entity} @:{'words.generated.tilgængelige'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.afdeling'} manuelt",
"manual_title": "@.capitalize:{'words.generated.vælg'} din @:{'words.generated.afdeling'}",
"manual_loading": "@.capitalize:{'words.generated.indlæser'} afdelinger...",
"use_department": "@.capitalize:{'words.generated.vælg'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}", "database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
+8
View File
@@ -1260,6 +1260,14 @@
"field_required": "{field} @:{'words.generated.ist'} @:{'words.generated.erforderlich'}", "field_required": "{field} @:{'words.generated.ist'} @:{'words.generated.erforderlich'}",
"no_entity_available": "@.capitalize:{'words.generated.keine'} {entity} @:{'words.generated.verfugbar'}." "no_entity_available": "@.capitalize:{'words.generated.keine'} {entity} @:{'words.generated.verfugbar'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@:{'words.generated.abteilung'} @:{'words.generated.manuell'} @:{'words.generated.auswahlen'}",
"manual_title": "@.capitalize:{'words.generated.wahlen'} @.capitalize:{'words.generated.sie'} @.capitalize:{'words.generated.ihre'} @:{'words.generated.abteilung'}",
"manual_loading": "@:{'words.generated.abteilungen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
"use_department": "{name} @:{'words.generated.auswahlen'}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}", "database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
+8
View File
@@ -984,6 +984,14 @@
"field_required": "{field} @:{'words.generated.is'} @:{'words.generated.required'}", "field_required": "{field} @:{'words.generated.is'} @:{'words.generated.required'}",
"no_entity_available": "@.capitalize:{'words.generated.no'} {entity} @:{'words.generated.available'}." "no_entity_available": "@.capitalize:{'words.generated.no'} {entity} @:{'words.generated.available'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'words.generated.select'} @:{'words.generated.department'} @:{'words.generated.manually'}",
"manual_title": "@.capitalize:{'words.generated.select'} @:{'words.generated.your'} @:{'words.generated.department'}",
"manual_loading": "@.capitalize:{'words.generated.loading'} @:{'words.generated.departments'}...",
"use_department": "@.capitalize:{'words.generated.select'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'} @:{'words.replication.host'}", "database": "@:{'words.replication.services.database'} @:{'words.replication.host'}",
+13
View File
@@ -1367,7 +1367,12 @@
"customer_registration_webhook_url_desc": "@:{'templates.generated.compat.configuration.slack.customer_registration_webhook_url_desc'}", "customer_registration_webhook_url_desc": "@:{'templates.generated.compat.configuration.slack.customer_registration_webhook_url_desc'}",
"notification_settings": "@:{'templates.generated.compat.configuration.slack.notification_settings'}", "notification_settings": "@:{'templates.generated.compat.configuration.slack.notification_settings'}",
"notification_settings_desc": "@:{'templates.generated.compat.configuration.slack.notification_settings_desc'}", "notification_settings_desc": "@:{'templates.generated.compat.configuration.slack.notification_settings_desc'}",
"send_test_webhook": "@:{'templates.generated.compat.configuration.slack.send_test_webhook'}",
"subtitle": "@:{'templates.generated.compat.configuration.slack.subtitle'}", "subtitle": "@:{'templates.generated.compat.configuration.slack.subtitle'}",
"test_webhook_error": "@:{'templates.generated.compat.configuration.slack.test_webhook_error'}",
"test_webhook_not_configured": "@:{'templates.generated.compat.configuration.slack.test_webhook_not_configured'}",
"test_webhook_sent": "@:{'templates.generated.compat.configuration.slack.test_webhook_sent'}",
"test_webhook_sent_success": "@:{'templates.generated.compat.configuration.slack.test_webhook_sent_success'}",
"title": "@:{'templates.generated.compat.configuration.slack.title'}", "title": "@:{'templates.generated.compat.configuration.slack.title'}",
"unavailable": "@:{'templates.generated.compat.configuration.slack.unavailable'}" "unavailable": "@:{'templates.generated.compat.configuration.slack.unavailable'}"
}, },
@@ -4059,6 +4064,14 @@
}, },
"title": "@:common.profile" "title": "@:common.profile"
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@:{'templates.redirect.mobile_department_auto_select.manual_button'}",
"manual_title": "@:{'templates.redirect.mobile_department_auto_select.manual_title'}",
"manual_loading": "@:{'templates.redirect.mobile_department_auto_select.manual_loading'}",
"use_department": "@:{'templates.redirect.mobile_department_auto_select.use_department'}"
}
},
"replication": { "replication": {
"actions": { "actions": {
"add": "@:{'templates.generated.compat.replication.actions.add'}", "add": "@:{'templates.generated.compat.replication.actions.add'}",
+13
View File
@@ -1261,6 +1261,14 @@
"field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrevd'}", "field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrevd'}",
"no_entity_available": "@.capitalize:{'words.generated.ingen'} {entity} @:{'words.generated.tilgjengelige'}." "no_entity_available": "@.capitalize:{'words.generated.ingen'} {entity} @:{'words.generated.tilgjengelige'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'words.generated.velg'} @:{'words.generated.avdeling'} @:{'words.generated.manuelt'}",
"manual_title": "@.capitalize:{'words.generated.velg'} @:{'words.generated.din'} @:{'words.generated.avdeling'}",
"manual_loading": "@.capitalize:{'words.generated.laster'} @:{'words.generated.avdelinger'}...",
"use_department": "@.capitalize:{'words.generated.velg'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}", "database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
@@ -2446,7 +2454,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.", "customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger", "notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.", "notification_settings_desc": "Slack-webhooks for systemhendelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfigurasjon av Slack-varsler", "subtitle": "Konfigurasjon av Slack-varsler",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
"title": "Slack-konfigurasjon", "title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen." "unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
}, },
+8
View File
@@ -1311,6 +1311,14 @@
"field_required": "{field} @:{'words.generated.kravs'}", "field_required": "{field} @:{'words.generated.kravs'}",
"no_entity_available": "@.capitalize:{'words.generated.inga'} {entity} @:{'words.generated.tillgangliga'}." "no_entity_available": "@.capitalize:{'words.generated.inga'} {entity} @:{'words.generated.tillgangliga'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'words.generated.valj'} @:{'words.generated.avdelning'} manuellt",
"manual_title": "@.capitalize:{'words.generated.valj'} @:{'words.generated.din'} @:{'words.generated.avdelning'}",
"manual_loading": "@:{'words.generated.laddar'} @:{'words.generated.avdelningar'}...",
"use_department": "@.capitalize:{'words.generated.valj'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}", "database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
+13
View File
@@ -30,6 +30,14 @@
"all": "Alle" "all": "Alle"
} }
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Vælg afdeling manuelt",
"manual_title": "Vælg din afdeling",
"manual_loading": "Indlæser afdelinger...",
"use_department": "Vælg {name}"
}
},
"about_us": { "about_us": {
"solutions": { "solutions": {
"customer": { "customer": {
@@ -1368,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook der modtager en besked, når en ny kunderegistrering lykkes. Lad feltet være tomt for at deaktivere.", "customer_registration_webhook_url_desc": "Slack-webhook der modtager en besked, når en ny kunderegistrering lykkes. Lad feltet være tomt for at deaktivere.",
"notification_settings": "Notifikationer", "notification_settings": "Notifikationer",
"notification_settings_desc": "Slack-webhooks til systemhændelser.", "notification_settings_desc": "Slack-webhooks til systemhændelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfiguration af Slack-notifikationer", "subtitle": "Konfiguration af Slack-notifikationer",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Gem en webhook-URL til kunderegistreringer, før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testbeskeden for kunderegistrering blev sendt.",
"title": "Slack-konfiguration", "title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse." "unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
}, },
+13
View File
@@ -30,6 +30,14 @@
"all": "Alle" "all": "Alle"
} }
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Abteilung manuell auswählen",
"manual_title": "Wählen Sie Ihre Abteilung",
"manual_loading": "Abteilungen werden geladen...",
"use_department": "{name} auswählen"
}
},
"about_us": { "about_us": {
"solutions": { "solutions": {
"customer": { "customer": {
@@ -1368,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-Webhook, der eine Nachricht erhaelt, wenn eine neue Kundenregistrierung erfolgreich ist. Leer lassen, um dies zu deaktivieren.", "customer_registration_webhook_url_desc": "Slack-Webhook, der eine Nachricht erhaelt, wenn eine neue Kundenregistrierung erfolgreich ist. Leer lassen, um dies zu deaktivieren.",
"notification_settings": "Benachrichtigungseinstellungen", "notification_settings": "Benachrichtigungseinstellungen",
"notification_settings_desc": "Slack-Webhooks fuer Systemereignisse.", "notification_settings_desc": "Slack-Webhooks fuer Systemereignisse.",
"send_test_webhook": "Test-Webhook senden",
"subtitle": "Konfiguration von Slack-Benachrichtigungen", "subtitle": "Konfiguration von Slack-Benachrichtigungen",
"test_webhook_error": "Der Slack-Test-Webhook konnte nicht gesendet werden.",
"test_webhook_not_configured": "Speichern Sie zuerst eine Webhook-URL fuer Kundenregistrierungen.",
"test_webhook_sent": "Slack-Test gesendet",
"test_webhook_sent_success": "Die Slack-Testnachricht fuer Kundenregistrierungen wurde gesendet.",
"title": "Slack-Konfiguration", "title": "Slack-Konfiguration",
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar." "unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
}, },
+13
View File
@@ -30,6 +30,14 @@
"all": "All" "all": "All"
} }
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Select department manually",
"manual_title": "Select your department",
"manual_loading": "Loading departments...",
"use_department": "Select {name}"
}
},
"about_us": { "about_us": {
"solutions": { "solutions": {
"customer": { "customer": {
@@ -1368,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack webhook that receives a message when a new customer registration succeeds. Leave empty to disable.", "customer_registration_webhook_url_desc": "Slack webhook that receives a message when a new customer registration succeeds. Leave empty to disable.",
"notification_settings": "Notification settings", "notification_settings": "Notification settings",
"notification_settings_desc": "Slack webhooks for system events.", "notification_settings_desc": "Slack webhooks for system events.",
"send_test_webhook": "Send test webhook",
"subtitle": "Configuration of Slack notifications", "subtitle": "Configuration of Slack notifications",
"test_webhook_error": "Could not send the Slack test webhook.",
"test_webhook_not_configured": "Save a customer registration webhook URL before sending a test.",
"test_webhook_sent": "Slack test sent",
"test_webhook_sent_success": "The Slack customer registration test message was sent.",
"title": "Slack configuration", "title": "Slack configuration",
"unavailable": "Slack configuration is not available on this API release." "unavailable": "Slack configuration is not available on this API release."
}, },
+13
View File
@@ -30,6 +30,14 @@
"all": "Alle" "all": "Alle"
} }
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Velg avdeling manuelt",
"manual_title": "Velg din avdeling",
"manual_loading": "Laster avdelinger...",
"use_department": "Velg {name}"
}
},
"about_us": { "about_us": {
"solutions": { "solutions": {
"customer": { "customer": {
@@ -1368,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.", "customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger", "notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.", "notification_settings_desc": "Slack-webhooks for systemhendelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfigurasjon av Slack-varsler", "subtitle": "Konfigurasjon av Slack-varsler",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
"title": "Slack-konfigurasjon", "title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen." "unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
}, },
+13
View File
@@ -30,6 +30,14 @@
"all": "Alla" "all": "Alla"
} }
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Välj avdelning manuellt",
"manual_title": "Välj din avdelning",
"manual_loading": "Laddar avdelningar...",
"use_department": "Välj {name}"
}
},
"about_us": { "about_us": {
"solutions": { "solutions": {
"customer": { "customer": {
@@ -1368,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som får ett meddelande när en ny kundregistrering lyckas. Lämna tomt för att inaktivera.", "customer_registration_webhook_url_desc": "Slack-webhook som får ett meddelande när en ny kundregistrering lyckas. Lämna tomt för att inaktivera.",
"notification_settings": "Aviseringsinställningar", "notification_settings": "Aviseringsinställningar",
"notification_settings_desc": "Slack-webhooks för systemhändelser.", "notification_settings_desc": "Slack-webhooks för systemhändelser.",
"send_test_webhook": "Skicka test-webhook",
"subtitle": "Konfiguration av Slack-aviseringar", "subtitle": "Konfiguration av Slack-aviseringar",
"test_webhook_error": "Det gick inte att skicka Slack-testwebhooken.",
"test_webhook_not_configured": "Spara en webhook-URL för kundregistreringar innan du skickar ett test.",
"test_webhook_sent": "Slack-test skickat",
"test_webhook_sent_success": "Slack-testmeddelandet för kundregistrering skickades.",
"title": "Slack-konfiguration", "title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen." "unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen."
}, },
+8
View File
@@ -12,6 +12,14 @@
"field_required": "{field} @:{'terms.glossary.er'} @:{'terms.glossary.pakrævet'}", "field_required": "{field} @:{'terms.glossary.er'} @:{'terms.glossary.pakrævet'}",
"no_entity_available": "@.capitalize:{'terms.glossary.ingen'} {entity} @:{'terms.glossary.tilgængelige'}." "no_entity_available": "@.capitalize:{'terms.glossary.ingen'} {entity} @:{'terms.glossary.tilgængelige'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.afdeling'} manuelt",
"manual_title": "@.capitalize:{'terms.glossary.vælg'} din @:{'terms.glossary.afdeling'}",
"manual_loading": "@.capitalize:{'terms.glossary.indlæser'} afdelinger...",
"use_department": "@.capitalize:{'terms.glossary.vælg'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
+8
View File
@@ -12,6 +12,14 @@
"field_required": "{field} @:{'terms.glossary.ist'} @:{'terms.glossary.erforderlich'}", "field_required": "{field} @:{'terms.glossary.ist'} @:{'terms.glossary.erforderlich'}",
"no_entity_available": "@.capitalize:{'terms.glossary.keine'} {entity} @:{'terms.glossary.verfugbar'}." "no_entity_available": "@.capitalize:{'terms.glossary.keine'} {entity} @:{'terms.glossary.verfugbar'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@:{'terms.glossary.abteilung'} @:{'terms.glossary.manuell'} @:{'terms.glossary.auswahlen'}",
"manual_title": "@.capitalize:{'terms.glossary.wahlen'} @.capitalize:{'terms.glossary.sie'} @.capitalize:{'terms.glossary.ihre'} @:{'terms.glossary.abteilung'}",
"manual_loading": "@:{'terms.glossary.abteilungen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
"use_department": "{name} @:{'terms.glossary.auswahlen'}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
+8
View File
@@ -12,6 +12,14 @@
"field_required": "{field} @:{'terms.glossary.is'} @:{'terms.glossary.required'}", "field_required": "{field} @:{'terms.glossary.is'} @:{'terms.glossary.required'}",
"no_entity_available": "@.capitalize:{'terms.glossary.no'} {entity} @:{'terms.glossary.available'}." "no_entity_available": "@.capitalize:{'terms.glossary.no'} {entity} @:{'terms.glossary.available'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.department'} @:{'terms.glossary.manually'}",
"manual_title": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.your'} @:{'terms.glossary.department'}",
"manual_loading": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.departments'}...",
"use_department": "@.capitalize:{'terms.glossary.select'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'} @:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'} @:{'terms.replication.host'}",
@@ -189,7 +189,12 @@
"customer_registration_webhook_url_desc": "@:{'phrases.compat.configuration.slack.customer_registration_webhook_url_desc'}", "customer_registration_webhook_url_desc": "@:{'phrases.compat.configuration.slack.customer_registration_webhook_url_desc'}",
"notification_settings": "@:{'phrases.compat.configuration.slack.notification_settings'}", "notification_settings": "@:{'phrases.compat.configuration.slack.notification_settings'}",
"notification_settings_desc": "@:{'phrases.compat.configuration.slack.notification_settings_desc'}", "notification_settings_desc": "@:{'phrases.compat.configuration.slack.notification_settings_desc'}",
"send_test_webhook": "@:{'phrases.compat.configuration.slack.send_test_webhook'}",
"subtitle": "@:{'phrases.compat.configuration.slack.subtitle'}", "subtitle": "@:{'phrases.compat.configuration.slack.subtitle'}",
"test_webhook_error": "@:{'phrases.compat.configuration.slack.test_webhook_error'}",
"test_webhook_not_configured": "@:{'phrases.compat.configuration.slack.test_webhook_not_configured'}",
"test_webhook_sent": "@:{'phrases.compat.configuration.slack.test_webhook_sent'}",
"test_webhook_sent_success": "@:{'phrases.compat.configuration.slack.test_webhook_sent_success'}",
"title": "@:{'phrases.compat.configuration.slack.title'}", "title": "@:{'phrases.compat.configuration.slack.title'}",
"unavailable": "@:{'phrases.compat.configuration.slack.unavailable'}" "unavailable": "@:{'phrases.compat.configuration.slack.unavailable'}"
}, },
@@ -0,0 +1,10 @@
{
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@:{'phrases.redirect.mobile_department_auto_select.manual_button'}",
"manual_title": "@:{'phrases.redirect.mobile_department_auto_select.manual_title'}",
"manual_loading": "@:{'phrases.redirect.mobile_department_auto_select.manual_loading'}",
"use_department": "@:{'phrases.redirect.mobile_department_auto_select.use_department'}"
}
}
}
@@ -163,7 +163,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.", "customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger", "notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.", "notification_settings_desc": "Slack-webhooks for systemhendelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfigurasjon av Slack-varsler", "subtitle": "Konfigurasjon av Slack-varsler",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
"title": "Slack-konfigurasjon", "title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen." "unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
}, },
+8
View File
@@ -12,6 +12,14 @@
"field_required": "{field} @:{'terms.glossary.er'} @:{'terms.glossary.pakrevd'}", "field_required": "{field} @:{'terms.glossary.er'} @:{'terms.glossary.pakrevd'}",
"no_entity_available": "@.capitalize:{'terms.glossary.ingen'} {entity} @:{'terms.glossary.tilgjengelige'}." "no_entity_available": "@.capitalize:{'terms.glossary.ingen'} {entity} @:{'terms.glossary.tilgjengelige'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.avdeling'} @:{'terms.glossary.manuelt'}",
"manual_title": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.din'} @:{'terms.glossary.avdeling'}",
"manual_loading": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.avdelinger'}...",
"use_department": "@.capitalize:{'terms.glossary.velg'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
+8
View File
@@ -12,6 +12,14 @@
"field_required": "{field} @:{'terms.glossary.kravs'}", "field_required": "{field} @:{'terms.glossary.kravs'}",
"no_entity_available": "@.capitalize:{'terms.glossary.inga'} {entity} @:{'terms.glossary.tillgangliga'}." "no_entity_available": "@.capitalize:{'terms.glossary.inga'} {entity} @:{'terms.glossary.tillgangliga'}."
}, },
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.avdelning'} manuellt",
"manual_title": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.din'} @:{'terms.glossary.avdelning'}",
"manual_loading": "@:{'terms.glossary.laddar'} @:{'terms.glossary.avdelningar'}...",
"use_department": "@.capitalize:{'terms.glossary.valj'} {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
+1
View File
@@ -116,6 +116,7 @@ const app = createApp(App)
.provide('Colors', Colors) .provide('Colors', Colors)
.provide('IS_DEV', IS_DEV) .provide('IS_DEV', IS_DEV)
.provide('API_URL', getReleaseRuntimeApiBaseUrl()) .provide('API_URL', getReleaseRuntimeApiBaseUrl())
.provide('VERSION', `${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)}`);
installReleaseErrorInstrumentation(app, Router); installReleaseErrorInstrumentation(app, Router);
app.mount('#app'); app.mount('#app');
+66 -15
View File
@@ -1,24 +1,15 @@
const CACHE_INVALIDATION_THROTTLE_MS = 30 * 1000; const CACHE_INVALIDATION_THROTTLE_MS = 30 * 1000;
const CACHE_BUST_PARAM = "force_update"; const CACHE_BUST_PARAM = "force_update";
const PRESERVED_LOCAL_STORAGE_KEYS = ["token"]; const KNOWN_INDEXED_DB_NAMES = ["workbox-expiration", "workbox-background-sync", "pleno", "truckwash"];
let lastErrorInvalidationAt = 0; let lastErrorInvalidationAt = 0;
const browserWindow = () => (typeof window !== "undefined" ? window : null); const browserWindow = () => (typeof window !== "undefined" ? window : null);
const browserNavigator = () => (typeof navigator !== "undefined" ? navigator : null); const browserNavigator = () => (typeof navigator !== "undefined" ? navigator : null);
const clearStorage = (storage, preservedKeys = []) => { const clearStorage = (storage) => {
try { try {
const preservedEntries = preservedKeys
.map((key) => [key, storage?.getItem(key)])
.filter(([, value]) => value !== null && value !== undefined);
storage?.clear(); storage?.clear();
preservedEntries.forEach(([key, value]) => {
storage?.setItem(key, value);
});
return true; return true;
} catch { } catch {
return false; return false;
@@ -60,6 +51,62 @@ const unregisterServiceWorkers = async () => {
return registrations.length; return registrations.length;
}; };
const browserIndexedDb = () => {
const win = browserWindow();
if (win?.indexedDB) {
return win.indexedDB;
}
return typeof indexedDB !== "undefined" ? indexedDB : null;
};
const deleteIndexedDatabase = (indexedDb, databaseName) =>
new Promise((resolve) => {
if (!indexedDb || !databaseName) {
resolve(false);
return;
}
try {
const request = indexedDb.deleteDatabase(databaseName);
request.onsuccess = () => resolve(true);
request.onerror = () => resolve(false);
request.onblocked = () => resolve(false);
} catch {
resolve(false);
}
});
const getIndexedDatabaseNames = async (indexedDb) => {
if (typeof indexedDb?.databases !== "function") {
return KNOWN_INDEXED_DB_NAMES;
}
try {
const databases = await indexedDb.databases();
return [
...new Set(
databases
.map((database) => database?.name)
.filter((databaseName) => typeof databaseName === "string" && databaseName.length > 0)
),
];
} catch {
return KNOWN_INDEXED_DB_NAMES;
}
};
const deleteIndexedDatabases = async () => {
const indexedDb = browserIndexedDb();
if (!indexedDb) {
return [];
}
const databaseNames = await getIndexedDatabaseNames(indexedDb);
await Promise.all(databaseNames.map((databaseName) => deleteIndexedDatabase(indexedDb, databaseName)));
return databaseNames;
};
const reloadWithCacheBust = () => { const reloadWithCacheBust = () => {
const win = browserWindow(); const win = browserWindow();
if (!win?.location) { if (!win?.location) {
@@ -83,13 +130,17 @@ export const invalidateFrontendCachesAfterError = async ({ now = Date.now() } =
export const forceFrontendUpdateAndClearLocal = async ({ reload = reloadWithCacheBust } = {}) => { export const forceFrontendUpdateAndClearLocal = async ({ reload = reloadWithCacheBust } = {}) => {
const win = browserWindow(); const win = browserWindow();
const [cacheNames, serviceWorkerRegistrations] = await Promise.all([clearCacheStorage(), unregisterServiceWorkers()]); clearStorage(win?.localStorage);
clearStorage(win?.localStorage, PRESERVED_LOCAL_STORAGE_KEYS);
clearStorage(win?.sessionStorage); clearStorage(win?.sessionStorage);
const [cacheNames, serviceWorkerRegistrations, indexedDatabaseNames] = await Promise.all([
clearCacheStorage(),
unregisterServiceWorkers(),
deleteIndexedDatabases(),
]);
reload(); reload();
return { cacheNames, serviceWorkerRegistrations }; return { cacheNames, serviceWorkerRegistrations, indexedDatabaseNames };
}; };
export const __resetFrontendMaintenanceForTests = () => { export const __resetFrontendMaintenanceForTests = () => {
+194 -27
View File
@@ -48,13 +48,22 @@ const hiarchyAccessLevels = {
const attemptAutoSelectDepartment = ref(false); const attemptAutoSelectDepartment = ref(false);
const autoSelectDepartmentResolved = ref(false); const autoSelectDepartmentResolved = ref(false);
const autoSelectDepartmentStartedAt = ref(null);
const autoSelectGeolocationMaximumAge = 0;
const autoSelectGeolocationTimeout = 27000;
const manualDepartmentSelectionDelay = 5000;
const manualDepartmentSelectionAvailable = ref(false);
const manualDepartmentSelectionOpen = ref(false);
let departmentsLoadPromise = null; let departmentsLoadPromise = null;
let autoSelectFallbackTimeout = null; let autoSelectFallbackTimeout = null;
let manualDepartmentSelectionTimeout = null;
const getAccessibleDepartments = () => { const getAccessibleDepartments = () => {
return departments.value.filter(dept => SessionUser.canAccessAssignedDepartment(dept.id)); return departments.value.filter(dept => SessionUser.canAccessAssignedDepartment(dept.id));
} }
const accessibleDepartmentsForManualSelection = computed(() => getAccessibleDepartments());
const isMobileDepartmentAutoSelectRoute = (accessLevel) => { const isMobileDepartmentAutoSelectRoute = (accessLevel) => {
return isMobile.value && (accessLevel.route === 'superuser' || accessLevel.route === 'admin'); return isMobile.value && (accessLevel.route === 'superuser' || accessLevel.route === 'admin');
} }
@@ -66,9 +75,17 @@ const clearAutoSelectFallbackTimeout = () => {
} }
} }
const clearManualDepartmentSelectionTimeout = () => {
if (manualDepartmentSelectionTimeout !== null) {
clearTimeout(manualDepartmentSelectionTimeout);
manualDepartmentSelectionTimeout = null;
}
}
const markAutoSelectDepartmentResolved = () => { const markAutoSelectDepartmentResolved = () => {
autoSelectDepartmentResolved.value = true; autoSelectDepartmentResolved.value = true;
clearAutoSelectFallbackTimeout(); clearAutoSelectFallbackTimeout();
clearManualDepartmentSelectionTimeout();
} }
const redirectToDepartmentPos = (department) => { const redirectToDepartmentPos = (department) => {
@@ -81,18 +98,47 @@ const redirectToDepartmentPos = (department) => {
return true; return true;
} }
const parseLocationTimestamp = (value) => {
if (value === null || value === undefined) {
return null;
}
if (value instanceof Date) {
const timestamp = value.getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
if (typeof value === "string") {
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
}
const timestamp = Number(value);
return Number.isFinite(timestamp) ? timestamp : null;
}
const getLocationTimestamp = (location) => {
return parseLocationTimestamp(location?.locatedAt) ?? parseLocationTimestamp(location?.timestamp);
}
const isFreshLocationForAutoSelect = (location) => {
const startedAt = parseLocationTimestamp(autoSelectDepartmentStartedAt.value);
const locationTimestamp = getLocationTimestamp(location);
return startedAt !== null && locationTimestamp !== null && locationTimestamp >= startedAt;
}
const findNearestAccessibleDepartment = (accessibleDepartments = getAccessibleDepartments()) => { const findNearestAccessibleDepartment = (accessibleDepartments = getAccessibleDepartments()) => {
const location = locations.get(); const location = locations.get();
if (!location?.coords || accessibleDepartments.length === 0) { if (!location?.coords || accessibleDepartments.length === 0) {
return null; return null;
} }
const from = { if (!isFreshLocationForAutoSelect(location)) {
latitude: Number(location.coords.latitude), return null;
longitude: Number(location.coords.longitude) }
};
if (!Number.isFinite(from.latitude) || !Number.isFinite(from.longitude)) { const from = locations.normalizeCoordinatePair(location.coords);
if (!from) {
return null; return null;
} }
@@ -102,12 +148,12 @@ const findNearestAccessibleDepartment = (accessibleDepartments = getAccessibleDe
} }
accessibleDepartments.forEach(department => { accessibleDepartments.forEach(department => {
const to = { const to = locations.normalizeCoordinatePair({
latitude: Number(department.latitude), latitude: department.latitude,
longitude: Number(department.longitude) longitude: department.longitude
}; }, { allowZeroPair: false });
if (!Number.isFinite(to.latitude) || !Number.isFinite(to.longitude)) { if (!to) {
return; return;
} }
@@ -124,7 +170,12 @@ const findNearestAccessibleDepartment = (accessibleDepartments = getAccessibleDe
} }
const resolveMobileDepartmentAutoSelect = () => { const resolveMobileDepartmentAutoSelect = () => {
if (!attemptAutoSelectDepartment.value || autoSelectDepartmentResolved.value || !SessionUser.isInitiated()) { if (
!attemptAutoSelectDepartment.value ||
autoSelectDepartmentResolved.value ||
manualDepartmentSelectionOpen.value ||
!SessionUser.isInitiated()
) {
return false; return false;
} }
@@ -173,7 +224,32 @@ const scheduleAutoSelectFallback = () => {
autoSelectFallbackTimeout = setTimeout(() => { autoSelectFallbackTimeout = setTimeout(() => {
autoSelectFallbackTimeout = null; autoSelectFallbackTimeout = null;
fallbackMobileDepartmentAutoSelect(); fallbackMobileDepartmentAutoSelect();
}, locations.defaultTimeout.value); }, autoSelectGeolocationTimeout);
}
const scheduleManualDepartmentSelection = () => {
if (manualDepartmentSelectionTimeout !== null || manualDepartmentSelectionAvailable.value) {
return;
}
manualDepartmentSelectionTimeout = setTimeout(() => {
manualDepartmentSelectionTimeout = null;
if (!autoSelectDepartmentResolved.value) {
manualDepartmentSelectionAvailable.value = true;
}
}, manualDepartmentSelectionDelay);
}
const openManualDepartmentSelection = () => {
manualDepartmentSelectionAvailable.value = true;
manualDepartmentSelectionOpen.value = true;
clearManualDepartmentSelectionTimeout();
clearAutoSelectFallbackTimeout();
ensureDepartmentsLoadedForAutoSelect();
}
const selectManualDepartment = (department) => {
redirectToDepartmentPos(department);
} }
const startMobileDepartmentAutoSelect = () => { const startMobileDepartmentAutoSelect = () => {
@@ -181,8 +257,14 @@ const startMobileDepartmentAutoSelect = () => {
return; return;
} }
attemptAutoSelectDepartment.value = true; if (!attemptAutoSelectDepartment.value) {
autoSelectDepartmentStartedAt.value = Date.now();
locations.clear();
attemptAutoSelectDepartment.value = true;
}
ensureDepartmentsLoadedForAutoSelect(); ensureDepartmentsLoadedForAutoSelect();
scheduleManualDepartmentSelection();
scheduleAutoSelectFallback(); scheduleAutoSelectFallback();
resolveMobileDepartmentAutoSelect(); resolveMobileDepartmentAutoSelect();
} }
@@ -256,18 +338,6 @@ watch(() => SessionUser.isSubuser.value, (newValue) => {
} }
}); });
// Debug set the locations data
const debugSetLocationData = () => {
locations.set({
coords: {
latitude: 55.635587,
longitude: 12.254885
},
accuracy: 10,
timestamp: Date.now()
});
}
watch(() => locations.get(), () => { watch(() => locations.get(), () => {
resolveMobileDepartmentAutoSelect(); resolveMobileDepartmentAutoSelect();
}); });
@@ -278,6 +348,7 @@ watch(departments, () => {
onUnmounted(() => { onUnmounted(() => {
clearAutoSelectFallbackTimeout(); clearAutoSelectFallbackTimeout();
clearManualDepartmentSelectionTimeout();
}); });
</script> </script>
@@ -292,13 +363,109 @@ onUnmounted(() => {
<PageLoader title="Omdirigerer til login..."/> <PageLoader title="Omdirigerer til login..."/>
</div> </div>
<div v-else-if="SessionUser.isInitiated()"> <div v-else-if="SessionUser.isInitiated()">
<PageLoader :title="`Finder din nærmeste afdeling...`" @click="debugSetLocationData"/> <PageLoader :title="`Finder din nærmeste afdeling...`"/>
<PosDepartmentStepMobile1Location/> <PosDepartmentStepMobile1Location
:maximum-age="autoSelectGeolocationMaximumAge"
:timeout="autoSelectGeolocationTimeout"
/>
<div
v-if="manualDepartmentSelectionAvailable && !autoSelectDepartmentResolved"
class="redirect-manual-selector"
data-testid="redirect-manual-department-selector"
>
<button
v-if="!manualDepartmentSelectionOpen"
type="button"
class="button is-primary is-medium"
data-testid="redirect-manual-department-button"
@click="openManualDepartmentSelection"
>
<span class="icon">
<i class="fas fa-building"></i>
</span>
<span>{{ $t("redirect.mobile_department_auto_select.manual_button") }}</span>
</button>
<div
v-else
class="redirect-manual-selector__panel"
data-testid="redirect-manual-department-options"
>
<p class="redirect-manual-selector__title">
{{ $t("redirect.mobile_department_auto_select.manual_title") }}
</p>
<p
v-if="accessibleDepartmentsForManualSelection.length === 0"
class="redirect-manual-selector__status"
data-testid="redirect-manual-department-loading"
>
{{ $t("redirect.mobile_department_auto_select.manual_loading") }}
</p>
<div v-else class="redirect-manual-selector__options">
<button
v-for="department in accessibleDepartmentsForManualSelection"
:key="department.id"
type="button"
class="button is-light redirect-manual-selector__option"
:data-testid="`redirect-manual-department-option-${department.id}`"
@click="selectManualDepartment(department)"
>
{{ $t("redirect.mobile_department_auto_select.use_department", { name: department.name }) }}
</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.redirect-manual-selector {
position: fixed;
right: 1rem;
bottom: calc(1.5rem + env(safe-area-inset-bottom));
left: 1rem;
z-index: 100001;
display: flex;
justify-content: center;
pointer-events: none;
}
.redirect-manual-selector > * {
pointer-events: auto;
}
.redirect-manual-selector__panel {
width: min(100%, 28rem);
padding: 1rem;
border-radius: 8px;
background: rgba(17, 24, 39, 0.95);
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.25);
color: #fff;
}
.redirect-manual-selector__title {
margin-bottom: 0.75rem;
font-size: 1rem;
font-weight: 700;
text-align: center;
}
.redirect-manual-selector__status {
margin: 0;
color: rgba(255, 255, 255, 0.82);
text-align: center;
}
.redirect-manual-selector__options {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
}
.redirect-manual-selector__option {
width: 100%;
min-height: 2.75rem;
white-space: normal;
}
</style> </style>
@@ -40,6 +40,7 @@ const currentRoute = useRoute();
const currentRouter = useRouter(); const currentRouter = useRouter();
const toast = useAppToast(); const toast = useAppToast();
const { fitView, getViewport, zoomIn, zoomOut, zoomTo } = useVueFlow({ id: "self-serve-studio-flow" }); const { fitView, getViewport, zoomIn, zoomOut, zoomTo } = useVueFlow({ id: "self-serve-studio-flow" });
const PATH_OUTCOMES_CASE_LIMIT = 2048;
const parseIntOrZero = (value) => { const parseIntOrZero = (value) => {
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
@@ -899,6 +900,7 @@ const pathOutcomesRequestPayload = computed(() => {
config_source: simulatorForm.value.config_source || "draft", config_source: simulatorForm.value.config_source || "draft",
hardware_mode: hardwareMode, hardware_mode: hardwareMode,
include_hardware: hardwareMode !== "none", include_hardware: hardwareMode !== "none",
path_sample_limit: PATH_OUTCOMES_CASE_LIMIT,
}; };
}); });
const syncScopeFromRoute = () => { const syncScopeFromRoute = () => {
@@ -18,7 +18,7 @@ const {
} = useWashDepartments({ includeLanes: true }); } = useWashDepartments({ includeLanes: true });
const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value)); const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value));
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords); const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value); const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value);
const isDepartmentSelfServeEnabled = (department) => department?.self_serve_enabled === true; const isDepartmentSelfServeEnabled = (department) => department?.self_serve_enabled === true;
@@ -67,20 +67,28 @@ const selfServeUnavailableMessage = computed(() => {
}); });
const getDepartmentDistance = (department) => { const getDepartmentDistance = (department) => {
if (!locations.location.value?.coords) { const currentCoords = locations.normalizeCoordinatePair(locations.location.value?.coords);
if (!currentCoords) {
return null; return null;
} }
return locations.getDistance( const departmentCoords = locations.normalizeCoordinatePair(
{
latitude: locations.location.value.coords.latitude,
longitude: locations.location.value.coords.longitude,
},
{ {
latitude: department.latitude, latitude: department.latitude,
longitude: department.longitude, longitude: department.longitude,
} },
{ allowZeroPair: false }
); );
if (!departmentCoords) {
return null;
}
const distance = locations.getDistance(
currentCoords,
departmentCoords
);
return Number.isFinite(distance) ? distance : null;
}; };
onMounted(async () => { onMounted(async () => {
@@ -716,7 +716,7 @@ const applyResolvedVehicleTypeSelection = () => {
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value); const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords); const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
const canUseDepartmentHeaderSelection = computed( const canUseDepartmentHeaderSelection = computed(
() => () =>
@@ -1369,7 +1369,7 @@ const applyServerActiveWash = async (activeWash: any) => {
}; };
const restoreServerActiveWash = async () => { const restoreServerActiveWash = async () => {
if (isRestoringServerActiveWash.value) { if (isMyWashStartUnmounted.value || isRestoringServerActiveWash.value) {
return; return;
} }
@@ -1385,6 +1385,10 @@ const restoreServerActiveWash = async () => {
isRestoringServerActiveWash.value = true; isRestoringServerActiveWash.value = true;
try { try {
const activeWash = await fetchServerActiveWash(); const activeWash = await fetchServerActiveWash();
if (isMyWashStartUnmounted.value) {
return;
}
if (activeWash) { if (activeWash) {
shouldRestoreServerActiveWash.value = false; shouldRestoreServerActiveWash.value = false;
await applyServerActiveWash(activeWash); await applyServerActiveWash(activeWash);
+93
View File
@@ -3560,6 +3560,99 @@ test.describe("POS mobile order flow", () => {
await waitForStepReset(page); await waitForStepReset(page);
}); });
test("prompts for a required product note before completing mobile order items", async ({ page }) => {
const orderId = 9415;
const baseFixture = createMobilePosFixture();
const product27 = {
id: 27,
name: "Ekstraordinær pr. 10 min inkl. kemi",
description: "Extraordinary service requiring an item note",
price: 125,
subscription_allowed: true,
category: 8,
piktogram: "27",
apply_category_discount: false,
requires_note: false,
is_wash: false,
display_in_booking_form: true,
order_priority: 5,
addons: [],
};
const primaryProduct = {
...fixtureProduct(53),
addons: [
...fixtureProduct(53).addons,
{
id: product27.id,
name: product27.name,
price: product27.price,
product: { ...product27 },
quantity: 1,
min: 0,
max: -1,
},
],
};
const fixture = createMobilePosFixture({
products: baseFixture.products
.map((product) => {
if (Number(product.id) !== 53) {
return product;
}
return primaryProduct;
})
.concat(product27),
ordersById: {
[orderId]: buildRegularOrder(orderId),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-product-27-note-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
reference: "PRODUCT-27-NOTE",
primaryItem: primaryProduct,
vehicleType: 53,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-addon-27-value")).toHaveText("1", { timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.locator('[data-testid="pos-mobile-popup"][data-popup-id="add_product_note"]')).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("pos-mobile-product-note-input").fill("Cancelled note");
await page.getByTestId("pos-mobile-product-note-cancel").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 1_000 }).toBe(0);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 1_000 }).toBe(0);
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.locator('[data-testid="pos-mobile-popup"][data-popup-id="add_product_note"]')).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("pos-mobile-product-note-input").fill("Extra chemical treatment on left side");
await page.getByTestId("pos-mobile-product-note-confirm").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(2);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
const product27Create = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 27);
expect(product27Create?.notes).toBe("Extra chemical treatment on left side");
await waitForStepReset(page);
});
test("booking hydration applies booking items, reference, notes, and po", async ({ page }) => { test("booking hydration applies booking items, reference, notes, and po", async ({ page }) => {
const orderId = 9405; const orderId = 9405;
const fixture = createMobilePosFixture({ const fixture = createMobilePosFixture({
+3 -1
View File
@@ -2610,8 +2610,9 @@ test.describe("All-in-one self-serve studio", () => {
}); });
expect(focusedNodeMetrics.count).toBeGreaterThanOrEqual(4); expect(focusedNodeMetrics.count).toBeGreaterThanOrEqual(4);
const isWebKitProject = testInfo.project.name.startsWith("webkit-"); const isWebKitProject = testInfo.project.name.startsWith("webkit-");
const isMobileProject = testInfo.project.name.includes("mobile");
const maxFocusedSpreadX = isWebKitProject ? 1120 : 1120; const maxFocusedSpreadX = isWebKitProject ? 1120 : 1120;
const maxFocusedSpreadY = isWebKitProject ? 560 : 460; const maxFocusedSpreadY = isWebKitProject || isMobileProject ? 560 : 460;
expect(focusedNodeMetrics.spreadX).toBeLessThan(maxFocusedSpreadX); expect(focusedNodeMetrics.spreadX).toBeLessThan(maxFocusedSpreadX);
expect(focusedNodeMetrics.spreadY).toBeLessThan(maxFocusedSpreadY); expect(focusedNodeMetrics.spreadY).toBeLessThan(maxFocusedSpreadY);
await page.getByTestId("studio-filter-lane").selectOption({ label: "Lane 7" }); await page.getByTestId("studio-filter-lane").selectOption({ label: "Lane 7" });
@@ -2963,6 +2964,7 @@ test.describe("All-in-one self-serve studio", () => {
await expect await expect
.poll(() => Object.prototype.hasOwnProperty.call(captured.pathOutcomes[0] || {}, "max_states")) .poll(() => Object.prototype.hasOwnProperty.call(captured.pathOutcomes[0] || {}, "max_states"))
.toBe(false); .toBe(false);
await expect.poll(() => captured.pathOutcomes[0]?.path_sample_limit).toBe(2048);
await expect(page.getByTestId("studio-path-outcomes-error")).toHaveCount(0); await expect(page.getByTestId("studio-path-outcomes-error")).toHaveCount(0);
await expect(page.getByTestId("studio-path-outcomes-summary")).toContainText("2"); await expect(page.getByTestId("studio-path-outcomes-summary")).toContainText("2");
await expect(page.getByTestId("studio-path-outcome-detail")).toContainText("MACHINE"); await expect(page.getByTestId("studio-path-outcome-detail")).toContainText("MACHINE");
+1 -2
View File
@@ -562,7 +562,7 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
}); });
test("vehicle next button shows a loading indicator while self-serve data loads", async ({ page }) => { test("vehicle next button is disabled while self-serve data loads", async ({ page }) => {
await mockApi(page, { await mockApi(page, {
authenticated: true, authenticated: true,
permissions: ["user"], permissions: ["user"],
@@ -587,7 +587,6 @@ test.describe("Self-serve wash", () => {
await nextButton.click(); await nextButton.click();
await expect(nextButton).toHaveClass(/is-loading/);
await expect(nextButton).toBeDisabled(); await expect(nextButton).toBeDisabled();
await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 });
}); });
+33
View File
@@ -9,6 +9,8 @@ export const DEFAULT_BOOKING_ID = 8101;
export const REGULAR_CUSTOMER_ID = 12345; export const REGULAR_CUSTOMER_ID = 12345;
export const CARD_CUSTOMER_ID = 999; export const CARD_CUSTOMER_ID = 999;
export const WASH_CERTIFICATE_PRODUCT_ID = 41; export const WASH_CERTIFICATE_PRODUCT_ID = 41;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
export const MOBILE_PERMISSIONS = ["admin", "department_access_1"]; export const MOBILE_PERMISSIONS = ["admin", "department_access_1"];
export const MOBILE_NEXT_STEP_COOLDOWN_MS = 2100; export const MOBILE_NEXT_STEP_COOLDOWN_MS = 2100;
@@ -325,6 +327,23 @@ function isWashCertificateProduct(product) {
return /vaskecertifikat|wash certificate|safety seal/i.test(String(product?.name ?? product?.product?.name ?? "")); return /vaskecertifikat|wash certificate|safety seal/i.test(String(product?.name ?? product?.product?.name ?? ""));
} }
function isEnabledFlag(value) {
return value === true || value === 1 || value === "1" || value === "true";
}
function productRequiresOrderItemNote(product) {
if (!product) {
return false;
}
const productId = Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
productId === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
String(product?.product?.name ?? product?.name ?? "").trim() === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
}
function orderContainsWashCertificate(fixture, orderId) { function orderContainsWashCertificate(fixture, orderId) {
return (fixture.orderItemsByOrderId[orderId] || []).some((item) => isWashCertificateProduct(item?.product || item)); return (fixture.orderItemsByOrderId[orderId] || []).some((item) => isWashCertificateProduct(item?.product || item));
} }
@@ -1960,6 +1979,20 @@ export async function mockMobilePosApi(page, fixture) {
); );
return; return;
} }
if (productRequiresOrderItemNote(product) && String(body.notes ?? "").trim() === "") {
await route.fulfill(
json(
{
success: false,
data: {
message: "Notes is required for this product",
},
},
400
)
);
return;
}
const orderItemId = fixture.nextOrderItemId++; const orderItemId = fixture.nextOrderItemId++;
const item = buildOrderItem( const item = buildOrderItem(
product, product,
+210 -17
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { defineComponent, h, nextTick } from "vue"; import { defineComponent, h, nextTick } from "vue";
import { mount } from "@vue/test-utils"; import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("vue-router", () => { vi.mock("vue-router", () => {
const push = vi.fn(); const push = vi.fn();
@@ -32,8 +32,28 @@ vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmen
return { return {
default: defineComponent({ default: defineComponent({
name: "MockPosDepartmentStepMobile1Location", name: "MockPosDepartmentStepMobile1Location",
setup() { props: {
return () => h("div", { "data-testid": "location-probe" }); enableHighAccuracy: {
type: Boolean,
default: true,
},
maximumAge: {
type: Number,
default: 30000,
},
timeout: {
type: Number,
default: 27000,
},
},
setup(props) {
return () =>
h("div", {
"data-testid": "location-probe",
"data-enable-high-accuracy": String(props.enableHighAccuracy),
"data-maximum-age": String(props.maximumAge),
"data-timeout": String(props.timeout),
});
}, },
}), }),
}; };
@@ -67,6 +87,20 @@ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartment
const locations = { const locations = {
location, location,
defaultTimeout, defaultTimeout,
normalizeCoordinatePair: (coordinates, { allowZeroPair = true } = {}) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
if (!allowZeroPair && latitude === 0 && longitude === 0) {
return null;
}
return { latitude, longitude };
},
set: (newLocation) => { set: (newLocation) => {
location.value = newLocation; location.value = newLocation;
}, },
@@ -129,7 +163,18 @@ import {
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue"; } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import { __sessionState } from "@/components/session/token/SessionUser.vue"; import { __sessionState } from "@/components/session/token/SessionUser.vue";
const mountDefaultPage = () => mount(DefaultPage); let wrapper = null;
const mountDefaultPage = () => {
wrapper = mount(DefaultPage, {
global: {
mocks: {
$t: (key, params = {}) => (params.name ? `${key}:${params.name}` : key),
},
},
});
return wrapper;
};
const setAdminSession = (permissions = ["admin"]) => { const setAdminSession = (permissions = ["admin"]) => {
__sessionState.initiated.value = true; __sessionState.initiated.value = true;
@@ -138,9 +183,21 @@ const setAdminSession = (permissions = ["admin"]) => {
__sessionState.token.value = "unit-token"; __sessionState.token.value = "unit-token";
}; };
const setLocation = ({ latitude, longitude, timestamp = Date.now() }) => {
__locationRef.value = {
coords: {
latitude,
longitude,
},
timestamp: new Date(timestamp),
locatedAt: timestamp,
};
};
describe("DefaultPage mobile department redirect", () => { describe("DefaultPage mobile department redirect", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-11T10:00:00.000Z"));
__routerPush.mockReset(); __routerPush.mockReset();
__getDepartmentsMock.mockReset(); __getDepartmentsMock.mockReset();
__getDepartmentsMock.mockResolvedValue(undefined); __getDepartmentsMock.mockResolvedValue(undefined);
@@ -151,16 +208,21 @@ describe("DefaultPage mobile department redirect", () => {
setAdminSession(["admin", "department_access_1", "department_access_2"]); setAdminSession(["admin", "department_access_1", "department_access_2"]);
}); });
afterEach(() => {
wrapper?.unmount();
wrapper = null;
vi.clearAllTimers();
vi.useRealTimers();
});
it("routes when geolocation arrives before departments finish loading", async () => { it("routes when geolocation arrives before departments finish loading", async () => {
mountDefaultPage(); mountDefaultPage();
await nextTick(); await nextTick();
__locationRef.value = { setLocation({
coords: { latitude: 55.5,
latitude: 55.5, longitude: 12.5,
longitude: 12.5, });
},
};
await nextTick(); await nextTick();
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 2 } }); expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 2 } });
@@ -175,24 +237,155 @@ describe("DefaultPage mobile department redirect", () => {
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 2 } }); expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 2 } });
}); });
it("requests a fresh geolocation fix for mobile department auto-selection", async () => {
const mounted = mountDefaultPage();
await nextTick();
const probe = mounted.find('[data-testid="location-probe"]');
expect(probe.attributes("data-enable-high-accuracy")).toBe("true");
expect(probe.attributes("data-maximum-age")).toBe("0");
expect(probe.attributes("data-timeout")).toBe("27000");
});
it("shows manual department selection after five seconds", async () => {
const mounted = mountDefaultPage();
await nextTick();
expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(false);
await vi.advanceTimersByTimeAsync(4999);
await nextTick();
expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await nextTick();
expect(mounted.find('[data-testid="redirect-manual-department-button"]').exists()).toBe(true);
});
it("lets the user manually select a department after GPS wait", async () => {
setAdminSession(["admin", "department_access_4", "department_access_6"]);
__departmentsRef.value = [
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
];
const mounted = mountDefaultPage();
await nextTick();
await vi.advanceTimersByTimeAsync(5000);
await nextTick();
await mounted.get('[data-testid="redirect-manual-department-button"]').trigger("click");
await nextTick();
expect(mounted.find('[data-testid="redirect-manual-department-options"]').exists()).toBe(true);
setLocation({
latitude: 55.458,
longitude: 12.182,
});
await nextTick();
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 4 } });
await vi.advanceTimersByTimeAsync(27000);
await nextTick();
expect(__routerPush).not.toHaveBeenCalledWith({ name: "admin" });
await mounted.get('[data-testid="redirect-manual-department-option-6"]').trigger("click");
await nextTick();
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 6 } });
});
it("ignores stale cached location and waits for a fresh Roskilde location", async () => {
setAdminSession(["admin", "department_access_4", "department_access_6"]);
__departmentsRef.value = [
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
];
setLocation({
latitude: 55.458,
longitude: 12.182,
timestamp: Date.now() - 30000,
});
mountDefaultPage();
await nextTick();
expect(__locationRef.value).toBeNull();
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 4 } });
await vi.advanceTimersByTimeAsync(100);
setLocation({
latitude: 55.642,
longitude: 12.08,
});
await nextTick();
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 6 } });
});
it("does not redirect from a location timestamp older than the auto-select attempt", async () => {
setAdminSession(["admin", "department_access_4", "department_access_6"]);
__departmentsRef.value = [
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
];
const staleTimestamp = Date.now() - 1000;
mountDefaultPage();
await nextTick();
setLocation({
latitude: 55.458,
longitude: 12.182,
timestamp: staleTimestamp,
});
await nextTick();
expect(__routerPush).not.toHaveBeenCalledWith({ name: "pos", params: { departmentId: 4 } });
await vi.advanceTimersByTimeAsync(27000);
await nextTick();
expect(__routerPush).toHaveBeenCalledWith({ name: "admin" });
});
it("routes immediately when only one accessible department exists", async () => {
setAdminSession(["admin", "department_access_6"]);
__departmentsRef.value = [
{ id: 4, name: "Køge", latitude: 55.458, longitude: 12.182 },
{ id: 6, name: "Roskilde", latitude: 55.642, longitude: 12.08 },
];
mountDefaultPage();
await nextTick();
expect(__routerPush).toHaveBeenCalledWith({ name: "pos", params: { departmentId: 6 } });
});
it("falls back after timeout even when location exists but nearest cannot be resolved", async () => { it("falls back after timeout even when location exists but nearest cannot be resolved", async () => {
__departmentsRef.value = [ __departmentsRef.value = [
{ id: 1, name: "Missing coordinates" }, { id: 1, name: "Missing coordinates" },
{ id: 2, name: "Also missing coordinates" }, { id: 2, name: "Also missing coordinates" },
]; ];
__locationRef.value = {
coords: {
latitude: 55.5,
longitude: 12.5,
},
};
mountDefaultPage(); mountDefaultPage();
await nextTick(); await nextTick();
setLocation({
latitude: 55.5,
longitude: 12.5,
});
await nextTick();
expect(__routerPush).not.toHaveBeenCalled(); expect(__routerPush).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(500); await vi.advanceTimersByTimeAsync(27000);
await nextTick(); await nextTick();
expect(__routerPush).toHaveBeenCalledWith({ name: "admin" }); expect(__routerPush).toHaveBeenCalledWith({ name: "admin" });
+8 -3
View File
@@ -19,8 +19,8 @@ const pressShift = () => {
}; };
describe("FrontendMaintenanceMenu", () => { describe("FrontendMaintenanceMenu", () => {
it("opens after three Shift presses and runs the force update cleanup after confirmation", async () => { it("opens after three Shift presses and runs the force update cleanup after in-app confirmation", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true); const confirmSpy = vi.spyOn(window, "confirm");
mount(FrontendMaintenanceMenu, { mount(FrontendMaintenanceMenu, {
global: { global: {
plugins: [i18n], plugins: [i18n],
@@ -39,7 +39,12 @@ describe("FrontendMaintenanceMenu", () => {
document.body.querySelector("[data-testid='frontend-maintenance-force-clear']").click(); document.body.querySelector("[data-testid='frontend-maintenance-force-clear']").click();
await flushMicrotasks(); await flushMicrotasks();
expect(window.confirm).toHaveBeenCalledTimes(1); expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).not.toBeNull();
document.body.querySelector("[data-testid='local-data-reset-confirm']").click();
await flushMicrotasks();
expect(confirmSpy).not.toHaveBeenCalled();
expect(forceFrontendUpdateAndClearLocal).toHaveBeenCalledTimes(1); expect(forceFrontendUpdateAndClearLocal).toHaveBeenCalledTimes(1);
}); });
}); });
+47 -5
View File
@@ -6,8 +6,9 @@ import {
invalidateFrontendCachesAfterError, invalidateFrontendCachesAfterError,
} from "@/services/frontendMaintenance.js"; } from "@/services/frontendMaintenance.js";
const installBrowserMaintenanceMocks = () => { const installBrowserMaintenanceMocks = ({ indexedDatabaseNames = [] } = {}) => {
const deletedCaches = []; const deletedCaches = [];
const deletedIndexedDatabases = [];
const update = vi.fn(async () => true); const update = vi.fn(async () => true);
const unregister = vi.fn(async () => true); const unregister = vi.fn(async () => true);
const getRegistrations = vi.fn(async () => [{ update, unregister }]); const getRegistrations = vi.fn(async () => [{ update, unregister }]);
@@ -18,6 +19,15 @@ const installBrowserMaintenanceMocks = () => {
return true; return true;
}), }),
}; };
const indexedDB = {
databases: vi.fn(async () => indexedDatabaseNames.map((name) => ({ name }))),
deleteDatabase: vi.fn((databaseName) => {
deletedIndexedDatabases.push(databaseName);
const request = {};
queueMicrotask(() => request.onsuccess?.());
return request;
}),
};
Object.defineProperty(window, "caches", { Object.defineProperty(window, "caches", {
configurable: true, configurable: true,
@@ -27,8 +37,12 @@ const installBrowserMaintenanceMocks = () => {
configurable: true, configurable: true,
value: { getRegistrations }, value: { getRegistrations },
}); });
Object.defineProperty(window, "indexedDB", {
configurable: true,
value: indexedDB,
});
return { caches, deletedCaches, getRegistrations, unregister, update }; return { caches, deletedCaches, deletedIndexedDatabases, getRegistrations, indexedDB, unregister, update };
}; };
describe("frontend maintenance", () => { describe("frontend maintenance", () => {
@@ -39,6 +53,9 @@ describe("frontend maintenance", () => {
afterEach(() => { afterEach(() => {
__resetFrontendMaintenanceForTests(); __resetFrontendMaintenanceForTests();
vi.restoreAllMocks(); vi.restoreAllMocks();
Reflect.deleteProperty(window, "caches");
Reflect.deleteProperty(window, "indexedDB");
Reflect.deleteProperty(navigator, "serviceWorker");
}); });
it("invalidates browser caches and asks service workers to update after frontend errors", async () => { it("invalidates browser caches and asks service workers to update after frontend errors", async () => {
@@ -66,8 +83,10 @@ describe("frontend maintenance", () => {
expect(mocks.update).toHaveBeenCalledTimes(1); expect(mocks.update).toHaveBeenCalledTimes(1);
}); });
it("clears local state except the session token, unregisters service workers, clears caches, and reloads", async () => { it("clears all local state, unregisters service workers, clears caches, deletes IndexedDB, and reloads", async () => {
const mocks = installBrowserMaintenanceMocks(); const mocks = installBrowserMaintenanceMocks({
indexedDatabaseNames: ["workbox-expiration", "truckwash-offline"],
});
const reload = vi.fn(); const reload = vi.fn();
localStorage.setItem("token", "secret-token"); localStorage.setItem("token", "secret-token");
localStorage.setItem("draft", "local-value"); localStorage.setItem("draft", "local-value");
@@ -76,10 +95,33 @@ describe("frontend maintenance", () => {
const result = await forceFrontendUpdateAndClearLocal({ reload }); const result = await forceFrontendUpdateAndClearLocal({ reload });
expect(result.cacheNames).toEqual(["pleno-api-cache", "pleno-website-cache"]); expect(result.cacheNames).toEqual(["pleno-api-cache", "pleno-website-cache"]);
expect(result.indexedDatabaseNames).toEqual(["workbox-expiration", "truckwash-offline"]);
expect(mocks.unregister).toHaveBeenCalledTimes(1); expect(mocks.unregister).toHaveBeenCalledTimes(1);
expect(localStorage.getItem("token")).toBe("secret-token"); expect(mocks.deletedCaches).toEqual(["pleno-api-cache", "pleno-website-cache"]);
expect(mocks.deletedIndexedDatabases).toEqual(["workbox-expiration", "truckwash-offline"]);
expect(localStorage.getItem("token")).toBeNull();
expect(localStorage.getItem("draft")).toBeNull(); expect(localStorage.getItem("draft")).toBeNull();
expect(sessionStorage.getItem("draft")).toBeNull(); expect(sessionStorage.getItem("draft")).toBeNull();
expect(reload).toHaveBeenCalledTimes(1); expect(reload).toHaveBeenCalledTimes(1);
}); });
it("falls back to known IndexedDB names when database enumeration is unavailable", async () => {
const mocks = installBrowserMaintenanceMocks();
delete mocks.indexedDB.databases;
const result = await forceFrontendUpdateAndClearLocal({ reload: vi.fn() });
expect(result.indexedDatabaseNames).toEqual([
"workbox-expiration",
"workbox-background-sync",
"pleno",
"truckwash",
]);
expect(mocks.deletedIndexedDatabases).toEqual([
"workbox-expiration",
"workbox-background-sync",
"pleno",
"truckwash",
]);
});
}); });
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { enableAutoUnmount, mount } from "@vue/test-utils";
import { nextTick } from "vue";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
enableAutoUnmount(afterEach);
const flush = async () => {
await Promise.resolve();
await nextTick();
};
const clickBodyButton = async (testId) => {
await flush();
document.body.querySelector(`[data-testid='${testId}']`).click();
await flush();
};
describe("LocalDataResetDialog", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
afterEach(() => {
document.body.innerHTML = "";
});
it("emits dismiss and model update when Nej is pressed", async () => {
const wrapper = mount(LocalDataResetDialog, {
attachTo: document.body,
props: {
modelValue: true,
},
});
await clickBodyButton("local-data-reset-cancel");
expect(wrapper.emitted("dismiss")).toHaveLength(1);
expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
});
it("emits confirm when Ja, ryd alt is pressed", async () => {
const wrapper = mount(LocalDataResetDialog, {
attachTo: document.body,
props: {
modelValue: true,
},
});
await clickBodyButton("local-data-reset-confirm");
expect(wrapper.emitted("confirm")).toHaveLength(1);
});
});
+191
View File
@@ -0,0 +1,191 @@
// @vitest-environment jsdom
import { enableAutoUnmount, mount } from "@vue/test-utils";
import { nextTick } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import MobileFooter from "@/components/viewport/page/footers/MobileFooter.vue";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
enableAutoUnmount(afterEach);
const mocks = vi.hoisted(() => {
const route = {
path: "/user/wash/start",
fullPath: "/user/wash/start",
};
return {
route,
push: vi.fn(),
};
});
vi.mock("vue-router", () => ({
useRouter: () => ({
currentRoute: {
value: mocks.route,
},
push: mocks.push,
}),
}));
vi.mock("@/services/frontendMaintenance.js", () => ({
forceFrontendUpdateAndClearLocal: vi.fn(async () => true),
}));
vi.mock("buefy", async () => {
const { defineComponent, h } = await import("vue");
const BButton = defineComponent({
name: "MockBButton",
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => {
const { type: _type, iconPack: _iconPack, expanded: _expanded, size: _size, ...buttonAttrs } = attrs;
return h(
"button",
{
...buttonAttrs,
type: "button",
disabled: Boolean(attrs.disabled),
},
slots.default ? slots.default() : []
);
};
},
});
const Passthrough = defineComponent({
name: "MockPassthrough",
setup(_props, { slots }) {
return () => h("div", slots.default ? slots.default() : []);
},
});
const BIcon = defineComponent({
name: "MockBIcon",
props: {
icon: {
type: String,
default: "",
},
},
setup(props) {
return () => h("span", { "data-testid": `mock-icon-${props.icon}` });
},
});
return {
BButton,
BField: Passthrough,
BIcon,
};
});
const flushMicrotasks = async () => {
await Promise.resolve();
await Promise.resolve();
await nextTick();
};
const mountFooter = () =>
mount(MobileFooter, {
attachTo: document.body,
});
const getHomeButton = (wrapper) => wrapper.get("[data-testid='mobile-footer-home']");
const dispatchHomePointerEvent = async (wrapper, eventName) => {
const event = new Event(eventName, {
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, "button", {
value: 0,
});
Object.defineProperty(event, "pointerType", {
value: "touch",
});
getHomeButton(wrapper).element.dispatchEvent(event);
await nextTick();
};
const clickDialogButton = async (testId) => {
await flushMicrotasks();
document.body.querySelector(`[data-testid='${testId}']`).click();
await flushMicrotasks();
};
const holdHomeFor = async (wrapper, durationMs) => {
await dispatchHomePointerEvent(wrapper, "pointerdown");
await vi.advanceTimersByTimeAsync(durationMs);
await nextTick();
};
describe("MobileFooter", () => {
beforeEach(() => {
vi.useFakeTimers();
document.body.innerHTML = "";
mocks.route.path = "/user/wash/start";
mocks.route.fullPath = "/user/wash/start";
mocks.push.mockReset();
vi.mocked(forceFrontendUpdateAndClearLocal).mockClear();
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
});
it("navigates to /user on a short Hjem click", async () => {
const wrapper = mountFooter();
await dispatchHomePointerEvent(wrapper, "pointerdown");
await dispatchHomePointerEvent(wrapper, "pointerup");
await getHomeButton(wrapper).trigger("click");
expect(mocks.push).toHaveBeenCalledTimes(1);
expect(mocks.push).toHaveBeenCalledWith("/user");
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
});
it("does not open the reset dialog when Hjem is held for less than 5 seconds", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 4999);
await dispatchHomePointerEvent(wrapper, "pointerup");
await vi.advanceTimersByTimeAsync(1);
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
});
it("opens the reset dialog after a 5 second Hjem hold and suppresses navigation", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 5000);
await dispatchHomePointerEvent(wrapper, "pointerup");
await getHomeButton(wrapper).trigger("click");
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).not.toBeNull();
expect(mocks.push).not.toHaveBeenCalled();
});
it("closes the reset dialog without cleanup when Nej is pressed", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 5000);
await flushMicrotasks();
await clickDialogButton("local-data-reset-cancel");
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
expect(forceFrontendUpdateAndClearLocal).not.toHaveBeenCalled();
});
it("runs the full local data cleanup when Ja, ryd alt is pressed", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 5000);
await flushMicrotasks();
await clickDialogButton("local-data-reset-confirm");
expect(forceFrontendUpdateAndClearLocal).toHaveBeenCalledTimes(1);
});
});
+10
View File
@@ -32,6 +32,16 @@ vi.mock("@/composables/useWashDepartments", () => ({
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
locations: { locations: {
location: mocks.locationRef, location: mocks.locationRef,
normalizeCoordinatePair: (coordinates) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude) ? { latitude, longitude } : null;
},
hasValidCoordinatePair: (coordinates) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude);
},
getDistance: mocks.getDistance, getDistance: mocks.getDistance,
}, },
})); }));
+5
View File
@@ -140,6 +140,11 @@ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartment
}, },
}, },
}, },
hasValidCoordinatePair: (coordinates) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude);
},
}, },
})); }));
@@ -38,6 +38,11 @@ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartment
return { return {
locations: { locations: {
location: ref(null), location: ref(null),
normalizeCoordinatePair: (coordinates) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude) ? { latitude, longitude } : null;
},
getDistance: () => 0, getDistance: () => 0,
}, },
}; };
@@ -150,6 +150,8 @@ describe("self-serve studio task editing", () => {
"const machineStartPathResultList = computed(() => pathResultList.value.filter(pathCaseShowsMachineStartTask))" "const machineStartPathResultList = computed(() => pathResultList.value.filter(pathCaseShowsMachineStartTask))"
); );
expect(source).toContain("const pathCaseList = computed(() => machineStartPathResultList.value)"); expect(source).toContain("const pathCaseList = computed(() => machineStartPathResultList.value)");
expect(source).toContain("const PATH_OUTCOMES_CASE_LIMIT = 2048");
expect(source).toContain("path_sample_limit: PATH_OUTCOMES_CASE_LIMIT");
expect(source).toContain('data-testid="studio-path-hidden-non-machine-start-cases"'); expect(source).toContain('data-testid="studio-path-hidden-non-machine-start-cases"');
}); });
+225
View File
@@ -49,6 +49,231 @@ describe("useSelfServeLogic", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
const makeRule = ({ id = 1, conditionId = 100, objectType = "question", objectId = 1, type = "IS_TRUE" } = {}) => ({
id,
condition_id: conditionId,
object_type: objectType,
object_id: objectId,
type,
name: `${type}-${id}`,
});
const applyEvaluationState = (logic, { answers = {}, rules = [], tasks = [], conditions = [] } = {}) => {
logic.answers.value = answers;
logic.rules.value = rules;
logic.tasks.value = tasks;
logic.conditions.value = conditions;
};
describe("condition evaluation engine", () => {
it.each([
["IS_TRUE", true, true],
["IS_TRUE", false, false],
["IS_TRUE", null, false],
["IS_TRUE", undefined, false],
["IS_FALSE", false, true],
["IS_FALSE", true, false],
["IS_FALSE", null, false],
["IS_FALSE", undefined, false],
["IS_TRUE_OR_NOT_SET", true, true],
["IS_TRUE_OR_NOT_SET", false, false],
["IS_TRUE_OR_NOT_SET", null, true],
["IS_TRUE_OR_NOT_SET", undefined, true],
["IS_FALSE_OR_NOT_SET", false, true],
["IS_FALSE_OR_NOT_SET", true, false],
["IS_FALSE_OR_NOT_SET", null, true],
["IS_FALSE_OR_NOT_SET", undefined, true],
["IS_SET", true, true],
["IS_SET", false, true],
["IS_SET", null, false],
["IS_SET", undefined, false],
["UNKNOWN_RULE", true, false],
])("evaluates %s for %s answers", (type, answerValue, expected) => {
const logic = useSelfServeLogic();
const answers = {};
if (answerValue !== undefined) {
answers[1] = answerValue;
}
const rule = makeRule({ type });
applyEvaluationState(logic, {
answers,
rules: [rule],
});
expect(logic.evaluateRule(rule)).toBe(expected);
expect(logic.evaluateCondition(100)).toBe(expected);
});
it("treats an empty condition id as satisfied", () => {
const logic = useSelfServeLogic();
expect(logic.evaluateCondition(null)).toBe(true);
expect(logic.evaluateCondition(0)).toBe(true);
});
it("rejects conditions with no rules when evaluated directly", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
conditions: [{ id: 100, name: "No rules" }],
});
expect(logic.evaluateCondition(100)).toBe(false);
expect(logic.evaluateCondition(999)).toBe(false);
});
it("evaluates nested condition references", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_TRUE" }),
],
});
expect(
logic.evaluateRule(makeRule({ conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }))
).toBe(true);
expect(logic.evaluateCondition(100)).toBe(true);
});
it("detects direct condition cycles", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
rules: [makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 100, type: "IS_TRUE" })],
});
expect(logic.evaluateCondition(100)).toBe(false);
});
it("detects cycles across nested condition references", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectType: "condition", objectId: 100, type: "IS_TRUE" }),
],
});
expect(logic.evaluateCondition(100)).toBe(false);
expect(logic.evaluateCondition(200)).toBe(false);
});
it("returns true for IS_TRUE_OR_ANY_TRUE when the nested condition is true", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_TRUE" }),
],
});
expect(logic.evaluateCondition(100)).toBe(true);
});
it("returns true for IS_TRUE_OR_ANY_TRUE when any nested rule is true", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true, 2: false },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_FALSE" }),
makeRule({ id: 3, conditionId: 200, objectId: 2, type: "IS_FALSE" }),
],
});
expect(logic.evaluateCondition(200)).toBe(false);
expect(logic.evaluateCondition(100)).toBe(true);
});
it("returns false for IS_TRUE_OR_ANY_TRUE when direct question answer is not true", () => {
const logic = useSelfServeLogic();
const rule = makeRule({ type: "IS_TRUE_OR_ANY_TRUE" });
applyEvaluationState(logic, {
answers: { 1: false },
rules: [rule],
});
expect(logic.evaluateRule(rule)).toBe(false);
expect(logic.evaluateCondition(100)).toBe(false);
});
it("returns false for IS_TRUE_OR_ANY_TRUE when nested rules are all false or missing", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_FALSE" }),
makeRule({ id: 3, conditionId: 300, objectType: "condition", objectId: 999, type: "IS_TRUE_OR_ANY_TRUE" }),
],
});
expect(logic.evaluateCondition(100)).toBe(false);
expect(logic.evaluateCondition(300)).toBe(false);
});
it("keeps tasks with no condition or missing condition rules active by default", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
tasks: [
{ id: 1, task_id: 1, task: "No condition", order_priority: 3, condition_id: null },
{ id: 2, task_id: 2, task: "Zero condition", order_priority: 2, condition_id: 0 },
{ id: 3, task_id: 3, task: "Missing rules", order_priority: 1, condition_id: 999 },
],
});
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([3, 2, 1]);
expect(logic.isTaskActive(999)).toBe(false);
});
it("filters and sorts active tasks based on evaluated conditions", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true, 2: false, 3: null },
rules: [
makeRule({ id: 1, conditionId: 100, objectId: 1, type: "IS_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 2, type: "IS_TRUE" }),
makeRule({ id: 3, conditionId: 300, objectId: 3, type: "IS_TRUE_OR_NOT_SET" }),
],
tasks: [
{
id: 1,
task_id: 1,
task: "Hidden false condition",
order_priority: 1,
condition_id: 200,
services: ["HIDDEN"],
},
{
id: 2,
task_id: 2,
task: "Active null condition",
order_priority: 3,
condition_id: 300,
services: ["OPTIONAL"],
},
{
id: 3,
task_id: 3,
task: "Active true condition",
order_priority: 2,
condition_id: 100,
services: ["MACHINE"],
},
],
});
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([3, 2]);
expect(logic.isTaskActive(1)).toBe(false);
expect(logic.isTaskActive(2)).toBe(true);
expect(logic.activeTaskServices.value).toEqual(["MACHINE", "OPTIONAL"]);
});
});
it("uses task attachments supplied by preview and summary data", async () => { it("uses task attachments supplied by preview and summary data", async () => {
const taskAttachments = [ const taskAttachments = [
{ id: 201, content: { other: "manual.pdf" }, download_link: "https://cdn.example.test/manual.pdf" }, { id: 201, content: { other: "manual.pdf" }, download_link: "https://cdn.example.test/manual.pdf" },
+114
View File
@@ -23,6 +23,20 @@ vi.mock("@/components/pagination/departmentTabs.vue", () => ({
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
locations: { locations: {
location: mocks.locationRef, location: mocks.locationRef,
normalizeCoordinatePair: (coordinates, { allowZeroPair = true } = {}) => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
if (!allowZeroPair && latitude === 0 && longitude === 0) {
return null;
}
return { latitude, longitude };
},
getDistance: mocks.getDistance, getDistance: mocks.getDistance,
}, },
})); }));
@@ -217,6 +231,106 @@ describe("useWashDepartments", () => {
expect(departments.isDepartmentSelectionDistanceBased.value).toBe(false); expect(departments.isDepartmentSelectionDistanceBased.value).toBe(false);
}); });
it("treats non-finite browser coordinates as missing instead of distance-selecting a department", async () => {
mocks.locationRef.value = {
coords: {
latitude: Number.POSITIVE_INFINITY,
longitude: Number.POSITIVE_INFINITY,
},
};
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Fallback",
address: "A",
latitude: 55.6,
longitude: 12.5,
lanes: [{ id: 10, selfserve_enabled: true }],
self_serve_enabled: true,
},
{
id: 2,
name: "Other",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11, selfserve_enabled: true }],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
expect(mocks.getDistance).not.toHaveBeenCalled();
expect(departments.nearestDepartment.value).toMatchObject({ id: 1, name: "Fallback", distance: null });
expect(departments.departmentSelectionStrategy.value).toBe("fallback");
});
it("skips default zero department coordinates when selecting by GPS", async () => {
mocks.getDistance.mockImplementation((_from, to) => {
if (to.latitude === 55.7) {
return 12;
}
return 1;
});
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Default coordinate row",
address: "A",
latitude: 0,
longitude: 0,
lanes: [{ id: 10, selfserve_enabled: true }],
self_serve_enabled: true,
},
{
id: 2,
name: "Real coordinate row",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11, selfserve_enabled: true }],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
expect(mocks.getDistance.mock.calls.every(([, to]) => to.latitude === 55.7 && to.longitude === 12.6)).toBe(true);
expect(departments.nearestDepartment.value).toMatchObject({
id: 2,
name: "Real coordinate row",
distance: 12,
});
expect(departments.departmentSelectionStrategy.value).toBe("distance");
});
it("pushes departments with default zero coordinates after valid coordinates when sorting by distance", async () => {
mocks.getDistance.mockImplementation((_from, to) => {
if (to.latitude === 55.7) {
return 12;
}
if (to.latitude === 55.8) {
return 8;
}
return 1;
});
const departments = mountDepartments({ includeLanes: true });
const unsortedDepartments = [
{ id: 1, name: "Default coordinate row", latitude: 0, longitude: 0 },
{ id: 2, name: "Farther valid row", latitude: 55.7, longitude: 12.6 },
{ id: 3, name: "Nearer valid row", latitude: 55.8, longitude: 12.7 },
];
expect(departments.orderDepartmentsByDistance(unsortedDepartments).map((department) => department.id)).toEqual([
3, 2, 1,
]);
});
it("clears the selected department for empty department responses", async () => { it("clears the selected department for empty department responses", async () => {
mocks.getDepartmentsGuest.mockResolvedValueOnce([ mocks.getDepartmentsGuest.mockResolvedValueOnce([
{ id: 1, name: "North", address: "A", latitude: 55.6, longitude: 12.5, lanes: [], self_serve_enabled: true }, { id: 1, name: "North", address: "A", latitude: 55.6, longitude: 12.5, lanes: [], self_serve_enabled: true },