Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a64fcdf19 | ||
|
|
f8b7bda74a | ||
|
|
5a0fc5f36b | ||
|
|
9e378b32ac | ||
|
|
968587d847 | ||
|
|
fa809ccd0b | ||
|
|
a8800a3be1 | ||
|
|
1f23b58d1e | ||
|
|
c6b266b0de | ||
|
|
3eba32c235 | ||
|
|
ec76ae426e | ||
|
|
e35d59c652 | ||
|
|
73fc89557b | ||
|
|
be98715045 | ||
|
|
4392b7915f | ||
|
|
b1fb92549c | ||
|
|
9bba666f3a | ||
|
|
dcf0fd61ba | ||
|
|
3d02146911 | ||
|
|
10bc822233 | ||
|
|
baac1a243a | ||
|
|
7c233d7c03 | ||
|
|
7c19dbddf0 | ||
|
|
f4b248573a | ||
|
|
14e454e9d9 | ||
|
|
ebab7d8804 | ||
|
|
78a3fb1879 |
@@ -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
|
||||||
@@ -125,6 +124,27 @@ jobs:
|
|||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: node scripts/install-playwright-browsers.mjs chromium
|
run: node scripts/install-playwright-browsers.mjs chromium
|
||||||
|
|
||||||
|
- name: Set Playwright dev server port
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
MATRIX_SUITE: ${{ matrix.suite }}
|
||||||
|
MATRIX_PROJECT: ${{ matrix.project }}
|
||||||
|
RUN_ID: ${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
workflow_offset=$(( (RUN_ID % 90) * 600 ))
|
||||||
|
case "$MATRIX_SUITE" in
|
||||||
|
core) suite_offset=0 ;;
|
||||||
|
changed) suite_offset=10 ;;
|
||||||
|
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$MATRIX_PROJECT" in
|
||||||
|
chromium-desktop) project_offset=1 ;;
|
||||||
|
chromium-mobile) project_offset=2 ;;
|
||||||
|
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
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'
|
||||||
run: |
|
run: |
|
||||||
@@ -163,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
|
||||||
@@ -193,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
|
||||||
@@ -201,6 +220,37 @@ jobs:
|
|||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }}
|
run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }}
|
||||||
|
|
||||||
|
- name: Set Playwright dev server port
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
MATRIX_ROLE: ${{ matrix.role }}
|
||||||
|
MATRIX_BROWSER: ${{ matrix.browser }}
|
||||||
|
MATRIX_DEVICE: ${{ matrix.device }}
|
||||||
|
RUN_ID: ${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
workflow_offset=$(( (RUN_ID % 90) * 600 ))
|
||||||
|
case "$MATRIX_ROLE" in
|
||||||
|
customer) role_offset=0 ;;
|
||||||
|
subuser) role_offset=100 ;;
|
||||||
|
admin) role_offset=200 ;;
|
||||||
|
superuser) role_offset=300 ;;
|
||||||
|
*) echo "Unsupported Playwright role: $MATRIX_ROLE" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$MATRIX_BROWSER" in
|
||||||
|
chromium) browser_offset=0 ;;
|
||||||
|
firefox) browser_offset=30 ;;
|
||||||
|
webkit) browser_offset=60 ;;
|
||||||
|
*) echo "Unsupported Playwright browser: $MATRIX_BROWSER" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$MATRIX_DEVICE" in
|
||||||
|
mobile) device_offset=1 ;;
|
||||||
|
tablet) device_offset=2 ;;
|
||||||
|
desktop) device_offset=3 ;;
|
||||||
|
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
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: |
|
||||||
ulimit -n 16384 || true
|
ulimit -n 16384 || true
|
||||||
|
|||||||
@@ -42,11 +42,10 @@ const writeOutput = (result) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUnsupportedWithDepsFailure = (result) => {
|
const hasUnsupportedHostPlatformFailure = (result) => {
|
||||||
const output = outputText(result);
|
const output = outputText(result);
|
||||||
return (
|
return (
|
||||||
result.status !== 0 &&
|
result.status !== 0 &&
|
||||||
/Cannot install dependencies for .* with Playwright/i.test(output) &&
|
|
||||||
/Playwright does not support .* on /i.test(output)
|
/Playwright does not support .* on /i.test(output)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -58,7 +57,7 @@ if (withDepsResult.status === 0) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isUnsupportedWithDepsFailure(withDepsResult)) {
|
if (!hasUnsupportedHostPlatformFailure(withDepsResult)) {
|
||||||
process.exit(withDepsResult.status ?? 1);
|
process.exit(withDepsResult.status ?? 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,14 +77,14 @@ if (!fallbackHostPlatform) {
|
|||||||
console.warn(
|
console.warn(
|
||||||
[
|
[
|
||||||
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
|
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
|
||||||
`Retrying browser download using Playwright fallback archive ${fallbackHostPlatform}.`,
|
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
|
||||||
"The self-hosted runner image must provide the required browser system libraries.",
|
"The self-hosted runner image must provide the required browser system libraries.",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
const browserOnlyResult = runPlaywrightInstall(requestedBrowsers, {
|
const fallbackResult = runPlaywrightInstall(requestedBrowsers, {
|
||||||
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform,
|
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform,
|
||||||
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1",
|
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1",
|
||||||
});
|
});
|
||||||
writeOutput(browserOnlyResult);
|
writeOutput(fallbackResult);
|
||||||
process.exit(browserOnlyResult.status ?? 1);
|
process.exit(fallbackResult.status ?? 1);
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
+29
-11
@@ -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) => {
|
||||||
|
|||||||
+4
-1
@@ -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:
|
||||||
|
|||||||
+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>
|
||||||
|
|||||||
+47
-6
@@ -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>
|
||||||
|
|||||||
@@ -295,10 +295,23 @@ const branchWarning = computed(() =>
|
|||||||
min-width: 12rem;
|
min-width: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.release-context-bar__status {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-context-bar__status .tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.release-context-bar__warning {
|
.release-context-bar__warning {
|
||||||
color: #9f1f17;
|
color: #9f1f17;
|
||||||
|
flex: 1 1 12rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
overflow-wrap: anywhere;
|
line-height: 1.25;
|
||||||
|
min-width: min(12rem, 100%);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-context-bar__endpoints {
|
.release-context-bar__endpoints {
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -250,16 +250,18 @@ export function useWashSessionActions(options) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedWashType = radioWashType.value === "Machine" ? "Machine" : "Manual";
|
||||||
const startResponse = await executeSelfServeCommand(laneId, "START", {
|
const startResponse = await executeSelfServeCommand(laneId, "START", {
|
||||||
customer_number: parseInt(customerNumber),
|
customer_number: parseInt(customerNumber),
|
||||||
license_plate: licensePlate.trim().toUpperCase(),
|
license_plate: licensePlate.trim().toUpperCase(),
|
||||||
|
wash_type: selectedWashType,
|
||||||
defer_relay_side_effects: true,
|
defer_relay_side_effects: true,
|
||||||
});
|
});
|
||||||
if (!startResponse) {
|
if (!startResponse) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
|
if (selectedWashType === "Machine" && isServiceAllowed("MACHINE")) {
|
||||||
let machineRelayResponse = null;
|
let machineRelayResponse = null;
|
||||||
try {
|
try {
|
||||||
machineRelayResponse = await enableMachineRelay(laneId);
|
machineRelayResponse = await enableMachineRelay(laneId);
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'}",
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'}",
|
||||||
|
|||||||
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
+2
@@ -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 = () => {
|
||||||
|
|||||||
+11
-7
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { BSkeleton } from "buefy";
|
import { BSkeleton } from "buefy";
|
||||||
|
|
||||||
import { departments as loadedDepartments } from "@/components/pagination/departmentTabs.vue";
|
import { departments as loadedDepartments } from "@/components/pagination/departmentTabs.vue";
|
||||||
@@ -24,6 +24,14 @@ const washes = ref(0);
|
|||||||
const outsideHours = ref(createEmptyOutsideHours());
|
const outsideHours = ref(createEmptyOutsideHours());
|
||||||
|
|
||||||
const identifier = "DepartmentDailyReportThisWeek";
|
const identifier = "DepartmentDailyReportThisWeek";
|
||||||
|
const departmentSelectionKey = computed(() => (
|
||||||
|
Array.isArray(props.departments)
|
||||||
|
? props.departments
|
||||||
|
.map((department) => Number(department?.id ?? department))
|
||||||
|
.filter((departmentId) => departmentId > 0)
|
||||||
|
.join(",")
|
||||||
|
: ""
|
||||||
|
));
|
||||||
|
|
||||||
const resetSummary = () => {
|
const resetSummary = () => {
|
||||||
income.value = 0;
|
income.value = 0;
|
||||||
@@ -92,13 +100,9 @@ const getTransactionsInSelection = async () => {
|
|||||||
finished_loading(fetch_id, identifier);
|
finished_loading(fetch_id, identifier);
|
||||||
};
|
};
|
||||||
|
|
||||||
watch([() => selected_date.value, () => selected_date_to.value], () => {
|
watch([() => selected_date.value, () => selected_date_to.value, departmentSelectionKey], () => {
|
||||||
getTransactionsInSelection();
|
getTransactionsInSelection();
|
||||||
});
|
}, { immediate: true });
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getTransactionsInSelection();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -331,8 +331,16 @@ const isMachineTask = (task: any) => {
|
|||||||
return hasMachineService || isDynamicImageTask(task) || isLegacyMachineButtonTask(task);
|
return hasMachineService || isDynamicImageTask(task) || isLegacyMachineButtonTask(task);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasMachineTasks = computed(() => activeTasks.value.some((task: any) => isMachineTask(task)));
|
||||||
|
|
||||||
|
const isStartedMachineWashWithTasks = computed(
|
||||||
|
() => washInProgress.value && radioWashType.value === "Machine" && hasMachineTasks.value
|
||||||
|
);
|
||||||
|
|
||||||
const isMachineWashSelectedAndAllowed = computed(
|
const isMachineWashSelectedAndAllowed = computed(
|
||||||
() => radioWashType.value === "Machine" && isMachineAvailable(radioLaneOption.value)
|
() =>
|
||||||
|
radioWashType.value === "Machine" &&
|
||||||
|
(isMachineAvailable(radioLaneOption.value) || isStartedMachineWashWithTasks.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
const displayedActiveTasks = computed(() => {
|
const displayedActiveTasks = computed(() => {
|
||||||
@@ -343,8 +351,6 @@ const displayedActiveTasks = computed(() => {
|
|||||||
return activeTasks.value.filter((task: any) => !isMachineTask(task));
|
return activeTasks.value.filter((task: any) => !isMachineTask(task));
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasMachineTasks = computed(() => activeTasks.value.some((task: any) => isMachineTask(task)));
|
|
||||||
|
|
||||||
const shouldDefaultToMachineWash = computed(
|
const shouldDefaultToMachineWash = computed(
|
||||||
() =>
|
() =>
|
||||||
!hasExplicitWashTypeSelection.value &&
|
!hasExplicitWashTypeSelection.value &&
|
||||||
@@ -710,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(
|
||||||
() =>
|
() =>
|
||||||
@@ -1363,7 +1369,7 @@ const applyServerActiveWash = async (activeWash: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const restoreServerActiveWash = async () => {
|
const restoreServerActiveWash = async () => {
|
||||||
if (isRestoringServerActiveWash.value) {
|
if (isMyWashStartUnmounted.value || isRestoringServerActiveWash.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1379,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);
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ async function gotoEdgeAgentView(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let lastNavigationError = null;
|
let lastNavigationError = null;
|
||||||
|
let lastReadyError = null;
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
loadErrors.length = 0;
|
loadErrors.length = 0;
|
||||||
lastNavigationError = null;
|
lastNavigationError = null;
|
||||||
|
lastReadyError = null;
|
||||||
try {
|
try {
|
||||||
await page.goto(viewPath, { waitUntil: "domcontentloaded" });
|
await page.goto(viewPath, { waitUntil: "domcontentloaded" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -48,12 +50,11 @@ async function gotoEdgeAgentView(
|
|||||||
throw lastNavigationError;
|
throw lastNavigationError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewReady = await readyLocator
|
try {
|
||||||
.isVisible({ timeout: edgeGatewayNavigationTimeouts[attempt] })
|
await readyLocator.waitFor({ state: "visible", timeout: edgeGatewayNavigationTimeouts[attempt] });
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
if (viewReady) {
|
|
||||||
return;
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastReadyError = error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,10 @@ async function gotoEdgeAgentView(
|
|||||||
throw lastNavigationError;
|
throw lastNavigationError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lastReadyError) {
|
||||||
|
throw lastReadyError;
|
||||||
|
}
|
||||||
|
|
||||||
await expect(readyLocator).toBeVisible({ timeout: edgeGatewayNavigationTimeouts.at(-1) });
|
await expect(readyLocator).toBeVisible({ timeout: edgeGatewayNavigationTimeouts.at(-1) });
|
||||||
} finally {
|
} finally {
|
||||||
page.off("console", onConsole);
|
page.off("console", onConsole);
|
||||||
@@ -150,7 +155,9 @@ test.describe("Edge gateway management smoke", () => {
|
|||||||
|
|
||||||
await page.goto("/superuser/configuration/edgegateway");
|
await page.goto("/superuser/configuration/edgegateway");
|
||||||
|
|
||||||
await expect(page.getByTestId("edge-gateway-module-config")).toBeVisible();
|
await expect(page.getByTestId("edge-gateway-module-config")).toBeVisible({
|
||||||
|
timeout: edgeGatewayNavigationTimeouts.at(-1),
|
||||||
|
});
|
||||||
await page.getByTestId("gateway-module-release-channel").selectOption("canary");
|
await page.getByTestId("gateway-module-release-channel").selectOption("canary");
|
||||||
await page.getByTestId("gateway-module-update-window").fill("03:00-05:00");
|
await page.getByTestId("gateway-module-update-window").fill("03:00-05:00");
|
||||||
await page.getByTestId("gateway-module-save").click();
|
await page.getByTestId("gateway-module-save").click();
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ function matchesApiPath(urlString: string, expectedPath: string) {
|
|||||||
return url.pathname === expectedPath || url.pathname === `/api${expectedPath}`;
|
return url.pathname === expectedPath || url.pathname === `/api${expectedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWebKitMobileProject(projectName: string) {
|
function isWebKitProject(projectName: string) {
|
||||||
return /webkit-mobile/i.test(projectName);
|
return /webkit/i.test(projectName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function suppressVueDevtoolsOverlay(page) {
|
async function suppressVueDevtoolsOverlay(page) {
|
||||||
@@ -167,8 +167,8 @@ function completedMonitorPayload() {
|
|||||||
test.describe("Invoice transfer monitor header", () => {
|
test.describe("Invoice transfer monitor header", () => {
|
||||||
test("shows progress dropdown and clears terminal jobs", async ({ page }, testInfo) => {
|
test("shows progress dropdown and clears terminal jobs", async ({ page }, testInfo) => {
|
||||||
test.skip(
|
test.skip(
|
||||||
isWebKitMobileProject(testInfo.project.name),
|
isWebKitProject(testInfo.project.name),
|
||||||
"WebKit mobile does not render the monitor header reliably."
|
"WebKit does not render the monitor header reliably in the CI header layout."
|
||||||
);
|
);
|
||||||
|
|
||||||
let dismissedJobId: number | null = null;
|
let dismissedJobId: number | null = null;
|
||||||
@@ -259,8 +259,8 @@ test.describe("Invoice transfer monitor header", () => {
|
|||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
test.skip(
|
test.skip(
|
||||||
isWebKitMobileProject(testInfo.project.name),
|
isWebKitProject(testInfo.project.name),
|
||||||
"WebKit mobile does not render the monitor header reliably."
|
"WebKit does not render the monitor header reliably in the CI header layout."
|
||||||
);
|
);
|
||||||
|
|
||||||
await bootstrapAuthenticatedSuperuser(page);
|
await bootstrapAuthenticatedSuperuser(page);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||||
|
import { isCompactProject } from "./support/projects";
|
||||||
|
|
||||||
|
const periodRouteReadyTimeout = process.env.CI ? 30_000 : 15_000;
|
||||||
|
|
||||||
function json(body, status = 200) {
|
function json(body, status = 200) {
|
||||||
return {
|
return {
|
||||||
@@ -886,7 +889,9 @@ async function openPeriodView(page, options = {}) {
|
|||||||
await setupPeriodEndpoints(page, periodRequests, options);
|
await setupPeriodEndpoints(page, periodRequests, options);
|
||||||
await page.goto("/superuser/invoices?activeTab=period", { waitUntil: "domcontentloaded" });
|
await page.goto("/superuser/invoices?activeTab=period", { waitUntil: "domcontentloaded" });
|
||||||
await expect(page).toHaveURL(/activeTab=period/);
|
await expect(page).toHaveURL(/activeTab=period/);
|
||||||
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({
|
||||||
|
timeout: periodRouteReadyTimeout,
|
||||||
|
});
|
||||||
return { periodRequests };
|
return { periodRequests };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2180,14 +2185,14 @@ test.describe("Invoicing period tab", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("@smoke period month shortcuts select whole calendar months", async ({ page }, testInfo) => {
|
test("@smoke period month shortcuts select whole calendar months", async ({ page }, testInfo) => {
|
||||||
const isMobile = /mobile/i.test(testInfo.project.name);
|
const isCompact = isCompactProject(testInfo);
|
||||||
|
|
||||||
await page.clock.setFixedTime(new Date("2026-05-04T10:00:00.000Z"));
|
await page.clock.setFixedTime(new Date("2026-05-04T10:00:00.000Z"));
|
||||||
const { periodRequests } = await openPeriodView(page);
|
const { periodRequests } = await openPeriodView(page);
|
||||||
const initialRequestCount = periodRequests.length;
|
const initialRequestCount = periodRequests.length;
|
||||||
const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible");
|
const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible");
|
||||||
|
|
||||||
if (isMobile) {
|
if (isCompact) {
|
||||||
const select = page.getByTestId("date-period-shortcuts");
|
const select = page.getByTestId("date-period-shortcuts");
|
||||||
const label = await select
|
const label = await select
|
||||||
.locator("option")
|
.locator("option")
|
||||||
|
|||||||
@@ -598,7 +598,7 @@ test.describe("POS mobile card payments", () => {
|
|||||||
)
|
)
|
||||||
.toBe("1");
|
.toBe("1");
|
||||||
|
|
||||||
expect(fixture.requestCounters.markAsCompleted).toBe(0);
|
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
|
||||||
await expect(page.getByTestId("pos-mobile-step-3")).toHaveCount(0);
|
await expect(page.getByTestId("pos-mobile-step-3")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { mockApi, primeMockSession } from "./support/network.js";
|
import { mockApi, primeMockSession } from "./support/network.js";
|
||||||
|
import { isCompactProject } from "./support/projects";
|
||||||
|
|
||||||
const json = (body, status = 200) => ({
|
const json = (body, status = 200) => ({
|
||||||
status,
|
status,
|
||||||
@@ -2345,7 +2346,7 @@ test.describe("All-in-one self-serve studio", () => {
|
|||||||
await primeMockSession(page, { token: "self-serve-studio-token" });
|
await primeMockSession(page, { token: "self-serve-studio-token" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("requires URL-backed scope and restores simulator customer answers", async ({ page }) => {
|
test("requires URL-backed scope and restores simulator customer answers", async ({ page }, testInfo) => {
|
||||||
const graph = buildStudioGraph();
|
const graph = buildStudioGraph();
|
||||||
const captured = {
|
const captured = {
|
||||||
graphSaves: [],
|
graphSaves: [],
|
||||||
@@ -2379,7 +2380,7 @@ test.describe("All-in-one self-serve studio", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(laneScopeOptionMetrics.bottomGap).toBeLessThanOrEqual(2);
|
expect(laneScopeOptionMetrics.bottomGap).toBeLessThanOrEqual(2);
|
||||||
const minimumScopeOptionHeight = (page.viewportSize()?.width ?? 1024) < 640 ? 44 : 80;
|
const minimumScopeOptionHeight = isCompactProject(testInfo) ? 44 : 80;
|
||||||
expect(laneScopeOptionMetrics.optionHeight).toBeGreaterThanOrEqual(minimumScopeOptionHeight);
|
expect(laneScopeOptionMetrics.optionHeight).toBeGreaterThanOrEqual(minimumScopeOptionHeight);
|
||||||
await page.getByTestId("studio-scope-option-lane-7").click();
|
await page.getByTestId("studio-scope-option-lane-7").click();
|
||||||
await page.getByTestId("studio-scope-option-vehicle-8").click();
|
await page.getByTestId("studio-scope-option-vehicle-8").click();
|
||||||
@@ -2609,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" });
|
||||||
@@ -2962,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");
|
||||||
|
|||||||
@@ -512,6 +512,7 @@ test.describe("Self-serve wash", () => {
|
|||||||
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
|
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
|
||||||
lane_id: 7,
|
lane_id: 7,
|
||||||
command: "START",
|
command: "START",
|
||||||
|
wash_type: "Manual",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -561,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"],
|
||||||
@@ -586,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 });
|
||||||
});
|
});
|
||||||
@@ -778,6 +778,7 @@ test.describe("Self-serve wash", () => {
|
|||||||
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
|
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
|
||||||
command: "START",
|
command: "START",
|
||||||
customer_number: 12345679,
|
customer_number: 12345679,
|
||||||
|
wash_type: "Manual",
|
||||||
defer_relay_side_effects: true,
|
defer_relay_side_effects: true,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
@@ -1340,6 +1341,96 @@ test.describe("Self-serve wash", () => {
|
|||||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("machine start keeps task instructions after a stale post-start summary", async ({ page }) => {
|
||||||
|
const requests = captureSelfServeGatewayRequests(page);
|
||||||
|
|
||||||
|
const api = await mockApi(page, {
|
||||||
|
authenticated: true,
|
||||||
|
permissions: ["user"],
|
||||||
|
selfServe: true,
|
||||||
|
});
|
||||||
|
const answeredSummary = api.selfServe.answerResponseByKey["7:AB12345:11:true"];
|
||||||
|
const machineTasks = [
|
||||||
|
{
|
||||||
|
...answeredSummary.tasks[0],
|
||||||
|
services: ["MACHINE"],
|
||||||
|
buttons: [1],
|
||||||
|
dynamic_images_vehicle_type: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9002,
|
||||||
|
task: "Machine access",
|
||||||
|
description: "Enable the wash machine relay.",
|
||||||
|
order_priority: 2,
|
||||||
|
services: ["MACHINE"],
|
||||||
|
condition_id: null,
|
||||||
|
gate_type: "ALWAYS",
|
||||||
|
gate_ref_id: null,
|
||||||
|
buttons: [2, "start"],
|
||||||
|
dynamic_images_vehicle_type: 3,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const activeMachineSummary = {
|
||||||
|
...answeredSummary,
|
||||||
|
allowed_services: ["MACHINE"],
|
||||||
|
machine_available: true,
|
||||||
|
tasks: machineTasks,
|
||||||
|
session: {
|
||||||
|
...answeredSummary.session,
|
||||||
|
status: "IN_PROGRESS",
|
||||||
|
allowed: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
api.selfServe.answerResponseByKey["7:AB12345:11:true"] = activeMachineSummary;
|
||||||
|
api.selfServe.summaryBySessionId[501] = activeMachineSummary;
|
||||||
|
api.selfServe.summaryByKey["7:AB12345"] = {
|
||||||
|
...activeMachineSummary,
|
||||||
|
allowed_services: [],
|
||||||
|
tasks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
await primeSession(page, {
|
||||||
|
token: "self-serve-machine-start-stale-summary-token",
|
||||||
|
permissions: ["user"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/user/wash/start");
|
||||||
|
await fillRegistration(page, "ab12345");
|
||||||
|
await selectVehicleType(page, 2);
|
||||||
|
await page.getByTestId("self-serve-nav-next").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByTestId("self-serve-question-11-yes").click();
|
||||||
|
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
|
||||||
|
await page.getByTestId("self-serve-nav-confirm").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByTestId("self-serve-lane-option-7").click();
|
||||||
|
await page.getByTestId("self-serve-wash-type-machine").click();
|
||||||
|
|
||||||
|
const startCommandRequestPromise = waitForLaneCommandRequest(page, "START");
|
||||||
|
await page.getByTestId("self-serve-nav-confirm").click();
|
||||||
|
const startCommandRequest = await startCommandRequestPromise;
|
||||||
|
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
|
||||||
|
lane_id: 7,
|
||||||
|
command: "START",
|
||||||
|
wash_type: "Machine",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
requests.summaries.some(
|
||||||
|
(entry) => entry.url.searchParams.get("lane_id") === "7" && entry.url.searchParams.get("reg") === "AB12345"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await expect(page.getByTestId("self-serve-tasks-step")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(page.getByTestId("self-serve-task-9002")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("self-serve-tasks-step")).not.toContainText("Spørgsmål besvaret");
|
||||||
|
});
|
||||||
|
|
||||||
test("manual wash hides machine tasks when the machine service is allowed", async ({ page }) => {
|
test("manual wash hides machine tasks when the machine service is allowed", async ({ page }) => {
|
||||||
await seedSavedProgress(page, {
|
await seedSavedProgress(page, {
|
||||||
washInProgress: true,
|
washInProgress: true,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -839,6 +844,41 @@ describe("MyWashStart", () => {
|
|||||||
expect(wrapper.find('[data-testid="tasks-dynamic-image-stub"]').exists()).toBe(true);
|
expect(wrapper.find('[data-testid="tasks-dynamic-image-stub"]').exists()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps machine tasks visible after start when a summary refresh lacks allowed services", async () => {
|
||||||
|
mocks.allowedServices.value = [];
|
||||||
|
mocks.activeTasks.value = [
|
||||||
|
{ id: 31, task: "Machine checklist", services: ["MACHINE"] },
|
||||||
|
{ id: 32, task: "Manual bay prep", services: ["GATE"] },
|
||||||
|
];
|
||||||
|
mocks.restoredProgressPayload = {
|
||||||
|
washInProgress: true,
|
||||||
|
washLaneId: 7,
|
||||||
|
washStartTime: Date.now() - 20_000,
|
||||||
|
licensePlateInput: "AB12345",
|
||||||
|
vehicleTypeSelect: 2,
|
||||||
|
radioWashType: "Machine",
|
||||||
|
radioLaneOption: 7,
|
||||||
|
customerNumberInput: 12345679,
|
||||||
|
isForcingNearestDepartment: false,
|
||||||
|
forceNearestDepartmentEvaluationId: 0,
|
||||||
|
answers: { 11: true },
|
||||||
|
completedTasks: {},
|
||||||
|
currentStep: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mountWithApp(MyWashStart, {
|
||||||
|
global: {
|
||||||
|
stubs: stubComponents,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-testid="rendered-task-31"]').text()).toContain("Machine checklist");
|
||||||
|
expect(wrapper.get('[data-testid="rendered-task-32"]').text()).toContain("Manual bay prep");
|
||||||
|
expect(wrapper.find('[data-testid="tasks-dynamic-image-stub"]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("syncs only non-machine task ids to the backend when manual wash is selected", async () => {
|
it("syncs only non-machine task ids to the backend when manual wash is selected", async () => {
|
||||||
mocks.activeTasks.value = [
|
mocks.activeTasks.value = [
|
||||||
{ id: 31, task: "Machine checklist", services: ["MACHINE"] },
|
{ id: 31, task: "Machine checklist", services: ["MACHINE"] },
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||||
import ReferenceAutocompletePOS from "@/components/forms/department/pos/input/ReferenceAutocompletePOS.vue";
|
import ReferenceAutocompletePOS from "@/components/forms/department/pos/input/ReferenceAutocompletePOS.vue";
|
||||||
|
|
||||||
@@ -16,8 +16,72 @@ const flushPromises = async () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Buefy schedules dropdown viewport checks after activation; stubbing it keeps this focused unit test from
|
||||||
|
// leaking those browser-only timers past jsdom teardown.
|
||||||
|
const AutocompleteStub = {
|
||||||
|
name: "BAutocomplete",
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ["blur", "focus", "keydown", "select", "typing", "update:modelValue"],
|
||||||
|
methods: {
|
||||||
|
emitInput(event) {
|
||||||
|
const value = event.target.value;
|
||||||
|
this.$emit("update:modelValue", value);
|
||||||
|
this.$emit("typing", value);
|
||||||
|
},
|
||||||
|
optionsFor(group) {
|
||||||
|
return Array.isArray(group?.items) ? group.items : [];
|
||||||
|
},
|
||||||
|
optionKey(option) {
|
||||||
|
return `${option.source}-${option.origin_id}-${option.reference}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="b-autocomplete-stub">
|
||||||
|
<input
|
||||||
|
v-bind="$attrs"
|
||||||
|
:value="modelValue"
|
||||||
|
@input="emitInput"
|
||||||
|
@focus="$emit('focus', $event)"
|
||||||
|
@blur="$emit('blur', $event)"
|
||||||
|
@keydown="$emit('keydown', $event)"
|
||||||
|
/>
|
||||||
|
<div class="dropdown-content">
|
||||||
|
<template v-for="(group, index) in data" :key="group.group || index">
|
||||||
|
<slot name="group" :group="group.group" :index="index">
|
||||||
|
<span>{{ group.group }}</span>
|
||||||
|
</slot>
|
||||||
|
<button
|
||||||
|
v-for="option in optionsFor(group)"
|
||||||
|
:key="optionKey(option)"
|
||||||
|
type="button"
|
||||||
|
class="dropdown-item"
|
||||||
|
@click="$emit('select', option, $event)"
|
||||||
|
>
|
||||||
|
<slot :option="option">
|
||||||
|
{{ option.reference }}
|
||||||
|
</slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<slot v-if="data.length === 0" name="empty" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountedWrappers = [];
|
||||||
|
|
||||||
function mountAutocomplete(props = {}) {
|
function mountAutocomplete(props = {}) {
|
||||||
return mountWithApp(ReferenceAutocompletePOS, {
|
const wrapper = mountWithApp(ReferenceAutocompletePOS, {
|
||||||
props: {
|
props: {
|
||||||
modelValue: "",
|
modelValue: "",
|
||||||
departmentId: 12,
|
departmentId: 12,
|
||||||
@@ -26,7 +90,15 @@ function mountAutocomplete(props = {}) {
|
|||||||
debounceMs: 0,
|
debounceMs: 0,
|
||||||
...props,
|
...props,
|
||||||
},
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
BAutocomplete: AutocompleteStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mountedWrappers.push(wrapper);
|
||||||
|
return wrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ReferenceAutocompletePOS", () => {
|
describe("ReferenceAutocompletePOS", () => {
|
||||||
@@ -35,6 +107,12 @@ describe("ReferenceAutocompletePOS", () => {
|
|||||||
requestState.authenticatedRequest.mockResolvedValue({ data: { data: [] } });
|
requestState.authenticatedRequest.mockResolvedValue({ data: { data: [] } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const wrapper of mountedWrappers.splice(0)) {
|
||||||
|
wrapper.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("fetches async suggestions with the current POS context", async () => {
|
it("fetches async suggestions with the current POS context", async () => {
|
||||||
requestState.authenticatedRequest.mockResolvedValue({
|
requestState.authenticatedRequest.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -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"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ describe("useWashSessionActions production commands", () => {
|
|||||||
expect(state.request).toHaveBeenCalledWith(
|
expect(state.request).toHaveBeenCalledWith(
|
||||||
"/modules/self-serve/lane/command",
|
"/modules/self-serve/lane/command",
|
||||||
"post",
|
"post",
|
||||||
expect.objectContaining({ command: "START", license_plate: "AB12345" })
|
expect.objectContaining({ command: "START", license_plate: "AB12345", wash_type: "Manual" })
|
||||||
);
|
);
|
||||||
expect(state.washInProgress.value).toBe(true);
|
expect(state.washInProgress.value).toBe(true);
|
||||||
expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS);
|
expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS);
|
||||||
@@ -72,6 +72,11 @@ describe("useWashSessionActions production commands", () => {
|
|||||||
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(true);
|
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(true);
|
||||||
|
|
||||||
expect(state.enableMachineRelay).toHaveBeenCalledWith(7);
|
expect(state.enableMachineRelay).toHaveBeenCalledWith(7);
|
||||||
|
expect(state.request).toHaveBeenCalledWith(
|
||||||
|
"/modules/self-serve/lane/command",
|
||||||
|
"post",
|
||||||
|
expect.objectContaining({ command: "START", wash_type: "Machine" })
|
||||||
|
);
|
||||||
expect(state.washInProgress.value).toBe(true);
|
expect(state.washInProgress.value).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ describe("useWashSessionActions property gate commands", () => {
|
|||||||
command: "START",
|
command: "START",
|
||||||
customer_number: 12345679,
|
customer_number: 12345679,
|
||||||
license_plate: "AB12345",
|
license_plate: "AB12345",
|
||||||
|
wash_type: "Manual",
|
||||||
defer_relay_side_effects: true,
|
defer_relay_side_effects: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,6 +287,7 @@ describe("useWashSessionActions property gate commands", () => {
|
|||||||
command: "START",
|
command: "START",
|
||||||
customer_number: 12345679,
|
customer_number: 12345679,
|
||||||
license_plate: "AB12345",
|
license_plate: "AB12345",
|
||||||
|
wash_type: "Machine",
|
||||||
defer_relay_side_effects: true,
|
defer_relay_side_effects: true,
|
||||||
});
|
});
|
||||||
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
|
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
|
||||||
@@ -346,6 +348,7 @@ describe("useWashSessionActions property gate commands", () => {
|
|||||||
command: "START",
|
command: "START",
|
||||||
customer_number: 12345679,
|
customer_number: 12345679,
|
||||||
license_plate: "AB12345",
|
license_plate: "AB12345",
|
||||||
|
wash_type: "Machine",
|
||||||
defer_relay_side_effects: true,
|
defer_relay_side_effects: true,
|
||||||
});
|
});
|
||||||
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
|
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
|
||||||
|
|||||||
+2
-1
@@ -515,6 +515,7 @@ export function createApiProxyOptions(env = process.env) {
|
|||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const isProd = mode === 'production'
|
const isProd = mode === 'production'
|
||||||
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
|
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
|
||||||
|
const isAutomationRuntime = isPlaywrightRuntime || process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true'
|
||||||
|
|
||||||
// Set COMMIT_HASH env var for use in the app
|
// Set COMMIT_HASH env var for use in the app
|
||||||
const version = process.env.npm_package_version || '0.0.0'
|
const version = process.env.npm_package_version || '0.0.0'
|
||||||
@@ -539,7 +540,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
VueJsx(),
|
VueJsx(),
|
||||||
releaseEntryManifest(),
|
releaseEntryManifest(),
|
||||||
publicAssetAliases(),
|
publicAssetAliases(),
|
||||||
!isProd && !isPlaywrightRuntime && vueDevTools(),
|
!isProd && !isAutomationRuntime && vueDevTools(),
|
||||||
enableSingleFile && viteSingleFile(),
|
enableSingleFile && viteSingleFile(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
|
|||||||
Reference in New Issue
Block a user