Compare commits

...
39 changed files with 932 additions and 106 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: |
@@ -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) => {
@@ -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,7 +18,9 @@ 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;
}; };
</script> </script>
@@ -17,6 +17,9 @@ export const Config = {
value: value, value: value,
}); });
}, },
test_customer_registration_webhook: async () => {
return authenticatedRequest("/slack/config/test", "POST", {});
},
keys: { keys: {
customer_registration_webhook_url: { customer_registration_webhook_url: {
get: async () => { get: async () => {
@@ -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;
}); });
+13
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": "Vælg afdeling manuelt",
"manual_title": "Vælg din afdeling",
"manual_loading": "Indlæser afdelinger...",
"use_department": "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'}",
@@ -2335,7 +2343,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
@@ -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": "Abteilung manuell auswählen",
"manual_title": "Wählen Sie Ihre Abteilung",
"manual_loading": "Abteilungen werden geladen...",
"use_department": "{name} auswählen"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}", "database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
@@ -2445,7 +2453,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
@@ -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": "Select department manually",
"manual_title": "Select your department",
"manual_loading": "Loading departments...",
"use_department": "Select {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'} @:{'words.replication.host'}", "database": "@:{'words.replication.services.database'} @:{'words.replication.host'}",
@@ -2169,7 +2177,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."
}, },
+8
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": "Velg avdeling manuelt",
"manual_title": "Velg din avdeling",
"manual_loading": "Laster avdelinger...",
"use_department": "Velg {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
@@ -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": "Välj avdelning manuellt",
"manual_title": "Välj din avdelning",
"manual_loading": "Laddar avdelningar...",
"use_department": "Välj {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}", "database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
@@ -2496,7 +2504,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
@@ -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": {
+8
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": {
+8
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": {
+8
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": {
+8
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": {
@@ -163,7 +163,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."
}, },
+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": "Vælg afdeling manuelt",
"manual_title": "Vælg din afdeling",
"manual_loading": "Indlæser afdelinger...",
"use_department": "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'}",
@@ -163,7 +163,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."
}, },
+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": "Abteilung manuell auswählen",
"manual_title": "Wählen Sie Ihre Abteilung",
"manual_loading": "Abteilungen werden geladen...",
"use_department": "{name} auswählen"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
@@ -163,7 +163,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."
}, },
+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": "Select department manually",
"manual_title": "Select your department",
"manual_loading": "Loading departments...",
"use_department": "Select {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.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": "Velg avdeling manuelt",
"manual_title": "Velg din avdeling",
"manual_loading": "Laster avdelinger...",
"use_department": "Velg {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
@@ -163,7 +163,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.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": "Välj avdelning manuellt",
"manual_title": "Välj din avdelning",
"manual_loading": "Laddar avdelningar...",
"use_department": "Välj {name}"
}
},
"replication": { "replication": {
"host_targets": { "host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}", "database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
+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 = () => {
@@ -1,5 +1,7 @@
<script setup> <script setup>
import { ref } from "vue"; import { ref } from "vue";
import Swal from "sweetalert2";
import { useI18n } from "vue-i18n";
import PageTitle from "@/components/global/PageTitle.vue"; import PageTitle from "@/components/global/PageTitle.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue"; import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue"; import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
@@ -7,9 +9,11 @@ import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrap
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue"; import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
const { t } = useI18n();
const module_config = ref([]); const module_config = ref([]);
const module_config_unavailable = ref(false); const module_config_unavailable = ref(false);
const module_config_forbidden = ref(false); const module_config_forbidden = ref(false);
const is_testing_webhook = ref(false);
const getRequestStatus = (error) => Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10); const getRequestStatus = (error) => Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10);
@@ -51,6 +55,38 @@ const getModuleConfigValue = (variable) => {
return config ? config.value : ""; return config ? config.value : "";
}; };
const testCustomerRegistrationWebhook = async () => {
if (is_testing_webhook.value) {
return;
}
is_testing_webhook.value = true;
try {
await SessionUser.superUser.modules.slack.config.test_customer_registration_webhook();
await Swal.fire({
title: t("configuration.slack.test_webhook_sent"),
text: t("configuration.slack.test_webhook_sent_success"),
icon: "success",
});
} catch (error) {
const status = getRequestStatus(error);
let text = t("configuration.slack.test_webhook_error");
if (status === 400) {
text = t("configuration.slack.test_webhook_not_configured");
} else if (status === 403) {
text = t("errors.forbidden");
}
await Swal.fire({
title: t("common.error"),
text,
icon: "error",
});
} finally {
is_testing_webhook.value = false;
}
};
getModuleConfig(); getModuleConfig();
</script> </script>
@@ -77,6 +113,19 @@ getModuleConfig();
:value="getModuleConfigValue('customer_registration_webhook_url')" :value="getModuleConfigValue('customer_registration_webhook_url')"
:on-save="SessionUser.superUser.modules.slack.config.keys.customer_registration_webhook_url.set" :on-save="SessionUser.superUser.modules.slack.config.keys.customer_registration_webhook_url.set"
/> />
<button
class="button is-dark mt-2"
type="button"
data-testid="slack-test-webhook-button"
:class="{ 'is-loading': is_testing_webhook }"
:disabled="is_testing_webhook"
@click="testCustomerRegistrationWebhook"
>
<span class="icon">
<i class="fab fa-slack" aria-hidden="true"></i>
</span>
<span>{{ $t("configuration.slack.send_test_webhook") }}</span>
</button>
</ConfigurationCategory> </ConfigurationCategory>
</template> </template>
<template v-else-if="module_config_unavailable" #content> <template v-else-if="module_config_unavailable" #content>
@@ -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(
() => () =>
+1
View File
@@ -2963,6 +2963,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");
+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" });
+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"');
}); });
+14
View File
@@ -32,6 +32,8 @@ describe("slack module contract", () => {
it("uses Slack config endpoints and customer registration variable", () => { it("uses Slack config endpoints and customer registration variable", () => {
expect(slackConfigSource).toContain('"/slack/config?variable=" + variable'); expect(slackConfigSource).toContain('"/slack/config?variable=" + variable');
expect(slackConfigSource).toContain('"/slack/config"'); expect(slackConfigSource).toContain('"/slack/config"');
expect(slackConfigSource).toContain('"/slack/config/test"');
expect(slackConfigSource).toContain("test_customer_registration_webhook");
expect(slackConfigSource).toContain('Config.get("customer_registration_webhook_url")'); expect(slackConfigSource).toContain('Config.get("customer_registration_webhook_url")');
expect(slackConfigSource).toContain('Config.set("customer_registration_webhook_url", value)'); expect(slackConfigSource).toContain('Config.set("customer_registration_webhook_url", value)');
}); });
@@ -51,6 +53,18 @@ describe("slack module contract", () => {
expect(slackPageSource).toContain( expect(slackPageSource).toContain(
':on-save="SessionUser.superUser.modules.slack.config.keys.customer_registration_webhook_url.set"' ':on-save="SessionUser.superUser.modules.slack.config.keys.customer_registration_webhook_url.set"'
); );
expect(slackPageSource).toContain("testCustomerRegistrationWebhook");
expect(slackPageSource).toContain(
"SessionUser.superUser.modules.slack.config.test_customer_registration_webhook()"
);
expect(slackPageSource).toContain('data-testid="slack-test-webhook-button"');
expect(slackPageSource).toContain(":class=\"{ 'is-loading': is_testing_webhook }\"");
expect(slackPageSource).toContain(':disabled="is_testing_webhook"');
expect(slackPageSource).toContain('$t("configuration.slack.send_test_webhook")');
expect(slackPageSource).toContain('t("configuration.slack.test_webhook_sent")');
expect(slackPageSource).toContain('t("configuration.slack.test_webhook_sent_success")');
expect(slackPageSource).toContain('t("configuration.slack.test_webhook_not_configured")');
expect(slackPageSource).toContain('t("configuration.slack.test_webhook_error")');
}); });
it("shows an unavailable state when the API release does not expose Slack config", () => { it("shows an unavailable state when the API release does not expose Slack config", () => {
+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 },